Files field - add files field display and input + filtering (#17637)

This PR introduces a new FILES field type for Twenty CRM, allowing users
to attach multiple files to any record.

- Files field display
- Files field preview
- Files field input
- Files field filtering

To test : 
1/ need to activate feature flag `IS_FILES_FIELD_ENABLED` + create a new
FILES field

- display in read only, edit, inline, table
- edit
- filter
- export


closes https://github.com/twentyhq/core-team-issues/issues/2154


<img width="354" height="134" alt="Screenshot 2026-02-02 at 19 11 01"
src="https://github.com/user-attachments/assets/3c3de89e-f6b6-4526-b710-e4c7ee1a6d30"
/>
<img width="1081" height="933" alt="Screenshot 2026-02-02 at 19 10 41"
src="https://github.com/user-attachments/assets/7c9a7278-edb7-4d4a-882c-f7404689d011"
/>
<img width="1073" height="719" alt="Screenshot 2026-02-02 at 19 10 33"
src="https://github.com/user-attachments/assets/79cc372b-56a2-4cbf-b4fe-023adc4753a8"
/>
This commit is contained in:
Etienne
2026-02-04 11:55:48 +01:00
committed by GitHub
parent 9b8eacb7f7
commit 4fcc424e24
73 changed files with 1872 additions and 455 deletions
@@ -7,4 +7,5 @@ export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RATING,
FieldMetadataType.SELECT,
FieldMetadataType.FILES,
];
@@ -7,5 +7,6 @@ export const TEXT_FILTER_TYPES = [
'LINKS',
'ARRAY',
'RAW_JSON',
'FILES',
'UUID',
];
@@ -32,6 +32,7 @@ export const useExportProcessRecordsForCSV = (objectNameSingular: string) => {
case FieldMetadataType.MULTI_SELECT:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.FILES:
return {
...processedRecord,
[field.name]: JSON.stringify(record[field.name]),
@@ -3,6 +3,7 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/AddressFieldInput';
import { DateFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/DateFieldInput';
import { EmailsFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput';
import { FilesFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/FilesFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput';
@@ -24,6 +25,7 @@ import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/is
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
@@ -64,6 +66,8 @@ export const FieldInput = () => {
<TextFieldInput />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput />
) : isFieldFiles(fieldDefinition) ? (
<FilesFieldInput />
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput />
) : isFieldDateTime(fieldDefinition) ? (
@@ -5,6 +5,7 @@ import { FormCurrencyFieldInput } from '@/object-record/record-field/ui/form-typ
import { FormDateFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateFieldInput';
import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput';
import { FormEmailsFieldInput } from '@/object-record/record-field/ui/form-types/components/FormEmailsFieldInput';
import { FormFilesFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFilesFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/ui/form-types/components/FormLinksFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
@@ -39,6 +40,7 @@ import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/is
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect';
@@ -145,6 +147,15 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
readonly={readonly}
/>
) : isFieldFiles(field) ? (
<FormFilesFieldInput
label={field.label}
defaultValue={defaultValue as null | undefined}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
placeholder={placeholder}
/>
) : isFieldPhones(field) ? (
<FormPhoneFieldInput
label={field.label}
@@ -0,0 +1,101 @@
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { TextVariableEditor } from '@/object-record/record-field/ui/form-types/components/TextVariableEditor';
import { useTextVariableEditor } from '@/object-record/record-field/ui/form-types/hooks/useTextVariableEditor';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { t } from '@lingui/core/macro';
import { useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
type FormFilesFieldInputProps = {
label?: string;
error?: string;
defaultValue: string | null | undefined | unknown;
onChange: (value: string | null) => void;
onBlur?: () => void;
readonly?: boolean;
VariablePicker?: VariablePickerComponent;
placeholder?: string;
};
export const FormFilesFieldInput = ({
label,
error,
defaultValue,
placeholder,
onChange,
onBlur,
readonly,
VariablePicker,
}: FormFilesFieldInputProps) => {
const instanceId = useId();
const stringDefaultValue =
typeof defaultValue === 'string'
? defaultValue
: defaultValue
? JSON.stringify(defaultValue)
: undefined;
const editor = useTextVariableEditor({
placeholder: placeholder ?? t`Enter files as JSON array`,
multiline: true,
readonly,
defaultValue: stringDefaultValue,
onUpdate: (editor) => {
const text = turnIntoEmptyStringIfWhitespacesOnly(editor.getText());
if (text === '') {
onChange(null);
return;
}
onChange(text);
},
});
const handleVariableTagInsert = (variableName: string) => {
if (!isDefined(editor)) {
throw new Error(
'Expected the editor to be defined when a variable is selected',
);
}
editor.commands.insertVariableTag(variableName);
};
if (!isDefined(editor)) {
return null;
}
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer multiline>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
multiline
onBlur={onBlur}
>
<TextVariableEditor editor={editor} multiline readonly={readonly} />
</FormFieldInputInnerContainer>
{VariablePicker && !readonly && (
<VariablePicker
instanceId={instanceId}
multiline
onVariableSelect={handleVariableTagInsert}
/>
)}
</FormFieldInputRowContainer>
<InputErrorHelper>{error}</InputErrorHelper>
</FormFieldInputContainer>
);
};
@@ -6,7 +6,9 @@ import { type TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useOpenJunctionRelationFieldInput } from '@/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput';
import { useOpenFilesFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput';
import { useOpenMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationManyToOneFieldInput';
import { useOpenMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput';
import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput';
@@ -18,6 +20,7 @@ import {
type FieldRelationMetadata,
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
@@ -50,6 +53,10 @@ export const useOpenFieldInputEditMode = () => {
const { openMorphRelationManyToOneFieldInput } =
useOpenMorphRelationManyToOneFieldInput();
const { openFilesFieldInput } = useOpenFilesFieldInput();
const { updateOneRecord } = useUpdateOneRecord();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const openFieldInput = useRecoilCallback(
@@ -81,6 +88,36 @@ export const useOpenFieldInputEditMode = () => {
fieldDefinition.metadata.settings,
);
// Handle Files field with custom behavior for empty state
if (isFieldFiles(fieldDefinition)) {
const objectMetadataItem = objectMetadataItems.find(
(item) =>
item.nameSingular ===
fieldDefinition.metadata.objectMetadataNameSingular,
);
if (isDefined(objectMetadataItem)) {
openFilesFieldInput({
fieldName: fieldDefinition.metadata.fieldName,
recordId,
prefix,
updateRecord: (updateInput) => {
updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: updateInput,
});
},
fieldDefinition: {
metadata: {
settings: fieldDefinition.metadata.settings ?? undefined,
},
},
});
return;
}
}
if (
isJunctionRelationsEnabled &&
isOneToMany &&
@@ -203,12 +240,14 @@ export const useOpenFieldInputEditMode = () => {
},
[
openActivityTargetCellEditMode,
openFilesFieldInput,
openJunctionRelationFieldInput,
openMorphRelationManyToOneFieldInput,
openMorphRelationOneToManyFieldInput,
openRelationFromManyFieldInput,
openRelationToOneFieldInput,
pushFocusItemToFocusStack,
updateOneRecord,
],
);
@@ -2,11 +2,13 @@ import { useFilesFieldDisplay } from '@/object-record/record-field/ui/meta-types
import { FilesDisplay } from '@/ui/field/display/components/FilesDisplay';
export const FilesFieldDisplay = () => {
const { fieldValue } = useFilesFieldDisplay();
const { fieldValue, disableChipClick } = useFilesFieldDisplay();
if (!Array.isArray(fieldValue)) {
return <></>;
}
return <FilesDisplay value={fieldValue} />;
return (
<FilesDisplay value={fieldValue} forceDisableClick={disableChipClick} />
);
};
@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { FilesDisplay } from '@/ui/field/display/components/FilesDisplay';
import { ComponentDecorator } from 'twenty-ui/testing';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta<typeof FilesDisplay> = {
title: 'UI/Data/Field/Display/FilesFieldDisplay',
decorators: [MemoryRouterDecorator, ComponentDecorator, SnackBarDecorator],
component: FilesDisplay,
args: {
value: [
{
fileId: 'file-1',
label: 'contract.pdf',
extension: '.pdf',
url: 'https://example.com/contract.pdf',
fileCategory: 'TEXT_DOCUMENT',
},
{
fileId: 'file-2',
label: 'invoice.xlsx',
extension: '.xlsx',
url: 'https://example.com/invoice.xlsx',
fileCategory: 'SPREADSHEET',
},
{
fileId: 'file-3',
label: 'logo.png',
extension: '.png',
url: 'https://example.com/logo.png',
fileCategory: 'IMAGE',
},
],
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof FilesDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'FilesFieldDisplay',
averageThresholdInMs: 0.8,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});
@@ -0,0 +1,42 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { useRecordFieldInput } from '@/object-record/record-field/ui/hooks/useRecordFieldInput';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/ui/states/recordFieldInputDraftValueComponentState';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const useFilesField = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(FieldMetadataType.FILES, isFieldFiles, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldFilesValue[]>(
recordStoreFamilySelector({
recordId,
fieldName: fieldName,
}),
);
const { setDraftValue } = useRecordFieldInput<FieldFilesValue[]>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
);
return {
fieldDefinition,
fieldValue,
draftValue,
setDraftValue,
setFieldValue,
};
};
@@ -9,11 +9,12 @@ import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecor
import { useContext } from 'react';
export const useFilesFieldDisplay = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const { recordId, fieldDefinition, disableChipClick } =
useContext(FieldContext);
const { fieldName } = fieldDefinition.metadata;
const fieldValue = useRecordFieldValue<FieldFilesValue | undefined>(
const fieldValue = useRecordFieldValue<FieldFilesValue[] | undefined>(
recordId,
fieldName,
fieldDefinition,
@@ -22,5 +23,6 @@ export const useFilesFieldDisplay = () => {
return {
fieldDefinition: fieldDefinition as FieldDefinition<FieldFilesMetadata>,
fieldValue,
disableChipClick,
};
};
@@ -0,0 +1,55 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { useUploadFilesFieldFileMutation } from '~/generated-metadata/graphql';
const DEFAULT_VALUE_BEFORE_SERVER_RESPONSE =
'default-value-before-server-response';
export const useUploadFilesFieldFile = () => {
const coreClient = useApolloCoreClient();
const [uploadFilesFieldFile] = useUploadFilesFieldFileMutation({
client: coreClient,
});
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const uploadFile = async (file: File) => {
try {
const result = await uploadFilesFieldFile({
variables: { file },
});
const uploadedFile = result?.data?.uploadFilesFieldFile;
if (!isDefined(uploadedFile)) {
throw new Error(t`File upload failed`);
}
const fileName = file.name;
enqueueSuccessSnackBar({
message: t`File "${fileName}" uploaded successfully`,
});
return {
fileId: uploadedFile.id,
label: file.name,
extension: DEFAULT_VALUE_BEFORE_SERVER_RESPONSE,
url: DEFAULT_VALUE_BEFORE_SERVER_RESPONSE,
};
} catch (error) {
const fileNameForError = file.name;
const errorMessage = String(error);
enqueueErrorSnackBar({
message: t`Failed to upload "${fileNameForError}"`,
});
throw new Error(
t`Failed to upload file "${fileNameForError}": ${errorMessage}`,
);
}
};
return { uploadFile };
};
@@ -0,0 +1,207 @@
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useFilesField } from '@/object-record/record-field/ui/meta-types/hooks/useFilesField';
import { useUploadFilesFieldFile } from '@/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile';
import { FilesFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem';
import { MultiItemFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput';
import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
import { uploadMultipleFiles } from '@/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { filesSchema } from '@/object-record/record-field/ui/types/guards/isFieldFilesValue';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const FilesFieldInput = () => {
const { setDraftValue, draftValue, fieldDefinition } = useFilesField();
const { uploadFile } = useUploadFilesFieldFile();
const { openFileUpload } = useFileUpload();
const { t } = useLingui();
const [isUploading, setIsUploading] = useState(false);
const { enqueueErrorSnackBar } = useSnackBar();
const setFilePreview = useSetRecoilState(filePreviewState);
const isAttachmentPreviewEnabled = useRecoilValue(
isAttachmentPreviewEnabledState,
);
const { onEscape, onClickOutside, onEnter } = useContext(
FieldInputEventContext,
);
const parseFilesArrayToFilesValue = useCallback(
(filesArray: FieldFilesValue[]) => {
const parseResponse = filesSchema.safeParse(filesArray);
if (parseResponse.success) {
return parseResponse.data;
}
return [];
},
[],
);
const files = useMemo(
() => (draftValue ?? []) as FieldFilesValue[],
[draftValue],
);
const maxNumberOfValues =
fieldDefinition.metadata.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
const handleChange = useCallback(
(updatedFiles: FieldFilesValue[]) => {
const nextValue = parseFilesArrayToFilesValue(updatedFiles);
if (isDefined(nextValue)) {
setDraftValue(nextValue);
}
},
[parseFilesArrayToFilesValue, setDraftValue],
);
const handleUploadClick = useCallback(() => {
if (isUploading) {
return;
}
openFileUpload({
multiple: true,
onUpload: async (selectedFiles: File[]) => {
if (
selectedFiles.length > maxNumberOfValues - files.length &&
files.length > 0
) {
enqueueErrorSnackBar({
message: t`Cannot upload more than ${maxNumberOfValues} files`,
});
return;
}
setIsUploading(true);
try {
const uploadedFiles = await uploadMultipleFiles(
selectedFiles,
uploadFile,
);
if (uploadedFiles.length > 0) {
const newFiles = [...files, ...uploadedFiles];
handleChange(newFiles);
onEnter?.({ newValue: parseFilesArrayToFilesValue(newFiles) });
}
} finally {
setIsUploading(false);
}
},
});
}, [
isUploading,
openFileUpload,
files,
maxNumberOfValues,
enqueueErrorSnackBar,
t,
uploadFile,
handleChange,
onEnter,
parseFilesArrayToFilesValue,
]);
const setIsFieldInError = useSetRecoilComponentState(
recordFieldInputIsFieldInErrorComponentState,
);
const handleError = (hasError: boolean, values: FieldFilesValue[]) => {
setIsFieldInError(hasError && values.length === 0);
};
const handleClickOutside = (
updatedFiles: FieldFilesValue[],
event: MouseEvent | TouchEvent,
) => {
onClickOutside?.({
newValue: parseFilesArrayToFilesValue(updatedFiles),
event,
});
};
const handleEscape = (updatedFiles: FieldFilesValue[]) => {
onEscape?.({ newValue: parseFilesArrayToFilesValue(updatedFiles) });
};
const handleEnter = (updatedFiles: FieldFilesValue[]) => {
onEnter?.({ newValue: parseFilesArrayToFilesValue(updatedFiles) });
};
const handlePreview = (file: FieldFilesValue) => {
if (!isAttachmentPreviewEnabled) return;
setFilePreview(file);
};
const validateInput = useCallback(
(input: string) => ({
isValid: input.trim().length > 0,
errorMessage: '',
}),
[],
);
const formatInput = useCallback(
(_input: string, index?: number): FieldFilesValue => {
if (
index !== undefined &&
index >= 0 &&
index < files.length &&
isDefined(files)
) {
const fileToEdit = files[index];
return {
...fileToEdit,
label: _input.trim(),
};
}
throw new Error('Cannot create file from text input');
},
[files],
);
if (files.length === 0) {
return null;
}
return (
<MultiItemFieldInput
items={files}
onChange={handleChange}
onEnter={handleEnter}
onEscape={handleEscape}
onClickOutside={handleClickOutside}
placeholder={t`File label`}
fieldMetadataType={FieldMetadataType.FILES}
validateInput={validateInput}
formatInput={formatInput}
renderItem={({ value: file, index, handleEdit, handleDelete }) => (
<FilesFieldMenuItem
key={file.fileId}
dropdownId={`${MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX}-${fieldDefinition.metadata.fieldName}-${index}`}
file={file}
onEdit={handleEdit}
onDelete={handleDelete}
onClick={() => handlePreview(file)}
/>
)}
newItemLabel={isUploading ? t`Uploading...` : t`Upload file`}
onAddClick={handleUploadClick}
onError={handleError}
maxItemCount={maxNumberOfValues}
/>
);
};
@@ -0,0 +1,49 @@
import { FileIcon } from '@/file/components/FileIcon';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { getFileCategoryFromExtension } from '@/object-record/record-field/ui/utils/getFileCategoryFromExtension';
import { Chip, ChipVariant } from 'twenty-ui/components';
import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
type FilesFieldMenuItemProps = {
dropdownId: string;
onEdit?: () => void;
onDelete?: () => void;
onClick?: () => void;
file: FieldFilesValue;
};
export const FilesFieldMenuItem = ({
dropdownId,
onEdit,
onDelete,
onClick,
file,
}: FilesFieldMenuItemProps) => {
return (
<MultiItemFieldMenuItem
dropdownId={dropdownId}
value={file.label}
onEdit={onEdit}
onDelete={onDelete}
onClick={onClick}
DisplayComponent={({ value }: { value: string }) => (
<Chip
label={value}
leftComponent={
<FileIcon
fileCategory={
file.fileCategory ??
getFileCategoryFromExtension(file.extension ?? '')
}
size="small"
/>
}
variant={ChipVariant.Rounded}
/>
)}
showPrimaryIcon={false}
showSetAsPrimaryButton={false}
showCopyButton={false}
/>
);
};
@@ -1,5 +1,6 @@
import React, { useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { useDebounce } from 'use-debounce';
import {
MultiItemBaseInput,
@@ -9,6 +10,7 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel
import { type PhoneRecord } from '@/object-record/record-field/ui/types/FieldMetadata';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { currentFocusedItemSelector } from '@/ui/utilities/focus/states/currentFocusedItemSelector';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -16,13 +18,14 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilValue } from 'recoil';
import { CustomError } from 'twenty-shared/utils';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { IconCheck, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
type MultiItemFieldInputProps<T> = {
@@ -34,7 +37,7 @@ type MultiItemFieldInputProps<T> = {
onError?: (hasError: boolean, values: any[]) => void;
placeholder: string;
validateInput?: (input: string) => { isValid: boolean; errorMessage: string };
formatInput?: (input: string) => T;
formatInput?: (input: string, itemIndex?: number) => T;
renderItem: (props: {
value: T;
index: number;
@@ -43,6 +46,7 @@ type MultiItemFieldInputProps<T> = {
handleDelete: () => void;
}) => React.ReactNode;
newItemLabel?: string;
onAddClick?: () => void;
fieldMetadataType: FieldMetadataType;
renderInput?: MultiItemBaseInputProps['renderInput'];
maxItemCount?: number;
@@ -61,6 +65,7 @@ export const MultiItemFieldInput = <T,>({
formatInput,
renderItem,
newItemLabel,
onAddClick,
fieldMetadataType,
renderInput,
onClickOutside,
@@ -95,43 +100,51 @@ export const MultiItemFieldInput = <T,>({
listenerId: instanceId,
});
const getItemValueAsString = (index: number): string => {
if (index >= items.length) {
return '';
}
const getItemValueAsString = useCallback(
(index: number): string => {
if (index >= items.length) {
return '';
}
let item;
switch (fieldMetadataType) {
case FieldMetadataType.LINKS:
item = items[index] as { label: string; url: string };
return item.url || '';
case FieldMetadataType.PHONES:
item = items[index] as PhoneRecord;
return item.callingCode + item.number;
case FieldMetadataType.EMAILS:
item = items[index] as string;
return item;
case FieldMetadataType.ARRAY:
item = items[index] as string;
return item;
default:
throw new CustomError(
`Unsupported field type: ${fieldMetadataType}`,
'UNSUPPORTED_FIELD_TYPE',
);
}
};
let item;
switch (fieldMetadataType) {
case FieldMetadataType.LINKS:
item = items[index] as { label: string; url: string };
return item.url || '';
case FieldMetadataType.PHONES:
item = items[index] as PhoneRecord;
return item.callingCode + item.number;
case FieldMetadataType.EMAILS:
item = items[index] as string;
return item;
case FieldMetadataType.ARRAY:
item = items[index] as string;
return item;
case FieldMetadataType.FILES:
item = items[index] as { label: string };
return item.label || '';
default:
throw new CustomError(
`Unsupported field type: ${fieldMetadataType}`,
'UNSUPPORTED_FIELD_TYPE',
);
}
},
[items, fieldMetadataType],
);
const shouldAutoEnterBecauseOnlyOneItemIsAllowed = maxItemCount === 1;
const shouldAutoEditFirstItemOnOpen =
items.length === 0 || maxItemCount === 1;
const [isInputDisplayed, setIsInputDisplayed] = useState(
shouldAutoEditFirstItemOnOpen,
shouldAutoEditFirstItemOnOpen && !isDefined(onAddClick),
);
const [inputValue, setInputValue] = useState(
shouldAutoEditFirstItemOnOpen ? getItemValueAsString(0) : '',
shouldAutoEditFirstItemOnOpen && !isDefined(onAddClick)
? getItemValueAsString(0)
: '',
);
const [itemToEditIndex, setItemToEditIndex] = useState(0);
@@ -142,6 +155,22 @@ export const MultiItemFieldInput = <T,>({
errorMessage: '',
});
const [searchFilter, setSearchFilter] = useState('');
const [debouncedSearchFilter] = useDebounce(searchFilter, 150);
const shouldShowSearch = items.length > 3;
const filteredItems = useMemo(() => {
if (!shouldShowSearch || !debouncedSearchFilter) {
return items;
}
const searchTerm = normalizeSearchText(debouncedSearchFilter);
return items.filter((_item, index) => {
const itemText = getItemValueAsString(index);
return normalizeSearchText(itemText).includes(searchTerm);
});
}, [items, debouncedSearchFilter, shouldShowSearch, getItemValueAsString]);
const isLimitReached =
typeof maxItemCount === 'number' && items.length >= maxItemCount;
@@ -162,6 +191,11 @@ export const MultiItemFieldInput = <T,>({
return;
}
if (isDefined(onAddClick)) {
onAddClick();
return;
}
setIsAddingNewItem(true);
setInputValue('');
setIsInputDisplayed(true);
@@ -204,10 +238,6 @@ export const MultiItemFieldInput = <T,>({
} => {
const sanitizedInput = inputValue.trim();
const newItem = formatInput
? formatInput(sanitizedInput)
: (sanitizedInput as unknown as T);
if (sanitizedInput === '' && isAddingNewItem) {
return { isValid: true, updatedItems: items };
}
@@ -227,6 +257,13 @@ export const MultiItemFieldInput = <T,>({
};
}
const newItem = formatInput
? formatInput(
sanitizedInput,
isAddingNewItem ? undefined : itemToEditIndex,
)
: (sanitizedInput as unknown as T);
if (validateInput !== undefined) {
const validationData = validateInput(sanitizedInput) ?? { isValid: true };
if (!validationData.isValid) {
@@ -252,8 +289,14 @@ export const MultiItemFieldInput = <T,>({
const handleDeleteItem = (index: number) => {
const updatedItems = toSpliced(items, index, 1);
onChange(updatedItems);
setIsInputDisplayed(false);
const shouldShowInputAfterDeletion =
updatedItems.length === 0 && !isDefined(onAddClick);
setIsInputDisplayed(shouldShowInputAfterDeletion);
setIsAddingNewItem(false);
if (shouldShowInputAfterDeletion) {
setInputValue('');
}
};
const handleEscape = () => {
@@ -269,31 +312,46 @@ export const MultiItemFieldInput = <T,>({
return (
<DropdownContent ref={containerRef}>
{!!items.length &&
{shouldShowSearch && !isInputDisplayed && (
<>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) =>
setSearchFilter(
turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
)
}
autoFocus
/>
<DropdownMenuSeparator />
</>
)}
{!!filteredItems.length &&
(!shouldAutoEnterBecauseOnlyOneItemIsAllowed || !isInputDisplayed) && (
<>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) =>
renderItem({
{filteredItems.map((item) => {
const originalIndex = items.indexOf(item);
return renderItem({
value: item,
index,
handleEdit: () => handleEditButtonClick(index),
handleSetPrimary: () => handleSetPrimaryItem(index),
index: originalIndex,
handleEdit: () => handleEditButtonClick(originalIndex),
handleSetPrimary: () => handleSetPrimaryItem(originalIndex),
handleDelete: () => {
handleDeleteItem(index);
handleDeleteItem(originalIndex);
},
}),
)}
});
})}
</DropdownMenuItemsContainer>
{isInputDisplayed || !isLimitReached ? (
<DropdownMenuSeparator />
) : null}
</>
)}
{isInputDisplayed || !items.length ? (
{isInputDisplayed ? (
<MultiItemBaseInput
instanceId={instanceId}
autoFocus
autoFocus={!shouldShowSearch}
placeholder={placeholder}
value={inputValue}
hasError={!errorData.isValid}
@@ -22,6 +22,7 @@ type MultiItemFieldMenuItemProps<T> = {
onSetAsPrimary?: () => void;
onDelete?: () => void;
onCopy?: (value: T) => void;
onClick?: () => void;
DisplayComponent: React.ComponentType<{ value: T }>;
showPrimaryIcon: boolean;
showSetAsPrimaryButton: boolean;
@@ -34,6 +35,7 @@ export const MultiItemFieldMenuItem = <T,>({
onEdit,
onSetAsPrimary,
onDelete,
onClick,
DisplayComponent,
showPrimaryIcon,
showSetAsPrimaryButton,
@@ -77,6 +79,7 @@ export const MultiItemFieldMenuItem = <T,>({
<MenuItemWithOptionDropdown
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
text={<DisplayComponent value={value} />}
isIconDisplayedOnHoverOnly={!showPrimaryIcon && !isDropdownOpen}
RightIcon={!isHovered && showPrimaryIcon ? IconBookmark : null}
@@ -5,6 +5,7 @@ import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
@@ -115,7 +116,11 @@ const meta: Meta = {
title: 'UI/Data/Field/Input/RelationOneToManyFieldInput',
component: RelationOneToManyFieldInputWithContext,
args: {},
decorators: [ObjectMetadataItemsDecorator, SnackBarDecorator],
decorators: [
ObjectMetadataItemsDecorator,
SnackBarDecorator,
FileUploadDecorator,
],
parameters: {
clearMocks: true,
msw: graphqlMocks,
@@ -0,0 +1,179 @@
import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
import { useUploadFilesFieldFile } from '@/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile';
import { uploadMultipleFiles } from '@/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix';
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { recordTableCellEditModePositionComponentState } from '@/object-record/record-table/states/recordTableCellEditModePositionComponentState';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveLastFocusItemFromFocusStackByComponentType } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackByComponentType';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { useLingui } from '@lingui/react/macro';
import { useRecoilCallback } from 'recoil';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
export const useOpenFilesFieldInput = () => {
const { openFileUpload } = useFileUpload();
const { uploadFile } = useUploadFilesFieldFile();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeLastFocusItemFromFocusStackByComponentType } =
useRemoveLastFocusItemFromFocusStackByComponentType();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const recordTableId = useAvailableComponentInstanceId(
RecordTableComponentInstanceContext,
);
const { enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const openFilesFieldInput = useRecoilCallback(
({ snapshot, set }) =>
async ({
fieldName,
recordId,
prefix,
updateRecord,
onClose,
fieldDefinition,
}: {
fieldName: string;
recordId: string;
prefix?: string;
updateRecord: (updateInput: Record<string, unknown>) => void;
onClose?: () => void;
fieldDefinition?: {
metadata: {
settings?: {
maxNumberOfValues?: number;
};
};
};
}) => {
const fieldValue = snapshot
.getLoadable<FieldFilesValue[]>(
recordStoreFamilySelector({
recordId,
fieldName,
}),
)
.getValue();
const instanceId = getRecordFieldInputInstanceId({
recordId,
fieldName,
prefix,
});
if (isDefined(fieldValue) && fieldValue.length > 0) {
pushFocusItemToFocusStack({
focusId: instanceId,
component: {
type: FocusComponentType.OPENED_FIELD_INPUT,
instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
return;
}
const isTableContext = prefix === RECORD_TABLE_CELL_INPUT_ID_PREFIX;
const maxNumberOfValues =
fieldDefinition?.metadata?.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
const currentFileCount = isDefined(fieldValue) ? fieldValue.length : 0;
openFileUpload({
multiple: true,
onUpload: async (selectedFiles: File[]) => {
if (selectedFiles.length + currentFileCount > maxNumberOfValues) {
enqueueErrorSnackBar({
message: t`Cannot upload more than ${maxNumberOfValues} files`,
});
if (isTableContext && isDefined(recordTableId)) {
set(
recordTableCellEditModePositionComponentState.atomFamily({
instanceId: recordTableId,
}),
null,
);
goBackToPreviousDropdownFocusId();
removeLastFocusItemFromFocusStackByComponentType({
componentType: FocusComponentType.OPENED_FIELD_INPUT,
});
} else {
onClose?.();
}
return;
}
try {
const uploadedFiles = await uploadMultipleFiles(
selectedFiles,
uploadFile,
);
if (uploadedFiles.length > 0) {
updateRecord({
[fieldName]: uploadedFiles,
});
}
} finally {
if (isTableContext && isDefined(recordTableId)) {
set(
recordTableCellEditModePositionComponentState.atomFamily({
instanceId: recordTableId,
}),
null,
);
goBackToPreviousDropdownFocusId();
removeLastFocusItemFromFocusStackByComponentType({
componentType: FocusComponentType.OPENED_FIELD_INPUT,
});
} else {
onClose?.();
}
}
},
onCancel: () => {
if (isTableContext && isDefined(recordTableId)) {
set(
recordTableCellEditModePositionComponentState.atomFamily({
instanceId: recordTableId,
}),
null,
);
goBackToPreviousDropdownFocusId();
removeLastFocusItemFromFocusStackByComponentType({
componentType: FocusComponentType.OPENED_FIELD_INPUT,
});
} else {
onClose?.();
}
},
});
},
[
openFileUpload,
uploadFile,
pushFocusItemToFocusStack,
recordTableId,
goBackToPreviousDropdownFocusId,
removeLastFocusItemFromFocusStackByComponentType,
enqueueErrorSnackBar,
t,
],
);
return { openFilesFieldInput };
};
@@ -0,0 +1,18 @@
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isDefined } from 'twenty-shared/utils';
export const uploadMultipleFiles = async (
files: File[],
uploadFile: (file: File) => Promise<FieldFilesValue | undefined>,
): Promise<FieldFilesValue[]> => {
const uploadedFiles: FieldFilesValue[] = [];
for (const file of files) {
const uploadedFile = await uploadFile(file);
if (isDefined(uploadedFile)) {
uploadedFiles.push(uploadedFile);
}
}
return uploadedFiles;
};
@@ -228,6 +228,7 @@ export type FieldMetadata =
| FieldActorMetadata
| FieldArrayMetadata
| FieldTsVectorMetadata
| FieldRawJsonMetadata
| FieldRichTextV2Metadata
| FieldRichTextMetadata;
@@ -335,10 +336,10 @@ export type FieldPhonesValue = {
additionalPhones?: PhoneRecord[] | null;
};
export type FieldFileValue = {
export type FieldFilesValue = {
fileId: string;
label: string;
fileCategory: FileCategory;
extension?: string;
url?: string;
fileCategory?: FileCategory;
};
export type FieldFilesValue = FieldFileValue[];
@@ -11,6 +11,7 @@ import {
type FieldDateTimeMetadata,
type FieldEmailMetadata,
type FieldEmailsMetadata,
type FieldFilesMetadata,
type FieldFullNameMetadata,
type FieldLinkMetadata,
type FieldLinksMetadata,
@@ -46,43 +47,45 @@ type AssertFieldMetadataFunction = <
? FieldEmailMetadata
: E extends 'EMAILS'
? FieldEmailsMetadata
: E extends 'SELECT'
? FieldSelectMetadata
: E extends 'MULTI_SELECT'
? FieldMultiSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'LINKS'
? FieldLinksMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'MORPH_RELATION'
? FieldMorphRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: E extends 'RICH_TEXT_V2'
? FieldRichTextV2Metadata
: E extends 'RICH_TEXT'
? FieldRichTextMetadata
: E extends 'ACTOR'
? FieldActorMetadata
: E extends 'ARRAY'
? FieldArrayMetadata
: E extends 'PHONES'
? FieldPhonesMetadata
: never,
: E extends 'FILES'
? FieldFilesMetadata
: E extends 'SELECT'
? FieldSelectMetadata
: E extends 'MULTI_SELECT'
? FieldMultiSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'LINKS'
? FieldLinksMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'MORPH_RELATION'
? FieldMorphRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: E extends 'RICH_TEXT_V2'
? FieldRichTextV2Metadata
: E extends 'RICH_TEXT'
? FieldRichTextMetadata
: E extends 'ACTOR'
? FieldActorMetadata
: E extends 'ARRAY'
? FieldArrayMetadata
: E extends 'PHONES'
? FieldPhonesMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (
@@ -1,20 +1,26 @@
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),
extension: z.string().optional(),
url: z.string().optional(),
fileCategory: z
.enum([
'ARCHIVE',
'AUDIO',
'IMAGE',
'PRESENTATION',
'SPREADSHEET',
'TEXT_DOCUMENT',
'VIDEO',
'OTHER',
] as const)
.optional(),
});
export const filesSchema = z.union([z.null(), z.array(fileSchema)]);
export const filesSchema = z.array(fileSchema);
export const isFieldFilesValue = (
fieldValue: unknown,
): fieldValue is FieldFilesValue => filesSchema.safeParse(fieldValue).success;
): fieldValue is FieldFilesValue[] => filesSchema.safeParse(fieldValue).success;
@@ -0,0 +1,50 @@
import { FILE_CATEGORIES, type FileCategory } from 'twenty-shared/types';
export const getFileCategoryFromExtension = (
extension?: string,
): FileCategory => {
if (!extension) {
return FILE_CATEGORIES.OTHER;
}
const ext = extension.toLowerCase().replace('.', '');
// Images
if (
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico'].includes(ext)
) {
return FILE_CATEGORIES.IMAGE;
}
// Videos
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v'].includes(ext)) {
return FILE_CATEGORIES.VIDEO;
}
// Audio
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'wma', 'aac'].includes(ext)) {
return FILE_CATEGORIES.AUDIO;
}
// Archives
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) {
return FILE_CATEGORIES.ARCHIVE;
}
// Spreadsheets
if (['xls', 'xlsx', 'csv', 'ods', 'numbers'].includes(ext)) {
return FILE_CATEGORIES.SPREADSHEET;
}
// Presentations
if (['ppt', 'pptx', 'odp', 'key'].includes(ext)) {
return FILE_CATEGORIES.PRESENTATION;
}
// Text documents
if (['doc', 'docx', 'txt', 'rtf', 'odt', 'pdf', 'md'].includes(ext)) {
return FILE_CATEGORIES.TEXT_DOCUMENT;
}
return FILE_CATEGORIES.OTHER;
};
@@ -84,6 +84,11 @@ export const FILTER_OPERANDS_MAP = {
RecordFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
FILES: [
RecordFilterOperand.CONTAINS,
RecordFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
DATE_TIME: [
RecordFilterOperand.IS,
RecordFilterOperand.IS_RELATIVE,
@@ -182,6 +187,8 @@ export const getRecordFilterOperands = ({
return FILTER_OPERANDS_MAP.NUMBER;
case 'RAW_JSON':
return FILTER_OPERANDS_MAP.RAW_JSON;
case 'FILES':
return FILTER_OPERANDS_MAP.FILES;
case 'DATE_TIME':
case 'DATE':
return FILTER_OPERANDS_MAP.DATE_TIME;
@@ -10,6 +10,7 @@ import {
type CurrencyFilter,
type DateFilter,
type EmailsFilter,
type FilesFilter,
type FloatFilter,
type FullNameFilter,
type LeafObjectRecordFilter,
@@ -34,6 +35,7 @@ import {
isMatchingBooleanFilter,
isMatchingCurrencyFilter,
isMatchingDateFilter,
isMatchingFilesFilter,
isMatchingFloatFilter,
isMatchingMultiSelectFilter,
isMatchingRatingFilter,
@@ -270,6 +272,12 @@ export const isRecordMatchingFilter = ({
value: record[filterKey],
});
}
case FieldMetadataType.FILES: {
return isMatchingFilesFilter({
filesFilter: filterValue as FilesFilter,
value: record[filterKey],
});
}
case FieldMetadataType.FULL_NAME: {
const fullNameFilter = filterValue as FullNameFilter;
@@ -5,6 +5,7 @@ import { type RecordTableEmptyStateNoGroupNoRecordAtAll } from '@/object-record/
import { fireEvent, userEvent, within } from 'storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { RecordTableDecorator } from '~/testing/decorators/RecordTableDecorator';
@@ -19,6 +20,7 @@ const meta: Meta = {
decorators: [
ComponentDecorator,
MemoryRouterDecorator,
FileUploadDecorator,
RecordTableDecorator,
ContextStoreDecorator,
SnackBarDecorator,
@@ -73,6 +73,18 @@ export const sanitizeRecordInput = ({
return undefined;
}
if (
isDefined(fieldMetadataItem) &&
fieldMetadataItem.type === FieldMetadataType.FILES &&
Array.isArray(fieldValue)
) {
const cleanedFiles = fieldValue.map((file: any) => ({
fileId: file.fileId,
label: file.label,
}));
return [fieldName, cleanedFiles];
}
// Todo: we should check that the fieldValue is a valid value
// (e.g. a string for a string field, following the right composite structure for composite fields)
return [fieldName, fieldValue];