From 30c13fc94763460bc7512d10124fbf8eb3f32d7d Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:03:49 +0100 Subject: [PATCH] Files v2 - Add new FILES field type (#17236) In this PR : - New field type FieldMetadataType.FILES added (as jsonb in DB) - Backend: GraphQL types, validation, REST API schema, data processor handling - Frontend: field configuration in settings - Feature flag IS_FILES_FIELD_ENABLED to gate the feature - Integration tests for create/filter validation - Dev seed: Feature flag enabled + FILES field on survey result object To do in next PRs : - Backend : -- new workspaceFile controller (for download) & new workspaceFile upload resolver (for upload) -- pre-hook/post-hook to ensure fileId existence + listener to ensure cleaning - Frontend : FILES field display/edit in table view, record, ... --- .../src/generated-metadata/graphql.ts | 2 + .../twenty-front/src/generated/graphql.ts | 2 + .../ui/components/FieldDisplay.tsx | 4 + .../record-field/ui/hooks/usePersistField.ts | 6 + .../display/components/FilesFieldDisplay.tsx | 12 + .../meta-types/hooks/useFilesFieldDisplay.ts | 26 ++ .../record-field/ui/types/FieldMetadata.ts | 14 + .../ui/types/guards/isFieldFiles.ts | 12 + .../ui/types/guards/isFieldFilesValue.ts | 20 ++ .../ui/utils/getFieldButtonIcon.ts | 2 + .../ui/utils/isFieldValueEmpty.ts | 10 + .../hooks/useBuildSpreadSheetImportFields.ts | 1 + .../buildRecordFromImportedStructuredRow.ts | 1 + .../utils/generateEmptyFieldValue.ts | 3 + .../SettingsNonCompositeFieldTypeConfigs.ts | 31 +- .../SettingsDataModelFieldMaxValuesForm.tsx | 4 + ...SettingsDataModelFieldSettingsFormCard.tsx | 8 + .../fields/forms/utils/canBeUnique.util.ts | 8 +- .../field/display/components/FilesDisplay.tsx | 24 ++ .../constants/DefaultIconsByFieldType.ts | 1 + .../SettingsObjectNewFieldSelect.tsx | 12 +- .../data-arg-processor/data-arg.processor.ts | 11 + ...validate-files-field-or-throw.util.spec.ts | 166 ++++++++++ .../validate-files-field-or-throw.util.ts | 79 +++++ ...etadata-create-gql-input-type.generator.ts | 7 +- ...etadata-create-gql-input-type.generator.ts | 6 +- ...lation-connect-gql-input-type.generator.ts | 19 +- ...ation-field-metadata-gql-type.generator.ts | 6 +- ...etadata-update-gql-input-type.generator.ts | 7 +- ...etadata-update-gql-input-type.generator.ts | 9 +- ...ield-metadata-gql-object-type.generator.ts | 5 +- ...ject-metadata-gql-object-type.generator.ts | 8 +- ...ield-metadata-gql-object-type.generator.ts | 6 +- .../graphql-types/input/files.input-type.ts | 18 ++ .../graphql-types/object/files.object-type.ts | 30 ++ .../services/type-mapper.service.ts | 119 ++++--- .../enums/feature-flag-key.enum.ts | 1 + .../utils/generate-random-field-value.util.ts | 4 + .../record-properties.zod-schema.ts | 5 + ...t-field-metadata-type-validator.service.ts | 2 + ...-to-flat-field-metadatas-to-create.util.ts | 1 + ...ate-files-flat-field-metadata.util.spec.ts | 89 ++++++ ...validate-files-flat-field-metadata.util.ts | 48 +++ .../workspace-entity-manager.spec.ts | 1 + ...ject-metadata-to-schema-properties.util.ts | 28 +- .../core/utils/seed-feature-flags.util.ts | 5 + .../survey-results-field-seeds.constant.ts | 9 + ...field-metadata-type-to-column-type.util.ts | 1 + ...-input-validation.integration-spec.ts.snap | 25 ++ ...e-input-by-field-metadata-type.constant.ts | 66 ++++ ...e-input-by-field-metadata-type.constant.ts | 37 +++ ...reate-input-validation.integration-spec.ts | 135 ++++++++ ...ql-create-input-validation-success.util.ts | 10 +- ...r-input-by-field-metadata-type.constant.ts | 26 ++ ...r-input-by-field-metadata-type.constant.ts | 19 +- ...ilter-input-validation.integration-spec.ts | 138 ++++++++ .../test-gql-successful-scenario.util.ts | 10 +- ...get-field-metadata-creation-inputs.util.ts | 12 + ...-test-objects-with-all-field-types.util.ts | 14 +- ...es-field-metadata.integration-spec.ts.snap | 211 ++++++++++++ ...es-field-metadata.integration-spec.ts.snap | 115 +++++++ ...e-files-field-metadata.integration-spec.ts | 302 ++++++++++++++++++ ...e-files-field-metadata.integration-spec.ts | 271 ++++++++++++++++ .../constants/FilesFieldMaxNumberOfValues.ts | 1 + packages/twenty-shared/src/constants/index.ts | 1 + .../src/types/FieldMetadataSettings.ts | 7 +- .../src/types/FieldMetadataType.ts | 35 +- .../twenty-shared/src/types/FileCategory.ts | 12 + packages/twenty-shared/src/types/index.ts | 3 + .../src/assets/icons/illustration-file.svg | 4 + .../icon/components/IllustrationIconFile.tsx | 22 ++ packages/twenty-ui/src/display/index.ts | 1 + 72 files changed, 2278 insertions(+), 92 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFiles.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx create mode 100644 packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/update-one-files-field-metadata.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts create mode 100644 packages/twenty-shared/src/constants/FilesFieldMaxNumberOfValues.ts create mode 100644 packages/twenty-shared/src/types/FileCategory.ts create mode 100644 packages/twenty-ui/src/assets/icons/illustration-file.svg create mode 100644 packages/twenty-ui/src/display/icon/components/IllustrationIconFile.tsx 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';