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, ...
This commit is contained in:
Etienne
2026-01-20 18:03:49 +01:00
committed by GitHub
parent 41329a0ea9
commit 30c13fc947
72 changed files with 2278 additions and 92 deletions
@@ -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 = () => {
<ActorFieldDisplay />
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldDisplay />
) : isFieldFiles(fieldDefinition) ? (
<FilesFieldDisplay />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay />
) : isFieldPhones(fieldDefinition) ? (
@@ -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;
@@ -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 <FilesDisplay value={fieldValue} />;
};
@@ -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<FieldFilesValue | undefined>(
recordId,
fieldName,
fieldDefinition,
);
return {
fieldDefinition: fieldDefinition as FieldDefinition<FieldFilesMetadata>,
fieldValue,
};
};
@@ -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[];
@@ -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<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldFilesMetadata> =>
field.type === FieldMetadataType.FILES;
@@ -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;
@@ -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;
@@ -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) ||
@@ -86,6 +86,7 @@ export const useBuildSpreadsheetImportFields = () => {
createBaseField(fieldMetadataItem, relationConnectFieldOverrides),
];
case FieldMetadataType.FILES:
case FieldMetadataType.POSITION:
case FieldMetadataType.MORPH_RELATION:
case FieldMetadataType.ACTOR:
@@ -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:
@@ -123,6 +123,9 @@ export const generateEmptyFieldValue = ({
case FieldMetadataType.TS_VECTOR: {
return null;
}
case FieldMetadataType.FILES: {
return null;
}
default: {
return assertUnreachable(
fieldMetadataItem.type,