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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user