Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/components/atomic-crm/notes/AttachmentField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useFieldValue, useRecordContext, useTranslate } from "ra-core";
import type { FileFieldProps } from "@/components/admin";
import { cn } from "@/lib/utils";

/**
* Displays a preview for a single note attachment in the note edition form.
*
* This component is inspired by react-admin's `ImageField` and is intended for
* usage inside a `<FileInput>`, where the current attachment is provided through
* the record context.
*
* @param props - Field props provided by react-admin file inputs.
* @returns An image preview for image attachments, or a regular link for other files.
*/
export const AttachmentField = (props: FileFieldProps) => {
const {
className,
empty,
title,
target,
download,
defaultValue,
source,
record: _recordProp,
...rest
} = props;
const record = useRecordContext();
const sourceValue = useFieldValue({ defaultValue, source, record });
const titleValue =
useFieldValue({
...props,
// @ts-expect-error We ignore here because title might be a custom label or undefined instead of a field name
source: title,
})?.toString() ?? title;
const translate = useTranslate();

if (sourceValue == null) {
if (!empty) {
return null;
}

return (
<div className={cn("inline-block", className)} {...rest}>
{typeof empty === "string" ? translate(empty, { _: empty }) : empty}
</div>
);
}

const type = record?.type ?? record?.rawFile?.type;
const srcValue = sourceValue.toString();

return (
<div className={cn("inline-block", className)} {...rest}>
{isImageMimeType(type) ? (
<a
href={srcValue}
title={titleValue}
target={target}
download={download}
// useful to prevent click bubbling in a DataTable with rowClick
onClick={(e) => e.stopPropagation()}
>
<img
alt={titleValue}
title={titleValue}
src={srcValue}
className="w-[200px] h-[100px] object-cover cursor-pointer object-left border border-border"
/>
</a>
) : (
<a
href={srcValue}
title={titleValue}
target={target}
download={download}
// useful to prevent click bubbling in a DataTable with rowClick
onClick={(e) => e.stopPropagation()}
>
{titleValue}
</a>
)}
</div>
);
};

/**
* Checks whether a mime type corresponds to an image.
*
* @param mimeType - The attachment mime type.
* @returns `true` when the mime type starts with `image/`.
*/
const isImageMimeType = (mimeType?: string): boolean => {
if (!mimeType) {
return false;
}
return mimeType.startsWith("image/");
};
37 changes: 28 additions & 9 deletions src/components/atomic-crm/notes/NoteAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { Paperclip } from "lucide-react";

import type { AttachmentNote, ContactNote, DealNote } from "../types";

/**
* Displays persisted note attachments in note show/list views.
*
* This component receives a full note record and renders all attachments.
*
* @param props - Component props.
* @param props.note - Note record containing attachments to render.
* @returns `null` when there are no attachments, otherwise attachment previews and links.
*/
export const NoteAttachments = ({ note }: { note: ContactNote | DealNote }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now this component seems to overlap with AttachmentField. When should we use one or the other? It's not clear.

Copy link
Collaborator

@WiXSL WiXSL Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AttachmentField renders a single attachment record, while NoteAttachments renders the note.attachments[] collection.
I clarified that distinction in the JSDoc.

if (!note.attachments || note.attachments.length === 0) {
return null;
Expand All @@ -20,15 +29,19 @@ export const NoteAttachments = ({ note }: { note: ContactNote | DealNote }) => {
<div className="grid grid-cols-4 gap-8">
{imageAttachments.map((attachment: AttachmentNote, index: number) => (
<div key={index}>
<img
src={attachment.src}
alt={attachment.title}
className="w-[200px] h-[100px] object-cover cursor-pointer object-left border border-border"
onClick={(e) => {
e.stopPropagation();
window.open(attachment.src, "_blank");
}}
/>
<a
href={attachment.src}
target="_blank"
rel="noopener noreferrer"
className="block"
onClick={(e) => e.stopPropagation()}
>
<img
src={attachment.src}
alt={attachment.title}
className="w-[200px] h-[100px] object-cover cursor-pointer object-left border border-border"
/>
</a>
</div>
))}
</div>
Expand All @@ -52,6 +65,12 @@ export const NoteAttachments = ({ note }: { note: ContactNote | DealNote }) => {
);
};

/**
* Checks whether a mime type corresponds to an image.
*
* @param mimeType - The attachment mime type.
* @returns `true` when the mime type starts with `image/`.
*/
const isImageMimeType = (mimeType?: string): boolean => {
if (!mimeType) {
return false;
Expand Down
4 changes: 2 additions & 2 deletions src/components/atomic-crm/notes/NoteInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { TextInput } from "@/components/admin/text-input";
import { FileInput } from "@/components/admin/file-input";
import { FileField } from "@/components/admin/file-field";
import { SelectInput } from "@/components/admin/select-input";
import { DateTimeInput } from "@/components/admin/date-time-input";
import { Button } from "@/components/ui/button";
Expand All @@ -11,6 +10,7 @@ import { cn } from "@/lib/utils";
import { Status } from "../misc/Status";
import { useConfigurationContext } from "../root/ConfigurationContext";
import { getCurrentDate } from "./utils";
import { AttachmentField } from "./AttachmentField";
import { foreignKeyMapping } from "./foreignKeyMapping";
import { AutocompleteInput, ReferenceInput } from "@/components/admin";
import { required } from "ra-core";
Expand Down Expand Up @@ -107,7 +107,7 @@ export const NoteInputs = ({
/>
</div>
<FileInput source="attachments" multiple>
<FileField source="src" title="title" target="_blank" />
<AttachmentField source="src" title="title" target="_blank" />
</FileInput>
</div>
</div>
Expand Down
27 changes: 25 additions & 2 deletions src/components/atomic-crm/providers/fakerest/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import fakeRestDataProvider from "ra-data-fakerest";
import type {
Company,
Contact,
ContactNote,
Deal,
DealNote,
Sale,
SalesFormData,
SignUpData,
Expand Down Expand Up @@ -243,6 +245,18 @@ async function updateCompany(
});
}

const preserveAttachmentMimeType = <
NoteType extends { attachments?: Array<{ rawFile?: File; type?: string }> },
>(
note: NoteType,
): NoteType => ({
...note,
attachments: (note.attachments ?? []).map((attachment) => ({
...attachment,
type: attachment.type ?? attachment.rawFile?.type,
})),
});

export const dataProvider = withLifecycleCallbacks(
withSupabaseFilterAdapter(dataProviderWithCustomMethod),
[
Expand Down Expand Up @@ -499,6 +513,14 @@ export const dataProvider = withLifecycleCallbacks(
return result;
},
} satisfies ResourceCallbacks<Deal>,
{
resource: "contact_notes",
beforeSave: async (params) => preserveAttachmentMimeType(params),
} satisfies ResourceCallbacks<ContactNote>,
{
resource: "deal_notes",
beforeSave: async (params) => preserveAttachmentMimeType(params),
} satisfies ResourceCallbacks<DealNote>,
],
);

Expand All @@ -507,10 +529,11 @@ export const dataProvider = withLifecycleCallbacks(
* That's not the most optimized way to store images in production, but it's
* enough to illustrate the idea of dataprovider decoration.
*/
const convertFileToBase64 = (file: { rawFile: Blob }) =>
const convertFileToBase64 = (file: { rawFile: Blob }): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
// We know result is a string as we used readAsDataURL
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file.rawFile);
});