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
@@ -1424,7 +1424,6 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
@@ -5929,6 +5928,13 @@ export type DeleteFileMutationVariables = Exact<{
export type DeleteFileMutation = { __typename?: 'Mutation', deleteFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } };
export type UploadFilesFieldFileMutationVariables = Exact<{
file: Scalars['Upload'];
}>;
export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } };
export type NavigationMenuItemFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string };
export type NavigationMenuItemQueryFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null };
@@ -10269,6 +10275,42 @@ export function useDeleteFileMutation(baseOptions?: Apollo.MutationHookOptions<D
export type DeleteFileMutationHookResult = ReturnType<typeof useDeleteFileMutation>;
export type DeleteFileMutationResult = Apollo.MutationResult<DeleteFileMutation>;
export type DeleteFileMutationOptions = Apollo.BaseMutationOptions<DeleteFileMutation, DeleteFileMutationVariables>;
export const UploadFilesFieldFileDocument = gql`
mutation UploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
id
path
size
createdAt
}
}
`;
export type UploadFilesFieldFileMutationFn = Apollo.MutationFunction<UploadFilesFieldFileMutation, UploadFilesFieldFileMutationVariables>;
/**
* __useUploadFilesFieldFileMutation__
*
* To run a mutation, you first call `useUploadFilesFieldFileMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUploadFilesFieldFileMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [uploadFilesFieldFileMutation, { data, loading, error }] = useUploadFilesFieldFileMutation({
* variables: {
* file: // value for 'file'
* },
* });
*/
export function useUploadFilesFieldFileMutation(baseOptions?: Apollo.MutationHookOptions<UploadFilesFieldFileMutation, UploadFilesFieldFileMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadFilesFieldFileMutation, UploadFilesFieldFileMutationVariables>(UploadFilesFieldFileDocument, options);
}
export type UploadFilesFieldFileMutationHookResult = ReturnType<typeof useUploadFilesFieldFileMutation>;
export type UploadFilesFieldFileMutationResult = Apollo.MutationResult<UploadFilesFieldFileMutation>;
export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions<UploadFilesFieldFileMutation, UploadFilesFieldFileMutationVariables>;
export const CreateNavigationMenuItemDocument = gql`
mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) {
createNavigationMenuItem(input: $input) {
@@ -1396,7 +1396,6 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
@@ -82,6 +82,7 @@ const StyledTitle = styled.div`
type DocumentViewerProps = {
documentName: string;
documentUrl: string;
documentExtension?: string;
};
// MS Office Online viewer requires documents to be publicly accessible from the internet.
@@ -160,13 +161,16 @@ const MIME_TYPE_MAPPING: Record<
export const DocumentViewer = ({
documentName,
documentUrl,
documentExtension,
}: DocumentViewerProps) => {
const { t } = useLingui();
const theme = useTheme();
const [csvPreview, setCsvPreview] = useState<string | undefined>(undefined);
const { extension } = getFileNameAndExtension(documentName);
const fileExtension = extension?.toLowerCase().replace('.', '') ?? '';
const fileExtension = isDefined(documentExtension)
? documentExtension.toLowerCase().replace('.', '')
: (extension?.toLowerCase().replace('.', '') ?? '');
const fileCategory = getFileType(documentName);
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
const isMsOfficeFile = MS_OFFICE_EXTENSIONS.includes(fileExtension);
@@ -19,6 +19,7 @@ import { SupportChatEffect } from '@/support/components/SupportChatEffect';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogComponentInstanceContext } from '@/ui/feedback/dialog-manager/contexts/DialogComponentInstanceContext';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { GlobalFilePreviewModal } from '@/ui/field/display/components/GlobalFilePreviewModal';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
@@ -64,6 +65,7 @@ export const AppRouterProviders = () => {
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
@@ -0,0 +1,96 @@
import {
FileUploadContext,
type FileUploadOptions,
} from '@/file-upload/contexts/FileUploadContext';
import styled from '@emotion/styled';
import { useCallback, useEffect, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledFileInput = styled.input`
display: none;
`;
export const FileUploadProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadOptions, setUploadOptions] = useState<FileUploadOptions | null>(
null,
);
const openFileUpload = useCallback((options: FileUploadOptions) => {
setUploadOptions(options);
setTimeout(() => {
fileInputRef.current?.click();
}, 0);
}, []);
const handleFileInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
const currentOptions = uploadOptions;
if (!isDefined(currentOptions)) {
return;
}
try {
if (!isDefined(files) || files.length === 0) {
currentOptions.onCancel?.();
} else {
const filesArray = Array.from(files);
await currentOptions.onUpload(filesArray);
}
} finally {
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
}
setUploadOptions(null);
}
},
[uploadOptions],
);
const handleFileInputCancel = useCallback(() => {
const currentOptions = uploadOptions;
if (!isDefined(currentOptions)) {
return;
}
try {
currentOptions.onCancel?.();
} finally {
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
}
setUploadOptions(null);
}
}, [uploadOptions]);
useEffect(() => {
const input = fileInputRef.current;
if (!input) {
return;
}
input.addEventListener('cancel', handleFileInputCancel);
return () => input.removeEventListener('cancel', handleFileInputCancel);
}, [handleFileInputCancel]);
return (
<FileUploadContext.Provider value={{ openFileUpload }}>
{children}
<StyledFileInput
ref={fileInputRef}
type="file"
multiple={uploadOptions?.multiple ?? false}
accept={uploadOptions?.accept}
onChange={handleFileInputChange}
/>
</FileUploadContext.Provider>
);
};
@@ -0,0 +1,18 @@
import { createContext } from 'react';
export type FileUploadCallback = (files: File[]) => void | Promise<void>;
export type FileUploadOptions = {
multiple?: boolean;
accept?: string;
onUpload: FileUploadCallback;
onCancel?: () => void;
};
export type FileUploadContextValue = {
openFileUpload: (options: FileUploadOptions) => void;
};
export const FileUploadContext = createContext<FileUploadContextValue | null>(
null,
);
@@ -0,0 +1,12 @@
import { FileUploadContext } from '@/file-upload/contexts/FileUploadContext';
import { useContext } from 'react';
export const useFileUpload = () => {
const context = useContext(FileUploadContext);
if (!context) {
throw new Error('useFileUpload must be used within a FileUploadProvider');
}
return context;
};
@@ -0,0 +1,8 @@
export { FileUploadProvider } from './components/FileUploadProvider';
export { FileUploadContext } from './contexts/FileUploadContext';
export type {
FileUploadCallback,
FileUploadContextValue,
FileUploadOptions,
} from './contexts/FileUploadContext';
export { useFileUpload } from './hooks/useFileUpload';
@@ -1,33 +1,56 @@
import { type AttachmentFileCategory } from '@/activities/files/types/AttachmentFileCategory';
import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
import { IconMapping } from '@/file/utils/fileIconMappings';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type FileCategory } from 'twenty-shared/types';
const StyledIconContainer = styled.div<{ background: string }>`
type FileIconSize = 'small' | 'medium';
const StyledIconContainer = styled.div<{
background: string;
size: FileIconSize;
}>`
align-items: center;
background: ${({ background }) => background};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.grayScale.gray1};
display: flex;
flex-shrink: 0;
height: ${({ size }) => (size === 'small' ? '14px' : 'auto')};
justify-content: center;
padding: ${({ theme }) => theme.spacing(1.25)};
padding: ${({ theme, size }) =>
size === 'small' ? '0' : theme.spacing(1.25)};
width: ${({ size }) => (size === 'small' ? '14px' : 'auto')};
`;
export const FileIcon = ({
fileCategory,
size = 'medium',
}: {
fileCategory: AttachmentFileCategory;
fileCategory: AttachmentFileCategory | FileCategory;
size?: FileIconSize;
}) => {
const theme = useTheme();
const iconColors = useFileCategoryColors();
const iconColors = {
ARCHIVE: theme.color.gray,
AUDIO: theme.color.pink,
IMAGE: theme.color.amber,
PRESENTATION: theme.color.orange,
SPREADSHEET: theme.color.turquoise,
TEXT_DOCUMENT: theme.color.blue,
VIDEO: theme.color.purple,
OTHER: theme.color.gray,
};
const Icon = IconMapping[fileCategory];
return (
<StyledIconContainer background={iconColors[fileCategory]}>
{Icon && <Icon size={theme.icon.size.sm} />}
<StyledIconContainer
background={iconColors[fileCategory] ?? theme.color.gray}
size={size}
>
{Icon && <Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />}
</StyledIconContainer>
);
};
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPLOAD_FILES_FIELD_FILE = gql`
mutation UploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
id
path
size
createdAt
}
}
`;
@@ -39,6 +39,7 @@ export const getFilterFilterableFieldMetadataItems = ({
FieldMetadataType.PHONES,
FieldMetadataType.ARRAY,
FieldMetadataType.UUID,
FieldMetadataType.FILES,
...(isJsonFilterEnabled ? [FieldMetadataType.RAW_JSON] : []),
].includes(field.type);
@@ -1,13 +1,13 @@
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { isNonCompositeField } from '@/object-record/object-filter-dropdown/utils/isNonCompositeField';
import { type ObjectPermissions } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
type MapFieldMetadataToGraphQLQueryArgs = {
objectMetadataItems: ObjectMetadataItem[];
@@ -297,6 +297,16 @@ ${mapObjectMetadataToGraphQLQuery({
}`;
}
if (fieldType === FieldMetadataType.FILES) {
return `${gqlField}
{
fileId
label
extension
url
}`;
}
if (fieldType === FieldMetadataType.RICH_TEXT_V2) {
return `${gqlField}
{
@@ -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];
@@ -32,6 +32,7 @@ import {
WidgetType,
} from '~/generated-metadata/graphql';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
@@ -306,6 +307,7 @@ const meta: Meta<typeof FieldWidget> = {
decorators: [
ComponentDecorator,
ChipGeneratorsDecorator,
FileUploadDecorator,
(Story) => (
<MemoryRouter>
<Story />
@@ -167,5 +167,5 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
],
[],
],
} as const satisfies SettingsFieldTypeConfig<FieldFilesValue>,
} as const satisfies SettingsFieldTypeConfig<FieldFilesValue[]>,
};
@@ -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 { isDefined } from 'twenty-shared/utils';
import { ChipVariant, LinkChip } from 'twenty-ui/components';
const MAX_WIDTH = 120;
type FileChipProps = {
file: FieldFilesValue;
onClick: (file: FieldFilesValue) => void;
forceDisableClick?: boolean;
};
export const FileChip = ({
file,
onClick,
forceDisableClick,
}: FileChipProps) => {
const handleClick = (event: React.MouseEvent): void => {
if (isDefined(forceDisableClick)) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick?.(file);
};
const fileIcon = (
<FileIcon
fileCategory={
file.fileCategory ?? getFileCategoryFromExtension(file.extension ?? '')
}
size="small"
/>
);
return (
<LinkChip
to="#"
label={file.label}
maxWidth={MAX_WIDTH}
leftComponent={fileIcon}
variant={ChipVariant.Highlighted}
onClick={forceDisableClick ? undefined : handleClick}
triggerEvent="CLICK"
/>
);
};
@@ -1,22 +1,48 @@
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { FileChip } from '@/ui/field/display/components/FileChip';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { t } from '@lingui/core/macro';
import { Chip, ChipVariant } from 'twenty-ui/components';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type FilesDisplayProps = {
value: FieldFilesValue;
value?: FieldFilesValue[];
forceDisableClick?: boolean;
};
//TODO: Draft version, UI to be improved
export const FilesDisplay = ({ value }: FilesDisplayProps) => {
export const FilesDisplay = ({
value,
forceDisableClick,
}: FilesDisplayProps) => {
const setFilePreview = useSetRecoilState(filePreviewState);
const isAttachmentPreviewEnabled = useRecoilValue(
isAttachmentPreviewEnabledState,
);
const handlePreview = (file: FieldFilesValue) => {
if (!isAttachmentPreviewEnabled) {
if (isDefined(file.url)) {
downloadFile(file.url, file.label ?? 'file');
}
return;
}
setFilePreview(file);
};
if (!isDefined(value) || value.length === 0) {
return <></>;
}
return (
<ExpandableList>
{value?.map((file, index) => (
<Chip
key={`${file.fileId}-${index}`}
variant={ChipVariant.Highlighted}
label={file.label}
emptyLabel={t`Untitled`}
{value.map((file) => (
<FileChip
key={file.fileId}
file={file}
onClick={handlePreview}
forceDisableClick={forceDisableClick}
/>
))}
</ExpandableList>
@@ -0,0 +1,147 @@
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { lazy, Suspense, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconDownload, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const DocumentViewer = lazy(() =>
import('@/activities/files/components/DocumentViewer').then((module) => ({
default: module.DocumentViewer,
})),
);
const GLOBAL_FILE_PREVIEW_MODAL_ID = 'global-file-preview-modal';
const StyledModalHeader = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
height: 60px;
justify-content: space-between;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(0, 4, 0, 4)};
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
width: 100%;
`;
const StyledModalTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledModalContent = styled.div`
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledLoadingContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
`;
const StyledLoadingText = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const GlobalFilePreviewModal = (): JSX.Element | null => {
const { t } = useLingui();
const [filePreview, setFilePreview] = useRecoilState(filePreviewState);
const { openModal, closeModal } = useModal();
useEffect(() => {
if (isDefined(filePreview)) {
openModal(GLOBAL_FILE_PREVIEW_MODAL_ID);
}
}, [filePreview, openModal]);
const handleClose = () => {
closeModal(GLOBAL_FILE_PREVIEW_MODAL_ID);
setFilePreview(null);
};
const handleDownload = () => {
if (!filePreview || !filePreview.url) return;
downloadFile(filePreview.url, filePreview.label ?? 'file');
};
if (!isDefined(filePreview)) {
return null;
}
return (
<>
{createPortal(
<Modal
modalId={GLOBAL_FILE_PREVIEW_MODAL_ID}
size="large"
isClosable
onClose={handleClose}
ignoreContainer
>
<StyledModalHeader>
<StyledHeader>
<StyledModalTitle>{filePreview.label}</StyledModalTitle>
<StyledButtonContainer>
<IconButton
Icon={IconDownload}
onClick={handleDownload}
size="small"
/>
<IconButton Icon={IconX} onClick={handleClose} size="small" />
</StyledButtonContainer>
</StyledHeader>
</StyledModalHeader>
<ScrollWrapper
componentInstanceId={`preview-modal-${filePreview.fileId ?? 'file'}`}
>
<StyledModalContent>
<Suspense
fallback={
<StyledLoadingContainer>
<StyledLoadingText>
{t`Loading document viewer...`}
</StyledLoadingText>
</StyledLoadingContainer>
}
>
<DocumentViewer
documentName={filePreview.label ?? t`Untitled`}
documentUrl={filePreview.url ?? ''}
documentExtension={filePreview.extension ?? ''}
/>
</Suspense>
</StyledModalContent>
</ScrollWrapper>
</Modal>,
document.body,
)}
</>
);
};
@@ -0,0 +1,7 @@
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { createState } from 'twenty-ui/utilities';
export const filePreviewState = createState<FieldFilesValue | null>({
key: 'filePreviewState',
defaultValue: null,
});
@@ -2,6 +2,7 @@ import { AuthModal } from '@/auth/components/AuthModal';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { AppPageErrorFallback } from '@/error-handler/components/AppPageErrorFallback';
import { FileUploadProvider } from '@/file-upload/components/FileUploadProvider';
import { InformationBannerIsImpersonating } from '@/information-banner/components/impersonate/InformationBannerIsImpersonating';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
@@ -73,54 +74,56 @@ export const DefaultLayout = () => {
}
`}
/>
<StyledLayout>
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
<InformationBannerIsImpersonating />
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAVIGATION_DRAWER_CONSTRAINTS.default +
76)) /
2
: 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
{!showAuthModal && <KeyboardShortcutMenu />}
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer />
)}
{showAuthModal ? (
<>
<FileUploadProvider>
<StyledLayout>
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
<InformationBannerIsImpersonating />
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAVIGATION_DRAWER_CONSTRAINTS.default +
76)) /
2
: 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
{!showAuthModal && <KeyboardShortcutMenu />}
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer />
)}
{showAuthModal ? (
<>
<StyledMainContainer>
<SignInBackgroundMockPage />
</StyledMainContainer>
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<StyledMainContainer>
<SignInBackgroundMockPage />
<AppErrorBoundary FallbackComponent={AppPageErrorFallback}>
<Outlet />
</AppErrorBoundary>
</StyledMainContainer>
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<StyledMainContainer>
<AppErrorBoundary FallbackComponent={AppPageErrorFallback}>
<Outlet />
</AppErrorBoundary>
</StyledMainContainer>
)}
</StyledPageContainer>
{isMobile && !showAuthModal && <MobileNavigationBar />}
</AppErrorBoundary>
</StyledLayout>
)}
</StyledPageContainer>
{isMobile && !showAuthModal && <MobileNavigationBar />}
</AppErrorBoundary>
</StyledLayout>
</FileUploadProvider>
</>
);
};
@@ -6,7 +6,6 @@ import { SettingsObjectNewFieldSelector } from '@/settings/data-model/fields/for
import { type FieldType } from '@/settings/data-model/types/FieldType';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro';
import { useEffect } from 'react';
@@ -15,10 +14,7 @@ import { useParams } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
FeatureFlagKey,
FieldMetadataType,
} from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const settingsDataModelFieldTypeFormSchema = z.object({
@@ -48,10 +44,6 @@ export const SettingsObjectNewFieldSelect = () => {
},
});
const isFilesFieldEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
);
const excludedFieldTypes: FieldType[] = (
[
FieldMetadataType.NUMERIC,
@@ -59,7 +51,6 @@ export const SettingsObjectNewFieldSelect = () => {
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.ACTOR,
FieldMetadataType.UUID,
!isFilesFieldEnabled ? FieldMetadataType.FILES : undefined,
] as const
).filter(isDefined);
@@ -0,0 +1,8 @@
import { FileUploadProvider } from '@/file-upload/components/FileUploadProvider';
import { type Decorator } from '@storybook/react-vite';
export const FileUploadDecorator: Decorator = (Story) => (
<FileUploadProvider>
<Story />
</FileUploadProvider>
);
@@ -19,5 +19,4 @@ export enum FeatureFlagKey {
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
}
@@ -477,6 +477,7 @@ export const generateFieldFilterZodSchema = (
return null;
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.FILES:
return z
.object({
eq: z.string().optional().describe('Raw JSON equals'),
@@ -1,6 +1,5 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateFilesFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util';
@@ -19,15 +18,9 @@ const createFlatEntityToValidate = (
const callValidator = (
flatEntityToValidate: FlatFieldMetadata<FieldMetadataType.FILES>,
featureFlagEnabled = true,
) =>
validateFilesFlatFieldMetadata({
flatEntityToValidate,
additionalCacheDataMaps: {
featureFlagsMap: {
[FeatureFlagKey.IS_FILES_FIELD_ENABLED]: featureFlagEnabled,
},
},
} as Parameters<typeof validateFilesFlatFieldMetadata>[0]);
describe('validateFilesFlatFieldMetadata', () => {
@@ -37,14 +30,6 @@ describe('validateFilesFlatFieldMetadata', () => {
expect(errors).toHaveLength(0);
});
it('should return error when feature flag is disabled', () => {
const errors = callValidator(createFlatEntityToValidate(), false);
expect(errors).toHaveLength(1);
expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
expect(errors[0].message).toContain('Files field type is not supported');
});
it('should return error when isUnique is true', () => {
const errors = callValidator(
createFlatEntityToValidate({ isUnique: true }),
@@ -3,25 +3,14 @@ import { FILES_FIELD_MAX_NUMBER_OF_VALUES } from 'twenty-shared/constants';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
export const validateFilesFlatFieldMetadata = ({
flatEntityToValidate,
additionalCacheDataMaps,
}: FlatFieldMetadataTypeValidationArgs<FieldMetadataType.FILES>): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
const { featureFlagsMap } = additionalCacheDataMaps;
if (featureFlagsMap[FeatureFlagKey.IS_FILES_FIELD_ENABLED] !== true) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'Files field type is not supported',
userFriendlyMessage: msg`Files field type is not supported`,
});
}
if (flatEntityToValidate.isUnique === true) {
errors.push({
@@ -240,7 +240,6 @@ describe('WorkspaceEntityManager', () => {
IS_SSE_DB_EVENTS_ENABLED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
IS_NAVIGATION_MENU_ITEM_ENABLED: false,
IS_FILES_FIELD_ENABLED: false,
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
@@ -1,6 +1,5 @@
import {
FieldMetadataType,
FILE_CATEGORIES,
NumberDataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
@@ -346,9 +345,11 @@ export const convertObjectMetadataToSchemaProperties = ({
},
...(forResponse
? {
fileCategory: {
extension: {
type: 'string',
},
url: {
type: 'string',
enum: Object.values(FILE_CATEGORIES),
},
}
: {}),
@@ -96,11 +96,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_MARKETPLACE_ENABLED,
workspaceId: workspaceId,
@@ -2,16 +2,12 @@ import gql from 'graphql-tag';
import request from 'supertest';
import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const uploadWorkspaceFieldFileMutation = gql`
mutation UploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
@@ -104,14 +100,6 @@ describe('files-field.controller - GET /files-field/:id', () => {
beforeAll(async () => {
jest.useRealTimers();
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
true,
),
);
const {
data: {
createOneObject: { id: objectMetadataId },
@@ -167,14 +155,6 @@ describe('files-field.controller - GET /files-field/:id', () => {
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
false,
),
);
});
it('should download file successfully with valid url', async () => {
@@ -1,16 +1,12 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const uploadWorkspaceFieldFileMutation = gql`
mutation UploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
@@ -156,14 +152,6 @@ describe('fileFieldSync - FILES field <> files sync', () => {
beforeAll(async () => {
jest.useRealTimers();
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
true,
),
);
const {
data: {
createOneObject: { id: objectMetadataId },
@@ -219,14 +207,6 @@ describe('fileFieldSync - FILES field <> files sync', () => {
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
false,
),
);
});
it('createMany without upsert - files sync successfully', async () => {
@@ -3,13 +3,8 @@ import { expectGqlCreateInputValidationError } from 'test/integration/graphql/su
import { expectRestCreateInputValidationError } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-rest-create-input-validation-error.util';
import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata';
import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const FIELD_METADATA_TYPE = FieldMetadataType.FILES;
const failingTestCases =
@@ -23,14 +18,6 @@ describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => {
let targetObjectMetadata2Id: string;
beforeAll(async () => {
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
true,
),
);
const setupTest = await setupTestObjectsWithAllFieldTypes(true);
objectMetadataId = setupTest.objectMetadataId;
@@ -46,14 +33,6 @@ describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => {
targetObjectMetadata1Id,
targetObjectMetadata2Id,
]);
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
false,
),
);
});
describe('Gql create input - failure', () => {
@@ -6,13 +6,8 @@ import { testRestFailingScenario } from 'test/integration/graphql/suites/inputs-
import { testRestSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-successful-scenario.util';
import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata';
import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const FIELD_METADATA_TYPE = FieldMetadataType.FILES;
const failingTestCases =
@@ -28,14 +23,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
let targetObjectMetadata2Id: string;
beforeAll(async () => {
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
true,
),
);
const setupTest = await setupTestObjectsWithAllFieldTypes(true);
objectMetadataId = setupTest.objectMetadataId;
@@ -51,14 +38,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
targetObjectMetadata1Id,
targetObjectMetadata2Id,
]);
await makeGraphqlAPIRequest(
updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
FeatureFlagKey.IS_FILES_FIELD_ENABLED,
false,
),
);
});
describe('Gql filter input - failure', () => {
@@ -171,41 +171,3 @@ exports[`createOne FILES field metadata - failing should fail to create files fi
"name": "GraphQLError",
}
`;
exports[`createOne FILES field metadata - feature flag disabled should fail to create files field when feature flag is disabled 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Files field type is not supported",
"userFriendlyMessage": "Files field type is not supported",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"name": "filesFieldDisabled",
"objectMetadataId": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;
@@ -3,21 +3,12 @@ import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-m
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
describe('createOne FILES field metadata - successful', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: true,
expectToFail: false,
});
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -45,12 +36,6 @@ describe('createOne FILES field metadata - successful', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: false,
expectToFail: false,
});
});
it('should create files field with maxNumberOfValues = 1', async () => {
@@ -136,12 +121,6 @@ describe('createOne FILES field metadata - failing', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: true,
expectToFail: false,
});
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -169,12 +148,6 @@ describe('createOne FILES field metadata - failing', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: false,
expectToFail: false,
});
});
it('should fail to create files field with maxNumberOfValues = 0', async () => {
@@ -237,66 +210,3 @@ describe('createOne FILES field metadata - failing', () => {
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
describe('createOne FILES field metadata - feature flag disabled', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: false,
expectToFail: false,
});
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFilesFieldFlagDisabledObject',
namePlural: 'testFilesFieldFlagDisabledObjects',
labelSingular: 'Test Files Field Flag Disabled Object',
labelPlural: 'Test Files Field Flag Disabled Objects',
icon: 'IconFile',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: false,
expectToFail: false,
});
});
it('should fail to create files field when feature flag is disabled', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldDisabled',
label: 'Files Field Disabled',
type: FieldMetadataType.FILES,
settings: {
maxNumberOfValues: 5,
},
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
@@ -5,22 +5,13 @@ import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-m
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
describe('updateOne FILES field metadata - successful', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: true,
expectToFail: false,
});
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -48,12 +39,6 @@ describe('updateOne FILES field metadata - successful', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: false,
expectToFail: false,
});
});
beforeEach(async () => {
@@ -153,12 +138,6 @@ describe('updateOne FILES field metadata - failing', () => {
let createdFieldMetadataId: string;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: true,
expectToFail: false,
});
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
@@ -219,12 +198,6 @@ describe('updateOne FILES field metadata - failing', () => {
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
value: false,
expectToFail: false,
});
});
it('should fail to update files field settings with maxNumberOfValues = 0', async () => {
@@ -20,6 +20,7 @@ export const FILTERABLE_FIELD_TYPES = [
'ACTOR',
'ARRAY',
'RAW_JSON',
'FILES',
'BOOLEAN',
'UUID',
] as const;
@@ -140,6 +140,11 @@ export type RawJsonFilter = {
is?: IsFilter;
};
export type FilesFilter = {
like?: string;
is?: IsFilter;
};
export type RichTextV2LeafFilter = {
ilike?: string;
};
@@ -169,6 +174,7 @@ export type LeafFilter =
| PhonesFilter
| ArrayFilter
| RawJsonFilter
| FilesFilter
| RichTextV2Filter
| TSVectorFilter
| undefined;
@@ -168,6 +168,7 @@ export type {
MultiSelectFilter,
ArrayFilter,
RawJsonFilter,
FilesFilter,
RichTextV2LeafFilter,
RichTextV2Filter,
TSVectorFilter,
@@ -19,6 +19,7 @@ export * from './utils/isMatchingArrayFilter';
export * from './utils/isMatchingBooleanFilter';
export * from './utils/isMatchingCurrencyFilter';
export * from './utils/isMatchingDateFilter';
export * from './utils/isMatchingFilesFilter';
export * from './utils/isMatchingFloatFilter';
export * from './utils/isMatchingMultiSelectFilter';
export * from './utils/isMatchingRatingFilter';
@@ -9,6 +9,7 @@ import {
type BooleanFilter,
type CurrencyFilter,
type DateFilter,
type FilesFilter,
type FloatFilter,
type MultiSelectFilter,
type PhonesFilter,
@@ -167,6 +168,27 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
);
}
case 'FILES':
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
},
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
);
}
case 'DATE': {
const itsARelativeDateFilter =
recordFilter.operand === RecordFilterOperand.IS_RELATIVE;
@@ -0,0 +1,227 @@
import { isMatchingFilesFilter } from '@/utils/filter/utils/isMatchingFilesFilter';
describe('isMatchingFilesFilter', () => {
describe('is filter', () => {
it('should return true when checking for NULL and value is null', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NULL' },
value: null,
}),
).toBe(true);
});
it('should return true when checking for NULL and value is empty array', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NULL' },
value: [],
}),
).toBe(true);
});
it('should return false when checking for NULL and value has files', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NULL' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(false);
});
it('should return true when checking for NOT_NULL and value has files', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NOT_NULL' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should return false when checking for NOT_NULL and value is null', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NOT_NULL' },
value: null,
}),
).toBe(false);
});
it('should return false when checking for NOT_NULL and value is empty array', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NOT_NULL' },
value: [],
}),
).toBe(false);
});
});
describe('like filter', () => {
it('should match files when like pattern matches JSON representation', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file.pdf%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should not match when like pattern does not match', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%document.docx%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(false);
});
it('should be case insensitive', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%FILE.PDF%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match partial file names', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%myfile%' },
value: [
{
fileId: '1',
label: 'myfile.pdf',
url: 'http://example.com/myfile.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match when any file in array matches', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%report%' },
value: [
{
fileId: '1',
label: 'invoice.pdf',
url: 'http://example.com/invoice.pdf',
extension: 'pdf',
},
{
fileId: '2',
label: 'annual_report.pdf',
url: 'http://example.com/report.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match by file extension', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%pdf%' },
value: [
{
fileId: '1',
label: 'document',
url: 'http://example.com/doc.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match by URL', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%example.com%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match by fileId', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file-123%' },
value: [
{
fileId: 'file-123',
label: 'document.pdf',
url: 'http://example.com/doc.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should not match when value is null', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file%' },
value: null,
}),
).toBe(false);
});
it('should not match when value is empty array', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file%' },
value: [],
}),
).toBe(false);
});
});
});
@@ -355,6 +355,7 @@ export const getEmptyRecordGqlOperationFilter = ({
],
};
break;
case 'FILES':
case 'RAW_JSON':
emptyRecordFilter = {
or: [
@@ -39,6 +39,8 @@ export const getFilterTypeFromFieldType = (
return 'ARRAY';
case FieldMetadataType.RAW_JSON:
return 'RAW_JSON';
case FieldMetadataType.FILES:
return 'FILES';
case FieldMetadataType.BOOLEAN:
return 'BOOLEAN';
case FieldMetadataType.TS_VECTOR:
@@ -0,0 +1,36 @@
import { type FilesFilter } from '@/types';
export const isMatchingFilesFilter = ({
filesFilter,
value,
}: {
filesFilter: FilesFilter;
value: Record<string, any> | null;
}) => {
switch (true) {
case filesFilter.like !== undefined: {
const escapedPattern = filesFilter.like.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const regexPattern = escapedPattern.replace(/%/g, '.*');
const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'is');
const stringValue = JSON.stringify(value, null, 1);
return regexCaseInsensitive.test(stringValue);
}
case filesFilter.is !== undefined: {
if (filesFilter.is === 'NULL') {
return value === null || value.length === 0;
} else {
return value !== null && value.length > 0;
}
}
default: {
throw new Error(
`Unexpected value for files filter : ${JSON.stringify(filesFilter)}`,
);
}
}
};
@@ -108,6 +108,7 @@ export { isMatchingArrayFilter } from './filter/utils/isMatchingArrayFilter';
export { isMatchingBooleanFilter } from './filter/utils/isMatchingBooleanFilter';
export { isMatchingCurrencyFilter } from './filter/utils/isMatchingCurrencyFilter';
export { isMatchingDateFilter } from './filter/utils/isMatchingDateFilter';
export { isMatchingFilesFilter } from './filter/utils/isMatchingFilesFilter';
export { isMatchingFloatFilter } from './filter/utils/isMatchingFloatFilter';
export { isMatchingMultiSelectFilter } from './filter/utils/isMatchingMultiSelectFilter';
export { isMatchingRatingFilter } from './filter/utils/isMatchingRatingFilter';
@@ -37,5 +37,5 @@ export const MAIN_COLORS_LIGHT = {
bronze: RadixColors.bronzeP3.bronze9,
gold: RadixColors.goldP3.gold9,
brown: RadixColors.brownP3.brown9,
gray: GRAY_SCALE_LIGHT.gray7,
gray: GRAY_SCALE_LIGHT.gray9,
};