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