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
@@ -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',
@@ -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',
@@ -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,
@@ -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<FieldArrayValue>,
[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<FieldFilesValue>,
};
@@ -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;
}
@@ -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) && (
<>
<SettingsDataModelFieldMaxValuesForm
@@ -7,9 +7,11 @@ export const canBeUnique = (
field: Pick<FieldMetadataItem, 'type' | 'isCustom'>,
) => {
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,
@@ -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 (
<ExpandableList>
{value?.map((file, index) => (
<Chip
key={`${file.fileId}-${index}`}
variant={ChipVariant.Highlighted}
label={file.label}
emptyLabel={t`Untitled`}
/>
))}
</ExpandableList>
);
};
@@ -6,6 +6,7 @@ export const DEFAULT_ICONS_BY_FIELD_TYPE: Record<FieldMetadataType, string> = {
[FieldMetadataType.CURRENCY]: 'IconMoneybag',
[FieldMetadataType.DATE]: 'IconCalendarEvent',
[FieldMetadataType.DATE_TIME]: 'IconCalendarClock',
[FieldMetadataType.FILES]: 'IconFile',
[FieldMetadataType.FULL_NAME]: 'IconUserCircle',
[FieldMetadataType.MULTI_SELECT]: 'IconTags',
[FieldMetadataType.NUMBER]: 'IconNumber9',
@@ -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);
@@ -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);
@@ -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);
});
});
});
@@ -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;
};
@@ -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}`;
@@ -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`;
@@ -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,
@@ -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`;
@@ -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}`;
@@ -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<GraphQLInputType>
| 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`;
@@ -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}`;
@@ -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}`;
@@ -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`;
@@ -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);
@@ -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);
@@ -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<GraphQLScalarType>
>([
[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<GraphQLOutputType> | undefined {
if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
return GraphQLID;
}
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
[FieldMetadataType.UUID, UUIDScalarType],
[FieldMetadataType.TEXT, GraphQLString],
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
[FieldMetadataType.DATE, DateScalarType],
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
[
FieldMetadataType.NUMBER,
getNumberScalarType(
(
typeOptions?.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>
)?.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<GraphQLInputType> | 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<FieldMetadataType.NUMBER>)
?.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],
@@ -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',
}
@@ -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`,
@@ -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;
@@ -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;
};
@@ -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: {
@@ -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;
@@ -81,6 +81,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
workspaceId: workspaceId,
value: true,
},
])
.execute();
};
@@ -58,4 +58,13 @@ export const SURVEY_RESULT_CUSTOM_FIELD_SEEDS: FieldMetadataSeed[] = [
displayedMaxRows: 1,
},
} as FieldMetadataDTO<FieldMetadataType.TEXT>,
{
type: FieldMetadataType.FILES,
label: 'Files',
name: 'files',
icon: 'IconFiles',
settings: {
maxNumberOfValues: 5,
},
} as FieldMetadataDTO<FieldMetadataType.FILES>,
];
@@ -35,6 +35,7 @@ export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
return 'enum';
case FieldMetadataType.FILES:
case FieldMetadataType.RAW_JSON:
return 'jsonb';
case FieldMetadataType.TS_VECTOR:
@@ -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\\""]"`;
@@ -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',
},
],
},
},
],
};
@@ -509,4 +509,41 @@ export const successfulCreateInputByFieldMetadataType: {
},
},
],
[FieldMetadataType.FILES]: [
{
input: {
filesField: [
{
fileId: '20202020-a21e-4ec2-873b-de4264d89025',
label: 'Document.pdf',
},
],
},
validateInput: (record: Record<string, any>) => {
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<string, any>) => {
return record.filesField === null;
},
},
{
input: {
filesField: null,
},
validateInput: (record: Record<string, any>) => {
return record.filesField === null;
},
},
],
};
@@ -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,
);
},
);
});
});
@@ -8,10 +8,18 @@ export const expectGqlCreateInputValidationSuccess = async (
objectMetadataSingularName: string,
input: any,
validateInput: (record: Record<string, any>) => 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,
});
@@ -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',
},
],
};
@@ -1047,7 +1047,8 @@ export const successfulFilterInputByFieldMetadataType: {
restFilterInput: 'arrayField[is]:NULL',
validateFilter: (record: Record<string, any>) => {
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<string, any>) => {
return record.filesField === null;
},
},
{
gqlFilterInput: { filesField: { is: 'NOT_NULL' } },
restFilterInput: 'filesField[is]:"NOT_NULL"',
validateFilter: (record: Record<string, any>) => {
return Array.isArray(record.filesField) && record.filesField.length > 0;
},
},
],
};
@@ -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,
);
},
);
});
});
@@ -7,11 +7,19 @@ export const testGqlSuccessfulScenario = async (
objectMetadataPluralName: string,
filter: any,
validateFilter: (record: Record<string, any>) => 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,
});
@@ -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',
@@ -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(),
@@ -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<String>,
"name": "filesFieldUnique",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "IDX_UNIQUE_47bf8eae9471bbd64cbb794a3b6",
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "filesFieldInvalid",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "filesFieldExceeds",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "filesFieldNoSettings",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "filesFieldDisabled",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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",
}
`;
@@ -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<String>,
"name": "testFilesForFailure",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "testFilesForFailure",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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<String>,
"name": "testFilesForFailure",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"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",
}
`;
@@ -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 });
});
});
@@ -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 });
});
});
@@ -0,0 +1 @@
export const FILES_FIELD_MAX_NUMBER_OF_VALUES = 10;
@@ -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';
@@ -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 =
@@ -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',
}
@@ -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;
@@ -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,
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 4.5C5.5 3.94772 5.94772 3.5 6.5 3.5H13.5L18.5 8.5V19.5C18.5 20.0523 18.0523 20.5 17.5 20.5H6.5C5.94772 20.5 5.5 20.0523 5.5 19.5V4.5Z" fill="currentFill"/>
<path d="M13.5 3.5V8.5H18.5M5.5 4.5C5.5 3.94772 5.94772 3.5 6.5 3.5H13.5L18.5 8.5V19.5C18.5 20.0523 18.0523 20.5 17.5 20.5H6.5C5.94772 20.5 5.5 20.0523 5.5 19.5V4.5Z" stroke="currentColor" stroke-width="1.49625" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

@@ -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<IconComponentProps, 'size'>;
export const IllustrationIconFile = (props: IllustrationIconFileProps) => {
const theme = useTheme();
const size = props.size ?? theme.icon.size.lg;
return (
<IllustrationIconWrapper>
<IllustrationIconFileRaw
height={size}
width={size}
fill={theme.accent.accent3}
color={theme.accent.accent8}
/>
</IllustrationIconWrapper>
);
};
+1
View File
@@ -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';