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>
This commit is contained in:
@@ -178,7 +178,7 @@ export const AttachmentRow = ({
|
||||
>
|
||||
<ActivityRow disabled>
|
||||
<StyledLeftContent>
|
||||
<FileIcon fileCategory={fileCategory} />
|
||||
<FileIcon fileCategory={fileCategory} thumbnailUrl={fileUrl} />
|
||||
{isEditing ? (
|
||||
<SettingsTextInput
|
||||
instanceId={`attachment-${attachment.id}-name`}
|
||||
|
||||
@@ -93,6 +93,7 @@ export const FileBlock = createReactBlockSpec(
|
||||
<StyledFileLine>
|
||||
<FileIcon
|
||||
fileCategory={block.props.fileCategory as AttachmentFileCategory}
|
||||
thumbnailUrl={safeUrl}
|
||||
/>
|
||||
<StyledLink
|
||||
href={safeUrl}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { type AttachmentFileCategory } from '@/activities/files/types/AttachmentFileCategory';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useFileIconColors } from '@/file/hooks/useFileIconColors';
|
||||
import { IconMapping } from '@/file/utils/fileIconMappings';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type FileCategory } from 'twenty-shared/types';
|
||||
import { AvatarOrIcon } from 'twenty-ui-deprecated/components';
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
ThemeContext,
|
||||
themeCssVariables,
|
||||
} from 'twenty-ui-deprecated/theme-constants';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useContext, useState } from 'react';
|
||||
import { FILE_CATEGORIES, type FileCategory } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { AvatarOrIcon } from 'twenty-ui/components';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type FileIconSize = 'small' | 'medium';
|
||||
|
||||
const THUMBNAIL_SIZE_PX_BY_FILE_ICON_SIZE: Record<FileIconSize, number> = {
|
||||
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<string>();
|
||||
|
||||
const shouldRenderThumbnail =
|
||||
fileCategory === FILE_CATEGORIES.IMAGE &&
|
||||
isNonEmptyString(thumbnailUrl) &&
|
||||
failedThumbnailUrl !== thumbnailUrl;
|
||||
|
||||
if (shouldRenderThumbnail) {
|
||||
return (
|
||||
<StyledThumbnail
|
||||
alt=""
|
||||
aria-hidden
|
||||
sizePx={THUMBNAIL_SIZE_PX_BY_FILE_ICON_SIZE[size]}
|
||||
src={thumbnailUrl}
|
||||
onError={() => setFailedThumbnailUrl(thumbnailUrl)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (size === 'small') {
|
||||
return (
|
||||
|
||||
@@ -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<typeof FileIcon> = {
|
||||
title: 'UI/File/FileIcon',
|
||||
component: FileIcon,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FileIcon>;
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
+1
@@ -36,6 +36,7 @@ export const FilesFieldMenuItem = ({
|
||||
getFileCategoryFromExtension(file.extension ?? '')
|
||||
}
|
||||
size="small"
|
||||
thumbnailUrl={file.url}
|
||||
/>
|
||||
}
|
||||
variant={ChipVariant.Rounded}
|
||||
|
||||
@@ -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 = (
|
||||
<FileIcon
|
||||
fileCategory={
|
||||
file.fileCategory ?? getFileCategoryFromExtension(file.extension ?? '')
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledClickableContainer
|
||||
<StyledClickableContainer
|
||||
clickable={isClickable}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<Chip
|
||||
label={label}
|
||||
alwaysShowTooltip={isDeleted}
|
||||
tooltipLabel={
|
||||
isDeleted ? t`File no longer exists - ${label}` : undefined
|
||||
}
|
||||
disabled={isDeleted}
|
||||
maxWidth={MAX_WIDTH}
|
||||
leftComponent={
|
||||
<FileIcon
|
||||
fileCategory={fileCategory}
|
||||
size="small"
|
||||
thumbnailUrl={isDeleted ? undefined : file.url}
|
||||
/>
|
||||
}
|
||||
variant={isDeleted ? ChipVariant.Static : ChipVariant.Highlighted}
|
||||
clickable={isClickable}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<Chip
|
||||
label={file.label}
|
||||
alwaysShowTooltip={isDeleted}
|
||||
tooltipLabel={
|
||||
isDeleted ? t`File no longer exists - ${file.label}` : undefined
|
||||
}
|
||||
disabled={isDeleted}
|
||||
maxWidth={MAX_WIDTH}
|
||||
leftComponent={fileIcon}
|
||||
variant={isDeleted ? ChipVariant.Static : ChipVariant.Highlighted}
|
||||
clickable={isClickable}
|
||||
/>
|
||||
</StyledClickableContainer>
|
||||
</>
|
||||
/>
|
||||
</StyledClickableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+132
@@ -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<typeof FileChip> = {
|
||||
title: 'UI/Field/Display/FileChip',
|
||||
component: FileChip,
|
||||
decorators: [ComponentDecorator],
|
||||
args: {
|
||||
onClick: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FileChip>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user