Files field - add files field display and input + filtering (#17637)

This PR introduces a new FILES field type for Twenty CRM, allowing users
to attach multiple files to any record.

- Files field display
- Files field preview
- Files field input
- Files field filtering

To test : 
1/ need to activate feature flag `IS_FILES_FIELD_ENABLED` + create a new
FILES field

- display in read only, edit, inline, table
- edit
- filter
- export


closes https://github.com/twentyhq/core-team-issues/issues/2154


<img width="354" height="134" alt="Screenshot 2026-02-02 at 19 11 01"
src="https://github.com/user-attachments/assets/3c3de89e-f6b6-4526-b710-e4c7ee1a6d30"
/>
<img width="1081" height="933" alt="Screenshot 2026-02-02 at 19 10 41"
src="https://github.com/user-attachments/assets/7c9a7278-edb7-4d4a-882c-f7404689d011"
/>
<img width="1073" height="719" alt="Screenshot 2026-02-02 at 19 10 33"
src="https://github.com/user-attachments/assets/79cc372b-56a2-4cbf-b4fe-023adc4753a8"
/>
This commit is contained in:
Etienne
2026-02-04 11:55:48 +01:00
committed by GitHub
parent 9b8eacb7f7
commit 4fcc424e24
73 changed files with 1872 additions and 455 deletions
@@ -0,0 +1,49 @@
import { FileIcon } from '@/file/components/FileIcon';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { getFileCategoryFromExtension } from '@/object-record/record-field/ui/utils/getFileCategoryFromExtension';
import { isDefined } from 'twenty-shared/utils';
import { ChipVariant, LinkChip } from 'twenty-ui/components';
const MAX_WIDTH = 120;
type FileChipProps = {
file: FieldFilesValue;
onClick: (file: FieldFilesValue) => void;
forceDisableClick?: boolean;
};
export const FileChip = ({
file,
onClick,
forceDisableClick,
}: FileChipProps) => {
const handleClick = (event: React.MouseEvent): void => {
if (isDefined(forceDisableClick)) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick?.(file);
};
const fileIcon = (
<FileIcon
fileCategory={
file.fileCategory ?? getFileCategoryFromExtension(file.extension ?? '')
}
size="small"
/>
);
return (
<LinkChip
to="#"
label={file.label}
maxWidth={MAX_WIDTH}
leftComponent={fileIcon}
variant={ChipVariant.Highlighted}
onClick={forceDisableClick ? undefined : handleClick}
triggerEvent="CLICK"
/>
);
};
@@ -1,22 +1,48 @@
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { FileChip } from '@/ui/field/display/components/FileChip';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { t } from '@lingui/core/macro';
import { Chip, ChipVariant } from 'twenty-ui/components';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type FilesDisplayProps = {
value: FieldFilesValue;
value?: FieldFilesValue[];
forceDisableClick?: boolean;
};
//TODO: Draft version, UI to be improved
export const FilesDisplay = ({ value }: FilesDisplayProps) => {
export const FilesDisplay = ({
value,
forceDisableClick,
}: FilesDisplayProps) => {
const setFilePreview = useSetRecoilState(filePreviewState);
const isAttachmentPreviewEnabled = useRecoilValue(
isAttachmentPreviewEnabledState,
);
const handlePreview = (file: FieldFilesValue) => {
if (!isAttachmentPreviewEnabled) {
if (isDefined(file.url)) {
downloadFile(file.url, file.label ?? 'file');
}
return;
}
setFilePreview(file);
};
if (!isDefined(value) || value.length === 0) {
return <></>;
}
return (
<ExpandableList>
{value?.map((file, index) => (
<Chip
key={`${file.fileId}-${index}`}
variant={ChipVariant.Highlighted}
label={file.label}
emptyLabel={t`Untitled`}
{value.map((file) => (
<FileChip
key={file.fileId}
file={file}
onClick={handlePreview}
forceDisableClick={forceDisableClick}
/>
))}
</ExpandableList>
@@ -0,0 +1,147 @@
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { lazy, Suspense, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconDownload, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const DocumentViewer = lazy(() =>
import('@/activities/files/components/DocumentViewer').then((module) => ({
default: module.DocumentViewer,
})),
);
const GLOBAL_FILE_PREVIEW_MODAL_ID = 'global-file-preview-modal';
const StyledModalHeader = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
height: 60px;
justify-content: space-between;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(0, 4, 0, 4)};
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
width: 100%;
`;
const StyledModalTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledModalContent = styled.div`
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledLoadingContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
`;
const StyledLoadingText = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const GlobalFilePreviewModal = (): JSX.Element | null => {
const { t } = useLingui();
const [filePreview, setFilePreview] = useRecoilState(filePreviewState);
const { openModal, closeModal } = useModal();
useEffect(() => {
if (isDefined(filePreview)) {
openModal(GLOBAL_FILE_PREVIEW_MODAL_ID);
}
}, [filePreview, openModal]);
const handleClose = () => {
closeModal(GLOBAL_FILE_PREVIEW_MODAL_ID);
setFilePreview(null);
};
const handleDownload = () => {
if (!filePreview || !filePreview.url) return;
downloadFile(filePreview.url, filePreview.label ?? 'file');
};
if (!isDefined(filePreview)) {
return null;
}
return (
<>
{createPortal(
<Modal
modalId={GLOBAL_FILE_PREVIEW_MODAL_ID}
size="large"
isClosable
onClose={handleClose}
ignoreContainer
>
<StyledModalHeader>
<StyledHeader>
<StyledModalTitle>{filePreview.label}</StyledModalTitle>
<StyledButtonContainer>
<IconButton
Icon={IconDownload}
onClick={handleDownload}
size="small"
/>
<IconButton Icon={IconX} onClick={handleClose} size="small" />
</StyledButtonContainer>
</StyledHeader>
</StyledModalHeader>
<ScrollWrapper
componentInstanceId={`preview-modal-${filePreview.fileId ?? 'file'}`}
>
<StyledModalContent>
<Suspense
fallback={
<StyledLoadingContainer>
<StyledLoadingText>
{t`Loading document viewer...`}
</StyledLoadingText>
</StyledLoadingContainer>
}
>
<DocumentViewer
documentName={filePreview.label ?? t`Untitled`}
documentUrl={filePreview.url ?? ''}
documentExtension={filePreview.extension ?? ''}
/>
</Suspense>
</StyledModalContent>
</ScrollWrapper>
</Modal>,
document.body,
)}
</>
);
};
@@ -0,0 +1,7 @@
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { createState } from 'twenty-ui/utilities';
export const filePreviewState = createState<FieldFilesValue | null>({
key: 'filePreviewState',
defaultValue: null,
});
@@ -2,6 +2,7 @@ import { AuthModal } from '@/auth/components/AuthModal';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { AppPageErrorFallback } from '@/error-handler/components/AppPageErrorFallback';
import { FileUploadProvider } from '@/file-upload/components/FileUploadProvider';
import { InformationBannerIsImpersonating } from '@/information-banner/components/impersonate/InformationBannerIsImpersonating';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
@@ -73,54 +74,56 @@ export const DefaultLayout = () => {
}
`}
/>
<StyledLayout>
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
<InformationBannerIsImpersonating />
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAVIGATION_DRAWER_CONSTRAINTS.default +
76)) /
2
: 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
{!showAuthModal && <KeyboardShortcutMenu />}
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer />
)}
{showAuthModal ? (
<>
<FileUploadProvider>
<StyledLayout>
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
<InformationBannerIsImpersonating />
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAVIGATION_DRAWER_CONSTRAINTS.default +
76)) /
2
: 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
{!showAuthModal && <KeyboardShortcutMenu />}
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer />
)}
{showAuthModal ? (
<>
<StyledMainContainer>
<SignInBackgroundMockPage />
</StyledMainContainer>
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<StyledMainContainer>
<SignInBackgroundMockPage />
<AppErrorBoundary FallbackComponent={AppPageErrorFallback}>
<Outlet />
</AppErrorBoundary>
</StyledMainContainer>
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<StyledMainContainer>
<AppErrorBoundary FallbackComponent={AppPageErrorFallback}>
<Outlet />
</AppErrorBoundary>
</StyledMainContainer>
)}
</StyledPageContainer>
{isMobile && !showAuthModal && <MobileNavigationBar />}
</AppErrorBoundary>
</StyledLayout>
)}
</StyledPageContainer>
{isMobile && !showAuthModal && <MobileNavigationBar />}
</AppErrorBoundary>
</StyledLayout>
</FileUploadProvider>
</>
);
};