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