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:
Matt Van Horn
2026-06-12 05:04:06 -07:00
committed by GitHub
parent 214dc70b67
commit cb653e4ecc
7 changed files with 300 additions and 40 deletions
@@ -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);
},
};