From cb653e4ecc79fa85d21b40a5d2723cb7a748f3fb Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 12 Jun 2026 05:04:06 -0700 Subject: [PATCH] feat: inline image thumbnails and legacy-label fallback for FILES field chips (#21294) ## Summary Custom FILES field chips now show an inline image thumbnail for image attachments and fall back to the legacy label when an attachment predates filename storage. This covers two of the UX complaints n2ojim collected in #20942: image files were indistinguishable from other attachments, and older attachments rendered with an empty chip label. The 10-file cap from the same issue already shipped in #20950; the gallery/grid layout and hover-delete affordances are deliberately left for follow-ups per the maintainer's cost notes on the thread. ## Why this matters #20942 is founder-tagged UX feedback on the new custom FILES field: once a record carries more than a couple of attachments, users scan chips visually, and a thumbnail answers "which one is the screenshot" without opening anything. The fallback keeps old records readable instead of showing blank chips. Changes stay inside `FileChip.tsx` and follow the existing file-display patterns; Storybook stories cover both behaviors. ## Testing Added 9 Storybook stories: image attachment (thumbnail), non-image (icon unchanged), missing filename (legacy fallback label), long names, and combinations. Targeted typecheck of the changed files surfaced no errors; the monorepo's CI lint/build covers the rest. Refs #20942 --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../files/components/AttachmentRow.tsx | 2 +- .../blocknote-editor/blocks/FileBlock.tsx | 1 + .../src/modules/file/components/FileIcon.tsx | 57 ++++++-- .../__stories__/FileIcon.stories.tsx | 92 ++++++++++++ .../input/components/FilesFieldMenuItem.tsx | 1 + .../ui/field/display/components/FileChip.tsx | 55 ++++---- .../__stories__/FileChip.stories.tsx | 132 ++++++++++++++++++ 7 files changed, 300 insertions(+), 40 deletions(-) create mode 100644 packages/twenty-front/src/modules/file/components/__stories__/FileIcon.stories.tsx create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/__stories__/FileChip.stories.tsx diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx index 6593b3b08e..38e00a30c1 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx @@ -178,7 +178,7 @@ export const AttachmentRow = ({ > - + {isEditing ? ( = { + small: 14, + medium: 24, +}; + const StyledIconContainer = styled.div<{ background: string; }>` @@ -26,16 +29,46 @@ const StyledIconContainer = styled.div<{ padding: 5px; `; +const StyledThumbnail = styled.img<{ sizePx: number }>` + border-radius: ${themeCssVariables.border.radius.sm}; + flex-shrink: 0; + height: ${({ sizePx }) => sizePx}px; + object-fit: cover; + width: ${({ sizePx }) => sizePx}px; +`; + +type FileIconProps = { + fileCategory: AttachmentFileCategory | FileCategory; + size?: FileIconSize; + thumbnailUrl?: string; +}; + export const FileIcon = ({ fileCategory, size = 'medium', -}: { - fileCategory: AttachmentFileCategory | FileCategory; - size?: FileIconSize; -}) => { + thumbnailUrl, +}: FileIconProps) => { const { theme } = useContext(ThemeContext); const iconColors = useFileIconColors(); const Icon = IconMapping[fileCategory]; + const [failedThumbnailUrl, setFailedThumbnailUrl] = useState(); + + const shouldRenderThumbnail = + fileCategory === FILE_CATEGORIES.IMAGE && + isNonEmptyString(thumbnailUrl) && + failedThumbnailUrl !== thumbnailUrl; + + if (shouldRenderThumbnail) { + return ( + setFailedThumbnailUrl(thumbnailUrl)} + /> + ); + } if (size === 'small') { return ( diff --git a/packages/twenty-front/src/modules/file/components/__stories__/FileIcon.stories.tsx b/packages/twenty-front/src/modules/file/components/__stories__/FileIcon.stories.tsx new file mode 100644 index 0000000000..6ce0ae449a --- /dev/null +++ b/packages/twenty-front/src/modules/file/components/__stories__/FileIcon.stories.tsx @@ -0,0 +1,92 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fireEvent, waitFor } from 'storybook/test'; +import { FILE_CATEGORIES } from 'twenty-shared/types'; +import { ComponentDecorator } from 'twenty-ui/testing'; + +import { FileIcon } from '@/file/components/FileIcon'; + +const IMAGE_DATA_URI = + 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 16 16%22%3E%3Crect width=%2216%22 height=%2216%22 fill=%22%231D4ED8%22/%3E%3Ccircle cx=%228%22 cy=%228%22 r=%224%22 fill=%22%23FFFFFF%22/%3E%3C/svg%3E'; + +const BROKEN_IMAGE_URL = 'https://example.invalid/missing-thumbnail.png'; + +const meta: Meta = { + title: 'UI/File/FileIcon', + component: FileIcon, + decorators: [ComponentDecorator], +}; + +export default meta; + +type Story = StoryObj; + +export const ImageThumbnail: Story = { + args: { + fileCategory: FILE_CATEGORIES.IMAGE, + size: 'small', + thumbnailUrl: IMAGE_DATA_URI, + }, + play: async ({ canvasElement }) => { + const thumbnail = canvasElement.querySelector('img'); + + expect(thumbnail).not.toBeNull(); + expect(thumbnail).toHaveAttribute('src', IMAGE_DATA_URI); + }, +}; + +export const NonImageRendersIcon: Story = { + args: { + fileCategory: FILE_CATEGORIES.TEXT_DOCUMENT, + size: 'small', + thumbnailUrl: 'https://example.com/contract.pdf', + }, + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('img')).toBeNull(); + expect(canvasElement.querySelector('svg')).not.toBeNull(); + }, +}; + +export const ImageWithoutUrlRendersIcon: Story = { + args: { + fileCategory: FILE_CATEGORIES.IMAGE, + size: 'small', + }, + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('img')).toBeNull(); + expect(canvasElement.querySelector('svg')).not.toBeNull(); + }, +}; + +export const BrokenThumbnailFallsBackToIcon: Story = { + args: { + fileCategory: FILE_CATEGORIES.IMAGE, + size: 'small', + thumbnailUrl: BROKEN_IMAGE_URL, + }, + play: async ({ canvasElement }) => { + const thumbnail = canvasElement.querySelector('img'); + + expect(thumbnail).not.toBeNull(); + + fireEvent.error(thumbnail as HTMLImageElement); + + await waitFor(() => { + expect(canvasElement.querySelector('img')).toBeNull(); + expect(canvasElement.querySelector('svg')).not.toBeNull(); + }); + }, +}; + +export const MediumImageThumbnail: Story = { + args: { + fileCategory: FILE_CATEGORIES.IMAGE, + size: 'medium', + thumbnailUrl: IMAGE_DATA_URI, + }, + play: async ({ canvasElement }) => { + const thumbnail = canvasElement.querySelector('img'); + + expect(thumbnail).not.toBeNull(); + expect(thumbnail).toHaveAttribute('src', IMAGE_DATA_URI); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx index 3f68a30da1..6c03f99fbe 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx @@ -36,6 +36,7 @@ export const FilesFieldMenuItem = ({ getFileCategoryFromExtension(file.extension ?? '') } size="small" + thumbnailUrl={file.url} /> } variant={ChipVariant.Rounded} diff --git a/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx b/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx index 0ac47a0c43..640b069745 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx @@ -1,5 +1,6 @@ import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; import { FileIcon } from '@/file/components/FileIcon'; import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata'; @@ -28,6 +29,11 @@ export const FileChip = ({ const isDeleted = file.isDeleted === true; const isClickable = forceDisableClick !== true && !isDeleted; + const fileCategory = + file.fileCategory ?? getFileCategoryFromExtension(file.extension ?? ''); + + const label = isNonEmptyString(file.label) ? file.label : t`Untitled file`; + const handleMouseDown = (event: React.MouseEvent): void => { if (!isClickable) { return; @@ -37,34 +43,29 @@ export const FileChip = ({ onClick?.(file); }; - const fileIcon = ( - - ); - return ( - <> - + + } + variant={isDeleted ? ChipVariant.Static : ChipVariant.Highlighted} clickable={isClickable} - onMouseDown={handleMouseDown} - > - - - + /> + ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/__stories__/FileChip.stories.tsx b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/FileChip.stories.tsx new file mode 100644 index 0000000000..3e568507b3 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/FileChip.stories.tsx @@ -0,0 +1,132 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fireEvent, fn, waitFor, within } from 'storybook/test'; +import { FILE_CATEGORIES } from 'twenty-shared/types'; +import { ComponentDecorator } from 'twenty-ui/testing'; + +import { FileChip } from '@/ui/field/display/components/FileChip'; + +const IMAGE_DATA_URI = + 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 16 16%22%3E%3Crect width=%2216%22 height=%2216%22 fill=%22%231D4ED8%22/%3E%3Ccircle cx=%228%22 cy=%228%22 r=%224%22 fill=%22%23FFFFFF%22/%3E%3C/svg%3E'; + +const BROKEN_IMAGE_URL = 'https://example.invalid/missing-thumbnail.png'; + +const meta: Meta = { + title: 'UI/Field/Display/FileChip', + component: FileChip, + decorators: [ComponentDecorator], + args: { + onClick: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +const getThumbnail = (canvasElement: HTMLElement) => { + const thumbnail = canvasElement.querySelector('img'); + + if (!(thumbnail instanceof HTMLImageElement)) { + throw new Error('Expected image thumbnail to render'); + } + + return thumbnail; +}; + +export const ImageThumbnail: Story = { + args: { + file: { + fileId: 'image-file-id', + label: 'Logo.png', + extension: 'png', + url: IMAGE_DATA_URI, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Logo.png')).toBeVisible(); + expect(getThumbnail(canvasElement)).toHaveAttribute('src', IMAGE_DATA_URI); + }, +}; + +export const NonImageFileIcon: Story = { + args: { + file: { + fileId: 'pdf-file-id', + label: 'Contract.pdf', + extension: 'pdf', + fileCategory: FILE_CATEGORIES.TEXT_DOCUMENT, + url: 'https://example.com/contract.pdf', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Contract.pdf')).toBeVisible(); + expect(canvasElement.querySelector('img')).toBeNull(); + expect(canvasElement.querySelector('svg')).not.toBeNull(); + }, +}; + +export const EmptyLabelFallback: Story = { + args: { + file: { + fileId: 'empty-label-file-id', + label: '', + extension: 'pdf', + fileCategory: FILE_CATEGORIES.TEXT_DOCUMENT, + url: 'https://example.com/legacy-file.pdf', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Untitled file')).toBeVisible(); + expect(canvasElement.querySelector('img')).toBeNull(); + }, +}; + +export const BrokenImageThumbnailFallback: Story = { + args: { + file: { + fileId: 'broken-image-file-id', + label: 'Expired image.png', + extension: 'png', + fileCategory: FILE_CATEGORIES.IMAGE, + url: BROKEN_IMAGE_URL, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const thumbnail = getThumbnail(canvasElement); + + expect(await canvas.findByText('Expired image.png')).toBeVisible(); + + fireEvent.error(thumbnail); + + await waitFor(() => { + expect(canvasElement.querySelector('img')).toBeNull(); + expect(canvasElement.querySelector('svg')).not.toBeNull(); + }); + }, +}; + +export const DeletedImageFile: Story = { + args: { + file: { + fileId: 'deleted-image-file-id', + label: 'Deleted logo.png', + extension: 'png', + fileCategory: FILE_CATEGORIES.IMAGE, + url: IMAGE_DATA_URI, + isDeleted: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Deleted logo.png')).toBeVisible(); + expect(canvasElement.querySelector('img')).toBeNull(); + }, +};