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
@@ -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,
@@ -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:
@@ -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<FieldMetadataType.FILES>> = {},
): FlatFieldMetadata<FieldMetadataType.FILES> =>
({
type: FieldMetadataType.FILES,
name: 'testFilesField',
label: 'Test Files Field',
settings: { maxNumberOfValues: 5 },
isUnique: false,
...overrides,
}) as FlatFieldMetadata<FieldMetadataType.FILES>;
const callValidator = (
flatEntityToValidate: FlatFieldMetadata<FieldMetadataType.FILES>,
featureFlagEnabled = true,
) =>
validateFilesFlatFieldMetadata({
flatEntityToValidate,
additionalCacheDataMaps: {
featureFlagsMap: {
[FeatureFlagKey.IS_FILES_FIELD_ENABLED]: featureFlagEnabled,
},
},
} as Parameters<typeof validateFilesFlatFieldMetadata>[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);
});
});
@@ -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<FieldMetadataType.FILES>): 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;
};