Files v2 - FILES field update/create in common API (#17400)
## FILES Field update interface
```
mutation {
updateCompany(
id: "company-uuid"
data: {
filesfield: [
{ fileId: "new-file-uuid", label: "new-document.pdf" }
]
}
) {
id
filesfield {
fileId
label
extension
}
}
}
```
This commit is contained in:
+1
-1
@@ -251,7 +251,7 @@ export class DataArgProcessor {
|
||||
const validatedValue = validateFilesFieldOrThrow(
|
||||
value,
|
||||
key,
|
||||
fieldMetadata.settings as FieldMetadataSettingsMapping['FILES'],
|
||||
fieldMetadata.settings as FieldMetadataSettingsMapping[FieldMetadataType.FILES],
|
||||
);
|
||||
|
||||
return transformRawJsonField(validatedValue);
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type FileItemInput = {
|
||||
fileId: string;
|
||||
label: string;
|
||||
};
|
||||
+65
-53
@@ -2,43 +2,68 @@ import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-pro
|
||||
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
describe('validateFilesFieldOrThrow', () => {
|
||||
const mockSettings = { maxNumberOfValues: 10 };
|
||||
|
||||
describe('valid inputs', () => {
|
||||
it('should return null when value is null', () => {
|
||||
const result = validateFilesFieldOrThrow(null, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
const result = validateFilesFieldOrThrow(null, 'testField', mockSettings);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the files array when all fields are valid', () => {
|
||||
it('should return the files array when value is valid', () => {
|
||||
const filesValue = [
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
|
||||
{ fileId: '660e8400-e29b-41d4-a716-446655440001', label: 'Document 2' },
|
||||
{
|
||||
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,
|
||||
});
|
||||
const result = validateFilesFieldOrThrow(
|
||||
filesValue,
|
||||
'testField',
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
|
||||
it('should return an empty array when value is an empty array', () => {
|
||||
const result = validateFilesFieldOrThrow([], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
const result = validateFilesFieldOrThrow([], 'testField', mockSettings);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse and return valid stringified JSON array', () => {
|
||||
const filesValue = [
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
|
||||
{
|
||||
fileId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Document 1',
|
||||
},
|
||||
];
|
||||
const stringifiedValue = JSON.stringify(filesValue);
|
||||
const result = validateFilesFieldOrThrow(stringifiedValue, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
const result = validateFilesFieldOrThrow(
|
||||
stringifiedValue,
|
||||
'testField',
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
|
||||
it('should accept files array up to max limit', () => {
|
||||
const filesValue = Array.from({ length: 10 }, (_, index) => ({
|
||||
fileId: `550e8400-e29b-41d4-a716-44665544000${index}`,
|
||||
label: `Document ${index}`,
|
||||
}));
|
||||
const result = validateFilesFieldOrThrow(
|
||||
filesValue,
|
||||
'testField',
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
@@ -47,51 +72,39 @@ describe('validateFilesFieldOrThrow', () => {
|
||||
describe('invalid inputs', () => {
|
||||
it('should throw when value is an invalid JSON string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow('not valid json', 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
validateFilesFieldOrThrow('not valid json', 'testField', mockSettings),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when value is not an array', () => {
|
||||
it('should throw when value is an object instead of array', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
mockSettings,
|
||||
),
|
||||
).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,
|
||||
}),
|
||||
validateFilesFieldOrThrow(['not an object'], 'testField', mockSettings),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when array item is null', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow([null], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
validateFilesFieldOrThrow([null], 'testField', mockSettings),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId key is missing', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow([{ label: 'test' }], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
validateFilesFieldOrThrow(
|
||||
[{ label: 'test' }],
|
||||
'testField',
|
||||
mockSettings,
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
@@ -100,12 +113,12 @@ describe('validateFilesFieldOrThrow', () => {
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: '550e8400-e29b-41d4-a716-446655440000' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
mockSettings,
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when extra keys are present', () => {
|
||||
it('should throw when extra keys are present in file item', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[
|
||||
@@ -116,7 +129,7 @@ describe('validateFilesFieldOrThrow', () => {
|
||||
},
|
||||
],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
mockSettings,
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
@@ -126,7 +139,7 @@ describe('validateFilesFieldOrThrow', () => {
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: 'not-a-uuid', label: 'test' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
mockSettings,
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
@@ -136,7 +149,7 @@ describe('validateFilesFieldOrThrow', () => {
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: 12345, label: 'test' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
mockSettings,
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
@@ -146,20 +159,19 @@ describe('validateFilesFieldOrThrow', () => {
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
mockSettings,
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
it('should throw when max number of files is exceeded', () => {
|
||||
|
||||
it('should throw when number of files exceeds max limit', () => {
|
||||
const filesValue = Array.from({ length: 11 }, (_, index) => ({
|
||||
fileId: `550e8400-e29b-41d4-a716-44665544000${index}`,
|
||||
label: `Document ${index}`,
|
||||
}));
|
||||
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440001', label: 'test' },
|
||||
],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 1 },
|
||||
),
|
||||
validateFilesFieldOrThrow(filesValue, 'testField', mockSettings),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-8
@@ -2,15 +2,19 @@ import { inspect } from 'util';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import {
|
||||
type FieldMetadataSettingsMapping,
|
||||
type FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
import { type FieldMetadataSettingsMapping } from 'twenty-shared/types';
|
||||
|
||||
import { type FileItemInput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
export const fileItemSchema = z
|
||||
const fileItemSchema = z
|
||||
.object({
|
||||
fileId: z.string().uuidv4(),
|
||||
label: z.string(),
|
||||
@@ -19,13 +23,11 @@ export const fileItemSchema = z
|
||||
|
||||
export const filesFieldSchema = z.array(fileItemSchema);
|
||||
|
||||
export type FileItem = z.infer<typeof fileItemSchema>;
|
||||
|
||||
export const validateFilesFieldOrThrow = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
settings: FieldMetadataSettingsMapping['FILES'],
|
||||
): FileItem[] | null => {
|
||||
settings: FieldMetadataSettingsMapping[FieldMetadataType.FILES],
|
||||
): FileItemInput[] | null => {
|
||||
if (isNull(value)) return null;
|
||||
|
||||
let parsedValue: unknown = value;
|
||||
@@ -67,10 +69,10 @@ export const validateFilesFieldOrThrow = (
|
||||
const maxNumberOfValues = settings.maxNumberOfValues;
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Max number of files is ${maxNumberOfValues}`,
|
||||
`Max number of files is ${maxNumberOfValues} for field "${fieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues}`,
|
||||
userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues} for field "${fieldName}"`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,6 @@ import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
|
||||
import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
|
||||
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
|
||||
@@ -31,6 +30,7 @@ import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runne
|
||||
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
|
||||
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
|
||||
+1
-1
@@ -5,7 +5,6 @@ import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
} from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
|
||||
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
Reference in New Issue
Block a user