diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 5122636bb2..4c085c5a4c 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -1444,6 +1444,7 @@ 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_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
@@ -1523,6 +1524,7 @@ export enum FieldMetadataType {
DATE = 'DATE',
DATE_TIME = 'DATE_TIME',
EMAILS = 'EMAILS',
+ FILES = 'FILES',
FULL_NAME = 'FULL_NAME',
LINKS = 'LINKS',
MORPH_RELATION = 'MORPH_RELATION',
diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts
index 4fb4dd8f3f..02eeaa9c99 100644
--- a/packages/twenty-front/src/generated/graphql.ts
+++ b/packages/twenty-front/src/generated/graphql.ts
@@ -1421,6 +1421,7 @@ 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_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
@@ -1500,6 +1501,7 @@ export enum FieldMetadataType {
DATE = 'DATE',
DATE_TIME = 'DATE_TIME',
EMAILS = 'EMAILS',
+ FILES = 'FILES',
FULL_NAME = 'FULL_NAME',
LINKS = 'LINKS',
MORPH_RELATION = 'MORPH_RELATION',
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldDisplay.tsx
index 44fdbc08f8..5a8499de8d 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldDisplay.tsx
@@ -4,6 +4,7 @@ import { ActorFieldDisplay } from '@/object-record/record-field/ui/meta-types/di
import { ArrayFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ArrayFieldDisplay';
import { BooleanFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/BooleanFieldDisplay';
import { EmailsFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/EmailsFieldDisplay';
+import { FilesFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay';
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { LinksFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/LinksFieldDisplay';
import { PhonesFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/PhonesFieldDisplay';
@@ -16,6 +17,7 @@ import { isFieldActor } from '@/object-record/record-field/ui/types/guards/isFie
import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray';
import { isFieldBoolean } from '@/object-record/record-field/ui/types/guards/isFieldBoolean';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
import { isFieldRating } from '@/object-record/record-field/ui/types/guards/isFieldRating';
@@ -118,6 +120,8 @@ export const FieldDisplay = () => {
) : isFieldArray(fieldDefinition) ? (
+ ) : isFieldFiles(fieldDefinition) ? (
+
) : isFieldEmails(fieldDefinition) ? (
) : isFieldPhones(fieldDefinition) ? (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts
index 4918c76c1c..827f5606e2 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts
@@ -31,6 +31,8 @@ import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFr
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray';
import { isFieldArrayValue } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
+import { isFieldFilesValue } from '@/object-record/record-field/ui/types/guards/isFieldFilesValue';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationManyToOneValue } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOneValue';
@@ -153,6 +155,9 @@ export const usePersistField = ({
const fieldIsArray =
isFieldArray(fieldDefinition) && isFieldArrayValue(valueToPersist);
+ const fieldIsFiles =
+ isFieldFiles(fieldDefinition) && isFieldFilesValue(valueToPersist);
+
const fieldIsUIReadOnly =
fieldDefinition.metadata.isUIReadOnly ?? false;
@@ -179,6 +184,7 @@ export const usePersistField = ({
fieldIsAddress ||
fieldIsRawJson ||
fieldIsArray ||
+ fieldIsFiles ||
fieldIsRichText ||
fieldIsRichTextV2;
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
new file mode 100644
index 0000000000..bbd3de8907
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx
@@ -0,0 +1,12 @@
+import { useFilesFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay';
+import { FilesDisplay } from '@/ui/field/display/components/FilesDisplay';
+
+export const FilesFieldDisplay = () => {
+ const { fieldValue } = useFilesFieldDisplay();
+
+ if (!Array.isArray(fieldValue)) {
+ return <>>;
+ }
+
+ return ;
+};
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
new file mode 100644
index 0000000000..aa17427579
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts
@@ -0,0 +1,26 @@
+import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
+import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
+import {
+ type FieldFilesMetadata,
+ type FieldFilesValue,
+} from '@/object-record/record-field/ui/types/FieldMetadata';
+import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecordFieldValue';
+
+import { useContext } from 'react';
+
+export const useFilesFieldDisplay = () => {
+ const { recordId, fieldDefinition } = useContext(FieldContext);
+
+ const { fieldName } = fieldDefinition.metadata;
+
+ const fieldValue = useRecordFieldValue(
+ recordId,
+ fieldName,
+ fieldDefinition,
+ );
+
+ return {
+ fieldDefinition: fieldDefinition as FieldDefinition,
+ fieldValue,
+ };
+};
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 57d1639443..800579d13c 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
@@ -6,6 +6,7 @@ import {
ConnectedAccountProvider,
type AllowedAddressSubField,
type FieldMetadataMultiItemSettings,
+ type FileCategory,
} from 'twenty-shared/types';
import { type ThemeColor } from 'twenty-ui/theme';
import { z } from 'zod';
@@ -189,6 +190,10 @@ export type FieldTsVectorMetadata = BaseFieldMetadata & {
settings?: null;
};
+export type FieldFilesMetadata = BaseFieldMetadata & {
+ settings?: FieldMetadataMultiItemSettings | null;
+};
+
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
@@ -196,6 +201,7 @@ export type FieldMetadata =
| FieldDateMetadata
| FieldEmailMetadata
| FieldEmailsMetadata
+ | FieldFilesMetadata
| FieldFullNameMetadata
| FieldLinkMetadata
| FieldLinksMetadata
@@ -319,3 +325,11 @@ export type FieldPhonesValue = {
primaryPhoneCallingCode?: string;
additionalPhones?: PhoneRecord[] | null;
};
+
+export type FieldFileValue = {
+ fileId: string;
+ label: string;
+ fileCategory: FileCategory;
+};
+
+export type FieldFilesValue = FieldFileValue[];
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFiles.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFiles.ts
new file mode 100644
index 0000000000..9cb271504e
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFiles.ts
@@ -0,0 +1,12 @@
+import { FieldMetadataType } from '~/generated-metadata/graphql';
+
+import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
+import {
+ type FieldFilesMetadata,
+ type FieldMetadata,
+} from '@/object-record/record-field/ui/types/FieldMetadata';
+
+export const isFieldFiles = (
+ field: Pick, 'type'>,
+): field is FieldDefinition =>
+ field.type === FieldMetadataType.FILES;
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
new file mode 100644
index 0000000000..e99bb83cd3
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts
@@ -0,0 +1,20 @@
+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),
+});
+
+export const filesSchema = z.union([z.null(), z.array(fileSchema)]);
+
+export const isFieldFilesValue = (
+ fieldValue: unknown,
+): fieldValue is FieldFilesValue => filesSchema.safeParse(fieldValue).success;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFieldButtonIcon.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFieldButtonIcon.ts
index 139b5e777f..225ee9bcfd 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFieldButtonIcon.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFieldButtonIcon.ts
@@ -9,6 +9,7 @@ import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/is
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { IconPencil, type IconComponent } from 'twenty-ui/display';
export const getFieldButtonIcon = (
@@ -28,6 +29,7 @@ export const getFieldButtonIcon = (
isFieldLinks(fieldDefinition) ||
isFieldEmails(fieldDefinition) ||
isFieldArray(fieldDefinition) ||
+ isFieldFiles(fieldDefinition) ||
isFieldPhones(fieldDefinition)
) {
return IconPencil;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts
index ac8cc9ce32..cc82fd129e 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts
@@ -10,6 +10,8 @@ import { isFieldAddressValue } from '@/object-record/record-field/ui/types/guard
import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray';
import { isFieldArrayValue } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
import { isFieldBoolean } from '@/object-record/record-field/ui/types/guards/isFieldBoolean';
+import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
+import { isFieldFilesValue } from '@/object-record/record-field/ui/types/guards/isFieldFilesValue';
import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '@/object-record/record-field/ui/types/guards/isFieldCurrencyValue';
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
@@ -110,6 +112,14 @@ export const isFieldValueEmpty = ({
);
}
+ if (isFieldFiles(fieldDefinition)) {
+ return (
+ !isFieldFilesValue(fieldValue) ||
+ !isDefined(fieldValue) ||
+ !isNonEmptyArray(fieldValue)
+ );
+ }
+
if (isFieldCurrency(fieldDefinition)) {
return (
!isFieldCurrencyValue(fieldValue) ||
diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields.ts
index 0404a40b13..e495b96e0f 100644
--- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields.ts
+++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields.ts
@@ -86,6 +86,7 @@ export const useBuildSpreadsheetImportFields = () => {
createBaseField(fieldMetadataItem, relationConnectFieldOverrides),
];
+ case FieldMetadataType.FILES:
case FieldMetadataType.POSITION:
case FieldMetadataType.MORPH_RELATION:
case FieldMetadataType.ACTOR:
diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts
index e59804d494..2680569df3 100644
--- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts
+++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts
@@ -354,6 +354,7 @@ export const buildRecordFromImportedStructuredRow = ({
recordToBuild[field.name] = importedFieldValue;
}
break;
+ case FieldMetadataType.FILES:
case FieldMetadataType.MORPH_RELATION:
case FieldMetadataType.POSITION:
case FieldMetadataType.RICH_TEXT:
diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts
index 7eec016225..4fca3e8405 100644
--- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts
+++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts
@@ -123,6 +123,9 @@ export const generateEmptyFieldValue = ({
case FieldMetadataType.TS_VECTOR: {
return null;
}
+ case FieldMetadataType.FILES: {
+ return null;
+ }
default: {
return assertUnreachable(
fieldMetadataItem.type,
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 4b889a1853..a2358d9847 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
@@ -3,6 +3,7 @@ import {
type FieldBooleanValue,
type FieldDateTimeValue,
type FieldDateValue,
+ type FieldFilesValue,
type FieldJsonValue,
type FieldMultiSelectValue,
type FieldNumberValue,
@@ -14,11 +15,12 @@ import {
import { DEFAULT_DATE_VALUE } from '@/settings/data-model/constants/DefaultDateValue';
import { type SettingsFieldTypeCategoryType } from '@/settings/data-model/types/SettingsFieldTypeCategoryType';
import { type SettingsNonCompositeFieldType } from '@/settings/data-model/types/SettingsNonCompositeFieldType';
-import { type FieldRatingValue } from 'twenty-shared/types';
+import { FILE_CATEGORIES, type FieldRatingValue } from 'twenty-shared/types';
import {
IllustrationIconArray,
IllustrationIconCalendarEvent,
IllustrationIconCalendarTime,
+ IllustrationIconFile,
IllustrationIconJson,
IllustrationIconNumbers,
IllustrationIconOneToMany,
@@ -139,4 +141,31 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
category: 'Advanced',
exampleValues: [['value1', 'value2'], ['value3'], []],
} as const satisfies SettingsFieldTypeConfig,
+ [FieldMetadataType.FILES]: {
+ label: 'Files',
+ Icon: IllustrationIconFile,
+ category: 'Advanced',
+ exampleValues: [
+ [
+ {
+ fileId: 'file-1',
+ label: 'Document.pdf',
+ fileCategory: FILE_CATEGORIES.TEXT_DOCUMENT,
+ },
+ {
+ fileId: 'file-2',
+ label: 'Image.png',
+ fileCategory: FILE_CATEGORIES.IMAGE,
+ },
+ ],
+ [
+ {
+ fileId: 'file-3',
+ label: 'Report.xlsx',
+ fileCategory: FILE_CATEGORIES.SPREADSHEET,
+ },
+ ],
+ [],
+ ],
+ } as const satisfies SettingsFieldTypeConfig,
};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx
index fcdce3117a..edd91c12fe 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx
@@ -53,6 +53,10 @@ export const SettingsDataModelFieldMaxValuesForm = ({
title = t`Maximum values`;
description = t`Limit how many values can be added to this field`;
break;
+ case FieldMetadataType.FILES:
+ title = t`Maximum files`;
+ description = t`Limit how many files can be attached to this field`;
+ break;
default:
return null;
}
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
index ba0439d6a0..23d001f8c7 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
@@ -117,6 +117,10 @@ const arrayFieldFormSchema = z
.merge(mergeSettingsSchemas(settingsDataModelFieldMaxValuesSchema))
.extend(isUniqueFieldFormSchema.shape);
+const filesFieldFormSchema = z
+ .object({ type: z.literal(FieldMetadataType.FILES) })
+ .merge(mergeSettingsSchemas(settingsDataModelFieldMaxValuesSchema));
+
const otherFieldsFormSchema = z
.object({
type: z.enum(
@@ -136,6 +140,7 @@ const otherFieldsFormSchema = z
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.ARRAY,
+ FieldMetadataType.FILES,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
@@ -159,6 +164,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
emailsFieldFormSchema,
linksFieldFormSchema,
arrayFieldFormSchema,
+ filesFieldFormSchema,
otherFieldsFormSchema,
],
);
@@ -178,6 +184,7 @@ const previewableTypes = [
FieldMetadataType.DATE,
FieldMetadataType.DATE_TIME,
FieldMetadataType.EMAILS,
+ FieldMetadataType.FILES,
FieldMetadataType.FULL_NAME,
FieldMetadataType.LINKS,
FieldMetadataType.MULTI_SELECT,
@@ -325,6 +332,7 @@ export const SettingsDataModelFieldSettingsFormCard = ({
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.ARRAY,
+ FieldMetadataType.FILES,
].includes(fieldType) && (
<>
,
) => {
if (
- [FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION].includes(
- field.type,
- ) ||
+ [
+ FieldMetadataType.MORPH_RELATION,
+ FieldMetadataType.RELATION,
+ FieldMetadataType.FILES,
+ ].includes(field.type) ||
(isCompositeFieldType(field.type) &&
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].subFields.every(
(subField) => !subField.isIncludedInUniqueConstraint,
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
new file mode 100644
index 0000000000..080604ed64
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx
@@ -0,0 +1,24 @@
+import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
+import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
+import { t } from '@lingui/core/macro';
+import { Chip, ChipVariant } from 'twenty-ui/components';
+
+type FilesDisplayProps = {
+ value: FieldFilesValue;
+};
+
+//TODO: Draft version, UI to be improved
+export const FilesDisplay = ({ value }: FilesDisplayProps) => {
+ return (
+
+ {value?.map((file, index) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/data-model/constants/DefaultIconsByFieldType.ts b/packages/twenty-front/src/pages/settings/data-model/constants/DefaultIconsByFieldType.ts
index e0efb8289c..89495e4f51 100644
--- a/packages/twenty-front/src/pages/settings/data-model/constants/DefaultIconsByFieldType.ts
+++ b/packages/twenty-front/src/pages/settings/data-model/constants/DefaultIconsByFieldType.ts
@@ -6,6 +6,7 @@ export const DEFAULT_ICONS_BY_FIELD_TYPE: Record = {
[FieldMetadataType.CURRENCY]: 'IconMoneybag',
[FieldMetadataType.DATE]: 'IconCalendarEvent',
[FieldMetadataType.DATE_TIME]: 'IconCalendarClock',
+ [FieldMetadataType.FILES]: 'IconFile',
[FieldMetadataType.FULL_NAME]: 'IconUserCircle',
[FieldMetadataType.MULTI_SELECT]: 'IconTags',
[FieldMetadataType.NUMBER]: 'IconNumber9',
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 107223d569..9ebccbaa18 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,6 +6,7 @@ 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';
@@ -14,7 +15,10 @@ 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 { FieldMetadataType } from '~/generated-metadata/graphql';
+import {
+ FeatureFlagKey,
+ FieldMetadataType,
+} from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const settingsDataModelFieldTypeFormSchema = z.object({
@@ -43,6 +47,11 @@ export const SettingsObjectNewFieldSelect = () => {
type: FieldMetadataType.TEXT,
},
});
+
+ const isFilesFieldEnabled = useIsFeatureEnabled(
+ FeatureFlagKey.IS_FILES_FIELD_ENABLED,
+ );
+
const excludedFieldTypes: FieldType[] = (
[
FieldMetadataType.NUMERIC,
@@ -50,6 +59,7 @@ export const SettingsObjectNewFieldSelect = () => {
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.ACTOR,
FieldMetadataType.UUID,
+ !isFilesFieldEnabled ? FieldMetadataType.FILES : undefined,
] as const
).filter(isDefined);
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts
index b9c0529906..df484bd759 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNull, isUndefined } from '@sniptt/guards';
import {
+ FieldMetadataFilesSettings,
FieldMetadataRelationSettings,
FieldMetadataType,
ObjectRecord,
@@ -29,6 +30,7 @@ import { validateBooleanFieldOrThrow } from 'src/engine/api/common/common-args-p
import { validateCurrencyFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-currency-field-or-throw.util';
import { validateDateAndDateTimeFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util';
import { validateEmailsFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util';
+import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
import { validateFullNameFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-full-name-field-or-throw.util';
import { validateLinksFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-links-field-or-throw.util';
import { validateMultiSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-multi-select-field-or-throw.util';
@@ -246,6 +248,15 @@ export class DataArgProcessor {
return transformEmailsValue(validatedValue);
}
+ case FieldMetadataType.FILES: {
+ const validatedValue = validateFilesFieldOrThrow(
+ value,
+ key,
+ fieldMetadata.settings as FieldMetadataFilesSettings,
+ );
+
+ return transformRawJsonField(validatedValue);
+ }
case FieldMetadataType.FULL_NAME: {
const validatedValue = validateFullNameFieldOrThrow(value, key);
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts
new file mode 100644
index 0000000000..72d1bd48d9
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts
@@ -0,0 +1,166 @@
+import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
+import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
+
+describe('validateFilesFieldOrThrow', () => {
+ describe('valid inputs', () => {
+ it('should return null when value is null', () => {
+ const result = validateFilesFieldOrThrow(null, 'testField', {
+ maxNumberOfValues: 10,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('should return the files array when all fields are valid', () => {
+ const filesValue = [
+ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
+ { fileId: '660e8400-e29b-41d4-a716-446655440001', label: 'Document 2' },
+ ];
+ const result = validateFilesFieldOrThrow(filesValue, 'testField', {
+ maxNumberOfValues: 10,
+ });
+
+ expect(result).toEqual(filesValue);
+ });
+
+ it('should return an empty array when value is an empty array', () => {
+ const result = validateFilesFieldOrThrow([], 'testField', {
+ maxNumberOfValues: 10,
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it('should parse and return valid stringified JSON array', () => {
+ const filesValue = [
+ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
+ ];
+ const stringifiedValue = JSON.stringify(filesValue);
+ const result = validateFilesFieldOrThrow(stringifiedValue, 'testField', {
+ maxNumberOfValues: 10,
+ });
+
+ expect(result).toEqual(filesValue);
+ });
+ });
+
+ describe('invalid inputs', () => {
+ it('should throw when value is an invalid JSON string', () => {
+ expect(() =>
+ validateFilesFieldOrThrow('not valid json', 'testField', {
+ maxNumberOfValues: 10,
+ }),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when value is not an array', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
+ 'testField',
+ { maxNumberOfValues: 10 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when value is undefined', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(undefined, 'testField', {
+ maxNumberOfValues: 10,
+ }),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when array item is not an object', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(['not an object'], 'testField', {
+ maxNumberOfValues: 10,
+ }),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when array item is null', () => {
+ expect(() =>
+ validateFilesFieldOrThrow([null], 'testField', {
+ maxNumberOfValues: 10,
+ }),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when fileId key is missing', () => {
+ expect(() =>
+ validateFilesFieldOrThrow([{ label: 'test' }], 'testField', {
+ maxNumberOfValues: 10,
+ }),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when label key is missing', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ [{ fileId: '550e8400-e29b-41d4-a716-446655440000' }],
+ 'testField',
+ { maxNumberOfValues: 10 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when extra keys are present', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ [
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440000',
+ label: 'test',
+ extraKey: 'invalid',
+ },
+ ],
+ 'testField',
+ { maxNumberOfValues: 10 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when fileId is not a valid UUID', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ [{ fileId: 'not-a-uuid', label: 'test' }],
+ 'testField',
+ { maxNumberOfValues: 10 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when fileId is not a string', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ [{ fileId: 12345, label: 'test' }],
+ 'testField',
+ { maxNumberOfValues: 10 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+
+ it('should throw when label is not a string', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ [{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }],
+ 'testField',
+ { maxNumberOfValues: 10 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+ it('should throw when max number of files is exceeded', () => {
+ expect(() =>
+ validateFilesFieldOrThrow(
+ [
+ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
+ { fileId: '550e8400-e29b-41d4-a716-446655440001', label: 'test' },
+ ],
+ 'testField',
+ { maxNumberOfValues: 1 },
+ ),
+ ).toThrow(CommonQueryRunnerException);
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts
new file mode 100644
index 0000000000..7f68251af3
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts
@@ -0,0 +1,79 @@
+import { inspect } from 'util';
+
+import { msg } from '@lingui/core/macro';
+import { isNull } from '@sniptt/guards';
+import { type FieldMetadataFilesSettings } from 'twenty-shared/types';
+import { z } from 'zod';
+
+import {
+ CommonQueryRunnerException,
+ CommonQueryRunnerExceptionCode,
+} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
+
+export const fileItemSchema = z
+ .object({
+ fileId: z.string().uuidv4(),
+ label: z.string(),
+ })
+ .strict();
+
+export const filesFieldSchema = z.array(fileItemSchema);
+
+export type FileItem = z.infer;
+
+export const validateFilesFieldOrThrow = (
+ value: unknown,
+ fieldName: string,
+ settings: FieldMetadataFilesSettings,
+): FileItem[] | null => {
+ if (isNull(value)) return null;
+
+ let parsedValue: unknown = value;
+
+ if (typeof value === 'string') {
+ try {
+ parsedValue = JSON.parse(value);
+ } catch {
+ const inspectedValue = inspect(value);
+
+ throw new CommonQueryRunnerException(
+ `Invalid value "${inspectedValue}" for FILES field "${fieldName}" - It should be an array of objects with "fileId" and "label" properties.`,
+ CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
+ {
+ userFriendlyMessage: msg`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - It should be an array of objects with "fileId" and "label" properties.`,
+ },
+ );
+ }
+ }
+
+ const result = filesFieldSchema.safeParse(parsedValue);
+
+ if (!result.success) {
+ const inspectedValue = inspect(parsedValue);
+ const errorMessage = result.error.issues
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
+ .join(', ');
+
+ throw new CommonQueryRunnerException(
+ `Invalid value "${inspectedValue}" for FILES field "${fieldName}" - ${errorMessage}`,
+ CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
+ {
+ userFriendlyMessage: msg`Invalid value for FILES field "${fieldName}" - ${errorMessage}`,
+ },
+ );
+ }
+
+ if (result.data.length > settings.maxNumberOfValues) {
+ const maxNumberOfValues = settings.maxNumberOfValues;
+
+ throw new CommonQueryRunnerException(
+ `Max number of files is ${maxNumberOfValues}`,
+ CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
+ {
+ userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues}`,
+ },
+ );
+ }
+
+ return result.data;
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/composite-field-metadata-create-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/composite-field-metadata-create-gql-input-type.generator.ts
index 685d6de7cf..f2fa527dec 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/composite-field-metadata-create-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/composite-field-metadata-create-gql-input-type.generator.ts
@@ -5,8 +5,8 @@ import {
GraphQLInputObjectType,
isObjectType,
} from 'graphql';
-import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
@@ -72,7 +72,10 @@ export class CompositeFieldMetadataCreateGqlInputTypeGenerator {
const type = isEnumFieldMetadataType(property.type)
? this.gqlTypesStorage.getGqlTypeByKey(key)
- : this.typeMapperService.mapToScalarType(property.type, typeOptions);
+ : this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: property.type,
+ typeOptions,
+ });
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts
index b257f26b9e..23f755aa7c 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts
@@ -187,10 +187,10 @@ export class ObjectMetadataCreateGqlInputTypeGenerator {
fieldMetadata: FlatFieldMetadata,
typeOptions: TypeOptions,
) {
- const type = this.typeMapperService.mapToScalarType(
- fieldMetadata.type,
+ const type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: fieldMetadata.type,
typeOptions,
- );
+ });
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-connect-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-connect-gql-input-type.generator.ts
index b3eb6a407b..7fed0223ff 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-connect-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-connect-gql-input-type.generator.ts
@@ -123,9 +123,10 @@ export class RelationConnectGqlInputTypeGenerator {
> = {};
uniqueProperties.forEach((property) => {
- const scalarType = this.typeMapperService.mapToScalarType(
- property.type,
- );
+ const scalarType =
+ this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: property.type,
+ });
compositeFields[property.name] = {
type: scalarType || GraphQLString,
@@ -144,10 +145,14 @@ export class RelationConnectGqlInputTypeGenerator {
};
}
} else {
- const scalarType = this.typeMapperService.mapToScalarType(
- field.type,
- { settings: field.settings, isIdField: field.name === 'id' },
- );
+ const scalarType =
+ this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: field.type,
+ typeOptions: {
+ settings: field.settings,
+ isIdField: field.name === 'id',
+ },
+ });
inputFields[field.name] = {
type: scalarType || GraphQLString,
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts
index 86ce60e3d2..6157bfa73b 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts
@@ -45,10 +45,10 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
- const type = this.typeMapperService.mapToScalarType(
- fieldMetadata.type,
+ const type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: fieldMetadata.type,
typeOptions,
- );
+ });
if (!isDefined(type)) {
const message = `Could not find a GraphQL input type for ${type} field metadata`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator.ts
index c4cb31a314..2ddea5c472 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator.ts
@@ -5,8 +5,8 @@ import {
GraphQLInputObjectType,
isObjectType,
} from 'graphql';
-import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
@@ -73,7 +73,10 @@ export class CompositeFieldMetadataUpdateGqlInputTypeGenerator {
const type = isEnumFieldMetadataType(property.type)
? this.gqlTypesStorage.getGqlTypeByKey(key)
- : this.typeMapperService.mapToScalarType(property.type, typeOptions);
+ : this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: property.type,
+ typeOptions,
+ });
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator.ts
index 3fe76af270..f5ee682d22 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator.ts
@@ -4,10 +4,12 @@ import {
GraphQLEnumType,
GraphQLInputFieldConfigMap,
GraphQLInputObjectType,
+ GraphQLList,
GraphQLScalarType,
isEnumType,
isInputObjectType,
isObjectType,
+ type GraphQLInputType,
} from 'graphql';
import { isDefined } from 'twenty-shared/utils';
@@ -97,6 +99,7 @@ export class ObjectMetadataUpdateGqlInputTypeGenerator {
| GraphQLInputObjectType
| GraphQLEnumType
| GraphQLScalarType
+ | GraphQLList
| undefined;
if (isEnumFieldMetadataType(fieldMetadata.type)) {
@@ -138,10 +141,10 @@ export class ObjectMetadataUpdateGqlInputTypeGenerator {
type = compositeType;
} else {
- type = this.typeMapperService.mapToScalarType(
- fieldMetadata.type,
+ type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
+ fieldMetadataType: fieldMetadata.type,
typeOptions,
- );
+ });
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator.ts
index 25c9efbef3..f1ad5622c6 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator.ts
@@ -71,7 +71,10 @@ export class CompositeFieldMetadataGqlObjectTypeGenerator {
const type = isEnumFieldMetadataType(property.type)
? this.gqlTypesStorage.getGqlTypeByKey(key)
- : this.typeMapperService.mapToScalarType(property.type, typeOptions);
+ : this.typeMapperService.mapToPreBuiltGraphQLOutputType({
+ fieldMetadataType: property.type,
+ typeOptions,
+ });
if (!isDefined(type) || isInputObjectType(type)) {
const message = `Could not find a GraphQL object type for ${compositeType.type} ${property.name}`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-gql-object-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-gql-object-type.generator.ts
index be61a9db53..597046e2a7 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-gql-object-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-gql-object-type.generator.ts
@@ -134,10 +134,10 @@ export class ObjectMetadataGqlObjectTypeGenerator {
type = enumFieldEnumType;
} else {
- type = this.typeMapperService.mapToScalarType(
- field.type,
- typeFactoryOptions,
- );
+ type = this.typeMapperService.mapToPreBuiltGraphQLOutputType({
+ fieldMetadataType: field.type,
+ typeOptions: typeFactoryOptions,
+ });
if (!isDefined(type)) {
const message = `Could not find a GraphQL output type for ${field.name} scalar field metadata of object ${objectNameSingular}`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/relation-field-metadata-gql-object-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/relation-field-metadata-gql-object-type.generator.ts
index fa6fd52255..3bbc587474 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/relation-field-metadata-gql-object-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/relation-field-metadata-gql-object-type.generator.ts
@@ -32,10 +32,10 @@ export class RelationFieldMetadataGqlObjectTypeGenerator {
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
- const type = this.typeMapperService.mapToScalarType(
- fieldMetadata.type,
+ const type = this.typeMapperService.mapToPreBuiltGraphQLOutputType({
+ fieldMetadataType: fieldMetadata.type,
typeOptions,
- );
+ });
if (!isDefined(type)) {
const message = `Could not find a GraphQL output type for ${type} field metadata`;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts
new file mode 100644
index 0000000000..29cec4067e
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts
@@ -0,0 +1,18 @@
+import {
+ GraphQLInputObjectType,
+ GraphQLList,
+ GraphQLNonNull,
+ GraphQLString,
+} from 'graphql';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+
+const FileInputType = new GraphQLInputObjectType({
+ name: 'FileInput',
+ fields: {
+ fileId: { type: new GraphQLNonNull(UUIDScalarType) },
+ label: { type: new GraphQLNonNull(GraphQLString) },
+ },
+});
+
+export const FilesInputType = new GraphQLList(FileInputType);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts
new file mode 100644
index 0000000000..fc4f6a3737
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts
@@ -0,0 +1,30 @@
+import {
+ GraphQLEnumType,
+ GraphQLList,
+ GraphQLNonNull,
+ GraphQLObjectType,
+ GraphQLString,
+} from 'graphql';
+import { FILE_CATEGORIES } from 'twenty-shared/types';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+
+const FileCategoryEnumType = new GraphQLEnumType({
+ name: 'FileCategory',
+ values: Object.fromEntries(
+ Object.values(FILE_CATEGORIES).map((value) => [value, { value }]),
+ ),
+});
+
+const FileObjectType = new GraphQLObjectType({
+ name: 'FileObject',
+ fields: {
+ fileId: { type: new GraphQLNonNull(UUIDScalarType) },
+ label: { type: new GraphQLNonNull(GraphQLString) },
+ fileCategory: { type: FileCategoryEnumType },
+ //TODO: Will be made non-nullable in a future PR
+ // fileCategory: { type: new GraphQLNonNull(FileCategoryEnumType) },
+ },
+});
+
+export const FilesObjectType = new GraphQLList(FileObjectType);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts
index 7408fa9cd4..ad071e28e6 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts
@@ -9,17 +9,18 @@ import {
type GraphQLInputType,
GraphQLList,
GraphQLNonNull,
+ type GraphQLOutputType,
type GraphQLScalarType,
GraphQLString,
type GraphQLType,
} from 'graphql';
import GraphQLJSON from 'graphql-type-json';
-import { isDefined } from 'twenty-shared/utils';
import {
- FieldMetadataType,
type FieldMetadataSettings,
+ FieldMetadataType,
NumberDataType,
} from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
@@ -34,11 +35,13 @@ import {
RawJsonFilterType,
StringFilterType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input';
+import { FilesInputType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type';
import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type';
import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
import { TSVectorFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/ts-vector-filter.input-type';
import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type';
+import { FilesObjectType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type';
import {
BigFloatScalarType,
DateScalarType,
@@ -63,43 +66,85 @@ const StringArrayScalarType = new GraphQLList(GraphQLString);
@Injectable()
export class TypeMapperService {
- mapToScalarType(
- fieldMetadataType: FieldMetadataType,
- typeOptions?: TypeOptions,
- ): GraphQLScalarType | undefined {
- if (
- typeOptions?.isIdField ||
- fieldMetadataType === FieldMetadataType.RELATION ||
- fieldMetadataType === FieldMetadataType.MORPH_RELATION
- ) {
+ private readonly baseTypeScalarMapping = new Map<
+ FieldMetadataType,
+ GraphQLScalarType | GraphQLList
+ >([
+ [FieldMetadataType.UUID, UUIDScalarType],
+ [FieldMetadataType.TEXT, GraphQLString],
+ [FieldMetadataType.DATE_TIME, GraphQLISODateTime],
+ [FieldMetadataType.DATE, DateScalarType],
+ [FieldMetadataType.BOOLEAN, GraphQLBoolean],
+ [FieldMetadataType.NUMERIC, BigFloatScalarType],
+ [FieldMetadataType.POSITION, PositionScalarType],
+ [FieldMetadataType.RAW_JSON, GraphQLJSON],
+ [FieldMetadataType.ARRAY, StringArrayScalarType],
+ [FieldMetadataType.RICH_TEXT, GraphQLString],
+ [FieldMetadataType.TS_VECTOR, TSVectorScalarType],
+ ]);
+
+ mapToPreBuiltGraphQLOutputType({
+ fieldMetadataType,
+ typeOptions,
+ }: {
+ fieldMetadataType: FieldMetadataType;
+ typeOptions?: TypeOptions;
+ }): GraphQLScalarType | GraphQLList | undefined {
+ if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
return GraphQLID;
}
- const typeScalarMapping = new Map([
- [FieldMetadataType.UUID, UUIDScalarType],
- [FieldMetadataType.TEXT, GraphQLString],
- [FieldMetadataType.DATE_TIME, GraphQLISODateTime],
- [FieldMetadataType.DATE, DateScalarType],
- [FieldMetadataType.BOOLEAN, GraphQLBoolean],
- [
- FieldMetadataType.NUMBER,
- getNumberScalarType(
- (
- typeOptions?.settings as FieldMetadataSettings
- )?.dataType ?? NumberDataType.FLOAT,
- ),
- ],
- [FieldMetadataType.NUMERIC, BigFloatScalarType],
- [FieldMetadataType.POSITION, PositionScalarType],
- [FieldMetadataType.RAW_JSON, GraphQLJSON],
- [
- FieldMetadataType.ARRAY,
- StringArrayScalarType as unknown as GraphQLScalarType,
- ],
- [FieldMetadataType.RICH_TEXT, GraphQLString],
- [FieldMetadataType.TS_VECTOR, TSVectorScalarType],
- ]);
- return typeScalarMapping.get(fieldMetadataType);
+ if (fieldMetadataType === FieldMetadataType.NUMBER) {
+ return this.getNumberScalarTypeFromOptions(typeOptions);
+ }
+
+ if (fieldMetadataType === FieldMetadataType.FILES) {
+ return FilesObjectType;
+ }
+
+ return this.baseTypeScalarMapping.get(fieldMetadataType);
+ }
+
+ mapToPreBuiltGraphQLInputType({
+ fieldMetadataType,
+ typeOptions,
+ }: {
+ fieldMetadataType: FieldMetadataType;
+ typeOptions?: TypeOptions;
+ }): GraphQLScalarType | GraphQLList | undefined {
+ if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
+ return GraphQLID;
+ }
+
+ if (fieldMetadataType === FieldMetadataType.NUMBER) {
+ return this.getNumberScalarTypeFromOptions(typeOptions);
+ }
+
+ if (fieldMetadataType === FieldMetadataType.FILES) {
+ return FilesInputType;
+ }
+
+ return this.baseTypeScalarMapping.get(fieldMetadataType);
+ }
+
+ private isIdOrRelationType(
+ fieldMetadataType: FieldMetadataType,
+ typeOptions?: TypeOptions,
+ ): boolean {
+ return (
+ typeOptions?.isIdField === true ||
+ fieldMetadataType === FieldMetadataType.RELATION ||
+ fieldMetadataType === FieldMetadataType.MORPH_RELATION
+ );
+ }
+
+ private getNumberScalarTypeFromOptions(
+ typeOptions?: TypeOptions,
+ ): GraphQLScalarType {
+ return getNumberScalarType(
+ (typeOptions?.settings as FieldMetadataSettings)
+ ?.dataType ?? NumberDataType.FLOAT,
+ );
}
mapToFilterType(
@@ -133,6 +178,7 @@ export class TypeMapperService {
],
[FieldMetadataType.NUMERIC, BigFloatFilterType],
[FieldMetadataType.POSITION, FloatFilterType],
+ [FieldMetadataType.FILES, RawJsonFilterType],
[FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.RICH_TEXT_V2, RichTextV2FilterType],
@@ -162,6 +208,7 @@ export class TypeMapperService {
[FieldMetadataType.SELECT, OrderByDirectionType],
[FieldMetadataType.MULTI_SELECT, OrderByDirectionType],
[FieldMetadataType.POSITION, OrderByDirectionType],
+ [FieldMetadataType.FILES, OrderByDirectionType],
[FieldMetadataType.RAW_JSON, OrderByDirectionType],
[FieldMetadataType.RICH_TEXT, OrderByDirectionType],
[FieldMetadataType.ARRAY, OrderByDirectionType],
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 2bf2cd2faa..abc490eecc 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
@@ -13,4 +13,5 @@ export enum FeatureFlagKey {
IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED',
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
+ IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
}
diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts
index ca46acac84..75e346dfeb 100644
--- a/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts
@@ -141,6 +141,10 @@ export const generateRandomFieldValue = ({
return [];
}
+ case FieldMetadataType.FILES: {
+ return null;
+ }
+
case FieldMetadataType.TS_VECTOR: {
throw new Error(
`We should not generate fake version for ${field.type} field`,
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts
index e24d29357a..0757414660 100644
--- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts
@@ -9,6 +9,7 @@ import { z } from 'zod';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
+import { filesFieldSchema } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@@ -243,6 +244,10 @@ export const generateRecordPropertiesZodSchema = (
});
break;
+ case FieldMetadataType.FILES:
+ fieldSchema = filesFieldSchema;
+ break;
+
default:
fieldSchema = getFieldZodType(field);
break;
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts
index 819478d6c3..fefc12af1b 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts
@@ -9,6 +9,7 @@ import { FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-en
import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util';
+import { validateFilesFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util';
import { validateMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util';
import { validateMorphRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-flat-field-metadata.util';
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type';
@@ -60,6 +61,7 @@ export class FlatFieldMetadataTypeValidatorService {
DATE: DEFAULT_NO_VALIDATION,
DATE_TIME: DEFAULT_NO_VALIDATION,
EMAILS: DEFAULT_NO_VALIDATION,
+ FILES: validateFilesFlatFieldMetadata,
FULL_NAME: DEFAULT_NO_VALIDATION,
LINKS: DEFAULT_NO_VALIDATION,
NUMBER: DEFAULT_NO_VALIDATION,
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts
index 01e463af30..f981f22b78 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts
@@ -172,6 +172,7 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
case FieldMetadataType.NUMERIC:
case FieldMetadataType.LINKS:
case FieldMetadataType.CURRENCY:
+ case FieldMetadataType.FILES:
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.POSITION:
case FieldMetadataType.ADDRESS:
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
new file mode 100644
index 0000000000..6c00af9d68
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts
@@ -0,0 +1,89 @@
+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';
+
+const createFlatEntityToValidate = (
+ overrides: Partial> = {},
+): FlatFieldMetadata =>
+ ({
+ type: FieldMetadataType.FILES,
+ name: 'testFilesField',
+ label: 'Test Files Field',
+ settings: { maxNumberOfValues: 5 },
+ isUnique: false,
+ ...overrides,
+ }) as FlatFieldMetadata;
+
+const callValidator = (
+ flatEntityToValidate: FlatFieldMetadata,
+ featureFlagEnabled = true,
+) =>
+ validateFilesFlatFieldMetadata({
+ flatEntityToValidate,
+ additionalCacheDataMaps: {
+ featureFlagsMap: {
+ [FeatureFlagKey.IS_FILES_FIELD_ENABLED]: featureFlagEnabled,
+ },
+ },
+ } as Parameters[0]);
+
+describe('validateFilesFlatFieldMetadata', () => {
+ it('should return no errors for a valid files field', () => {
+ const errors = callValidator(createFlatEntityToValidate());
+
+ 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 }),
+ );
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
+ expect(errors[0].message).toContain(
+ 'Files field is not supported for unique fields',
+ );
+ });
+
+ it('should return error when settings is undefined', () => {
+ const errors = callValidator(
+ createFlatEntityToValidate({ settings: undefined }),
+ );
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
+ expect(errors[0].message).toContain(
+ 'maxNumberOfValues must be defined in settings',
+ );
+ });
+
+ it('should return error when maxNumberOfValues is 0', () => {
+ const errors = callValidator(
+ createFlatEntityToValidate({ settings: { maxNumberOfValues: 0 } }),
+ );
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
+ });
+
+ it('should return error when maxNumberOfValues exceeds max (11)', () => {
+ const errors = callValidator(
+ createFlatEntityToValidate({ settings: { maxNumberOfValues: 11 } }),
+ );
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
+ });
+});
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
new file mode 100644
index 0000000000..effb11e861
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts
@@ -0,0 +1,48 @@
+import { msg } from '@lingui/core/macro';
+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({
+ code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
+ message: 'Files field is not supported for unique fields',
+ userFriendlyMessage: msg`Files field is not supported for unique fields`,
+ });
+ }
+
+ if (
+ !isDefined(flatEntityToValidate?.settings?.maxNumberOfValues) ||
+ flatEntityToValidate.settings.maxNumberOfValues < 1 ||
+ flatEntityToValidate.settings.maxNumberOfValues >
+ FILES_FIELD_MAX_NUMBER_OF_VALUES
+ ) {
+ errors.push({
+ code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
+ message: `maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to ${FILES_FIELD_MAX_NUMBER_OF_VALUES}`,
+ userFriendlyMessage: msg`maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to ${FILES_FIELD_MAX_NUMBER_OF_VALUES}`,
+ });
+ }
+
+ return errors;
+};
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 b4d0e25d2b..d45460fc3e 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
@@ -223,6 +223,7 @@ describe('WorkspaceEntityManager', () => {
IS_IF_ELSE_ENABLED: false,
IS_SSE_DB_EVENTS_ENABLED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
+ IS_FILES_FIELD_ENABLED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
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 d733606cc8..cfc0d4f6a0 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,7 +1,8 @@
import {
FieldMetadataType,
- type FieldMetadataSettings,
+ FILE_CATEGORIES,
NumberDataType,
+ type FieldMetadataSettings,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -330,6 +331,31 @@ export const convertObjectMetadataToSchemaProperties = ({
},
};
break;
+ case FieldMetadataType.FILES:
+ itemProperty = {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ fileId: {
+ type: 'string',
+ format: 'uuid',
+ },
+ label: {
+ type: 'string',
+ },
+ ...(forResponse
+ ? {
+ fileCategory: {
+ type: 'string',
+ enum: Object.values(FILE_CATEGORIES),
+ },
+ }
+ : {}),
+ },
+ },
+ };
+ break;
default:
itemProperty = getFieldProperties(field);
break;
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 8ef3a8a060..30f6e8d0dc 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
@@ -81,6 +81,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
+ {
+ key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
+ workspaceId: workspaceId,
+ value: true,
+ },
])
.execute();
};
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/survey-results-field-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/survey-results-field-seeds.constant.ts
index d563d87b2f..845d35abe5 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/survey-results-field-seeds.constant.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/survey-results-field-seeds.constant.ts
@@ -58,4 +58,13 @@ export const SURVEY_RESULT_CUSTOM_FIELD_SEEDS: FieldMetadataSeed[] = [
displayedMaxRows: 1,
},
} as FieldMetadataDTO,
+ {
+ type: FieldMetadataType.FILES,
+ label: 'Files',
+ name: 'files',
+ icon: 'IconFiles',
+ settings: {
+ maxNumberOfValues: 5,
+ },
+ } as FieldMetadataDTO,
];
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util.ts
index 46f60c09a2..af0becff15 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util.ts
@@ -35,6 +35,7 @@ export const fieldMetadataTypeToColumnType = (
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
return 'enum';
+ case FieldMetadataType.FILES:
case FieldMetadataType.RAW_JSON:
return 'jsonb';
case FieldMetadataType.TS_VECTOR:
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap
new file mode 100644
index 0000000000..3e6d822611
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":"not-a-files-array"} 1`] = `"Expected type "FileInput" to be an object."`;
+
+exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","fileType":"application/pdf"}]} 1`] = `"Field "fileType" is not defined by type "FileInput". Did you mean "fileId"?"`;
+
+exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440001","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440002","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440003","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440004","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440005","label":"Document.pdf"}]} 1`] = `"Max number of files is 2"`;
+
+exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]} 1`] = `"String cannot represent a non string value: 12345"`;
+
+exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"not-a-uuid","label":"Document.pdf"}]} 1`] = `"Invalid UUID"`;
+
+exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"invalidField":"test"}]} 1`] = `"Field "fileId" of required type "UUID!" was not provided."`;
+
+exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":"not-a-files-array"} 1`] = `"["Invalid value \\"'not-a-files-array'\\" for FILES field \\"filesField\\" - It should be an array of objects with \\"fileId\\" and \\"label\\" properties."]"`;
+
+exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","fileType":"application/pdf"}]} 1`] = `"["Invalid value \\"[\\n {\\n fileId: '550e8400-e29b-41d4-a716-446655440000',\\n label: 'Document.pdf',\\n fileType: 'application/pdf'\\n }\\n]\\" for FILES field \\"filesField\\" - 0: Unrecognized key: \\"fileType\\""]"`;
+
+exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440001","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440002","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440003","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440004","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440005","label":"Document.pdf"}]} 1`] = `"["Max number of files is 2"]"`;
+
+exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]} 1`] = `"["Invalid value \\"[ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 } ]\\" for FILES field \\"filesField\\" - 0.label: Invalid input: expected string, received number"]"`;
+
+exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"not-a-uuid","label":"Document.pdf"}]} 1`] = `"["Invalid value \\"[ { fileId: 'not-a-uuid', label: 'Document.pdf' } ]\\" for FILES field \\"filesField\\" - 0.fileId: Invalid UUID"]"`;
+
+exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"invalidField":"test"}]} 1`] = `"["Invalid value \\"[ { invalidField: 'test' } ]\\" for FILES field \\"filesField\\" - 0.fileId: Invalid input: expected string, received undefined, 0.label: Invalid input: expected string, received undefined, 0: Unrecognized key: \\"invalidField\\""]"`;
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts
index 76d4f02162..53873835f7 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts
@@ -386,4 +386,70 @@ export const failingCreateInputByFieldMetadataType: {
},
},
],
+ [FieldMetadataType.FILES]: [
+ {
+ input: {
+ filesField: 'not-a-files-array',
+ },
+ },
+ {
+ input: {
+ filesField: [{ invalidField: 'test' }],
+ },
+ },
+ {
+ input: {
+ filesField: [{ fileId: 'not-a-uuid', label: 'Document.pdf' }],
+ },
+ },
+ {
+ input: {
+ filesField: [
+ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 },
+ ],
+ },
+ },
+ {
+ input: {
+ filesField: [
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440000',
+ label: 'Document.pdf',
+ fileType: 'application/pdf',
+ },
+ ],
+ },
+ },
+ //Should fail because max number of files is 2
+ {
+ input: {
+ filesField: [
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440000',
+ label: 'Document.pdf',
+ },
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440001',
+ label: 'Document.pdf',
+ },
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440002',
+ label: 'Document.pdf',
+ },
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440003',
+ label: 'Document.pdf',
+ },
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440004',
+ label: 'Document.pdf',
+ },
+ {
+ fileId: '550e8400-e29b-41d4-a716-446655440005',
+ label: 'Document.pdf',
+ },
+ ],
+ },
+ },
+ ],
};
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts
index 9071fa27ac..344a50e28d 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts
@@ -509,4 +509,41 @@ export const successfulCreateInputByFieldMetadataType: {
},
},
],
+ [FieldMetadataType.FILES]: [
+ {
+ input: {
+ filesField: [
+ {
+ fileId: '20202020-a21e-4ec2-873b-de4264d89025',
+ label: 'Document.pdf',
+ },
+ ],
+ },
+ validateInput: (record: Record) => {
+ return (
+ Array.isArray(record.filesField) &&
+ record.filesField.length === 1 &&
+ record.filesField[0].fileId ===
+ '20202020-a21e-4ec2-873b-de4264d89025' &&
+ record.filesField[0].label === 'Document.pdf'
+ );
+ },
+ },
+ {
+ input: {
+ filesField: [],
+ },
+ validateInput: (record: Record) => {
+ return record.filesField === null;
+ },
+ },
+ {
+ input: {
+ filesField: null,
+ },
+ validateInput: (record: Record) => {
+ return record.filesField === null;
+ },
+ },
+ ],
};
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
new file mode 100644
index 0000000000..fc1c26fcdb
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts
@@ -0,0 +1,135 @@
+import { failingCreateInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant';
+import { successfulCreateInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant';
+import { expectGqlCreateInputValidationError } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-error.util';
+import { expectGqlCreateInputValidationSuccess } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-success.util';
+import { expectRestCreateInputValidationError } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-rest-create-input-validation-error.util';
+import { expectRestCreateInputValidationSuccess } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-rest-create-input-validation-success.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 =
+ failingCreateInputByFieldMetadataType[FIELD_METADATA_TYPE];
+const successfulTestCases =
+ successfulCreateInputByFieldMetadataType[FIELD_METADATA_TYPE];
+
+describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => {
+ let objectMetadataId: string;
+ let objectMetadataSingularName: string;
+ let objectMetadataPluralName: string;
+ let targetObjectMetadata1Id: string;
+ 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;
+ objectMetadataSingularName = setupTest.objectMetadataSingularName;
+ objectMetadataPluralName = setupTest.objectMetadataPluralName;
+ targetObjectMetadata1Id = setupTest.targetObjectMetadata1Id;
+ targetObjectMetadata2Id = setupTest.targetObjectMetadata2Id;
+ });
+
+ afterAll(async () => {
+ await destroyManyObjectsMetadata([
+ objectMetadataId,
+ targetObjectMetadata1Id,
+ targetObjectMetadata2Id,
+ ]);
+
+ await makeGraphqlAPIRequest(
+ updateFeatureFlagFactory(
+ SEED_APPLE_WORKSPACE_ID,
+ FeatureFlagKey.IS_FILES_FIELD_ENABLED,
+ false,
+ ),
+ );
+ });
+
+ describe('Gql create input - failure', () => {
+ it.each(
+ failingTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedInput: JSON.stringify(testCase.input),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} - should fail with : $stringifiedInput`,
+ async ({ input }) => {
+ await expectGqlCreateInputValidationError(
+ objectMetadataSingularName,
+ input,
+ );
+ },
+ );
+ });
+
+ describe('Rest create input - failure', () => {
+ it.each(
+ failingTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedInput: JSON.stringify(testCase.input),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} - should fail with : $stringifiedInput`,
+ async ({ input }) => {
+ await expectRestCreateInputValidationError(
+ objectMetadataPluralName,
+ input,
+ );
+ },
+ );
+ });
+
+ describe('Gql create input - success', () => {
+ it.each(
+ successfulTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedInput: JSON.stringify(testCase.input),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} - should succeed with : $stringifiedInput`,
+ async ({ input, validateInput }) => {
+ await expectGqlCreateInputValidationSuccess(
+ objectMetadataSingularName,
+ input,
+ validateInput,
+ true,
+ );
+ },
+ );
+ });
+
+ describe('Rest create input - success', () => {
+ it.each(
+ successfulTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedInput: JSON.stringify(testCase.input),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} - should succeed with : $stringifiedInput`,
+ async ({ input, validateInput }) => {
+ await expectRestCreateInputValidationSuccess(
+ objectMetadataPluralName,
+ objectMetadataSingularName,
+ input,
+ validateInput,
+ );
+ },
+ );
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-success.util.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-success.util.ts
index a1729c07bc..e2acce5813 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-success.util.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-success.util.ts
@@ -8,10 +8,18 @@ export const expectGqlCreateInputValidationSuccess = async (
objectMetadataSingularName: string,
input: any,
validateInput: (record: Record) => boolean,
+ withFilesField: boolean = false,
) => {
const createOneOperation = createOneOperationFactory({
objectMetadataSingularName: objectMetadataSingularName,
- gqlFields: TEST_OBJECT_GQL_FIELDS,
+ gqlFields:
+ TEST_OBJECT_GQL_FIELDS +
+ (withFilesField
+ ? ` filesField {
+ fileId
+ label
+ }`
+ : ''),
data: input,
});
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant.ts
index 5e352ca11e..b1b3cf8fe3 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant.ts
@@ -420,4 +420,30 @@ export const failingFilterInputByFieldMetadataType: {
restErrorMessage: 'array value expected',
},
],
+ [FieldMetadataType.FILES]: [
+ {
+ gqlFilterInput: { filesField: { containsIlike: {} } },
+ gqlErrorMessage: 'is not defined by type',
+ restFilterInput: 'filesField[containsAny]:"{}"',
+ restErrorMessage: 'array value expected',
+ },
+ {
+ gqlFilterInput: { filesField: { containsIlike: [] } },
+ gqlErrorMessage: 'is not defined by type',
+ restFilterInput: 'filesField[containsAny]:"[]"',
+ restErrorMessage: 'array value expected',
+ },
+ {
+ gqlFilterInput: { filesField: { containsIlike: true } },
+ gqlErrorMessage: 'is not defined by type',
+ restFilterInput: 'filesField[containsAny]:"true"',
+ restErrorMessage: 'array value expected',
+ },
+ {
+ gqlFilterInput: { filesField: { containsIlike: 2 } },
+ gqlErrorMessage: 'is not defined by type',
+ restFilterInput: 'filesField[containsAny]:2',
+ restErrorMessage: 'array value expected',
+ },
+ ],
};
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts
index e9e74fbedd..cc00dd8af5 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts
@@ -1047,7 +1047,8 @@ export const successfulFilterInputByFieldMetadataType: {
restFilterInput: 'arrayField[is]:NULL',
validateFilter: (record: Record) => {
return (
- Array.isArray(record.arrayField) && record.arrayField.length === 0
+ record.arrayField === null ||
+ (Array.isArray(record.arrayField) && record.arrayField.length === 0)
);
},
},
@@ -1060,4 +1061,20 @@ export const successfulFilterInputByFieldMetadataType: {
// },
// },
],
+ [FieldMetadataType.FILES]: [
+ {
+ gqlFilterInput: { filesField: { is: 'NULL' } },
+ restFilterInput: 'filesField[is]:NULL',
+ validateFilter: (record: Record) => {
+ return record.filesField === null;
+ },
+ },
+ {
+ gqlFilterInput: { filesField: { is: 'NOT_NULL' } },
+ restFilterInput: 'filesField[is]:"NOT_NULL"',
+ validateFilter: (record: Record) => {
+ return Array.isArray(record.filesField) && record.filesField.length > 0;
+ },
+ },
+ ],
};
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
new file mode 100644
index 0000000000..8afa406c9d
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts
@@ -0,0 +1,138 @@
+import { failingFilterInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant';
+import { successfulFilterInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant';
+import { testGqlFailingScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-failing-scenario.util';
+import { testGqlSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util';
+import { testRestFailingScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-failing-scenario.util';
+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 =
+ failingFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
+const successfulTestCases =
+ successfulFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
+
+describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
+ let objectMetadataId: string;
+ let objectMetadataSingularName: string;
+ let objectMetadataPluralName: string;
+ let targetObjectMetadata1Id: string;
+ 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;
+ objectMetadataSingularName = setupTest.objectMetadataSingularName;
+ objectMetadataPluralName = setupTest.objectMetadataPluralName;
+ targetObjectMetadata1Id = setupTest.targetObjectMetadata1Id;
+ targetObjectMetadata2Id = setupTest.targetObjectMetadata2Id;
+ });
+
+ afterAll(async () => {
+ await destroyManyObjectsMetadata([
+ objectMetadataId,
+ targetObjectMetadata1Id,
+ targetObjectMetadata2Id,
+ ]);
+
+ await makeGraphqlAPIRequest(
+ updateFeatureFlagFactory(
+ SEED_APPLE_WORKSPACE_ID,
+ FeatureFlagKey.IS_FILES_FIELD_ENABLED,
+ false,
+ ),
+ );
+ });
+
+ describe('Gql filter input - failure', () => {
+ it.each(
+ failingTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedFilter: JSON.stringify(testCase.gqlFilterInput),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
+ async ({ gqlFilterInput: filter, gqlErrorMessage: errorMessage }) => {
+ await testGqlFailingScenario(
+ objectMetadataSingularName,
+ objectMetadataPluralName,
+ filter,
+ errorMessage,
+ );
+ },
+ );
+ });
+
+ describe('Rest filter input - failure', () => {
+ it.each(
+ failingTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedFilter: JSON.stringify(testCase.restFilterInput),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
+ async ({ restFilterInput: filter, restErrorMessage: errorMessage }) => {
+ await testRestFailingScenario(
+ objectMetadataPluralName,
+ filter,
+ errorMessage,
+ );
+ },
+ );
+ });
+
+ describe('Gql filter input - success', () => {
+ it.each(
+ successfulTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedFilter: JSON.stringify(testCase.gqlFilterInput),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} field type - should succeed with filter : $stringifiedFilter`,
+ async ({ gqlFilterInput: filter, validateFilter }) => {
+ await testGqlSuccessfulScenario(
+ objectMetadataSingularName,
+ objectMetadataPluralName,
+ filter,
+ validateFilter,
+ true,
+ );
+ },
+ );
+ });
+
+ describe('Rest filter input - success', () => {
+ it.each(
+ successfulTestCases.map((testCase) => ({
+ ...testCase,
+ stringifiedFilter: JSON.stringify(testCase.restFilterInput),
+ })),
+ )(
+ `${FIELD_METADATA_TYPE} field type - should succeed with filter : $stringifiedFilter`,
+ async ({ restFilterInput, validateFilter }) => {
+ await testRestSuccessfulScenario(
+ objectMetadataPluralName,
+ restFilterInput,
+ validateFilter,
+ );
+ },
+ );
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util.ts
index a5bfab78eb..a27ac4f6e0 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util.ts
@@ -7,11 +7,19 @@ export const testGqlSuccessfulScenario = async (
objectMetadataPluralName: string,
filter: any,
validateFilter: (record: Record) => boolean,
+ withFilesField: boolean = false,
) => {
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: objectMetadataSingularName,
objectMetadataPluralName: objectMetadataPluralName,
- gqlFields: TEST_OBJECT_GQL_FIELDS,
+ gqlFields:
+ TEST_OBJECT_GQL_FIELDS +
+ (withFilesField
+ ? ` filesField {
+ fileId
+ label
+ }`
+ : ''),
filter,
});
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/get-field-metadata-creation-inputs.util.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/get-field-metadata-creation-inputs.util.ts
index 4ca725ed9e..1c127e791a 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/get-field-metadata-creation-inputs.util.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/get-field-metadata-creation-inputs.util.ts
@@ -5,6 +5,8 @@ import {
import {
FieldMetadataType,
RelationType,
+ type FieldMetadataFilesSettings,
+ type FieldMetadataMultiItemSettings,
type RelationCreationPayload,
} from 'twenty-shared/types';
@@ -18,6 +20,7 @@ type FieldMetadataCreationInput = {
options?: FieldMetadataComplexOption[];
relationCreationPayload?: RelationCreationPayload;
morphRelationsCreationPayload?: RelationCreationPayload[];
+ settings?: FieldMetadataMultiItemSettings | FieldMetadataFilesSettings;
};
export const getFieldMetadataCreationInputs = (
@@ -159,6 +162,15 @@ export const getFieldMetadataCreationInputs = (
type: FieldMetadataType.ARRAY,
objectMetadataId,
},
+ [FieldMetadataType.FILES]: {
+ name: 'filesField',
+ label: 'filesField',
+ type: FieldMetadataType.FILES,
+ objectMetadataId,
+ settings: {
+ maxNumberOfValues: 2,
+ },
+ },
[FieldMetadataType.UUID]: {
name: 'uuidField',
label: 'uuidField',
diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts
index 4eb27ca5f5..a75d61fb22 100644
--- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts
@@ -35,7 +35,9 @@ export const joinColumnNameForManyToOneMorphRelationField1 =
targetObjectMetadataNamePlural: TEST_TARGET_OBJECT_METADATA_NAME_PLURAL_1,
}) + 'Id';
-export const setupTestObjectsWithAllFieldTypes = async () => {
+export const setupTestObjectsWithAllFieldTypes = async (
+ withFilesField: boolean = false,
+) => {
const createdObjectMetadata = await createOneObjectMetadata({
input: {
nameSingular: TEST_OBJECT_METADATA_NAME_SINGULAR,
@@ -163,6 +165,16 @@ export const setupTestObjectsWithAllFieldTypes = async () => {
test: 'test',
},
arrayField: ['test'],
+ ...(withFilesField
+ ? {
+ filesField: [
+ {
+ fileId: '20202020-a21e-4ec2-873b-de4264d89025',
+ label: 'Document.pdf',
+ },
+ ],
+ }
+ : {}),
},
{
id: v4(),
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
new file mode 100644
index 0000000000..183439ab34
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap
@@ -0,0 +1,211 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`createOne FILES field metadata - failing should fail to create files field with isUnique = true 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "Files field is not supported for unique fields",
+ "userFriendlyMessage": "Files field is not supported for unique fields",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "filesFieldUnique",
+ "objectMetadataId": Any,
+ "universalIdentifier": Any,
+ },
+ "metadataName": "fieldMetadata",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ "index": [
+ {
+ "errors": [
+ {
+ "code": "INDEX_FIELD_NOT_FOUND",
+ "message": "Could not find index field related field metadata",
+ "userFriendlyMessage": "Field referenced in index does not exist",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "IDX_UNIQUE_47bf8eae9471bbd64cbb794a3b6",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "index",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 fieldMetadata, 1 index",
+ "summary": {
+ "fieldMetadata": 1,
+ "index": 1,
+ "totalErrors": 2,
+ },
+ "userFriendlyMessage": "Metadata validation failed",
+ },
+ "message": "Multiple validation errors occurred while creating fields",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`createOne FILES field metadata - failing should fail to create files field with maxNumberOfValues = 0 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ "userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "filesFieldInvalid",
+ "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",
+}
+`;
+
+exports[`createOne FILES field metadata - failing should fail to create files field with maxNumberOfValues = 11 (exceeds max) 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ "userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "filesFieldExceeds",
+ "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",
+}
+`;
+
+exports[`createOne FILES field metadata - failing should fail to create files field without settings 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ "userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "filesFieldNoSettings",
+ "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",
+}
+`;
+
+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/__snapshots__/update-one-files-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/update-one-files-field-metadata.integration-spec.ts.snap
new file mode 100644
index 0000000000..97a9ea9c68
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/update-one-files-field-metadata.integration-spec.ts.snap
@@ -0,0 +1,115 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`updateOne FILES field metadata - failing should fail to update files field settings with maxNumberOfValues = 0 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ "userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "testFilesForFailure",
+ "objectMetadataId": Any,
+ "universalIdentifier": Any,
+ },
+ "metadataName": "fieldMetadata",
+ "status": "fail",
+ "type": "update",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 fieldMetadata",
+ "summary": {
+ "fieldMetadata": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "Metadata validation failed",
+ },
+ "message": "Multiple validation errors occurred while updating field",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`updateOne FILES field metadata - failing should fail to update files field settings with maxNumberOfValues = 11 (exceeds max) 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ "userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 10",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "testFilesForFailure",
+ "objectMetadataId": Any,
+ "universalIdentifier": Any,
+ },
+ "metadataName": "fieldMetadata",
+ "status": "fail",
+ "type": "update",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 fieldMetadata",
+ "summary": {
+ "fieldMetadata": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "Metadata validation failed",
+ },
+ "message": "Multiple validation errors occurred while updating field",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`updateOne FILES field metadata - failing should fail to update files field with isUnique = true 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "fieldMetadata": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_FIELD_INPUT",
+ "message": "Files field is not supported for unique fields",
+ "userFriendlyMessage": "Files field is not supported for unique fields",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "id": Any,
+ "name": "testFilesForFailure",
+ "objectMetadataId": Any,
+ "universalIdentifier": Any,
+ },
+ "metadataName": "fieldMetadata",
+ "status": "fail",
+ "type": "update",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 fieldMetadata",
+ "summary": {
+ "fieldMetadata": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "Metadata validation failed",
+ },
+ "message": "Multiple validation errors occurred while updating field",
+ "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
new file mode 100644
index 0000000000..5287a181ea
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts
@@ -0,0 +1,302 @@
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.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 { 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: {
+ nameSingular: 'testFilesFieldObject',
+ namePlural: 'testFilesFieldObjects',
+ labelSingular: 'Test Files Field Object',
+ labelPlural: 'Test Files Field 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 create files field with maxNumberOfValues = 1', async () => {
+ const { data, errors } = await createOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldOne',
+ label: 'Files Field One',
+ type: FieldMetadataType.FILES,
+ settings: { maxNumberOfValues: 1 },
+ },
+ gqlFields: `
+ id
+ type
+ name
+ label
+ settings
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data).not.toBeNull();
+ expect(data.createOneField).toBeDefined();
+ expect(data.createOneField.type).toBe(FieldMetadataType.FILES);
+ expect(data.createOneField.settings).toEqual({ maxNumberOfValues: 1 });
+ });
+
+ it('should create files field with maxNumberOfValues = 5', async () => {
+ const { data, errors } = await createOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldFive',
+ label: 'Files Field Five',
+ type: FieldMetadataType.FILES,
+ settings: { maxNumberOfValues: 5 },
+ },
+ gqlFields: `
+ id
+ type
+ name
+ label
+ settings
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data).not.toBeNull();
+ expect(data.createOneField).toBeDefined();
+ expect(data.createOneField.type).toBe(FieldMetadataType.FILES);
+ expect(data.createOneField.settings).toEqual({ maxNumberOfValues: 5 });
+ });
+
+ it('should create files field with maxNumberOfValues = 10 (max allowed)', async () => {
+ const { data, errors } = await createOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldTen',
+ label: 'Files Field Ten',
+ type: FieldMetadataType.FILES,
+ settings: { maxNumberOfValues: 10 },
+ },
+ gqlFields: `
+ id
+ type
+ name
+ label
+ settings
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data).not.toBeNull();
+ expect(data.createOneField).toBeDefined();
+ expect(data.createOneField.type).toBe(FieldMetadataType.FILES);
+ expect(data.createOneField.settings).toEqual({ maxNumberOfValues: 10 });
+ });
+});
+
+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: {
+ nameSingular: 'testFilesFieldFailingObject',
+ namePlural: 'testFilesFieldFailingObjects',
+ labelSingular: 'Test Files Field Failing Object',
+ labelPlural: 'Test Files Field Failing 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 with maxNumberOfValues = 0', async () => {
+ const { errors } = await createOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldInvalid',
+ label: 'Files Field Invalid',
+ type: FieldMetadataType.FILES,
+ settings: { maxNumberOfValues: 0 },
+ },
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ it('should fail to create files field with maxNumberOfValues = 11 (exceeds max)', async () => {
+ const { errors } = await createOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldExceeds',
+ label: 'Files Field Exceeds',
+ type: FieldMetadataType.FILES,
+ settings: { maxNumberOfValues: 11 },
+ },
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ it('should fail to create files field without settings', async () => {
+ const { errors } = await createOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldNoSettings',
+ label: 'Files Field No Settings',
+ type: FieldMetadataType.FILES,
+ },
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ it('should fail to create files field with isUnique = true', async () => {
+ const { errors } = await createOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ name: 'filesFieldUnique',
+ label: 'Files Field Unique',
+ type: FieldMetadataType.FILES,
+ settings: { maxNumberOfValues: 5 },
+ isUnique: true,
+ },
+ });
+
+ 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
new file mode 100644
index 0000000000..cfc03e662a
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts
@@ -0,0 +1,271 @@
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
+import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
+import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-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 { 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: {
+ nameSingular: 'testFilesUpdateObject',
+ namePlural: 'testFilesUpdateObjects',
+ labelSingular: 'Test Files Update Object',
+ labelPlural: 'Test Files Update 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,
+ });
+ });
+
+ beforeEach(async () => {
+ const { data } = await createOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ type: FieldMetadataType.FILES,
+ name: 'testFiles',
+ label: 'Test Files',
+ description: 'Initial description',
+ icon: 'IconFile',
+ isLabelSyncedWithName: false,
+ settings: {
+ maxNumberOfValues: 5,
+ },
+ },
+ gqlFields: `
+ id
+ `,
+ });
+
+ createdFieldMetadataId = data.createOneField.id;
+ });
+
+ afterEach(async () => {
+ await updateOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload: { isActive: false },
+ },
+ });
+ await deleteOneFieldMetadata({
+ expectToFail: false,
+ input: { idToDelete: createdFieldMetadataId },
+ });
+ });
+
+ it('should update files field basic metadata (label, description, icon)', async () => {
+ const updatePayload = {
+ label: 'Updated Files',
+ description: 'Updated description',
+ icon: 'IconFiles',
+ };
+
+ const { data, errors } = await updateOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload,
+ },
+ gqlFields: `
+ id
+ type
+ name
+ label
+ description
+ icon
+ settings
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data.updateOneField).toMatchObject(updatePayload);
+ });
+
+ it('should update files field settings with maxNumberOfValues = 5', async () => {
+ const updatePayload = {
+ settings: { maxNumberOfValues: 5 },
+ };
+
+ const { data, errors } = await updateOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload,
+ },
+ gqlFields: `
+ id
+ type
+ name
+ label
+ description
+ icon
+ settings
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data.updateOneField).toMatchObject(updatePayload);
+ });
+});
+
+describe('updateOne FILES field metadata - failing', () => {
+ 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: {
+ nameSingular: 'testFilesUpdateFailingObject',
+ namePlural: 'testFilesUpdateFailingObjects',
+ labelSingular: 'Test Files Update Failing Object',
+ labelPlural: 'Test Files Update Failing Objects',
+ icon: 'IconFile',
+ isLabelSyncedWithName: false,
+ },
+ });
+
+ createdObjectMetadataId = data.createOneObject.id;
+
+ const { data: fieldData } = await createOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ objectMetadataId: createdObjectMetadataId,
+ type: FieldMetadataType.FILES,
+ name: 'testFilesForFailure',
+ label: 'Test Files For Failure',
+ description: 'Initial description',
+ icon: 'IconFile',
+ isLabelSyncedWithName: false,
+ settings: {
+ maxNumberOfValues: 5,
+ },
+ },
+ gqlFields: `
+ id
+ `,
+ });
+
+ createdFieldMetadataId = fieldData.createOneField.id;
+ });
+
+ afterAll(async () => {
+ await updateOneFieldMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload: { isActive: false },
+ },
+ });
+ await deleteOneFieldMetadata({
+ expectToFail: false,
+ input: { idToDelete: createdFieldMetadataId },
+ });
+
+ 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 update files field settings with maxNumberOfValues = 0', async () => {
+ const { errors } = await updateOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload: {
+ settings: { maxNumberOfValues: 0 },
+ },
+ },
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ it('should fail to update files field settings with maxNumberOfValues = 11 (exceeds max)', async () => {
+ const { errors } = await updateOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload: {
+ settings: { maxNumberOfValues: 11 },
+ },
+ },
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ it('should fail to update files field with isUnique = true', async () => {
+ const { errors } = await updateOneFieldMetadata({
+ expectToFail: true,
+ input: {
+ idToUpdate: createdFieldMetadataId,
+ updatePayload: {
+ isUnique: true,
+ },
+ },
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+});
diff --git a/packages/twenty-shared/src/constants/FilesFieldMaxNumberOfValues.ts b/packages/twenty-shared/src/constants/FilesFieldMaxNumberOfValues.ts
new file mode 100644
index 0000000000..9111b9f7c7
--- /dev/null
+++ b/packages/twenty-shared/src/constants/FilesFieldMaxNumberOfValues.ts
@@ -0,0 +1 @@
+export const FILES_FIELD_MAX_NUMBER_OF_VALUES = 10;
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index f716f5dfab..50f07917cb 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -17,6 +17,7 @@ export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterV
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
+export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues';
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
diff --git a/packages/twenty-shared/src/types/FieldMetadataSettings.ts b/packages/twenty-shared/src/types/FieldMetadataSettings.ts
index f82cbd4351..043f3a5959 100644
--- a/packages/twenty-shared/src/types/FieldMetadataSettings.ts
+++ b/packages/twenty-shared/src/types/FieldMetadataSettings.ts
@@ -1,9 +1,9 @@
import { type AllowedAddressSubField } from '@/types/AddressFieldsType';
import { type FieldMetadataMultiItemSettings } from '@/types/FieldMetadataMultiItemSettings';
-import { type RelationType } from '@/types/RelationType';
import { type FieldMetadataType } from '@/types/FieldMetadataType';
import { type IsExactly } from '@/types/IsExactly';
import { type RelationOnDeleteAction } from '@/types/RelationOnDeleteAction.type';
+import { type RelationType } from '@/types/RelationType';
export enum NumberDataType {
FLOAT = 'float',
@@ -46,6 +46,10 @@ export type FieldMetadataAddressSettings = {
subFields?: AllowedAddressSubField[];
};
+export type FieldMetadataFilesSettings = {
+ maxNumberOfValues: number;
+};
+
export type FieldMetadataTsVectorSettings = {
asExpression?: string;
generatedType?: 'STORED' | 'VIRTUAL';
@@ -64,6 +68,7 @@ type FieldMetadataSettingsMapping = {
[FieldMetadataType.EMAILS]: FieldMetadataMultiItemSettings | null;
[FieldMetadataType.LINKS]: FieldMetadataMultiItemSettings | null;
[FieldMetadataType.ARRAY]: FieldMetadataMultiItemSettings | null;
+ [FieldMetadataType.FILES]: FieldMetadataFilesSettings;
};
export type AllFieldMetadataSettings =
diff --git a/packages/twenty-shared/src/types/FieldMetadataType.ts b/packages/twenty-shared/src/types/FieldMetadataType.ts
index 287d2c59d6..dbd839aa9e 100644
--- a/packages/twenty-shared/src/types/FieldMetadataType.ts
+++ b/packages/twenty-shared/src/types/FieldMetadataType.ts
@@ -1,27 +1,28 @@
export enum FieldMetadataType {
- UUID = 'UUID',
- TEXT = 'TEXT',
- PHONES = 'PHONES',
- EMAILS = 'EMAILS',
- DATE_TIME = 'DATE_TIME',
- DATE = 'DATE',
+ ACTOR = 'ACTOR',
+ ADDRESS = 'ADDRESS',
+ ARRAY = 'ARRAY',
BOOLEAN = 'BOOLEAN',
+ CURRENCY = 'CURRENCY',
+ DATE = 'DATE',
+ DATE_TIME = 'DATE_TIME',
+ EMAILS = 'EMAILS',
+ FILES = 'FILES',
+ FULL_NAME = 'FULL_NAME',
+ LINKS = 'LINKS',
+ MORPH_RELATION = 'MORPH_RELATION',
+ MULTI_SELECT = 'MULTI_SELECT',
NUMBER = 'NUMBER',
NUMERIC = 'NUMERIC',
- LINKS = 'LINKS',
- CURRENCY = 'CURRENCY',
- FULL_NAME = 'FULL_NAME',
- RATING = 'RATING',
- SELECT = 'SELECT',
- MULTI_SELECT = 'MULTI_SELECT',
- RELATION = 'RELATION',
- MORPH_RELATION = 'MORPH_RELATION',
+ PHONES = 'PHONES',
POSITION = 'POSITION',
- ADDRESS = 'ADDRESS',
+ RATING = 'RATING',
RAW_JSON = 'RAW_JSON',
+ RELATION = 'RELATION',
RICH_TEXT = 'RICH_TEXT',
RICH_TEXT_V2 = 'RICH_TEXT_V2',
- ACTOR = 'ACTOR',
- ARRAY = 'ARRAY',
+ SELECT = 'SELECT',
+ TEXT = 'TEXT',
TS_VECTOR = 'TS_VECTOR',
+ UUID = 'UUID',
}
diff --git a/packages/twenty-shared/src/types/FileCategory.ts b/packages/twenty-shared/src/types/FileCategory.ts
new file mode 100644
index 0000000000..5238c866e5
--- /dev/null
+++ b/packages/twenty-shared/src/types/FileCategory.ts
@@ -0,0 +1,12 @@
+export const FILE_CATEGORIES = {
+ ARCHIVE: 'ARCHIVE',
+ AUDIO: 'AUDIO',
+ IMAGE: 'IMAGE',
+ PRESENTATION: 'PRESENTATION',
+ SPREADSHEET: 'SPREADSHEET',
+ TEXT_DOCUMENT: 'TEXT_DOCUMENT',
+ VIDEO: 'VIDEO',
+ OTHER: 'OTHER',
+} as const;
+
+export type FileCategory = keyof typeof FILE_CATEGORIES;
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 8cc5851ded..e278c51f81 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -101,6 +101,7 @@ export type {
FieldMetadataDateTimeSettings,
FieldMetadataRelationSettings,
FieldMetadataAddressSettings,
+ FieldMetadataFilesSettings,
FieldMetadataTsVectorSettings,
AllFieldMetadataSettings,
FieldMetadataSettings,
@@ -108,6 +109,8 @@ export type {
export { NumberDataType, DateDisplayFormat } from './FieldMetadataSettings';
export { FieldMetadataType } from './FieldMetadataType';
export type { FieldRatingValue } from './FieldRatingValue';
+export type { FileCategory } from './FileCategory';
+export { FILE_CATEGORIES } from './FileCategory';
export { FileFolder } from './FileFolder';
export type {
FilterableFieldType,
diff --git a/packages/twenty-ui/src/assets/icons/illustration-file.svg b/packages/twenty-ui/src/assets/icons/illustration-file.svg
new file mode 100644
index 0000000000..5005f2d576
--- /dev/null
+++ b/packages/twenty-ui/src/assets/icons/illustration-file.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/twenty-ui/src/display/icon/components/IllustrationIconFile.tsx b/packages/twenty-ui/src/display/icon/components/IllustrationIconFile.tsx
new file mode 100644
index 0000000000..bf9a31266e
--- /dev/null
+++ b/packages/twenty-ui/src/display/icon/components/IllustrationIconFile.tsx
@@ -0,0 +1,22 @@
+import { useTheme } from '@emotion/react';
+import { IllustrationIconWrapper } from '@ui/display/icon/components/IllustrationIconWrapper';
+
+import IllustrationIconFileRaw from '@assets/icons/illustration-file.svg?react';
+import { type IconComponentProps } from '@ui/display/icon/types/IconComponent';
+
+type IllustrationIconFileProps = Pick;
+
+export const IllustrationIconFile = (props: IllustrationIconFileProps) => {
+ const theme = useTheme();
+ const size = props.size ?? theme.icon.size.lg;
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts
index 3d759e1e92..b34cf1d5dd 100644
--- a/packages/twenty-ui/src/display/index.ts
+++ b/packages/twenty-ui/src/display/index.ts
@@ -46,6 +46,7 @@ export { IllustrationIconArray } from './icon/components/IllustrationIconArray';
export { IllustrationIconCalendarEvent } from './icon/components/IllustrationIconCalendarEvent';
export { IllustrationIconCalendarTime } from './icon/components/IllustrationIconCalendarTime';
export { IllustrationIconCurrency } from './icon/components/IllustrationIconCurrency';
+export { IllustrationIconFile } from './icon/components/IllustrationIconFile';
export { IllustrationIconJson } from './icon/components/IllustrationIconJson';
export { IllustrationIconLink } from './icon/components/IllustrationIconLink';
export { IllustrationIconMail } from './icon/components/IllustrationIconMail';