diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 66f84ce460..703dee6046 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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; export type DeleteFileMutationResult = Apollo.MutationResult; export type DeleteFileMutationOptions = Apollo.BaseMutationOptions; +export const UploadFilesFieldFileDocument = gql` + mutation UploadFilesFieldFile($file: Upload!) { + uploadFilesFieldFile(file: $file) { + id + path + size + createdAt + } +} + `; +export type UploadFilesFieldFileMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UploadFilesFieldFileDocument, options); + } +export type UploadFilesFieldFileMutationHookResult = ReturnType; +export type UploadFilesFieldFileMutationResult = Apollo.MutationResult; +export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions; export const CreateNavigationMenuItemDocument = gql` mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) { createNavigationMenuItem(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 184aaca8cf..6646b5d788 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -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', diff --git a/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx b/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx index 3175c957a1..53be3e97d6 100644 --- a/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx @@ -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(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); diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index 0203f5b8ca..5cc818f27d 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -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 = () => { + diff --git a/packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx b/packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx new file mode 100644 index 0000000000..ccd4a8e900 --- /dev/null +++ b/packages/twenty-front/src/modules/file-upload/components/FileUploadProvider.tsx @@ -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(null); + const [uploadOptions, setUploadOptions] = useState( + null, + ); + + const openFileUpload = useCallback((options: FileUploadOptions) => { + setUploadOptions(options); + + setTimeout(() => { + fileInputRef.current?.click(); + }, 0); + }, []); + + const handleFileInputChange = useCallback( + async (event: React.ChangeEvent) => { + 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 ( + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts b/packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts new file mode 100644 index 0000000000..ec07a5de14 --- /dev/null +++ b/packages/twenty-front/src/modules/file-upload/contexts/FileUploadContext.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +export type FileUploadCallback = (files: File[]) => void | Promise; + +export type FileUploadOptions = { + multiple?: boolean; + accept?: string; + onUpload: FileUploadCallback; + onCancel?: () => void; +}; + +export type FileUploadContextValue = { + openFileUpload: (options: FileUploadOptions) => void; +}; + +export const FileUploadContext = createContext( + null, +); diff --git a/packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts b/packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts new file mode 100644 index 0000000000..6de581c748 --- /dev/null +++ b/packages/twenty-front/src/modules/file-upload/hooks/useFileUpload.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/file-upload/index.ts b/packages/twenty-front/src/modules/file-upload/index.ts new file mode 100644 index 0000000000..1a568781d2 --- /dev/null +++ b/packages/twenty-front/src/modules/file-upload/index.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/file/components/FileIcon.tsx b/packages/twenty-front/src/modules/file/components/FileIcon.tsx index 6330ae2b70..b485c03d25 100644 --- a/packages/twenty-front/src/modules/file/components/FileIcon.tsx +++ b/packages/twenty-front/src/modules/file/components/FileIcon.tsx @@ -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 ( - - {Icon && } + + {Icon && } ); }; diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts b/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts new file mode 100644 index 0000000000..853c6b950f --- /dev/null +++ b/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts index dc397cb1ec..f75e5cb933 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getFilterFilterableFieldMetadataItems.ts @@ -39,6 +39,7 @@ export const getFilterFilterableFieldMetadataItems = ({ FieldMetadataType.PHONES, FieldMetadataType.ARRAY, FieldMetadataType.UUID, + FieldMetadataType.FILES, ...(isJsonFilterEnabled ? [FieldMetadataType.RAW_JSON] : []), ].includes(field.type); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index b35a3fe288..ff2076893d 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -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} { diff --git a/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts b/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts index 3419cc4706..d3047e736b 100644 --- a/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts +++ b/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts @@ -7,4 +7,5 @@ export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [ FieldMetadataType.MULTI_SELECT, FieldMetadataType.RATING, FieldMetadataType.SELECT, + FieldMetadataType.FILES, ]; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts index f8eb69a50e..6dd61ea1ce 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts @@ -7,5 +7,6 @@ export const TEXT_FILTER_TYPES = [ 'LINKS', 'ARRAY', 'RAW_JSON', + 'FILES', 'UUID', ]; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts index 2a9c173af7..817d6ea910 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts @@ -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]), diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx index 65c2d20500..3cfaf0fc36 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx @@ -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 = () => { ) : isFieldEmails(fieldDefinition) ? ( + ) : isFieldFiles(fieldDefinition) ? ( + ) : isFieldFullName(fieldDefinition) ? ( ) : isFieldDateTime(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx index d16e17c09e..b718f6ae02 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx @@ -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) ? ( + ) : isFieldPhones(field) ? ( 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 ( + + {label ? {label} : null} + + + + + + + {VariablePicker && !readonly && ( + + )} + + {error} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts index 0ee8c80be5..b444c6a5a3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts @@ -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, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx index bbd3de8907..a87934aa77 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/FilesFieldDisplay.tsx @@ -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 ; + return ( + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx new file mode 100644 index 0000000000..7d26b57812 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/FilesFieldDisplay.perf.stories.tsx @@ -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 = { + 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; + +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, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts new file mode 100644 index 0000000000..eb4bea3664 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesField.ts @@ -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( + recordStoreFamilySelector({ + recordId, + fieldName: fieldName, + }), + ); + + const { setDraftValue } = useRecordFieldInput(); + + const draftValue = useRecoilComponentValue( + recordFieldInputDraftValueComponentState, + ); + + return { + fieldDefinition, + fieldValue, + draftValue, + setDraftValue, + setFieldValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts index aa17427579..fac1d57a40 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilesFieldDisplay.ts @@ -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( + const fieldValue = useRecordFieldValue( recordId, fieldName, fieldDefinition, @@ -22,5 +23,6 @@ export const useFilesFieldDisplay = () => { return { fieldDefinition: fieldDefinition as FieldDefinition, fieldValue, + disableChipClick, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts new file mode 100644 index 0000000000..ae12f0d6f1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx new file mode 100644 index 0000000000..dfed2191b8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldInput.tsx @@ -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 ( + ( + handlePreview(file)} + /> + )} + newItemLabel={isUploading ? t`Uploading...` : t`Upload file`} + onAddClick={handleUploadClick} + onError={handleError} + maxItemCount={maxNumberOfValues} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx new file mode 100644 index 0000000000..cb4a2e875d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/FilesFieldMenuItem.tsx @@ -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 ( + ( + + } + variant={ChipVariant.Rounded} + /> + )} + showPrimaryIcon={false} + showSetAsPrimaryButton={false} + showCopyButton={false} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx index bc8e1b50b7..7b8edcfa1b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx @@ -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 = { @@ -34,7 +37,7 @@ type MultiItemFieldInputProps = { 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 = { handleDelete: () => void; }) => React.ReactNode; newItemLabel?: string; + onAddClick?: () => void; fieldMetadataType: FieldMetadataType; renderInput?: MultiItemBaseInputProps['renderInput']; maxItemCount?: number; @@ -61,6 +65,7 @@ export const MultiItemFieldInput = ({ formatInput, renderItem, newItemLabel, + onAddClick, fieldMetadataType, renderInput, onClickOutside, @@ -95,43 +100,51 @@ export const MultiItemFieldInput = ({ 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 = ({ 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 = ({ return; } + if (isDefined(onAddClick)) { + onAddClick(); + return; + } + setIsAddingNewItem(true); setInputValue(''); setIsInputDisplayed(true); @@ -204,10 +238,6 @@ export const MultiItemFieldInput = ({ } => { 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 = ({ }; } + 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 = ({ 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 = ({ return ( - {!!items.length && + {shouldShowSearch && !isInputDisplayed && ( + <> + + setSearchFilter( + turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value), + ) + } + autoFocus + /> + + + )} + {!!filteredItems.length && (!shouldAutoEnterBecauseOnlyOneItemIsAllowed || !isInputDisplayed) && ( <> - {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); }, - }), - )} + }); + })} {isInputDisplayed || !isLimitReached ? ( ) : null} )} - {isInputDisplayed || !items.length ? ( + {isInputDisplayed ? ( = { 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 = ({ onEdit, onSetAsPrimary, onDelete, + onClick, DisplayComponent, showPrimaryIcon, showSetAsPrimaryButton, @@ -77,6 +79,7 @@ export const MultiItemFieldMenuItem = ({ } isIconDisplayedOnHoverOnly={!showPrimaryIcon && !isDropdownOpen} RightIcon={!isHovered && showPrimaryIcon ? IconBookmark : null} diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx index 4c5814eb1f..f91a55fbda 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/RelationOneToManyFieldInput.stories.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx new file mode 100644 index 0000000000..eacaf52421 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenFilesFieldInput.tsx @@ -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) => void; + onClose?: () => void; + fieldDefinition?: { + metadata: { + settings?: { + maxNumberOfValues?: number; + }; + }; + }; + }) => { + const fieldValue = snapshot + .getLoadable( + 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 }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts new file mode 100644 index 0000000000..0982d5647d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/uploadMultipleFiles.ts @@ -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, +): Promise => { + const uploadedFiles: FieldFilesValue[] = []; + + for (const file of files) { + const uploadedFile = await uploadFile(file); + if (isDefined(uploadedFile)) { + uploadedFiles.push(uploadedFile); + } + } + + return uploadedFiles; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts index 933d7a09a1..f12819776e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts @@ -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[]; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts index b8cb3213de..dc21e7f9e2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/assertFieldMetadata.ts @@ -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: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts index e99bb83cd3..b66a308d3f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldFilesValue.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts new file mode 100644 index 0000000000..3be6c91b75 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/getFileCategoryFromExtension.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts index 791f78357f..85b8600ee8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 79bfcc0e05..f91dd70755 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx b/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx index c41cddeb08..b91dfbebd2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/__stories__/RecordTable.stories.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts index a1de2e936f..1c1c5a1f5e 100644 --- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts @@ -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]; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx index 6e20ee1715..c27265f569 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx @@ -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 = { decorators: [ ComponentDecorator, ChipGeneratorsDecorator, + FileUploadDecorator, (Story) => ( diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts index a2358d9847..c6b838e587 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts @@ -167,5 +167,5 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel ], [], ], - } as const satisfies SettingsFieldTypeConfig, + } as const satisfies SettingsFieldTypeConfig, }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx b/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx new file mode 100644 index 0000000000..5e9b6549d5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/FileChip.tsx @@ -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 = ( + + ); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx index 080604ed64..c646bc3cdf 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/FilesDisplay.tsx @@ -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 ( - {value?.map((file, index) => ( - ( + ))} diff --git a/packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx b/packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx new file mode 100644 index 0000000000..b8956f18a7 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/GlobalFilePreviewModal.tsx @@ -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( + + + + {filePreview.label} + + + + + + + + + + + {t`Loading document viewer...`} + + + } + > + + + + + , + document.body, + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts b/packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts new file mode 100644 index 0000000000..6242cc7936 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/states/filePreviewState.ts @@ -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({ + key: 'filePreviewState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 2568084ba9..46f7326af2 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -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 = () => { } `} /> - - - - - {!showAuthModal && } - {showAuthModal ? ( - - ) : useShowFullScreen ? null : ( - - )} - {showAuthModal ? ( - <> + + + + + + {!showAuthModal && } + {showAuthModal ? ( + + ) : useShowFullScreen ? null : ( + + )} + {showAuthModal ? ( + <> + + + + + + + + + + + + ) : ( - + + + - - - - - - - - - ) : ( - - - - - - )} - - {isMobile && !showAuthModal && } - - + )} + + {isMobile && !showAuthModal && } + + + ); }; diff --git a/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx b/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx index 9ebccbaa18..b751aea8c0 100644 --- a/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx @@ -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); diff --git a/packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx b/packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx new file mode 100644 index 0000000000..30f47d6fc1 --- /dev/null +++ b/packages/twenty-front/src/testing/decorators/FileUploadDecorator.tsx @@ -0,0 +1,8 @@ +import { FileUploadProvider } from '@/file-upload/components/FileUploadProvider'; +import { type Decorator } from '@storybook/react-vite'; + +export const FileUploadDecorator: Decorator = (Story) => ( + + + +); diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 307dfd4651..774dbf9d26 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts index 472bb6def9..a81c390f99 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts @@ -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'), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts index 6c00af9d68..e4b7b1cadc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/__tests__/validate-files-flat-field-metadata.util.spec.ts @@ -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, - featureFlagEnabled = true, ) => validateFilesFlatFieldMetadata({ flatEntityToValidate, - additionalCacheDataMaps: { - featureFlagsMap: { - [FeatureFlagKey.IS_FILES_FIELD_ENABLED]: featureFlagEnabled, - }, - }, } as Parameters[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 }), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts index effb11e861..fa20c5f219 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util.ts @@ -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): 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({ diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 5c8203d489..bc1997afb1 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts index cfc0d4f6a0..77a7cd6e8d 100644 --- a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts +++ b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts @@ -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), }, } : {}), diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 4e2e450899..32085bc033 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -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, diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts index f6b6aa0b66..71eb9100ff 100644 --- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts @@ -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 () => { diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts index 1405c1233f..463dd8fcdf 100644 --- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts @@ -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 () => { diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts index 129514396c..4ee9019574 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts @@ -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', () => { diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts index 8afa406c9d..420e754f25 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/files-field-filter-input-validation.integration-spec.ts @@ -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', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap index 183439ab34..77f53adbc7 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/__snapshots__/create-one-files-field-metadata.integration-spec.ts.snap @@ -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, - "name": "filesFieldDisabled", - "objectMetadataId": Any, - "universalIdentifier": Any, - }, - "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", -} -`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts index 5287a181ea..2533457760 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/create-one-files-field-metadata.integration-spec.ts @@ -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 }); - }); -}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts index cfc03e662a..cfb0fe3fa6 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/atomic/update-one-files-field-metadata.integration-spec.ts @@ -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 () => { diff --git a/packages/twenty-shared/src/types/FilterableFieldType.ts b/packages/twenty-shared/src/types/FilterableFieldType.ts index 26e5740e01..1e75a34c11 100644 --- a/packages/twenty-shared/src/types/FilterableFieldType.ts +++ b/packages/twenty-shared/src/types/FilterableFieldType.ts @@ -20,6 +20,7 @@ export const FILTERABLE_FIELD_TYPES = [ 'ACTOR', 'ARRAY', 'RAW_JSON', + 'FILES', 'BOOLEAN', 'UUID', ] as const; diff --git a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts index ee07101a60..d4c612d86e 100644 --- a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts @@ -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; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 3ad653918a..f315290b7c 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -168,6 +168,7 @@ export type { MultiSelectFilter, ArrayFilter, RawJsonFilter, + FilesFilter, RichTextV2LeafFilter, RichTextV2Filter, TSVectorFilter, diff --git a/packages/twenty-shared/src/utils/filter/index.ts b/packages/twenty-shared/src/utils/filter/index.ts index 9a2f8040db..b73e2859b0 100644 --- a/packages/twenty-shared/src/utils/filter/index.ts +++ b/packages/twenty-shared/src/utils/filter/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts index bbfc3f4157..c4edb41954 100644 --- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts @@ -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; diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts new file mode 100644 index 0000000000..ee35b794ea --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingFilesFilter.test.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts index 480c7f731f..46a0d32155 100644 --- a/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts @@ -355,6 +355,7 @@ export const getEmptyRecordGqlOperationFilter = ({ ], }; break; + case 'FILES': case 'RAW_JSON': emptyRecordFilter = { or: [ diff --git a/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts b/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts index bd50c50fc5..23d69c54a4 100644 --- a/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts +++ b/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts @@ -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: diff --git a/packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts b/packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts new file mode 100644 index 0000000000..2c0739a6c8 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/isMatchingFilesFilter.ts @@ -0,0 +1,36 @@ +import { type FilesFilter } from '@/types'; + +export const isMatchingFilesFilter = ({ + filesFilter, + value, +}: { + filesFilter: FilesFilter; + value: Record | 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)}`, + ); + } + } +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 21b7c1e452..b46a43b6ea 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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'; diff --git a/packages/twenty-ui/src/theme/constants/MainColorsLight.ts b/packages/twenty-ui/src/theme/constants/MainColorsLight.ts index ac5a334931..4a00386d13 100644 --- a/packages/twenty-ui/src/theme/constants/MainColorsLight.ts +++ b/packages/twenty-ui/src/theme/constants/MainColorsLight.ts @@ -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, };