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(); + }, +};