From 4fcc424e246ce21af0c53b0c0ca5449071cfeb5b Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Wed, 4 Feb 2026 11:55:48 +0100
Subject: [PATCH] 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
---
.../src/generated-metadata/graphql.ts | 44 +++-
.../twenty-front/src/generated/graphql.ts | 1 -
.../files/components/DocumentViewer.tsx | 6 +-
.../app/components/AppRouterProviders.tsx | 2 +
.../components/FileUploadProvider.tsx | 96 ++++++++
.../file-upload/contexts/FileUploadContext.ts | 18 ++
.../file-upload/hooks/useFileUpload.ts | 12 +
.../src/modules/file-upload/index.ts | 8 +
.../src/modules/file/components/FileIcon.tsx | 37 ++-
.../graphql/mutations/uploadFilesFieldFile.ts | 12 +
.../getFilterFilterableFieldMetadataItems.ts | 1 +
.../utils/mapFieldMetadataToGraphQLQuery.ts | 12 +-
.../constants/FieldsNotOverwrittenAtDraft.ts | 1 +
.../constants/TextFilterTypes.ts | 1 +
.../hooks/useExportProcessRecordsForCSV.ts | 1 +
.../record-field/ui/components/FieldInput.tsx | 4 +
.../ui/components/FormFieldInput.tsx | 11 +
.../components/FormFilesFieldInput.tsx | 101 ++++++++
.../ui/hooks/useOpenFieldInputEditMode.ts | 39 +++
.../display/components/FilesFieldDisplay.tsx | 6 +-
.../perf/FilesFieldDisplay.perf.stories.tsx | 60 +++++
.../ui/meta-types/hooks/useFilesField.ts | 42 ++++
.../meta-types/hooks/useFilesFieldDisplay.ts | 6 +-
.../hooks/useUploadFilesFieldFile.ts | 55 +++++
.../input/components/FilesFieldInput.tsx | 207 ++++++++++++++++
.../input/components/FilesFieldMenuItem.tsx | 49 ++++
.../input/components/MultiItemFieldInput.tsx | 150 ++++++++----
.../components/MultiItemFieldMenuItem.tsx | 3 +
.../RelationOneToManyFieldInput.stories.tsx | 7 +-
.../input/hooks/useOpenFilesFieldInput.tsx | 179 ++++++++++++++
.../meta-types/utils/uploadMultipleFiles.ts | 18 ++
.../record-field/ui/types/FieldMetadata.ts | 9 +-
.../ui/types/guards/assertFieldMetadata.ts | 77 +++---
.../ui/types/guards/isFieldFilesValue.ts | 26 +-
.../ui/utils/getFileCategoryFromExtension.ts | 50 ++++
.../utils/getRecordFilterOperands.ts | 7 +
.../utils/isRecordMatchingFilter.ts | 8 +
.../__stories__/RecordTable.stories.tsx | 2 +
.../utils/sanitizeRecordInput.ts | 12 +
.../__stories__/FieldWidget.stories.tsx | 2 +
.../SettingsNonCompositeFieldTypeConfigs.ts | 2 +-
.../ui/field/display/components/FileChip.tsx | 49 ++++
.../field/display/components/FilesDisplay.tsx | 48 +++-
.../components/GlobalFilePreviewModal.tsx | 147 ++++++++++++
.../field/display/states/filePreviewState.ts | 7 +
.../layout/page/components/DefaultLayout.tsx | 95 ++++----
.../SettingsObjectNewFieldSelect.tsx | 11 +-
.../decorators/FileUploadDecorator.tsx | 8 +
.../enums/feature-flag-key.enum.ts | 1 -
.../zod-schemas/field-filters.zod-schema.ts | 1 +
...ate-files-flat-field-metadata.util.spec.ts | 15 --
...validate-files-flat-field-metadata.util.ts | 11 -
.../workspace-entity-manager.spec.ts | 1 -
...ject-metadata-to-schema-properties.util.ts | 7 +-
.../core/utils/seed-feature-flags.util.ts | 5 -
.../files-field-download.integration-spec.ts | 20 --
.../files-field-sync.integration-spec.ts | 20 --
...reate-input-validation.integration-spec.ts | 21 --
...ilter-input-validation.integration-spec.ts | 21 --
...es-field-metadata.integration-spec.ts.snap | 38 ---
...e-files-field-metadata.integration-spec.ts | 90 -------
...e-files-field-metadata.integration-spec.ts | 27 ---
.../src/types/FilterableFieldType.ts | 1 +
.../src/types/RecordGqlOperationFilter.ts | 6 +
packages/twenty-shared/src/types/index.ts | 1 +
.../twenty-shared/src/utils/filter/index.ts | 1 +
.../turnRecordFilterIntoGqlOperationFilter.ts | 22 ++
.../__tests__/isMatchingFilesFilter.test.ts | 227 ++++++++++++++++++
.../utils/getEmptyRecordGqlOperationFilter.ts | 1 +
.../utils/getFilterTypeFromFieldType.ts | 2 +
.../filter/utils/isMatchingFilesFilter.ts | 36 +++
packages/twenty-shared/src/utils/index.ts | 1 +
.../src/theme/constants/MainColorsLight.ts | 2 +-
73 files changed, 1872 insertions(+), 455 deletions(-)
create mode 100644 packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx
create mode 100644 packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts
create mode 100644 packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts
create mode 100644 packages/twenty-front/src/modules/file-upload/index.ts
create mode 100644 packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFilesFieldInput.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts
create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx
create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx
create mode 100644 packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts
create mode 100644 packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx
create mode 100644 packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts
create mode 100644 packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 66f84ce460..703dee6046 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -1424,7 +1424,6 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
- IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
@@ -5929,6 +5928,13 @@ export type DeleteFileMutationVariables = Exact<{
export type DeleteFileMutation = { __typename?: 'Mutation', deleteFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } };
+export type UploadFilesFieldFileMutationVariables = Exact<{
+ file: Scalars['Upload'];
+}>;
+
+
+export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } };
+
export type NavigationMenuItemFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string };
export type NavigationMenuItemQueryFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null };
@@ -10269,6 +10275,42 @@ export function useDeleteFileMutation(baseOptions?: Apollo.MutationHookOptions;
export type DeleteFileMutationResult = Apollo.MutationResult;
export type DeleteFileMutationOptions = Apollo.BaseMutationOptions;
+export const UploadFilesFieldFileDocument = gql`
+ mutation UploadFilesFieldFile($file: Upload!) {
+ uploadFilesFieldFile(file: $file) {
+ id
+ path
+ size
+ createdAt
+ }
+}
+ `;
+export type UploadFilesFieldFileMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useUploadFilesFieldFileMutation__
+ *
+ * To run a mutation, you first call `useUploadFilesFieldFileMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUploadFilesFieldFileMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [uploadFilesFieldFileMutation, { data, loading, error }] = useUploadFilesFieldFileMutation({
+ * variables: {
+ * file: // value for 'file'
+ * },
+ * });
+ */
+export function useUploadFilesFieldFileMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(UploadFilesFieldFileDocument, options);
+ }
+export type UploadFilesFieldFileMutationHookResult = ReturnType;
+export type UploadFilesFieldFileMutationResult = Apollo.MutationResult;
+export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions;
export const CreateNavigationMenuItemDocument = gql`
mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) {
createNavigationMenuItem(input: $input) {
diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts
index 184aaca8cf..6646b5d788 100644
--- a/packages/twenty-front/src/generated/graphql.ts
+++ b/packages/twenty-front/src/generated/graphql.ts
@@ -1396,7 +1396,6 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
- IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
diff --git a/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx b/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx
index 3175c957a1..53be3e97d6 100644
--- a/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx
+++ b/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx
@@ -82,6 +82,7 @@ const StyledTitle = styled.div`
type DocumentViewerProps = {
documentName: string;
documentUrl: string;
+ documentExtension?: string;
};
// MS Office Online viewer requires documents to be publicly accessible from the internet.
@@ -160,13 +161,16 @@ const MIME_TYPE_MAPPING: Record<
export const DocumentViewer = ({
documentName,
documentUrl,
+ documentExtension,
}: DocumentViewerProps) => {
const { t } = useLingui();
const theme = useTheme();
const [csvPreview, setCsvPreview] = useState(undefined);
const { extension } = getFileNameAndExtension(documentName);
- const fileExtension = extension?.toLowerCase().replace('.', '') ?? '';
+ const fileExtension = isDefined(documentExtension)
+ ? documentExtension.toLowerCase().replace('.', '')
+ : (extension?.toLowerCase().replace('.', '') ?? '');
const fileCategory = getFileType(documentName);
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
const isMsOfficeFile = MS_OFFICE_EXTENSIONS.includes(fileExtension);
diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx
index 0203f5b8ca..5cc818f27d 100644
--- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx
+++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx
@@ -19,6 +19,7 @@ import { SupportChatEffect } from '@/support/components/SupportChatEffect';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogComponentInstanceContext } from '@/ui/feedback/dialog-manager/contexts/DialogComponentInstanceContext';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
+import { GlobalFilePreviewModal } from '@/ui/field/display/components/GlobalFilePreviewModal';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
@@ -64,6 +65,7 @@ export const AppRouterProviders = () => {
+
diff --git a/packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx b/packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx
new file mode 100644
index 0000000000..ccd4a8e900
--- /dev/null
+++ b/packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx
@@ -0,0 +1,96 @@
+import {
+ FileUploadContext,
+ type FileUploadOptions,
+} from '@/file-upload/contexts/FileUploadContext';
+import styled from '@emotion/styled';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+
+const StyledFileInput = styled.input`
+ display: none;
+`;
+
+export const FileUploadProvider = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ const fileInputRef = useRef(null);
+ const [uploadOptions, setUploadOptions] = useState(
+ null,
+ );
+
+ const openFileUpload = useCallback((options: FileUploadOptions) => {
+ setUploadOptions(options);
+
+ setTimeout(() => {
+ fileInputRef.current?.click();
+ }, 0);
+ }, []);
+
+ const handleFileInputChange = useCallback(
+ async (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ const currentOptions = uploadOptions;
+
+ if (!isDefined(currentOptions)) {
+ return;
+ }
+
+ try {
+ if (!isDefined(files) || files.length === 0) {
+ currentOptions.onCancel?.();
+ } else {
+ const filesArray = Array.from(files);
+ await currentOptions.onUpload(filesArray);
+ }
+ } finally {
+ if (isDefined(fileInputRef.current)) {
+ fileInputRef.current.value = '';
+ }
+ setUploadOptions(null);
+ }
+ },
+ [uploadOptions],
+ );
+
+ const handleFileInputCancel = useCallback(() => {
+ const currentOptions = uploadOptions;
+
+ if (!isDefined(currentOptions)) {
+ return;
+ }
+
+ try {
+ currentOptions.onCancel?.();
+ } finally {
+ if (isDefined(fileInputRef.current)) {
+ fileInputRef.current.value = '';
+ }
+ setUploadOptions(null);
+ }
+ }, [uploadOptions]);
+
+ useEffect(() => {
+ const input = fileInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.addEventListener('cancel', handleFileInputCancel);
+ return () => input.removeEventListener('cancel', handleFileInputCancel);
+ }, [handleFileInputCancel]);
+
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts b/packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts
new file mode 100644
index 0000000000..ec07a5de14
--- /dev/null
+++ b/packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts
@@ -0,0 +1,18 @@
+import { createContext } from 'react';
+
+export type FileUploadCallback = (files: File[]) => void | Promise;
+
+export type FileUploadOptions = {
+ multiple?: boolean;
+ accept?: string;
+ onUpload: FileUploadCallback;
+ onCancel?: () => void;
+};
+
+export type FileUploadContextValue = {
+ openFileUpload: (options: FileUploadOptions) => void;
+};
+
+export const FileUploadContext = createContext(
+ null,
+);
diff --git a/packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts b/packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts
new file mode 100644
index 0000000000..6de581c748
--- /dev/null
+++ b/packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts
@@ -0,0 +1,12 @@
+import { FileUploadContext } from '@/file-upload/contexts/FileUploadContext';
+import { useContext } from 'react';
+
+export const useFileUpload = () => {
+ const context = useContext(FileUploadContext);
+
+ if (!context) {
+ throw new Error('useFileUpload must be used within a FileUploadProvider');
+ }
+
+ return context;
+};
diff --git a/packages/twenty-front/src/modules/file-upload/index.ts b/packages/twenty-front/src/modules/file-upload/index.ts
new file mode 100644
index 0000000000..1a568781d2
--- /dev/null
+++ b/packages/twenty-front/src/modules/file-upload/index.ts
@@ -0,0 +1,8 @@
+export { FileUploadProvider } from './components/FileUploadProvider';
+export { FileUploadContext } from './contexts/FileUploadContext';
+export type {
+ FileUploadCallback,
+ FileUploadContextValue,
+ FileUploadOptions,
+} from './contexts/FileUploadContext';
+export { useFileUpload } from './hooks/useFileUpload';
diff --git a/packages/twenty-front/src/modules/file/components/FileIcon.tsx b/packages/twenty-front/src/modules/file/components/FileIcon.tsx
index 6330ae2b70..b485c03d25 100644
--- a/packages/twenty-front/src/modules/file/components/FileIcon.tsx
+++ b/packages/twenty-front/src/modules/file/components/FileIcon.tsx
@@ -1,33 +1,56 @@
import { type AttachmentFileCategory } from '@/activities/files/types/AttachmentFileCategory';
-import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
import { IconMapping } from '@/file/utils/fileIconMappings';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import { type FileCategory } from 'twenty-shared/types';
-const StyledIconContainer = styled.div<{ background: string }>`
+type FileIconSize = 'small' | 'medium';
+
+const StyledIconContainer = styled.div<{
+ background: string;
+ size: FileIconSize;
+}>`
align-items: center;
background: ${({ background }) => background};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.grayScale.gray1};
display: flex;
flex-shrink: 0;
+ height: ${({ size }) => (size === 'small' ? '14px' : 'auto')};
justify-content: center;
- padding: ${({ theme }) => theme.spacing(1.25)};
+ padding: ${({ theme, size }) =>
+ size === 'small' ? '0' : theme.spacing(1.25)};
+ width: ${({ size }) => (size === 'small' ? '14px' : 'auto')};
`;
export const FileIcon = ({
fileCategory,
+ size = 'medium',
}: {
- fileCategory: AttachmentFileCategory;
+ fileCategory: AttachmentFileCategory | FileCategory;
+ size?: FileIconSize;
}) => {
const theme = useTheme();
- const iconColors = useFileCategoryColors();
+
+ const iconColors = {
+ ARCHIVE: theme.color.gray,
+ AUDIO: theme.color.pink,
+ IMAGE: theme.color.amber,
+ PRESENTATION: theme.color.orange,
+ SPREADSHEET: theme.color.turquoise,
+ TEXT_DOCUMENT: theme.color.blue,
+ VIDEO: theme.color.purple,
+ OTHER: theme.color.gray,
+ };
const Icon = IconMapping[fileCategory];
return (
-
- {Icon && }
+
+ {Icon && }
);
};
diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts b/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts
new file mode 100644
index 0000000000..853c6b950f
--- /dev/null
+++ b/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts
@@ -0,0 +1,12 @@
+import { gql } from '@apollo/client';
+
+export const UPLOAD_FILES_FIELD_FILE = gql`
+ mutation UploadFilesFieldFile($file: Upload!) {
+ uploadFilesFieldFile(file: $file) {
+ id
+ path
+ size
+ createdAt
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts
index dc397cb1ec..f75e5cb933 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts
@@ -39,6 +39,7 @@ export const getFilterFilterableFieldMetadataItems = ({
FieldMetadataType.PHONES,
FieldMetadataType.ARRAY,
FieldMetadataType.UUID,
+ FieldMetadataType.FILES,
...(isJsonFilterEnabled ? [FieldMetadataType.RAW_JSON] : []),
].includes(field.type);
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts
index b35a3fe288..ff2076893d 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts
@@ -1,13 +1,13 @@
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { isNonCompositeField } from '@/object-record/object-filter-dropdown/utils/isNonCompositeField';
import { type ObjectPermissions } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
-import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
type MapFieldMetadataToGraphQLQueryArgs = {
objectMetadataItems: ObjectMetadataItem[];
@@ -297,6 +297,16 @@ ${mapObjectMetadataToGraphQLQuery({
}`;
}
+ if (fieldType === FieldMetadataType.FILES) {
+ return `${gqlField}
+ {
+ fileId
+ label
+ extension
+ url
+ }`;
+ }
+
if (fieldType === FieldMetadataType.RICH_TEXT_V2) {
return `${gqlField}
{
diff --git a/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts b/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts
index 3419cc4706..d3047e736b 100644
--- a/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts
+++ b/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts
@@ -7,4 +7,5 @@ export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RATING,
FieldMetadataType.SELECT,
+ FieldMetadataType.FILES,
];
diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts
index f8eb69a50e..6dd61ea1ce 100644
--- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts
+++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts
@@ -7,5 +7,6 @@ export const TEXT_FILTER_TYPES = [
'LINKS',
'ARRAY',
'RAW_JSON',
+ 'FILES',
'UUID',
];
diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts
index 2a9c173af7..817d6ea910 100644
--- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts
+++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts
@@ -32,6 +32,7 @@ export const useExportProcessRecordsForCSV = (objectNameSingular: string) => {
case FieldMetadataType.MULTI_SELECT:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RAW_JSON:
+ case FieldMetadataType.FILES:
return {
...processedRecord,
[field.name]: JSON.stringify(record[field.name]),
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx
index 65c2d20500..3cfaf0fc36 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx
@@ -3,6 +3,7 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/AddressFieldInput';
import { DateFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/DateFieldInput';
import { EmailsFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput';
+import { FilesFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/FilesFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput';
@@ -24,6 +25,7 @@ import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/is
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
@@ -64,6 +66,8 @@ export const FieldInput = () => {
) : isFieldEmails(fieldDefinition) ? (
+ ) : isFieldFiles(fieldDefinition) ? (
+
) : isFieldFullName(fieldDefinition) ? (
) : isFieldDateTime(fieldDefinition) ? (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx
index d16e17c09e..b718f6ae02 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx
@@ -5,6 +5,7 @@ import { FormCurrencyFieldInput } from '@/object-record/record-field/ui/form-typ
import { FormDateFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateFieldInput';
import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput';
import { FormEmailsFieldInput } from '@/object-record/record-field/ui/form-types/components/FormEmailsFieldInput';
+import { FormFilesFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFilesFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/ui/form-types/components/FormLinksFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
@@ -39,6 +40,7 @@ import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/is
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect';
@@ -145,6 +147,15 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
readonly={readonly}
/>
+ ) : isFieldFiles(field) ? (
+
) : isFieldPhones(field) ? (
void;
+ onBlur?: () => void;
+ readonly?: boolean;
+ VariablePicker?: VariablePickerComponent;
+ placeholder?: string;
+};
+
+export const FormFilesFieldInput = ({
+ label,
+ error,
+ defaultValue,
+ placeholder,
+ onChange,
+ onBlur,
+ readonly,
+ VariablePicker,
+}: FormFilesFieldInputProps) => {
+ const instanceId = useId();
+
+ const stringDefaultValue =
+ typeof defaultValue === 'string'
+ ? defaultValue
+ : defaultValue
+ ? JSON.stringify(defaultValue)
+ : undefined;
+
+ const editor = useTextVariableEditor({
+ placeholder: placeholder ?? t`Enter files as JSON array`,
+ multiline: true,
+ readonly,
+ defaultValue: stringDefaultValue,
+ onUpdate: (editor) => {
+ const text = turnIntoEmptyStringIfWhitespacesOnly(editor.getText());
+
+ if (text === '') {
+ onChange(null);
+
+ return;
+ }
+
+ onChange(text);
+ },
+ });
+
+ const handleVariableTagInsert = (variableName: string) => {
+ if (!isDefined(editor)) {
+ throw new Error(
+ 'Expected the editor to be defined when a variable is selected',
+ );
+ }
+
+ editor.commands.insertVariableTag(variableName);
+ };
+
+ if (!isDefined(editor)) {
+ return null;
+ }
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+
+
+
+ {VariablePicker && !readonly && (
+
+ )}
+
+ {error}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts
index 0ee8c80be5..b444c6a5a3 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts
@@ -6,7 +6,9 @@ import { type TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
+import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useOpenJunctionRelationFieldInput } from '@/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput';
+import { useOpenFilesFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput';
import { useOpenMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationManyToOneFieldInput';
import { useOpenMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput';
import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput';
@@ -18,6 +20,7 @@ import {
type FieldRelationMetadata,
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
@@ -50,6 +53,10 @@ export const useOpenFieldInputEditMode = () => {
const { openMorphRelationManyToOneFieldInput } =
useOpenMorphRelationManyToOneFieldInput();
+ const { openFilesFieldInput } = useOpenFilesFieldInput();
+
+ const { updateOneRecord } = useUpdateOneRecord();
+
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const openFieldInput = useRecoilCallback(
@@ -81,6 +88,36 @@ export const useOpenFieldInputEditMode = () => {
fieldDefinition.metadata.settings,
);
+ // Handle Files field with custom behavior for empty state
+ if (isFieldFiles(fieldDefinition)) {
+ const objectMetadataItem = objectMetadataItems.find(
+ (item) =>
+ item.nameSingular ===
+ fieldDefinition.metadata.objectMetadataNameSingular,
+ );
+
+ if (isDefined(objectMetadataItem)) {
+ openFilesFieldInput({
+ fieldName: fieldDefinition.metadata.fieldName,
+ recordId,
+ prefix,
+ updateRecord: (updateInput) => {
+ updateOneRecord({
+ objectNameSingular: objectMetadataItem.nameSingular,
+ idToUpdate: recordId,
+ updateOneRecordInput: updateInput,
+ });
+ },
+ fieldDefinition: {
+ metadata: {
+ settings: fieldDefinition.metadata.settings ?? undefined,
+ },
+ },
+ });
+ return;
+ }
+ }
+
if (
isJunctionRelationsEnabled &&
isOneToMany &&
@@ -203,12 +240,14 @@ export const useOpenFieldInputEditMode = () => {
},
[
openActivityTargetCellEditMode,
+ openFilesFieldInput,
openJunctionRelationFieldInput,
openMorphRelationManyToOneFieldInput,
openMorphRelationOneToManyFieldInput,
openRelationFromManyFieldInput,
openRelationToOneFieldInput,
pushFocusItemToFocusStack,
+ updateOneRecord,
],
);
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx
index bbd3de8907..a87934aa77 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx
@@ -2,11 +2,13 @@ import { useFilesFieldDisplay } from '@/object-record/record-field/ui/meta-types
import { FilesDisplay } from '@/ui/field/display/components/FilesDisplay';
export const FilesFieldDisplay = () => {
- const { fieldValue } = useFilesFieldDisplay();
+ const { fieldValue, disableChipClick } = useFilesFieldDisplay();
if (!Array.isArray(fieldValue)) {
return <>>;
}
- return ;
+ return (
+
+ );
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx
new file mode 100644
index 0000000000..7d26b57812
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx
@@ -0,0 +1,60 @@
+import { type Meta, type StoryObj } from '@storybook/react-vite';
+
+import { FilesDisplay } from '@/ui/field/display/components/FilesDisplay';
+import { ComponentDecorator } from 'twenty-ui/testing';
+import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
+import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
+import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Display/FilesFieldDisplay',
+ decorators: [MemoryRouterDecorator, ComponentDecorator, SnackBarDecorator],
+ component: FilesDisplay,
+ args: {
+ value: [
+ {
+ fileId: 'file-1',
+ label: 'contract.pdf',
+ extension: '.pdf',
+ url: 'https://example.com/contract.pdf',
+ fileCategory: 'TEXT_DOCUMENT',
+ },
+ {
+ fileId: 'file-2',
+ label: 'invoice.xlsx',
+ extension: '.xlsx',
+ url: 'https://example.com/invoice.xlsx',
+ fileCategory: 'SPREADSHEET',
+ },
+ {
+ fileId: 'file-3',
+ label: 'logo.png',
+ extension: '.png',
+ url: 'https://example.com/logo.png',
+ fileCategory: 'IMAGE',
+ },
+ ],
+ },
+ parameters: {
+ chromatic: { disableSnapshot: true },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Elipsis: Story = {
+ parameters: {
+ container: { width: 50 },
+ },
+};
+
+export const Performance = getProfilingStory({
+ componentName: 'FilesFieldDisplay',
+ averageThresholdInMs: 0.8,
+ numberOfRuns: 50,
+ numberOfTestsPerRun: 100,
+});
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts
new file mode 100644
index 0000000000..eb4bea3664
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts
@@ -0,0 +1,42 @@
+import { useContext } from 'react';
+import { useRecoilState } from 'recoil';
+
+import { useRecordFieldInput } from '@/object-record/record-field/ui/hooks/useRecordFieldInput';
+import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
+import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
+
+import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
+import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/ui/states/recordFieldInputDraftValueComponentState';
+import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
+import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
+
+export const useFilesField = () => {
+ const { recordId, fieldDefinition } = useContext(FieldContext);
+
+ assertFieldMetadata(FieldMetadataType.FILES, isFieldFiles, fieldDefinition);
+
+ const fieldName = fieldDefinition.metadata.fieldName;
+
+ const [fieldValue, setFieldValue] = useRecoilState(
+ recordStoreFamilySelector({
+ recordId,
+ fieldName: fieldName,
+ }),
+ );
+
+ const { setDraftValue } = useRecordFieldInput();
+
+ const draftValue = useRecoilComponentValue(
+ recordFieldInputDraftValueComponentState,
+ );
+
+ return {
+ fieldDefinition,
+ fieldValue,
+ draftValue,
+ setDraftValue,
+ setFieldValue,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts
index aa17427579..fac1d57a40 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts
@@ -9,11 +9,12 @@ import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecor
import { useContext } from 'react';
export const useFilesFieldDisplay = () => {
- const { recordId, fieldDefinition } = useContext(FieldContext);
+ const { recordId, fieldDefinition, disableChipClick } =
+ useContext(FieldContext);
const { fieldName } = fieldDefinition.metadata;
- const fieldValue = useRecordFieldValue(
+ const fieldValue = useRecordFieldValue(
recordId,
fieldName,
fieldDefinition,
@@ -22,5 +23,6 @@ export const useFilesFieldDisplay = () => {
return {
fieldDefinition: fieldDefinition as FieldDefinition,
fieldValue,
+ disableChipClick,
};
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
new file mode 100644
index 0000000000..ae12f0d6f1
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
@@ -0,0 +1,55 @@
+import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useLingui } from '@lingui/react/macro';
+import { isDefined } from 'twenty-shared/utils';
+import { useUploadFilesFieldFileMutation } from '~/generated-metadata/graphql';
+
+const DEFAULT_VALUE_BEFORE_SERVER_RESPONSE =
+ 'default-value-before-server-response';
+
+export const useUploadFilesFieldFile = () => {
+ const coreClient = useApolloCoreClient();
+ const [uploadFilesFieldFile] = useUploadFilesFieldFileMutation({
+ client: coreClient,
+ });
+ const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
+ const { t } = useLingui();
+
+ const uploadFile = async (file: File) => {
+ try {
+ const result = await uploadFilesFieldFile({
+ variables: { file },
+ });
+
+ const uploadedFile = result?.data?.uploadFilesFieldFile;
+
+ if (!isDefined(uploadedFile)) {
+ throw new Error(t`File upload failed`);
+ }
+
+ const fileName = file.name;
+ enqueueSuccessSnackBar({
+ message: t`File "${fileName}" uploaded successfully`,
+ });
+
+ return {
+ fileId: uploadedFile.id,
+ label: file.name,
+ extension: DEFAULT_VALUE_BEFORE_SERVER_RESPONSE,
+ url: DEFAULT_VALUE_BEFORE_SERVER_RESPONSE,
+ };
+ } catch (error) {
+ const fileNameForError = file.name;
+ const errorMessage = String(error);
+ enqueueErrorSnackBar({
+ message: t`Failed to upload "${fileNameForError}"`,
+ });
+
+ throw new Error(
+ t`Failed to upload file "${fileNameForError}": ${errorMessage}`,
+ );
+ }
+ };
+
+ return { uploadFile };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx
new file mode 100644
index 0000000000..dfed2191b8
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx
@@ -0,0 +1,207 @@
+import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
+import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
+import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
+import { useFilesField } from '@/object-record/record-field/ui/meta-types/hooks/useFilesField';
+import { useUploadFilesFieldFile } from '@/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile';
+import { FilesFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem';
+import { MultiItemFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput';
+import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
+import { uploadMultipleFiles } from '@/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles';
+import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
+import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
+import { filesSchema } from '@/object-record/record-field/ui/types/guards/isFieldFilesValue';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
+import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
+import { useLingui } from '@lingui/react/macro';
+import { useCallback, useContext, useMemo, useState } from 'react';
+import { useRecoilValue, useSetRecoilState } from 'recoil';
+import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
+import { isDefined } from 'twenty-shared/utils';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
+
+export const FilesFieldInput = () => {
+ const { setDraftValue, draftValue, fieldDefinition } = useFilesField();
+ const { uploadFile } = useUploadFilesFieldFile();
+ const { openFileUpload } = useFileUpload();
+ const { t } = useLingui();
+ const [isUploading, setIsUploading] = useState(false);
+ const { enqueueErrorSnackBar } = useSnackBar();
+ const setFilePreview = useSetRecoilState(filePreviewState);
+ const isAttachmentPreviewEnabled = useRecoilValue(
+ isAttachmentPreviewEnabledState,
+ );
+
+ const { onEscape, onClickOutside, onEnter } = useContext(
+ FieldInputEventContext,
+ );
+
+ const parseFilesArrayToFilesValue = useCallback(
+ (filesArray: FieldFilesValue[]) => {
+ const parseResponse = filesSchema.safeParse(filesArray);
+ if (parseResponse.success) {
+ return parseResponse.data;
+ }
+ return [];
+ },
+ [],
+ );
+
+ const files = useMemo(
+ () => (draftValue ?? []) as FieldFilesValue[],
+ [draftValue],
+ );
+
+ const maxNumberOfValues =
+ fieldDefinition.metadata.settings?.maxNumberOfValues ??
+ MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
+
+ const handleChange = useCallback(
+ (updatedFiles: FieldFilesValue[]) => {
+ const nextValue = parseFilesArrayToFilesValue(updatedFiles);
+ if (isDefined(nextValue)) {
+ setDraftValue(nextValue);
+ }
+ },
+ [parseFilesArrayToFilesValue, setDraftValue],
+ );
+
+ const handleUploadClick = useCallback(() => {
+ if (isUploading) {
+ return;
+ }
+
+ openFileUpload({
+ multiple: true,
+ onUpload: async (selectedFiles: File[]) => {
+ if (
+ selectedFiles.length > maxNumberOfValues - files.length &&
+ files.length > 0
+ ) {
+ enqueueErrorSnackBar({
+ message: t`Cannot upload more than ${maxNumberOfValues} files`,
+ });
+ return;
+ }
+
+ setIsUploading(true);
+
+ try {
+ const uploadedFiles = await uploadMultipleFiles(
+ selectedFiles,
+ uploadFile,
+ );
+
+ if (uploadedFiles.length > 0) {
+ const newFiles = [...files, ...uploadedFiles];
+ handleChange(newFiles);
+ onEnter?.({ newValue: parseFilesArrayToFilesValue(newFiles) });
+ }
+ } finally {
+ setIsUploading(false);
+ }
+ },
+ });
+ }, [
+ isUploading,
+ openFileUpload,
+ files,
+ maxNumberOfValues,
+ enqueueErrorSnackBar,
+ t,
+ uploadFile,
+ handleChange,
+ onEnter,
+ parseFilesArrayToFilesValue,
+ ]);
+
+ const setIsFieldInError = useSetRecoilComponentState(
+ recordFieldInputIsFieldInErrorComponentState,
+ );
+
+ const handleError = (hasError: boolean, values: FieldFilesValue[]) => {
+ setIsFieldInError(hasError && values.length === 0);
+ };
+
+ const handleClickOutside = (
+ updatedFiles: FieldFilesValue[],
+ event: MouseEvent | TouchEvent,
+ ) => {
+ onClickOutside?.({
+ newValue: parseFilesArrayToFilesValue(updatedFiles),
+ event,
+ });
+ };
+
+ const handleEscape = (updatedFiles: FieldFilesValue[]) => {
+ onEscape?.({ newValue: parseFilesArrayToFilesValue(updatedFiles) });
+ };
+
+ const handleEnter = (updatedFiles: FieldFilesValue[]) => {
+ onEnter?.({ newValue: parseFilesArrayToFilesValue(updatedFiles) });
+ };
+
+ const handlePreview = (file: FieldFilesValue) => {
+ if (!isAttachmentPreviewEnabled) return;
+ setFilePreview(file);
+ };
+
+ const validateInput = useCallback(
+ (input: string) => ({
+ isValid: input.trim().length > 0,
+ errorMessage: '',
+ }),
+ [],
+ );
+
+ const formatInput = useCallback(
+ (_input: string, index?: number): FieldFilesValue => {
+ if (
+ index !== undefined &&
+ index >= 0 &&
+ index < files.length &&
+ isDefined(files)
+ ) {
+ const fileToEdit = files[index];
+ return {
+ ...fileToEdit,
+ label: _input.trim(),
+ };
+ }
+ throw new Error('Cannot create file from text input');
+ },
+ [files],
+ );
+
+ if (files.length === 0) {
+ return null;
+ }
+
+ return (
+ (
+ handlePreview(file)}
+ />
+ )}
+ newItemLabel={isUploading ? t`Uploading...` : t`Upload file`}
+ onAddClick={handleUploadClick}
+ onError={handleError}
+ maxItemCount={maxNumberOfValues}
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx
new file mode 100644
index 0000000000..cb4a2e875d
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx
@@ -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 { Chip, ChipVariant } from 'twenty-ui/components';
+import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
+
+type FilesFieldMenuItemProps = {
+ dropdownId: string;
+ onEdit?: () => void;
+ onDelete?: () => void;
+ onClick?: () => void;
+ file: FieldFilesValue;
+};
+
+export const FilesFieldMenuItem = ({
+ dropdownId,
+ onEdit,
+ onDelete,
+ onClick,
+ file,
+}: FilesFieldMenuItemProps) => {
+ return (
+ (
+
+ }
+ variant={ChipVariant.Rounded}
+ />
+ )}
+ showPrimaryIcon={false}
+ showSetAsPrimaryButton={false}
+ showCopyButton={false}
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
index bc8e1b50b7..7b8edcfa1b 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
@@ -1,5 +1,6 @@
-import React, { useRef, useState } from 'react';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
+import { useDebounce } from 'use-debounce';
import {
MultiItemBaseInput,
@@ -9,6 +10,7 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel
import { type PhoneRecord } from '@/object-record/record-field/ui/types/FieldMetadata';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { currentFocusedItemSelector } from '@/ui/utilities/focus/states/currentFocusedItemSelector';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -16,13 +18,14 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilValue } from 'recoil';
-import { CustomError } from 'twenty-shared/utils';
+import { CustomError, isDefined } from 'twenty-shared/utils';
import { IconCheck, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced';
+import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
type MultiItemFieldInputProps = {
@@ -34,7 +37,7 @@ type MultiItemFieldInputProps = {
onError?: (hasError: boolean, values: any[]) => void;
placeholder: string;
validateInput?: (input: string) => { isValid: boolean; errorMessage: string };
- formatInput?: (input: string) => T;
+ formatInput?: (input: string, itemIndex?: number) => T;
renderItem: (props: {
value: T;
index: number;
@@ -43,6 +46,7 @@ type MultiItemFieldInputProps = {
handleDelete: () => void;
}) => React.ReactNode;
newItemLabel?: string;
+ onAddClick?: () => void;
fieldMetadataType: FieldMetadataType;
renderInput?: MultiItemBaseInputProps['renderInput'];
maxItemCount?: number;
@@ -61,6 +65,7 @@ export const MultiItemFieldInput = ({
formatInput,
renderItem,
newItemLabel,
+ onAddClick,
fieldMetadataType,
renderInput,
onClickOutside,
@@ -95,43 +100,51 @@ export const MultiItemFieldInput = ({
listenerId: instanceId,
});
- const getItemValueAsString = (index: number): string => {
- if (index >= items.length) {
- return '';
- }
+ const getItemValueAsString = useCallback(
+ (index: number): string => {
+ if (index >= items.length) {
+ return '';
+ }
- let item;
- switch (fieldMetadataType) {
- case FieldMetadataType.LINKS:
- item = items[index] as { label: string; url: string };
- return item.url || '';
- case FieldMetadataType.PHONES:
- item = items[index] as PhoneRecord;
- return item.callingCode + item.number;
- case FieldMetadataType.EMAILS:
- item = items[index] as string;
- return item;
- case FieldMetadataType.ARRAY:
- item = items[index] as string;
- return item;
- default:
- throw new CustomError(
- `Unsupported field type: ${fieldMetadataType}`,
- 'UNSUPPORTED_FIELD_TYPE',
- );
- }
- };
+ let item;
+ switch (fieldMetadataType) {
+ case FieldMetadataType.LINKS:
+ item = items[index] as { label: string; url: string };
+ return item.url || '';
+ case FieldMetadataType.PHONES:
+ item = items[index] as PhoneRecord;
+ return item.callingCode + item.number;
+ case FieldMetadataType.EMAILS:
+ item = items[index] as string;
+ return item;
+ case FieldMetadataType.ARRAY:
+ item = items[index] as string;
+ return item;
+ case FieldMetadataType.FILES:
+ item = items[index] as { label: string };
+ return item.label || '';
+ default:
+ throw new CustomError(
+ `Unsupported field type: ${fieldMetadataType}`,
+ 'UNSUPPORTED_FIELD_TYPE',
+ );
+ }
+ },
+ [items, fieldMetadataType],
+ );
const shouldAutoEnterBecauseOnlyOneItemIsAllowed = maxItemCount === 1;
const shouldAutoEditFirstItemOnOpen =
items.length === 0 || maxItemCount === 1;
const [isInputDisplayed, setIsInputDisplayed] = useState(
- shouldAutoEditFirstItemOnOpen,
+ shouldAutoEditFirstItemOnOpen && !isDefined(onAddClick),
);
const [inputValue, setInputValue] = useState(
- shouldAutoEditFirstItemOnOpen ? getItemValueAsString(0) : '',
+ shouldAutoEditFirstItemOnOpen && !isDefined(onAddClick)
+ ? getItemValueAsString(0)
+ : '',
);
const [itemToEditIndex, setItemToEditIndex] = useState(0);
@@ -142,6 +155,22 @@ export const MultiItemFieldInput = ({
errorMessage: '',
});
+ const [searchFilter, setSearchFilter] = useState('');
+ const [debouncedSearchFilter] = useDebounce(searchFilter, 150);
+
+ const shouldShowSearch = items.length > 3;
+
+ const filteredItems = useMemo(() => {
+ if (!shouldShowSearch || !debouncedSearchFilter) {
+ return items;
+ }
+ const searchTerm = normalizeSearchText(debouncedSearchFilter);
+ return items.filter((_item, index) => {
+ const itemText = getItemValueAsString(index);
+ return normalizeSearchText(itemText).includes(searchTerm);
+ });
+ }, [items, debouncedSearchFilter, shouldShowSearch, getItemValueAsString]);
+
const isLimitReached =
typeof maxItemCount === 'number' && items.length >= maxItemCount;
@@ -162,6 +191,11 @@ export const MultiItemFieldInput = ({
return;
}
+ if (isDefined(onAddClick)) {
+ onAddClick();
+ return;
+ }
+
setIsAddingNewItem(true);
setInputValue('');
setIsInputDisplayed(true);
@@ -204,10 +238,6 @@ export const MultiItemFieldInput = ({
} => {
const sanitizedInput = inputValue.trim();
- const newItem = formatInput
- ? formatInput(sanitizedInput)
- : (sanitizedInput as unknown as T);
-
if (sanitizedInput === '' && isAddingNewItem) {
return { isValid: true, updatedItems: items };
}
@@ -227,6 +257,13 @@ export const MultiItemFieldInput = ({
};
}
+ const newItem = formatInput
+ ? formatInput(
+ sanitizedInput,
+ isAddingNewItem ? undefined : itemToEditIndex,
+ )
+ : (sanitizedInput as unknown as T);
+
if (validateInput !== undefined) {
const validationData = validateInput(sanitizedInput) ?? { isValid: true };
if (!validationData.isValid) {
@@ -252,8 +289,14 @@ export const MultiItemFieldInput = ({
const handleDeleteItem = (index: number) => {
const updatedItems = toSpliced(items, index, 1);
onChange(updatedItems);
- setIsInputDisplayed(false);
+
+ const shouldShowInputAfterDeletion =
+ updatedItems.length === 0 && !isDefined(onAddClick);
+ setIsInputDisplayed(shouldShowInputAfterDeletion);
setIsAddingNewItem(false);
+ if (shouldShowInputAfterDeletion) {
+ setInputValue('');
+ }
};
const handleEscape = () => {
@@ -269,31 +312,46 @@ export const MultiItemFieldInput = ({
return (
- {!!items.length &&
+ {shouldShowSearch && !isInputDisplayed && (
+ <>
+
+ setSearchFilter(
+ turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
+ )
+ }
+ autoFocus
+ />
+
+ >
+ )}
+ {!!filteredItems.length &&
(!shouldAutoEnterBecauseOnlyOneItemIsAllowed || !isInputDisplayed) && (
<>
- {items.map((item, index) =>
- renderItem({
+ {filteredItems.map((item) => {
+ const originalIndex = items.indexOf(item);
+ return renderItem({
value: item,
- index,
- handleEdit: () => handleEditButtonClick(index),
- handleSetPrimary: () => handleSetPrimaryItem(index),
+ index: originalIndex,
+ handleEdit: () => handleEditButtonClick(originalIndex),
+ handleSetPrimary: () => handleSetPrimaryItem(originalIndex),
handleDelete: () => {
- handleDeleteItem(index);
+ handleDeleteItem(originalIndex);
},
- }),
- )}
+ });
+ })}
{isInputDisplayed || !isLimitReached ? (
) : null}
>
)}
- {isInputDisplayed || !items.length ? (
+ {isInputDisplayed ? (
= {
onSetAsPrimary?: () => void;
onDelete?: () => void;
onCopy?: (value: T) => void;
+ onClick?: () => void;
DisplayComponent: React.ComponentType<{ value: T }>;
showPrimaryIcon: boolean;
showSetAsPrimaryButton: boolean;
@@ -34,6 +35,7 @@ export const MultiItemFieldMenuItem = ({
onEdit,
onSetAsPrimary,
onDelete,
+ onClick,
DisplayComponent,
showPrimaryIcon,
showSetAsPrimaryButton,
@@ -77,6 +79,7 @@ export const MultiItemFieldMenuItem = ({
}
isIconDisplayedOnHoverOnly={!showPrimaryIcon && !isDropdownOpen}
RightIcon={!isHovered && showPrimaryIcon ? IconBookmark : null}
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx
index 4c5814eb1f..f91a55fbda 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx
@@ -5,6 +5,7 @@ import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
+import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
@@ -115,7 +116,11 @@ const meta: Meta = {
title: 'UI/Data/Field/Input/RelationOneToManyFieldInput',
component: RelationOneToManyFieldInputWithContext,
args: {},
- decorators: [ObjectMetadataItemsDecorator, SnackBarDecorator],
+ decorators: [
+ ObjectMetadataItemsDecorator,
+ SnackBarDecorator,
+ FileUploadDecorator,
+ ],
parameters: {
clearMocks: true,
msw: graphqlMocks,
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx
new file mode 100644
index 0000000000..eacaf52421
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx
@@ -0,0 +1,179 @@
+import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
+import { useUploadFilesFieldFile } from '@/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile';
+import { uploadMultipleFiles } from '@/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles';
+import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
+import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
+import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix';
+import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
+import { recordTableCellEditModePositionComponentState } from '@/object-record/record-table/states/recordTableCellEditModePositionComponentState';
+import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
+import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
+import { useRemoveLastFocusItemFromFocusStackByComponentType } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackByComponentType';
+import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
+import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
+import { useLingui } from '@lingui/react/macro';
+import { useRecoilCallback } from 'recoil';
+import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
+import { isDefined } from 'twenty-shared/utils';
+
+export const useOpenFilesFieldInput = () => {
+ const { openFileUpload } = useFileUpload();
+ const { uploadFile } = useUploadFilesFieldFile();
+ const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
+ const { removeLastFocusItemFromFocusStackByComponentType } =
+ useRemoveLastFocusItemFromFocusStackByComponentType();
+ const { goBackToPreviousDropdownFocusId } =
+ useGoBackToPreviousDropdownFocusId();
+ const recordTableId = useAvailableComponentInstanceId(
+ RecordTableComponentInstanceContext,
+ );
+ const { enqueueErrorSnackBar } = useSnackBar();
+ const { t } = useLingui();
+
+ const openFilesFieldInput = useRecoilCallback(
+ ({ snapshot, set }) =>
+ async ({
+ fieldName,
+ recordId,
+ prefix,
+ updateRecord,
+ onClose,
+ fieldDefinition,
+ }: {
+ fieldName: string;
+ recordId: string;
+ prefix?: string;
+ updateRecord: (updateInput: Record) => void;
+ onClose?: () => void;
+ fieldDefinition?: {
+ metadata: {
+ settings?: {
+ maxNumberOfValues?: number;
+ };
+ };
+ };
+ }) => {
+ const fieldValue = snapshot
+ .getLoadable(
+ recordStoreFamilySelector({
+ recordId,
+ fieldName,
+ }),
+ )
+ .getValue();
+
+ const instanceId = getRecordFieldInputInstanceId({
+ recordId,
+ fieldName,
+ prefix,
+ });
+
+ if (isDefined(fieldValue) && fieldValue.length > 0) {
+ pushFocusItemToFocusStack({
+ focusId: instanceId,
+ component: {
+ type: FocusComponentType.OPENED_FIELD_INPUT,
+ instanceId,
+ },
+ globalHotkeysConfig: {
+ enableGlobalHotkeysConflictingWithKeyboard: false,
+ },
+ });
+ return;
+ }
+
+ const isTableContext = prefix === RECORD_TABLE_CELL_INPUT_ID_PREFIX;
+
+ const maxNumberOfValues =
+ fieldDefinition?.metadata?.settings?.maxNumberOfValues ??
+ MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
+
+ const currentFileCount = isDefined(fieldValue) ? fieldValue.length : 0;
+
+ openFileUpload({
+ multiple: true,
+ onUpload: async (selectedFiles: File[]) => {
+ if (selectedFiles.length + currentFileCount > maxNumberOfValues) {
+ enqueueErrorSnackBar({
+ message: t`Cannot upload more than ${maxNumberOfValues} files`,
+ });
+
+ if (isTableContext && isDefined(recordTableId)) {
+ set(
+ recordTableCellEditModePositionComponentState.atomFamily({
+ instanceId: recordTableId,
+ }),
+ null,
+ );
+ goBackToPreviousDropdownFocusId();
+ removeLastFocusItemFromFocusStackByComponentType({
+ componentType: FocusComponentType.OPENED_FIELD_INPUT,
+ });
+ } else {
+ onClose?.();
+ }
+ return;
+ }
+
+ try {
+ const uploadedFiles = await uploadMultipleFiles(
+ selectedFiles,
+ uploadFile,
+ );
+
+ if (uploadedFiles.length > 0) {
+ updateRecord({
+ [fieldName]: uploadedFiles,
+ });
+ }
+ } finally {
+ if (isTableContext && isDefined(recordTableId)) {
+ set(
+ recordTableCellEditModePositionComponentState.atomFamily({
+ instanceId: recordTableId,
+ }),
+ null,
+ );
+ goBackToPreviousDropdownFocusId();
+ removeLastFocusItemFromFocusStackByComponentType({
+ componentType: FocusComponentType.OPENED_FIELD_INPUT,
+ });
+ } else {
+ onClose?.();
+ }
+ }
+ },
+ onCancel: () => {
+ if (isTableContext && isDefined(recordTableId)) {
+ set(
+ recordTableCellEditModePositionComponentState.atomFamily({
+ instanceId: recordTableId,
+ }),
+ null,
+ );
+ goBackToPreviousDropdownFocusId();
+ removeLastFocusItemFromFocusStackByComponentType({
+ componentType: FocusComponentType.OPENED_FIELD_INPUT,
+ });
+ } else {
+ onClose?.();
+ }
+ },
+ });
+ },
+ [
+ openFileUpload,
+ uploadFile,
+ pushFocusItemToFocusStack,
+ recordTableId,
+ goBackToPreviousDropdownFocusId,
+ removeLastFocusItemFromFocusStackByComponentType,
+ enqueueErrorSnackBar,
+ t,
+ ],
+ );
+
+ return { openFilesFieldInput };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts
new file mode 100644
index 0000000000..0982d5647d
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts
@@ -0,0 +1,18 @@
+import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
+import { isDefined } from 'twenty-shared/utils';
+
+export const uploadMultipleFiles = async (
+ files: File[],
+ uploadFile: (file: File) => Promise,
+): Promise => {
+ const uploadedFiles: FieldFilesValue[] = [];
+
+ for (const file of files) {
+ const uploadedFile = await uploadFile(file);
+ if (isDefined(uploadedFile)) {
+ uploadedFiles.push(uploadedFile);
+ }
+ }
+
+ return uploadedFiles;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts
index 933d7a09a1..f12819776e 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts
@@ -228,6 +228,7 @@ export type FieldMetadata =
| FieldActorMetadata
| FieldArrayMetadata
| FieldTsVectorMetadata
+ | FieldRawJsonMetadata
| FieldRichTextV2Metadata
| FieldRichTextMetadata;
@@ -335,10 +336,10 @@ export type FieldPhonesValue = {
additionalPhones?: PhoneRecord[] | null;
};
-export type FieldFileValue = {
+export type FieldFilesValue = {
fileId: string;
label: string;
- fileCategory: FileCategory;
+ extension?: string;
+ url?: string;
+ fileCategory?: FileCategory;
};
-
-export type FieldFilesValue = FieldFileValue[];
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts
index b8cb3213de..dc21e7f9e2 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts
@@ -11,6 +11,7 @@ import {
type FieldDateTimeMetadata,
type FieldEmailMetadata,
type FieldEmailsMetadata,
+ type FieldFilesMetadata,
type FieldFullNameMetadata,
type FieldLinkMetadata,
type FieldLinksMetadata,
@@ -46,43 +47,45 @@ type AssertFieldMetadataFunction = <
? FieldEmailMetadata
: E extends 'EMAILS'
? FieldEmailsMetadata
- : E extends 'SELECT'
- ? FieldSelectMetadata
- : E extends 'MULTI_SELECT'
- ? FieldMultiSelectMetadata
- : E extends 'RATING'
- ? FieldRatingMetadata
- : E extends 'LINK'
- ? FieldLinkMetadata
- : E extends 'LINKS'
- ? FieldLinksMetadata
- : E extends 'NUMBER'
- ? FieldNumberMetadata
- : E extends 'PHONE'
- ? FieldPhoneMetadata
- : E extends 'RELATION'
- ? FieldRelationMetadata
- : E extends 'MORPH_RELATION'
- ? FieldMorphRelationMetadata
- : E extends 'TEXT'
- ? FieldTextMetadata
- : E extends 'UUID'
- ? FieldUuidMetadata
- : E extends 'ADDRESS'
- ? FieldAddressMetadata
- : E extends 'RAW_JSON'
- ? FieldRawJsonMetadata
- : E extends 'RICH_TEXT_V2'
- ? FieldRichTextV2Metadata
- : E extends 'RICH_TEXT'
- ? FieldRichTextMetadata
- : E extends 'ACTOR'
- ? FieldActorMetadata
- : E extends 'ARRAY'
- ? FieldArrayMetadata
- : E extends 'PHONES'
- ? FieldPhonesMetadata
- : never,
+ : E extends 'FILES'
+ ? FieldFilesMetadata
+ : E extends 'SELECT'
+ ? FieldSelectMetadata
+ : E extends 'MULTI_SELECT'
+ ? FieldMultiSelectMetadata
+ : E extends 'RATING'
+ ? FieldRatingMetadata
+ : E extends 'LINK'
+ ? FieldLinkMetadata
+ : E extends 'LINKS'
+ ? FieldLinksMetadata
+ : E extends 'NUMBER'
+ ? FieldNumberMetadata
+ : E extends 'PHONE'
+ ? FieldPhoneMetadata
+ : E extends 'RELATION'
+ ? FieldRelationMetadata
+ : E extends 'MORPH_RELATION'
+ ? FieldMorphRelationMetadata
+ : E extends 'TEXT'
+ ? FieldTextMetadata
+ : E extends 'UUID'
+ ? FieldUuidMetadata
+ : E extends 'ADDRESS'
+ ? FieldAddressMetadata
+ : E extends 'RAW_JSON'
+ ? FieldRawJsonMetadata
+ : E extends 'RICH_TEXT_V2'
+ ? FieldRichTextV2Metadata
+ : E extends 'RICH_TEXT'
+ ? FieldRichTextMetadata
+ : E extends 'ACTOR'
+ ? FieldActorMetadata
+ : E extends 'ARRAY'
+ ? FieldArrayMetadata
+ : E extends 'PHONES'
+ ? FieldPhonesMetadata
+ : never,
>(
fieldType: E,
fieldTypeGuard: (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts
index e99bb83cd3..b66a308d3f 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts
@@ -1,20 +1,26 @@
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
-import { FILE_CATEGORIES } from 'twenty-shared/types';
import { z } from 'zod';
-const fileCategoryValues = Object.values(FILE_CATEGORIES) as [
- string,
- ...string[],
-];
-
const fileSchema = z.object({
fileId: z.string(),
label: z.string(),
- fileCategory: z.enum(fileCategoryValues),
+ extension: z.string().optional(),
+ url: z.string().optional(),
+ fileCategory: z
+ .enum([
+ 'ARCHIVE',
+ 'AUDIO',
+ 'IMAGE',
+ 'PRESENTATION',
+ 'SPREADSHEET',
+ 'TEXT_DOCUMENT',
+ 'VIDEO',
+ 'OTHER',
+ ] as const)
+ .optional(),
});
-export const filesSchema = z.union([z.null(), z.array(fileSchema)]);
-
+export const filesSchema = z.array(fileSchema);
export const isFieldFilesValue = (
fieldValue: unknown,
-): fieldValue is FieldFilesValue => filesSchema.safeParse(fieldValue).success;
+): fieldValue is FieldFilesValue[] => filesSchema.safeParse(fieldValue).success;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts
new file mode 100644
index 0000000000..3be6c91b75
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts
@@ -0,0 +1,50 @@
+import { FILE_CATEGORIES, type FileCategory } from 'twenty-shared/types';
+
+export const getFileCategoryFromExtension = (
+ extension?: string,
+): FileCategory => {
+ if (!extension) {
+ return FILE_CATEGORIES.OTHER;
+ }
+
+ const ext = extension.toLowerCase().replace('.', '');
+
+ // Images
+ if (
+ ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico'].includes(ext)
+ ) {
+ return FILE_CATEGORIES.IMAGE;
+ }
+
+ // Videos
+ if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v'].includes(ext)) {
+ return FILE_CATEGORIES.VIDEO;
+ }
+
+ // Audio
+ if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'wma', 'aac'].includes(ext)) {
+ return FILE_CATEGORIES.AUDIO;
+ }
+
+ // Archives
+ if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) {
+ return FILE_CATEGORIES.ARCHIVE;
+ }
+
+ // Spreadsheets
+ if (['xls', 'xlsx', 'csv', 'ods', 'numbers'].includes(ext)) {
+ return FILE_CATEGORIES.SPREADSHEET;
+ }
+
+ // Presentations
+ if (['ppt', 'pptx', 'odp', 'key'].includes(ext)) {
+ return FILE_CATEGORIES.PRESENTATION;
+ }
+
+ // Text documents
+ if (['doc', 'docx', 'txt', 'rtf', 'odt', 'pdf', 'md'].includes(ext)) {
+ return FILE_CATEGORIES.TEXT_DOCUMENT;
+ }
+
+ return FILE_CATEGORIES.OTHER;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts
index 791f78357f..85b8600ee8 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts
@@ -84,6 +84,11 @@ export const FILTER_OPERANDS_MAP = {
RecordFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
+ FILES: [
+ RecordFilterOperand.CONTAINS,
+ RecordFilterOperand.DOES_NOT_CONTAIN,
+ ...emptyOperands,
+ ],
DATE_TIME: [
RecordFilterOperand.IS,
RecordFilterOperand.IS_RELATIVE,
@@ -182,6 +187,8 @@ export const getRecordFilterOperands = ({
return FILTER_OPERANDS_MAP.NUMBER;
case 'RAW_JSON':
return FILTER_OPERANDS_MAP.RAW_JSON;
+ case 'FILES':
+ return FILTER_OPERANDS_MAP.FILES;
case 'DATE_TIME':
case 'DATE':
return FILTER_OPERANDS_MAP.DATE_TIME;
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
index 79bfcc0e05..f91dd70755 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
@@ -10,6 +10,7 @@ import {
type CurrencyFilter,
type DateFilter,
type EmailsFilter,
+ type FilesFilter,
type FloatFilter,
type FullNameFilter,
type LeafObjectRecordFilter,
@@ -34,6 +35,7 @@ import {
isMatchingBooleanFilter,
isMatchingCurrencyFilter,
isMatchingDateFilter,
+ isMatchingFilesFilter,
isMatchingFloatFilter,
isMatchingMultiSelectFilter,
isMatchingRatingFilter,
@@ -270,6 +272,12 @@ export const isRecordMatchingFilter = ({
value: record[filterKey],
});
}
+ case FieldMetadataType.FILES: {
+ return isMatchingFilesFilter({
+ filesFilter: filterValue as FilesFilter,
+ value: record[filterKey],
+ });
+ }
case FieldMetadataType.FULL_NAME: {
const fullNameFilter = filterValue as FullNameFilter;
diff --git a/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx b/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx
index c41cddeb08..b91dfbebd2 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx
@@ -5,6 +5,7 @@ import { type RecordTableEmptyStateNoGroupNoRecordAtAll } from '@/object-record/
import { fireEvent, userEvent, within } from 'storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
+import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { RecordTableDecorator } from '~/testing/decorators/RecordTableDecorator';
@@ -19,6 +20,7 @@ const meta: Meta = {
decorators: [
ComponentDecorator,
MemoryRouterDecorator,
+ FileUploadDecorator,
RecordTableDecorator,
ContextStoreDecorator,
SnackBarDecorator,
diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts
index a1de2e936f..1c1c5a1f5e 100644
--- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts
+++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts
@@ -73,6 +73,18 @@ export const sanitizeRecordInput = ({
return undefined;
}
+ if (
+ isDefined(fieldMetadataItem) &&
+ fieldMetadataItem.type === FieldMetadataType.FILES &&
+ Array.isArray(fieldValue)
+ ) {
+ const cleanedFiles = fieldValue.map((file: any) => ({
+ fileId: file.fileId,
+ label: file.label,
+ }));
+ return [fieldName, cleanedFiles];
+ }
+
// Todo: we should check that the fieldValue is a valid value
// (e.g. a string for a string field, following the right composite structure for composite fields)
return [fieldName, fieldValue];
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx
index 6e20ee1715..c27265f569 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx
@@ -32,6 +32,7 @@ import {
WidgetType,
} from '~/generated-metadata/graphql';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
+import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
@@ -306,6 +307,7 @@ const meta: Meta = {
decorators: [
ComponentDecorator,
ChipGeneratorsDecorator,
+ FileUploadDecorator,
(Story) => (
diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts
index a2358d9847..c6b838e587 100644
--- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts
+++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts
@@ -167,5 +167,5 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
],
[],
],
- } as const satisfies SettingsFieldTypeConfig,
+ } as const satisfies SettingsFieldTypeConfig,
};
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx b/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx
new file mode 100644
index 0000000000..5e9b6549d5
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx
@@ -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 = (
+
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx
index 080604ed64..c646bc3cdf 100644
--- a/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx
+++ b/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx
@@ -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 (
- {value?.map((file, index) => (
- (
+
))}
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx b/packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx
new file mode 100644
index 0000000000..b8956f18a7
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx
@@ -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(
+
+
+
+ {filePreview.label}
+
+
+
+
+
+
+
+
+
+
+ {t`Loading document viewer...`}
+
+
+ }
+ >
+
+
+
+
+ ,
+ document.body,
+ )}
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts b/packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts
new file mode 100644
index 0000000000..6242cc7936
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts
@@ -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({
+ key: 'filePreviewState',
+ defaultValue: null,
+});
diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx
index 2568084ba9..46f7326af2 100644
--- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx
@@ -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 = () => {
}
`}
/>
-
-
-
-
- {!showAuthModal && }
- {showAuthModal ? (
-
- ) : useShowFullScreen ? null : (
-
- )}
- {showAuthModal ? (
- <>
+
+
+
+
+
+ {!showAuthModal && }
+ {showAuthModal ? (
+
+ ) : useShowFullScreen ? null : (
+
+ )}
+ {showAuthModal ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
-
+
+
+
-
-
-
-
-
-
-
- >
- ) : (
-
-
-
-
-
- )}
-
- {isMobile && !showAuthModal && }
-
-
+ )}
+
+ {isMobile && !showAuthModal && }
+
+
+
>
);
};
diff --git a/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx b/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx
index 9ebccbaa18..b751aea8c0 100644
--- a/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx
@@ -6,7 +6,6 @@ import { SettingsObjectNewFieldSelector } from '@/settings/data-model/fields/for
import { type FieldType } from '@/settings/data-model/types/FieldType';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
-import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro';
import { useEffect } from 'react';
@@ -15,10 +14,7 @@ import { useParams } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
-import {
- FeatureFlagKey,
- FieldMetadataType,
-} from '~/generated-metadata/graphql';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const settingsDataModelFieldTypeFormSchema = z.object({
@@ -48,10 +44,6 @@ export const SettingsObjectNewFieldSelect = () => {
},
});
- const isFilesFieldEnabled = useIsFeatureEnabled(
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- );
-
const excludedFieldTypes: FieldType[] = (
[
FieldMetadataType.NUMERIC,
@@ -59,7 +51,6 @@ export const SettingsObjectNewFieldSelect = () => {
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.ACTOR,
FieldMetadataType.UUID,
- !isFilesFieldEnabled ? FieldMetadataType.FILES : undefined,
] as const
).filter(isDefined);
diff --git a/packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx b/packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx
new file mode 100644
index 0000000000..30f47d6fc1
--- /dev/null
+++ b/packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx
@@ -0,0 +1,8 @@
+import { FileUploadProvider } from '@/file-upload/components/FileUploadProvider';
+import { type Decorator } from '@storybook/react-vite';
+
+export const FileUploadDecorator: Decorator = (Story) => (
+
+
+
+);
diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts
index 307dfd4651..774dbf9d26 100644
--- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts
@@ -19,5 +19,4 @@ export enum FeatureFlagKey {
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
- IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
}
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts
index 472bb6def9..a81c390f99 100644
--- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts
@@ -477,6 +477,7 @@ export const generateFieldFilterZodSchema = (
return null;
case FieldMetadataType.RAW_JSON:
+ case FieldMetadataType.FILES:
return z
.object({
eq: z.string().optional().describe('Raw JSON equals'),
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts
index 6c00af9d68..e4b7b1cadc 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts
@@ -1,6 +1,5 @@
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateFilesFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util';
@@ -19,15 +18,9 @@ const createFlatEntityToValidate = (
const callValidator = (
flatEntityToValidate: FlatFieldMetadata,
- featureFlagEnabled = true,
) =>
validateFilesFlatFieldMetadata({
flatEntityToValidate,
- additionalCacheDataMaps: {
- featureFlagsMap: {
- [FeatureFlagKey.IS_FILES_FIELD_ENABLED]: featureFlagEnabled,
- },
- },
} as Parameters[0]);
describe('validateFilesFlatFieldMetadata', () => {
@@ -37,14 +30,6 @@ describe('validateFilesFlatFieldMetadata', () => {
expect(errors).toHaveLength(0);
});
- it('should return error when feature flag is disabled', () => {
- const errors = callValidator(createFlatEntityToValidate(), false);
-
- expect(errors).toHaveLength(1);
- expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
- expect(errors[0].message).toContain('Files field type is not supported');
- });
-
it('should return error when isUnique is true', () => {
const errors = callValidator(
createFlatEntityToValidate({ isUnique: true }),
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts
index effb11e861..fa20c5f219 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts
@@ -3,25 +3,14 @@ import { FILES_FIELD_MAX_NUMBER_OF_VALUES } from 'twenty-shared/constants';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
export const validateFilesFlatFieldMetadata = ({
flatEntityToValidate,
- additionalCacheDataMaps,
}: FlatFieldMetadataTypeValidationArgs): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
- const { featureFlagsMap } = additionalCacheDataMaps;
-
- if (featureFlagsMap[FeatureFlagKey.IS_FILES_FIELD_ENABLED] !== true) {
- errors.push({
- code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
- message: 'Files field type is not supported',
- userFriendlyMessage: msg`Files field type is not supported`,
- });
- }
if (flatEntityToValidate.isUnique === true) {
errors.push({
diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
index 5c8203d489..bc1997afb1 100644
--- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
@@ -240,7 +240,6 @@ describe('WorkspaceEntityManager', () => {
IS_SSE_DB_EVENTS_ENABLED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
IS_NAVIGATION_MENU_ITEM_ENABLED: false,
- IS_FILES_FIELD_ENABLED: false,
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
diff --git a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
index cfc0d4f6a0..77a7cd6e8d 100644
--- a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
+++ b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
@@ -1,6 +1,5 @@
import {
FieldMetadataType,
- FILE_CATEGORIES,
NumberDataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
@@ -346,9 +345,11 @@ export const convertObjectMetadataToSchemaProperties = ({
},
...(forResponse
? {
- fileCategory: {
+ extension: {
+ type: 'string',
+ },
+ url: {
type: 'string',
- enum: Object.values(FILE_CATEGORIES),
},
}
: {}),
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts
index 4e2e450899..32085bc033 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts
@@ -96,11 +96,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
- {
- key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- workspaceId: workspaceId,
- value: true,
- },
{
key: FeatureFlagKey.IS_MARKETPLACE_ENABLED,
workspaceId: workspaceId,
diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts
index f6b6aa0b66..71eb9100ff 100644
--- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts
@@ -2,16 +2,12 @@ import gql from 'graphql-tag';
import request from 'supertest';
import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
-import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
-import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
-
const uploadWorkspaceFieldFileMutation = gql`
mutation UploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
@@ -104,14 +100,6 @@ describe('files-field.controller - GET /files-field/:id', () => {
beforeAll(async () => {
jest.useRealTimers();
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- true,
- ),
- );
-
const {
data: {
createOneObject: { id: objectMetadataId },
@@ -167,14 +155,6 @@ describe('files-field.controller - GET /files-field/:id', () => {
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
-
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- false,
- ),
- );
});
it('should download file successfully with valid url', async () => {
diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts
index 1405c1233f..463dd8fcdf 100644
--- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts
@@ -1,16 +1,12 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
-import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
-import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
-
const uploadWorkspaceFieldFileMutation = gql`
mutation UploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
@@ -156,14 +152,6 @@ describe('fileFieldSync - FILES field <> files sync', () => {
beforeAll(async () => {
jest.useRealTimers();
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- true,
- ),
- );
-
const {
data: {
createOneObject: { id: objectMetadataId },
@@ -219,14 +207,6 @@ describe('fileFieldSync - FILES field <> files sync', () => {
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
-
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- false,
- ),
- );
});
it('createMany without upsert - files sync successfully', async () => {
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts
index 129514396c..4ee9019574 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts
@@ -3,13 +3,8 @@ import { expectGqlCreateInputValidationError } from 'test/integration/graphql/su
import { expectRestCreateInputValidationError } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-rest-create-input-validation-error.util';
import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata';
import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
-import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
-import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
-import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
-
const FIELD_METADATA_TYPE = FieldMetadataType.FILES;
const failingTestCases =
@@ -23,14 +18,6 @@ describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => {
let targetObjectMetadata2Id: string;
beforeAll(async () => {
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- true,
- ),
- );
-
const setupTest = await setupTestObjectsWithAllFieldTypes(true);
objectMetadataId = setupTest.objectMetadataId;
@@ -46,14 +33,6 @@ describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => {
targetObjectMetadata1Id,
targetObjectMetadata2Id,
]);
-
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- false,
- ),
- );
});
describe('Gql create input - failure', () => {
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts
index 8afa406c9d..420e754f25 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts
@@ -6,13 +6,8 @@ import { testRestFailingScenario } from 'test/integration/graphql/suites/inputs-
import { testRestSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-successful-scenario.util';
import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata';
import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
-import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
-import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
-import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
-
const FIELD_METADATA_TYPE = FieldMetadataType.FILES;
const failingTestCases =
@@ -28,14 +23,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
let targetObjectMetadata2Id: string;
beforeAll(async () => {
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- true,
- ),
- );
-
const setupTest = await setupTestObjectsWithAllFieldTypes(true);
objectMetadataId = setupTest.objectMetadataId;
@@ -51,14 +38,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
targetObjectMetadata1Id,
targetObjectMetadata2Id,
]);
-
- await makeGraphqlAPIRequest(
- updateFeatureFlagFactory(
- SEED_APPLE_WORKSPACE_ID,
- FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- false,
- ),
- );
});
describe('Gql filter input - failure', () => {
diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap
index 183439ab34..77f53adbc7 100644
--- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap
@@ -171,41 +171,3 @@ exports[`createOne FILES field metadata - failing should fail to create files fi
"name": "GraphQLError",
}
`;
-
-exports[`createOne FILES field metadata - feature flag disabled should fail to create files field when feature flag is disabled 1`] = `
-{
- "extensions": {
- "code": "METADATA_VALIDATION_FAILED",
- "errors": {
- "fieldMetadata": [
- {
- "errors": [
- {
- "code": "INVALID_FIELD_INPUT",
- "message": "Files field type is not supported",
- "userFriendlyMessage": "Files field type is not supported",
- },
- ],
- "flatEntityMinimalInformation": {
- "id": Any,
- "name": "filesFieldDisabled",
- "objectMetadataId": Any,
- "universalIdentifier": Any,
- },
- "metadataName": "fieldMetadata",
- "status": "fail",
- "type": "create",
- },
- ],
- },
- "message": "Validation failed for 1 fieldMetadata",
- "summary": {
- "fieldMetadata": 1,
- "totalErrors": 1,
- },
- "userFriendlyMessage": "Metadata validation failed",
- },
- "message": "Multiple validation errors occurred while creating fields",
- "name": "GraphQLError",
-}
-`;
diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts
index 5287a181ea..2533457760 100644
--- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts
@@ -3,21 +3,12 @@ import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-m
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
-import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
-
describe('createOne FILES field metadata - successful', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: true,
- expectToFail: false,
- });
-
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -45,12 +36,6 @@ describe('createOne FILES field metadata - successful', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
-
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: false,
- expectToFail: false,
- });
});
it('should create files field with maxNumberOfValues = 1', async () => {
@@ -136,12 +121,6 @@ describe('createOne FILES field metadata - failing', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: true,
- expectToFail: false,
- });
-
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -169,12 +148,6 @@ describe('createOne FILES field metadata - failing', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
-
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: false,
- expectToFail: false,
- });
});
it('should fail to create files field with maxNumberOfValues = 0', async () => {
@@ -237,66 +210,3 @@ describe('createOne FILES field metadata - failing', () => {
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
-
-describe('createOne FILES field metadata - feature flag disabled', () => {
- let createdObjectMetadataId: string;
-
- beforeAll(async () => {
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: false,
- expectToFail: false,
- });
-
- const { data } = await createOneObjectMetadata({
- expectToFail: false,
- input: {
- nameSingular: 'testFilesFieldFlagDisabledObject',
- namePlural: 'testFilesFieldFlagDisabledObjects',
- labelSingular: 'Test Files Field Flag Disabled Object',
- labelPlural: 'Test Files Field Flag Disabled Objects',
- icon: 'IconFile',
- isLabelSyncedWithName: false,
- },
- });
-
- createdObjectMetadataId = data.createOneObject.id;
- });
-
- afterAll(async () => {
- await updateOneObjectMetadata({
- expectToFail: false,
- input: {
- idToUpdate: createdObjectMetadataId,
- updatePayload: { isActive: false },
- },
- });
- await deleteOneObjectMetadata({
- expectToFail: false,
- input: { idToDelete: createdObjectMetadataId },
- });
-
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: false,
- expectToFail: false,
- });
- });
-
- it('should fail to create files field when feature flag is disabled', async () => {
- const { errors } = await createOneFieldMetadata({
- expectToFail: true,
- input: {
- objectMetadataId: createdObjectMetadataId,
- name: 'filesFieldDisabled',
- label: 'Files Field Disabled',
- type: FieldMetadataType.FILES,
- settings: {
- maxNumberOfValues: 5,
- },
- },
- });
-
- expectOneNotInternalServerErrorSnapshot({ errors });
- });
-});
diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts
index cfc03e662a..cfb0fe3fa6 100644
--- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts
@@ -5,22 +5,13 @@ import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-m
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
-import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
-
describe('updateOne FILES field metadata - successful', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: true,
- expectToFail: false,
- });
-
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -48,12 +39,6 @@ describe('updateOne FILES field metadata - successful', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
-
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: false,
- expectToFail: false,
- });
});
beforeEach(async () => {
@@ -153,12 +138,6 @@ describe('updateOne FILES field metadata - failing', () => {
let createdFieldMetadataId: string;
beforeAll(async () => {
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: true,
- expectToFail: false,
- });
-
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -219,12 +198,6 @@ describe('updateOne FILES field metadata - failing', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
-
- await updateFeatureFlag({
- featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
- value: false,
- expectToFail: false,
- });
});
it('should fail to update files field settings with maxNumberOfValues = 0', async () => {
diff --git a/packages/twenty-shared/src/types/FilterableFieldType.ts b/packages/twenty-shared/src/types/FilterableFieldType.ts
index 26e5740e01..1e75a34c11 100644
--- a/packages/twenty-shared/src/types/FilterableFieldType.ts
+++ b/packages/twenty-shared/src/types/FilterableFieldType.ts
@@ -20,6 +20,7 @@ export const FILTERABLE_FIELD_TYPES = [
'ACTOR',
'ARRAY',
'RAW_JSON',
+ 'FILES',
'BOOLEAN',
'UUID',
] as const;
diff --git a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts
index ee07101a60..d4c612d86e 100644
--- a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts
+++ b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts
@@ -140,6 +140,11 @@ export type RawJsonFilter = {
is?: IsFilter;
};
+export type FilesFilter = {
+ like?: string;
+ is?: IsFilter;
+};
+
export type RichTextV2LeafFilter = {
ilike?: string;
};
@@ -169,6 +174,7 @@ export type LeafFilter =
| PhonesFilter
| ArrayFilter
| RawJsonFilter
+ | FilesFilter
| RichTextV2Filter
| TSVectorFilter
| undefined;
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 3ad653918a..f315290b7c 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -168,6 +168,7 @@ export type {
MultiSelectFilter,
ArrayFilter,
RawJsonFilter,
+ FilesFilter,
RichTextV2LeafFilter,
RichTextV2Filter,
TSVectorFilter,
diff --git a/packages/twenty-shared/src/utils/filter/index.ts b/packages/twenty-shared/src/utils/filter/index.ts
index 9a2f8040db..b73e2859b0 100644
--- a/packages/twenty-shared/src/utils/filter/index.ts
+++ b/packages/twenty-shared/src/utils/filter/index.ts
@@ -19,6 +19,7 @@ export * from './utils/isMatchingArrayFilter';
export * from './utils/isMatchingBooleanFilter';
export * from './utils/isMatchingCurrencyFilter';
export * from './utils/isMatchingDateFilter';
+export * from './utils/isMatchingFilesFilter';
export * from './utils/isMatchingFloatFilter';
export * from './utils/isMatchingMultiSelectFilter';
export * from './utils/isMatchingRatingFilter';
diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
index bbfc3f4157..c4edb41954 100644
--- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
+++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
@@ -9,6 +9,7 @@ import {
type BooleanFilter,
type CurrencyFilter,
type DateFilter,
+ type FilesFilter,
type FloatFilter,
type MultiSelectFilter,
type PhonesFilter,
@@ -167,6 +168,27 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
);
}
+ case 'FILES':
+ switch (recordFilter.operand) {
+ case RecordFilterOperand.CONTAINS:
+ return {
+ [correspondingFieldMetadataItem.name]: {
+ like: `%${recordFilter.value}%`,
+ } as FilesFilter,
+ };
+ case RecordFilterOperand.DOES_NOT_CONTAIN:
+ return {
+ not: {
+ [correspondingFieldMetadataItem.name]: {
+ like: `%${recordFilter.value}%`,
+ } as FilesFilter,
+ },
+ };
+ default:
+ throw new Error(
+ `Unknown operand ${recordFilter.operand} for ${filterType} filter`,
+ );
+ }
case 'DATE': {
const itsARelativeDateFilter =
recordFilter.operand === RecordFilterOperand.IS_RELATIVE;
diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts
new file mode 100644
index 0000000000..ee35b794ea
--- /dev/null
+++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts
@@ -0,0 +1,227 @@
+import { isMatchingFilesFilter } from '@/utils/filter/utils/isMatchingFilesFilter';
+
+describe('isMatchingFilesFilter', () => {
+ describe('is filter', () => {
+ it('should return true when checking for NULL and value is null', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { is: 'NULL' },
+ value: null,
+ }),
+ ).toBe(true);
+ });
+
+ it('should return true when checking for NULL and value is empty array', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { is: 'NULL' },
+ value: [],
+ }),
+ ).toBe(true);
+ });
+
+ it('should return false when checking for NULL and value has files', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { is: 'NULL' },
+ value: [
+ {
+ fileId: '1',
+ label: 'file.pdf',
+ url: 'http://example.com/file.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true when checking for NOT_NULL and value has files', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { is: 'NOT_NULL' },
+ value: [
+ {
+ fileId: '1',
+ label: 'file.pdf',
+ url: 'http://example.com/file.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should return false when checking for NOT_NULL and value is null', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { is: 'NOT_NULL' },
+ value: null,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return false when checking for NOT_NULL and value is empty array', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { is: 'NOT_NULL' },
+ value: [],
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe('like filter', () => {
+ it('should match files when like pattern matches JSON representation', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%file.pdf%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'file.pdf',
+ url: 'http://example.com/file.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should not match when like pattern does not match', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%document.docx%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'file.pdf',
+ url: 'http://example.com/file.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(false);
+ });
+
+ it('should be case insensitive', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%FILE.PDF%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'file.pdf',
+ url: 'http://example.com/file.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should match partial file names', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%myfile%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'myfile.pdf',
+ url: 'http://example.com/myfile.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should match when any file in array matches', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%report%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'invoice.pdf',
+ url: 'http://example.com/invoice.pdf',
+ extension: 'pdf',
+ },
+ {
+ fileId: '2',
+ label: 'annual_report.pdf',
+ url: 'http://example.com/report.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should match by file extension', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%pdf%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'document',
+ url: 'http://example.com/doc.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should match by URL', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%example.com%' },
+ value: [
+ {
+ fileId: '1',
+ label: 'file.pdf',
+ url: 'http://example.com/file.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should match by fileId', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%file-123%' },
+ value: [
+ {
+ fileId: 'file-123',
+ label: 'document.pdf',
+ url: 'http://example.com/doc.pdf',
+ extension: 'pdf',
+ },
+ ],
+ }),
+ ).toBe(true);
+ });
+
+ it('should not match when value is null', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%file%' },
+ value: null,
+ }),
+ ).toBe(false);
+ });
+
+ it('should not match when value is empty array', () => {
+ expect(
+ isMatchingFilesFilter({
+ filesFilter: { like: '%file%' },
+ value: [],
+ }),
+ ).toBe(false);
+ });
+ });
+});
diff --git a/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts
index 480c7f731f..46a0d32155 100644
--- a/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts
+++ b/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts
@@ -355,6 +355,7 @@ export const getEmptyRecordGqlOperationFilter = ({
],
};
break;
+ case 'FILES':
case 'RAW_JSON':
emptyRecordFilter = {
or: [
diff --git a/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts b/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts
index bd50c50fc5..23d69c54a4 100644
--- a/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts
+++ b/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts
@@ -39,6 +39,8 @@ export const getFilterTypeFromFieldType = (
return 'ARRAY';
case FieldMetadataType.RAW_JSON:
return 'RAW_JSON';
+ case FieldMetadataType.FILES:
+ return 'FILES';
case FieldMetadataType.BOOLEAN:
return 'BOOLEAN';
case FieldMetadataType.TS_VECTOR:
diff --git a/packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts b/packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts
new file mode 100644
index 0000000000..2c0739a6c8
--- /dev/null
+++ b/packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts
@@ -0,0 +1,36 @@
+import { type FilesFilter } from '@/types';
+
+export const isMatchingFilesFilter = ({
+ filesFilter,
+ value,
+}: {
+ filesFilter: FilesFilter;
+ value: Record | null;
+}) => {
+ switch (true) {
+ case filesFilter.like !== undefined: {
+ const escapedPattern = filesFilter.like.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ '\\$&',
+ );
+ const regexPattern = escapedPattern.replace(/%/g, '.*');
+ const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'is');
+
+ const stringValue = JSON.stringify(value, null, 1);
+
+ return regexCaseInsensitive.test(stringValue);
+ }
+ case filesFilter.is !== undefined: {
+ if (filesFilter.is === 'NULL') {
+ return value === null || value.length === 0;
+ } else {
+ return value !== null && value.length > 0;
+ }
+ }
+ default: {
+ throw new Error(
+ `Unexpected value for files filter : ${JSON.stringify(filesFilter)}`,
+ );
+ }
+ }
+};
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index 21b7c1e452..b46a43b6ea 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -108,6 +108,7 @@ export { isMatchingArrayFilter } from './filter/utils/isMatchingArrayFilter';
export { isMatchingBooleanFilter } from './filter/utils/isMatchingBooleanFilter';
export { isMatchingCurrencyFilter } from './filter/utils/isMatchingCurrencyFilter';
export { isMatchingDateFilter } from './filter/utils/isMatchingDateFilter';
+export { isMatchingFilesFilter } from './filter/utils/isMatchingFilesFilter';
export { isMatchingFloatFilter } from './filter/utils/isMatchingFloatFilter';
export { isMatchingMultiSelectFilter } from './filter/utils/isMatchingMultiSelectFilter';
export { isMatchingRatingFilter } from './filter/utils/isMatchingRatingFilter';
diff --git a/packages/twenty-ui/src/theme/constants/MainColorsLight.ts b/packages/twenty-ui/src/theme/constants/MainColorsLight.ts
index ac5a334931..4a00386d13 100644
--- a/packages/twenty-ui/src/theme/constants/MainColorsLight.ts
+++ b/packages/twenty-ui/src/theme/constants/MainColorsLight.ts
@@ -37,5 +37,5 @@ export const MAIN_COLORS_LIGHT = {
bronze: RadixColors.bronzeP3.bronze9,
gold: RadixColors.goldP3.gold9,
brown: RadixColors.brownP3.brown9,
- gray: GRAY_SCALE_LIGHT.gray7,
+ gray: GRAY_SCALE_LIGHT.gray9,
};