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:
+1
@@ -7,4 +7,5 @@ export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
FieldMetadataType.RATING,
|
||||
FieldMetadataType.SELECT,
|
||||
FieldMetadataType.FILES,
|
||||
];
|
||||
|
||||
+1
@@ -7,5 +7,6 @@ export const TEXT_FILTER_TYPES = [
|
||||
'LINKS',
|
||||
'ARRAY',
|
||||
'RAW_JSON',
|
||||
'FILES',
|
||||
'UUID',
|
||||
];
|
||||
|
||||
+1
@@ -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]),
|
||||
|
||||
+4
@@ -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) ? (
|
||||
|
||||
+11
@@ -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}
|
||||
|
||||
+101
@@ -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>
|
||||
);
|
||||
};
|
||||
+39
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+4
-2
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
+60
@@ -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,
|
||||
});
|
||||
+42
@@ -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,
|
||||
};
|
||||
};
|
||||
+4
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+55
@@ -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 };
|
||||
};
|
||||
+207
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+49
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+104
-46
@@ -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}
|
||||
|
||||
+3
@@ -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}
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+179
@@ -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 };
|
||||
};
|
||||
+18
@@ -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;
|
||||
};
|
||||
+5
-4
@@ -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[];
|
||||
|
||||
+40
-37
@@ -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: (
|
||||
|
||||
+16
-10
@@ -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;
|
||||
|
||||
+50
@@ -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;
|
||||
};
|
||||
+7
@@ -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;
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
|
||||
+2
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user