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:
@@ -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
@@ -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) ? (
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
|
||||
+12
@@ -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} />;
|
||||
};
|
||||
+26
@@ -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,
|
||||
};
|
||||
};
|
||||
+14
@@ -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[];
|
||||
|
||||
+12
@@ -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;
|
||||
+20
@@ -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;
|
||||
+2
@@ -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
@@ -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) ||
|
||||
|
||||
+1
@@ -86,6 +86,7 @@ export const useBuildSpreadsheetImportFields = () => {
|
||||
createBaseField(fieldMetadataItem, relationConnectFieldOverrides),
|
||||
];
|
||||
|
||||
case FieldMetadataType.FILES:
|
||||
case FieldMetadataType.POSITION:
|
||||
case FieldMetadataType.MORPH_RELATION:
|
||||
case FieldMetadataType.ACTOR:
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+30
-1
@@ -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>,
|
||||
};
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+5
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -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',
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
|
||||
+11
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNull, isUndefined } from '@sniptt/guards';
|
||||
import {
|
||||
FieldMetadataFilesSettings,
|
||||
FieldMetadataRelationSettings,
|
||||
FieldMetadataType,
|
||||
ObjectRecord,
|
||||
@@ -29,6 +30,7 @@ import { validateBooleanFieldOrThrow } from 'src/engine/api/common/common-args-p
|
||||
import { validateCurrencyFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-currency-field-or-throw.util';
|
||||
import { validateDateAndDateTimeFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util';
|
||||
import { validateEmailsFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util';
|
||||
import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
|
||||
import { validateFullNameFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-full-name-field-or-throw.util';
|
||||
import { validateLinksFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-links-field-or-throw.util';
|
||||
import { validateMultiSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-multi-select-field-or-throw.util';
|
||||
@@ -246,6 +248,15 @@ export class DataArgProcessor {
|
||||
|
||||
return transformEmailsValue(validatedValue);
|
||||
}
|
||||
case FieldMetadataType.FILES: {
|
||||
const validatedValue = validateFilesFieldOrThrow(
|
||||
value,
|
||||
key,
|
||||
fieldMetadata.settings as FieldMetadataFilesSettings,
|
||||
);
|
||||
|
||||
return transformRawJsonField(validatedValue);
|
||||
}
|
||||
case FieldMetadataType.FULL_NAME: {
|
||||
const validatedValue = validateFullNameFieldOrThrow(value, key);
|
||||
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
|
||||
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
describe('validateFilesFieldOrThrow', () => {
|
||||
describe('valid inputs', () => {
|
||||
it('should return null when value is null', () => {
|
||||
const result = validateFilesFieldOrThrow(null, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the files array when all fields are valid', () => {
|
||||
const filesValue = [
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
|
||||
{ fileId: '660e8400-e29b-41d4-a716-446655440001', label: 'Document 2' },
|
||||
];
|
||||
const result = validateFilesFieldOrThrow(filesValue, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
|
||||
it('should return an empty array when value is an empty array', () => {
|
||||
const result = validateFilesFieldOrThrow([], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse and return valid stringified JSON array', () => {
|
||||
const filesValue = [
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
|
||||
];
|
||||
const stringifiedValue = JSON.stringify(filesValue);
|
||||
const result = validateFilesFieldOrThrow(stringifiedValue, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should throw when value is an invalid JSON string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow('not valid json', 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when value is not an array', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when value is undefined', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(undefined, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when array item is not an object', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(['not an object'], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when array item is null', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow([null], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId key is missing', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow([{ label: 'test' }], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when label key is missing', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: '550e8400-e29b-41d4-a716-446655440000' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when extra keys are present', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[
|
||||
{
|
||||
fileId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'test',
|
||||
extraKey: 'invalid',
|
||||
},
|
||||
],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId is not a valid UUID', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: 'not-a-uuid', label: 'test' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId is not a string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: 12345, label: 'test' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when label is not a string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
it('should throw when max number of files is exceeded', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440001', label: 'test' },
|
||||
],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 1 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { inspect } from 'util';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { type FieldMetadataFilesSettings } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
export const fileItemSchema = z
|
||||
.object({
|
||||
fileId: z.string().uuidv4(),
|
||||
label: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const filesFieldSchema = z.array(fileItemSchema);
|
||||
|
||||
export type FileItem = z.infer<typeof fileItemSchema>;
|
||||
|
||||
export const validateFilesFieldOrThrow = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
settings: FieldMetadataFilesSettings,
|
||||
): FileItem[] | null => {
|
||||
if (isNull(value)) return null;
|
||||
|
||||
let parsedValue: unknown = value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
const inspectedValue = inspect(value);
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - It should be an array of objects with "fileId" and "label" properties.`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - It should be an array of objects with "fileId" and "label" properties.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = filesFieldSchema.safeParse(parsedValue);
|
||||
|
||||
if (!result.success) {
|
||||
const inspectedValue = inspect(parsedValue);
|
||||
const errorMessage = result.error.issues
|
||||
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||
.join(', ');
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - ${errorMessage}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid value for FILES field "${fieldName}" - ${errorMessage}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (result.data.length > settings.maxNumberOfValues) {
|
||||
const maxNumberOfValues = settings.maxNumberOfValues;
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Max number of files is ${maxNumberOfValues}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
+5
-2
@@ -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}`;
|
||||
|
||||
+3
-3
@@ -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`;
|
||||
|
||||
+12
-7
@@ -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,
|
||||
|
||||
+3
-3
@@ -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
-2
@@ -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}`;
|
||||
|
||||
+6
-3
@@ -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`;
|
||||
|
||||
+4
-1
@@ -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}`;
|
||||
|
||||
+4
-4
@@ -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}`;
|
||||
|
||||
+3
-3
@@ -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`;
|
||||
|
||||
+18
@@ -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);
|
||||
+30
@@ -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);
|
||||
+83
-36
@@ -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],
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+4
@@ -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`,
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+89
@@ -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);
|
||||
});
|
||||
});
|
||||
+48
@@ -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;
|
||||
};
|
||||
+1
@@ -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: {
|
||||
|
||||
+27
-1
@@ -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;
|
||||
|
||||
+5
@@ -81,6 +81,11 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
+9
@@ -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>,
|
||||
];
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+25
@@ -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\\""]"`;
|
||||
+66
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+37
@@ -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;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+135
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+26
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+18
-1
@@ -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;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+138
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+12
@@ -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',
|
||||
|
||||
+13
-1
@@ -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(),
|
||||
|
||||
+211
@@ -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",
|
||||
}
|
||||
`;
|
||||
+115
@@ -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",
|
||||
}
|
||||
`;
|
||||
+302
@@ -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 });
|
||||
});
|
||||
});
|
||||
+271
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user