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:
+11
@@ -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);
|
||||
|
||||
|
||||
+166
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
@@ -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<typeof fileItemSchema>;
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user