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:
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user