diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts index dc317a7931..0f7af59ff0 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts @@ -4,7 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { convertCurrencyToCurrencyMicros } from '~/utils/convert-currency-amount'; +import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; import { FieldContext } from '../../contexts/FieldContext'; import { usePersistField } from '../../hooks/usePersistField'; @@ -45,7 +45,7 @@ export const useCurrencyField = () => { const newCurrencyValue = { amountMicros: isNaN(amount) ? null - : convertCurrencyToCurrencyMicros(amount), + : convertCurrencyAmountToCurrencyMicros(amount), currencyCode, }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index c93c8bfdad..98b0b63708 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -19,7 +19,7 @@ import { import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; -import { useSpreadsheetRecordImport } from '@/object-record/spreadsheet-import/useSpreadsheetRecordImport'; +import { useOpenObjectRecordsSpreasheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; @@ -114,8 +114,8 @@ export const RecordIndexOptionsDropdownContent = ({ ? handleBoardFieldVisibilityChange : handleColumnVisibilityChange; - const { openRecordSpreadsheetImport } = - useSpreadsheetRecordImport(objectNameSingular); + const { openObjectRecordsSpreasheetImportDialog } = + useOpenObjectRecordsSpreasheetImportDialog(objectNameSingular); const { progress, download } = useExportTableData({ delayMs: 100, @@ -135,7 +135,7 @@ export const RecordIndexOptionsDropdownContent = ({ hasSubMenu /> openRecordSpreadsheetImport()} + onClick={() => openObjectRecordsSpreasheetImportDialog()} LeftIcon={IconFileImport} text="Import" /> diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts index c186bf1e29..e62a90ea5b 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts @@ -1,7 +1,8 @@ -import { useMemo } from 'react'; import { json2csv } from 'json-2-csv'; +import { useMemo } from 'react'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { useProcessRecordsForCSVExport } from '@/object-record/record-index/options/hooks/useProcessRecordsForCSVExport'; import { useTableData, UseTableDataOptions, @@ -66,12 +67,15 @@ export const generateCsv: GenerateExport = ({ .filter(isDefined) .join(' '), }; + const fieldsWithSubFields = rows.find((row) => { const fieldValue = (row as any)[column.field]; + const hasSubFields = fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue); + return hasSubFields; }); @@ -84,8 +88,10 @@ export const generateCsv: GenerateExport = ({ field: `${column.field}.${key}`, title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`, })); + return nestedFieldsWithoutTypename; } + return [column]; }); @@ -138,12 +144,17 @@ export const useExportTableData = ({ pageSize = 30, recordIndexId, }: UseExportTableDataOptions) => { + const { processRecordsForCSVExport } = + useProcessRecordsForCSVExport(objectNameSingular); + const downloadCsv = useMemo( () => - (rows: ObjectRecord[], columns: ColumnDefinition[]) => { - csvDownloader(filename, { rows, columns }); + (records: ObjectRecord[], columns: ColumnDefinition[]) => { + const recordsProcessedForExport = processRecordsForCSVExport(records); + + csvDownloader(filename, { rows: recordsProcessedForExport, columns }); }, - [filename], + [filename, processRecordsForCSVExport], ); const { getTableData: download, progress } = useTableData({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useProcessRecordsForCSVExport.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useProcessRecordsForCSVExport.ts new file mode 100644 index 0000000000..41eff23af0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useProcessRecordsForCSVExport.ts @@ -0,0 +1,39 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from 'twenty-ui'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { convertCurrencyMicrosToCurrencyAmount } from '~/utils/convertCurrencyToCurrencyMicros'; + +export const useProcessRecordsForCSVExport = (objectNameSingular: string) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const processRecordsForCSVExport = (records: ObjectRecord[]) => { + return records.map((record) => { + const currencyFields = objectMetadataItem.fields.filter( + (field) => field.type === FieldMetadataType.Currency, + ); + + const processedRecord = { + ...record, + }; + + for (const currencyField of currencyFields) { + if (isDefined(record[currencyField.name])) { + processedRecord[currencyField.name] = { + amountMicros: convertCurrencyMicrosToCurrencyAmount( + record[currencyField.name].amountMicros, + ), + currencyCode: record[currencyField.name].currencyCode, + } satisfies FieldCurrencyValue; + } + } + + return processedRecord; + }); + }; + + return { processRecordsForCSVExport }; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx b/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.tsx similarity index 63% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx rename to packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.tsx index 33f43785b2..a5f5a78e7c 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.tsx @@ -1,14 +1,14 @@ -import { ReactNode } from 'react'; import { gql } from '@apollo/client'; import { MockedProvider } from '@apollo/client/testing'; import { act, renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; import { RecoilRoot, useRecoilValue } from 'recoil'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState'; -import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; -import { useSpreadsheetRecordImport } from '../useSpreadsheetRecordImport'; +import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext'; +import { useOpenObjectRecordsSpreasheetImportDialog } from '../hooks/useOpenObjectRecordsSpreasheetImportDialog'; const companyId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a'; @@ -62,7 +62,6 @@ const companyMocks = [ variables: { data: [ { - address: 'test', domainName: 'example.com', employees: 0, idealCustomerProfile: true, @@ -94,67 +93,81 @@ const fakeCsv = () => { const Wrapper = ({ children }: { children: ReactNode }) => ( - + {children} - + ); +// TODO: improve object metadata item seeds to have more field types to add tests on composite fields here describe('useSpreadsheetCompanyImport', () => { it('should work as expected', async () => { const { result } = renderHook( () => { - const spreadsheetImport = useRecoilValue(spreadsheetImportState); - const { openRecordSpreadsheetImport } = useSpreadsheetRecordImport( + const spreadsheetImportDialog = useRecoilValue( + spreadsheetImportDialogState, + ); + const { + openObjectRecordsSpreasheetImportDialog: openRecordSpreadsheetImport, + } = useOpenObjectRecordsSpreasheetImportDialog( CoreObjectNameSingular.Company, ); - return { openRecordSpreadsheetImport, spreadsheetImport }; + return { + openRecordSpreadsheetImport, + spreadsheetImportDialog, + }; }, { wrapper: Wrapper, }, ); - const { spreadsheetImport, openRecordSpreadsheetImport } = result.current; + const { spreadsheetImportDialog, openRecordSpreadsheetImport } = + result.current; - expect(spreadsheetImport.isOpen).toBe(false); - expect(spreadsheetImport.options).toBeNull(); + expect(spreadsheetImportDialog.isOpen).toBe(false); + expect(spreadsheetImportDialog.options).toBeNull(); await act(async () => { openRecordSpreadsheetImport(); }); - const { spreadsheetImport: updatedImport } = result.current; + const { spreadsheetImportDialog: spreadsheetImportDialogAfterOpen } = + result.current; - expect(updatedImport.isOpen).toBe(true); - expect(updatedImport.options).toHaveProperty('onSubmit'); - expect(updatedImport.options?.onSubmit).toBeInstanceOf(Function); - expect(updatedImport.options).toHaveProperty('fields'); - expect(Array.isArray(updatedImport.options?.fields)).toBe(true); + expect(spreadsheetImportDialogAfterOpen.isOpen).toBe(true); + expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty('onSubmit'); + expect(spreadsheetImportDialogAfterOpen.options?.onSubmit).toBeInstanceOf( + Function, + ); + expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty('fields'); + expect( + Array.isArray(spreadsheetImportDialogAfterOpen.options?.fields), + ).toBe(true); act(() => { - updatedImport.options?.onSubmit( + spreadsheetImportDialogAfterOpen.options?.onSubmit( { - validData: [ + validStructuredRows: [ { id: companyId, name: 'Example Company', domainName: 'example.com', idealCustomerProfile: true, - address: 'test', employees: '0', }, ], - invalidData: [], - all: [ + invalidStructuredRows: [], + allStructuredRows: [ { id: companyId, name: 'Example Company', domainName: 'example.com', __index: 'cbc3985f-dde9-46d1-bae2-c124141700ac', idealCustomerProfile: true, - address: 'test', employees: '0', }, ], diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts new file mode 100644 index 0000000000..b7d1ff7103 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts @@ -0,0 +1,28 @@ +import { + FieldAddressValue, + FieldCurrencyValue, + FieldFullNameValue, +} from '@/object-record/record-field/types/FieldMetadata'; +import { CompositeFieldLabels } from '@/object-record/spreadsheet-import/types/CompositeFieldLabels'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const COMPOSITE_FIELD_IMPORT_LABELS = { + [FieldMetadataType.FullName]: { + firstNameLabel: 'First Name', + lastNameLabel: 'Last Name', + } satisfies CompositeFieldLabels, + [FieldMetadataType.Currency]: { + currencyCodeLabel: 'Currency Code', + amountMicrosLabel: 'Amount', + } satisfies CompositeFieldLabels, + [FieldMetadataType.Address]: { + addressStreet1Label: 'Address 1', + addressStreet2Label: 'Address 2', + addressCityLabel: 'City', + addressPostcodeLabel: 'Post Code', + addressStateLabel: 'State', + addressCountryLabel: 'Country', + addressLatLabel: 'Latitude', + addressLngLabel: 'Longitude', + } satisfies CompositeFieldLabels, +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts new file mode 100644 index 0000000000..3a8d68d55a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts @@ -0,0 +1,128 @@ +import { useIcons } from 'twenty-ui'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; + +import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels'; +import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport'; +import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/util/getSpreadSheetFieldValidationDefinitions'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const useBuildAvailableFieldsForImport = () => { + const { getIcon } = useIcons(); + + const buildAvailableFieldsForImport = ( + fieldMetadataItems: FieldMetadataItem[], + ) => { + const availableFieldsForImport: AvailableFieldForImport[] = []; + + for (const fieldMetadataItem of fieldMetadataItems) { + if (fieldMetadataItem.type === FieldMetadataType.FullName) { + const { firstNameLabel, lastNameLabel } = + COMPOSITE_FIELD_IMPORT_LABELS[FieldMetadataType.FullName]; + + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: `${firstNameLabel} (${fieldMetadataItem.label})`, + key: `${firstNameLabel} (${fieldMetadataItem.name})`, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + `${firstNameLabel} (${fieldMetadataItem.label})`, + ), + }); + + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: `${lastNameLabel} (${fieldMetadataItem.label})`, + key: `${lastNameLabel} (${fieldMetadataItem.name})`, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + `${lastNameLabel} (${fieldMetadataItem.label})`, + ), + }); + } else if (fieldMetadataItem.type === FieldMetadataType.Relation) { + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: fieldMetadataItem.label + ' (ID)', + key: fieldMetadataItem.name, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + fieldMetadataItem.label + ' (ID)', + ), + }); + } else if (fieldMetadataItem.type === FieldMetadataType.Currency) { + const { currencyCodeLabel, amountMicrosLabel } = + COMPOSITE_FIELD_IMPORT_LABELS[FieldMetadataType.Currency]; + + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: `${currencyCodeLabel} (${fieldMetadataItem.label})`, + key: `${currencyCodeLabel} (${fieldMetadataItem.name})`, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + `${currencyCodeLabel} (${fieldMetadataItem.label})`, + ), + }); + + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: `${amountMicrosLabel} (${fieldMetadataItem.label})`, + key: `${amountMicrosLabel} (${fieldMetadataItem.name})`, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + FieldMetadataType.Number, + `${amountMicrosLabel} (${fieldMetadataItem.label})`, + ), + }); + } else if (fieldMetadataItem.type === FieldMetadataType.Address) { + Object.entries( + COMPOSITE_FIELD_IMPORT_LABELS[FieldMetadataType.Address], + ).forEach(([_, fieldLabel]) => { + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: `${fieldLabel} (${fieldMetadataItem.label})`, + key: `${fieldLabel} (${fieldMetadataItem.name})`, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: + getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + `${fieldLabel} (${fieldMetadataItem.label})`, + ), + }); + }); + } else { + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: fieldMetadataItem.label, + key: fieldMetadataItem.name, + fieldType: { + type: 'input', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + fieldMetadataItem.label, + ), + }); + } + } + + return availableFieldsForImport; + }; + + return { buildAvailableFieldsForImport }; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog.ts new file mode 100644 index 0000000000..80b547eaa5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog.ts @@ -0,0 +1,78 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; +import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport'; +import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/util/buildRecordFromImportedStructuredRow'; +import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; +import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const useOpenObjectRecordsSpreasheetImportDialog = ( + objectNameSingular: string, +) => { + const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog(); + const { enqueueSnackBar } = useSnackBar(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { createManyRecords } = useCreateManyRecords({ + objectNameSingular, + }); + + const { buildAvailableFieldsForImport } = useBuildAvailableFieldsForImport(); + + const openObjectRecordsSpreasheetImportDialog = ( + options?: Omit< + SpreadsheetImportDialogOptions, + 'fields' | 'isOpen' | 'onClose' + >, + ) => { + const availableFieldMetadataItems = objectMetadataItem.fields + .filter( + (fieldMetadataItem) => + fieldMetadataItem.isActive && + (!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') && + fieldMetadataItem.name !== 'createdAt' && + (fieldMetadataItem.type !== FieldMetadataType.Relation || + fieldMetadataItem.toRelationMetadata), + ) + .sort((fieldMetadataItemA, fieldMetadataItemB) => + fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), + ); + + const availableFields = buildAvailableFieldsForImport( + availableFieldMetadataItems, + ); + + openSpreadsheetImportDialog({ + ...options, + onSubmit: async (data) => { + const createInputs = data.validStructuredRows.map((record) => { + const fieldMapping: Record = + buildRecordFromImportedStructuredRow( + record, + availableFieldMetadataItems, + ); + + return fieldMapping; + }); + + try { + await createManyRecords(createInputs, true); + } catch (error: any) { + enqueueSnackBar(error?.message || 'Something went wrong', { + variant: SnackBarVariant.Error, + }); + } + }, + fields: availableFields, + }); + }; + + return { + openObjectRecordsSpreasheetImportDialog, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts new file mode 100644 index 0000000000..0716f0acf5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts @@ -0,0 +1,12 @@ +import { FieldValidationDefinition } from '@/spreadsheet-import/types'; +import { IconComponent } from 'twenty-ui'; + +export type AvailableFieldForImport = { + icon: IconComponent; + label: string; + key: string; + fieldType: { + type: 'input' | 'checkbox'; + }; + fieldValidationDefinitions?: FieldValidationDefinition[]; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/CompositeFieldLabels.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/CompositeFieldLabels.ts new file mode 100644 index 0000000000..335c396cca --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/CompositeFieldLabels.ts @@ -0,0 +1,5 @@ +import { KeyOfCompositeField } from '@/object-record/spreadsheet-import/types/KeyOfCompositeField'; + +export type CompositeFieldLabels = { + [key in `${KeyOfCompositeField}Label`]: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/KeyOfCompositeField.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/KeyOfCompositeField.ts new file mode 100644 index 0000000000..29eeea021c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/KeyOfCompositeField.ts @@ -0,0 +1,3 @@ +export type KeyOfCompositeField = keyof Omit extends string + ? keyof Omit + : never; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts deleted file mode 100644 index 28516c7939..0000000000 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { isNonEmptyString } from '@sniptt/guards'; -import { useIcons } from 'twenty-ui'; - -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; -import { getSpreadSheetValidation } from '@/object-record/spreadsheet-import/util/getSpreadSheetValidation'; -import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport'; -import { Field, SpreadsheetOptions } from '@/spreadsheet-import/types'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { isDefined } from '~/utils/isDefined'; - -const firstName = 'Firstname'; -const lastName = 'Lastname'; - -export const useSpreadsheetRecordImport = (objectNameSingular: string) => { - const { openSpreadsheetImport } = useSpreadsheetImport(); - const { enqueueSnackBar } = useSnackBar(); - const { getIcon } = useIcons(); - - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - const fields = objectMetadataItem.fields - .filter( - (x) => - x.isActive && - (!x.isSystem || x.name === 'id') && - x.name !== 'createdAt' && - (x.type !== FieldMetadataType.Relation || x.toRelationMetadata), - ) - .sort((a, b) => a.name.localeCompare(b.name)); - - const templateFields: Field[] = []; - for (const field of fields) { - if (field.type === FieldMetadataType.FullName) { - templateFields.push({ - icon: getIcon(field.icon), - label: `${firstName} (${field.label})`, - key: `${firstName} (${field.name})`, - fieldType: { - type: 'input', - }, - validations: getSpreadSheetValidation( - field.type, - `${firstName} (${field.label})`, - ), - }); - templateFields.push({ - icon: getIcon(field.icon), - label: `${lastName} (${field.label})`, - key: `${lastName} (${field.name})`, - fieldType: { - type: 'input', - }, - validations: getSpreadSheetValidation( - field.type, - `${lastName} (${field.label})`, - ), - }); - } else if (field.type === FieldMetadataType.Relation) { - templateFields.push({ - icon: getIcon(field.icon), - label: field.label + ' (ID)', - key: field.name, - fieldType: { - type: 'input', - }, - validations: getSpreadSheetValidation( - field.type, - field.label + ' (ID)', - ), - }); - } else if (field.type === FieldMetadataType.Select) { - templateFields.push({ - icon: getIcon(field.icon), - label: field.label, - key: field.name, - fieldType: { - type: 'select', - options: - field.options?.map((option) => ({ - label: option.label, - value: option.value, - })) || [], - }, - validations: getSpreadSheetValidation( - field.type, - field.label + ' (ID)', - ), - }); - } else if (field.type === FieldMetadataType.Boolean) { - templateFields.push({ - icon: getIcon(field.icon), - label: field.label, - key: field.name, - fieldType: { - type: 'checkbox', - }, - validations: getSpreadSheetValidation(field.type, field.label), - }); - } else { - templateFields.push({ - icon: getIcon(field.icon), - label: field.label, - key: field.name, - fieldType: { - type: 'input', - }, - validations: getSpreadSheetValidation(field.type, field.label), - }); - } - } - - const { createManyRecords } = useCreateManyRecords({ - objectNameSingular, - }); - - const openRecordSpreadsheetImport = ( - options?: Omit, 'fields' | 'isOpen' | 'onClose'>, - ) => { - openSpreadsheetImport({ - ...options, - onSubmit: async (data) => { - const createInputs = data.validData.map((record) => { - const fieldMapping: Record = {}; - for (const field of fields) { - const value = record[field.name]; - - switch (field.type) { - case FieldMetadataType.Boolean: - if (value !== undefined) { - fieldMapping[field.name] = value === 'true' || value === true; - } - break; - case FieldMetadataType.Number: - case FieldMetadataType.Numeric: - if (value !== undefined) { - fieldMapping[field.name] = Number(value); - } - break; - case FieldMetadataType.Currency: - if (value !== undefined) { - fieldMapping[field.name] = { - amountMicros: Number(value), - currencyCode: 'USD', - }; - } - break; - case FieldMetadataType.Link: - if (value !== undefined) { - fieldMapping[field.name] = { - label: field.name, - url: value || null, - }; - } - break; - case FieldMetadataType.Relation: - if ( - isDefined(value) && - (isNonEmptyString(value) || value !== false) - ) { - fieldMapping[field.name + 'Id'] = value; - } - break; - case FieldMetadataType.FullName: - if ( - isDefined( - record[`${firstName} (${field.name})`] || - record[`${lastName} (${field.name})`], - ) - ) { - fieldMapping[field.name] = { - firstName: record[`${firstName} (${field.name})`] || '', - lastName: record[`${lastName} (${field.name})`] || '', - }; - } - break; - default: - if (value !== undefined) { - fieldMapping[field.name] = value; - } - break; - } - } - return fieldMapping; - }); - try { - await createManyRecords(createInputs, true); - } catch (error: any) { - enqueueSnackBar(error?.message || 'Something went wrong', { - variant: SnackBarVariant.Error, - }); - } - }, - fields: templateFields, - }); - }; - - return { openRecordSpreadsheetImport }; -}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/buildRecordFromImportedStructuredRow.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/buildRecordFromImportedStructuredRow.ts new file mode 100644 index 0000000000..89646bf9a4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/buildRecordFromImportedStructuredRow.ts @@ -0,0 +1,147 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels'; +import { ImportedStructuredRow } from '@/spreadsheet-import/types'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-ui'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { castToString } from '~/utils/castToString'; +import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; + +export const buildRecordFromImportedStructuredRow = ( + importedStructuredRow: ImportedStructuredRow, + fields: FieldMetadataItem[], +) => { + const recordToBuild: Record = {}; + + const { + ADDRESS: { + addressCityLabel, + addressCountryLabel, + addressLatLabel, + addressLngLabel, + addressPostcodeLabel, + addressStateLabel, + addressStreet1Label, + addressStreet2Label, + }, + CURRENCY: { amountMicrosLabel, currencyCodeLabel }, + FULL_NAME: { firstNameLabel, lastNameLabel }, + } = COMPOSITE_FIELD_IMPORT_LABELS; + + for (const field of fields) { + const importedFieldValue = importedStructuredRow[field.name]; + + switch (field.type) { + case FieldMetadataType.Boolean: + recordToBuild[field.name] = + importedFieldValue === 'true' || importedFieldValue === true; + break; + case FieldMetadataType.Number: + case FieldMetadataType.Numeric: + recordToBuild[field.name] = Number(importedFieldValue); + break; + case FieldMetadataType.Currency: + if ( + isDefined( + importedStructuredRow[`${amountMicrosLabel} (${field.name})`], + ) || + isDefined( + importedStructuredRow[`${currencyCodeLabel} (${field.name})`], + ) + ) { + recordToBuild[field.name] = { + amountMicros: convertCurrencyAmountToCurrencyMicros( + Number( + importedStructuredRow[`${amountMicrosLabel} (${field.name})`], + ), + ), + currencyCode: + importedStructuredRow[`${currencyCodeLabel} (${field.name})`] || + 'USD', + }; + } + break; + case FieldMetadataType.Address: { + if ( + isDefined( + importedStructuredRow[`${addressStreet1Label} (${field.name})`] || + importedStructuredRow[`${addressStreet2Label} (${field.name})`] || + importedStructuredRow[`${addressCityLabel} (${field.name})`] || + importedStructuredRow[ + `${addressPostcodeLabel} (${field.name})` + ] || + importedStructuredRow[`${addressStateLabel} (${field.name})`] || + importedStructuredRow[`${addressCountryLabel} (${field.name})`] || + importedStructuredRow[`${addressLatLabel} (${field.name})`] || + importedStructuredRow[`${addressLngLabel} (${field.name})`], + ) + ) { + recordToBuild[field.name] = { + addressStreet1: castToString( + importedStructuredRow[`${addressStreet1Label} (${field.name})`], + ), + addressStreet2: castToString( + importedStructuredRow[`${addressStreet2Label} (${field.name})`], + ), + addressCity: castToString( + importedStructuredRow[`${addressCityLabel} (${field.name})`], + ), + addressPostcode: castToString( + importedStructuredRow[`${addressPostcodeLabel} (${field.name})`], + ), + addressState: castToString( + importedStructuredRow[`${addressStateLabel} (${field.name})`], + ), + addressCountry: castToString( + importedStructuredRow[`${addressCountryLabel} (${field.name})`], + ), + addressLat: Number( + importedStructuredRow[`${addressLatLabel} (${field.name})`], + ), + addressLng: Number( + importedStructuredRow[`${addressLngLabel} (${field.name})`], + ), + } satisfies FieldAddressValue; + } + break; + } + case FieldMetadataType.Link: + if (importedFieldValue !== undefined) { + recordToBuild[field.name] = { + label: field.name, + url: importedFieldValue || null, + }; + } + break; + case FieldMetadataType.Relation: + if ( + isDefined(importedFieldValue) && + (isNonEmptyString(importedFieldValue) || importedFieldValue !== false) + ) { + recordToBuild[field.name + 'Id'] = importedFieldValue; + } + break; + case FieldMetadataType.FullName: + if ( + isDefined( + importedStructuredRow[`${firstNameLabel} (${field.name})`] ?? + importedStructuredRow[`${lastNameLabel} (${field.name})`], + ) + ) { + recordToBuild[field.name] = { + firstName: + importedStructuredRow[`${firstNameLabel} (${field.name})`] ?? '', + lastName: + importedStructuredRow[`${lastNameLabel} (${field.name})`] ?? '', + }; + } + break; + default: + recordToBuild[field.name] = importedFieldValue; + break; + } + } + + return recordToBuild; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/getSpreadSheetValidation.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/getSpreadSheetFieldValidationDefinitions.ts similarity index 53% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/util/getSpreadSheetValidation.ts rename to packages/twenty-front/src/modules/object-record/spreadsheet-import/util/getSpreadSheetFieldValidationDefinitions.ts index 7cd171b8dc..ad08de8936 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/getSpreadSheetValidation.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/getSpreadSheetFieldValidationDefinitions.ts @@ -1,14 +1,37 @@ import { isValidPhoneNumber } from 'libphonenumber-js'; -import { isValidUuid } from '@/object-record/spreadsheet-import/util/isValidUuid'; -import { Validation } from '@/spreadsheet-import/types'; +import { FieldValidationDefinition } from '@/spreadsheet-import/types'; +import { isDefined } from 'twenty-ui'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isValidUuid } from '~/utils/isValidUuid'; -export const getSpreadSheetValidation = ( +export const getSpreadSheetFieldValidationDefinitions = ( type: FieldMetadataType, fieldName: string, -): Validation[] => { +): FieldValidationDefinition[] => { switch (type) { + case FieldMetadataType.FullName: + return [ + { + rule: 'object', + isValid: ({ + firstName, + lastName, + }: { + firstName: string; + lastName: string; + }) => { + return ( + isDefined(firstName) && + isDefined(lastName) && + typeof firstName === 'string' && + typeof lastName === 'string' + ); + }, + errorMessage: fieldName + ' must be a full name', + level: 'error', + }, + ]; case FieldMetadataType.Number: return [ { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx index 1d64ac51ff..b8b548c5a2 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx @@ -1,13 +1,13 @@ import { createContext } from 'react'; -import { SpreadsheetOptions } from '@/spreadsheet-import/types'; +import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const RsiContext = createContext({} as any); type ProvidersProps = { children: React.ReactNode; - values: SpreadsheetOptions; + values: SpreadsheetImportDialogOptions; }; export const Providers = ({ diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx index c54a5498a5..1ae9cd6f7c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx @@ -1,50 +1,57 @@ import { act, renderHook } from '@testing-library/react'; import { RecoilRoot, useRecoilState } from 'recoil'; -import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport'; -import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState'; +import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; +import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow'; -import { RawData, SpreadsheetOptions } from '@/spreadsheet-import/types'; +import { + ImportedRow, + SpreadsheetImportDialogOptions, +} from '@/spreadsheet-import/types'; const Wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); type SpreadsheetKey = 'spreadsheet_key'; -export const mockedSpreadsheetOptions: SpreadsheetOptions = { - isOpen: true, - onClose: () => {}, - fields: [], - uploadStepHook: async () => [], - selectHeaderStepHook: async (headerValues: RawData, data: RawData[]) => ({ - headerValues, - data, - }), - matchColumnsStepHook: async () => [], - rowHook: () => ({ spreadsheet_key: 'rowHook' }), - tableHook: () => [{ spreadsheet_key: 'tableHook' }], - onSubmit: async () => {}, - allowInvalidSubmit: false, - customTheme: {}, - maxRecords: 10, - maxFileSize: 50, - autoMapHeaders: true, - autoMapDistance: 1, - initialStepState: { - type: StepType.upload, - }, - dateFormat: 'MM/DD/YY', - parseRaw: true, - rtl: false, - selectHeader: true, -}; +export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions = + { + isOpen: true, + onClose: () => {}, + fields: [], + uploadStepHook: async () => [], + selectHeaderStepHook: async ( + headerValues: ImportedRow, + data: ImportedRow[], + ) => ({ + headerRow: headerValues, + importedRows: data, + }), + matchColumnsStepHook: async () => [], + rowHook: () => ({ spreadsheet_key: 'rowHook' }), + tableHook: () => [{ spreadsheet_key: 'tableHook' }], + onSubmit: async () => {}, + allowInvalidSubmit: false, + customTheme: {}, + maxRecords: 10, + maxFileSize: 50, + autoMapHeaders: true, + autoMapDistance: 1, + initialStepState: { + type: StepType.upload, + }, + dateFormat: 'MM/DD/YY', + parseRaw: true, + rtl: false, + selectHeader: true, + }; describe('useSpreadsheetImport', () => { it('should set isOpen to true, and update the options in the Recoil state', async () => { const { result } = renderHook( () => ({ - useSpreadsheetImport: useSpreadsheetImport(), - spreadsheetImportState: useRecoilState(spreadsheetImportState)[0], + useSpreadsheetImport: useOpenSpreadsheetImportDialog(), + spreadsheetImportState: useRecoilState(spreadsheetImportDialogState)[0], }), { wrapper: Wrapper, @@ -55,7 +62,7 @@ describe('useSpreadsheetImport', () => { options: null, }); act(() => { - result.current.useSpreadsheetImport.openSpreadsheetImport( + result.current.useSpreadsheetImport.openSpreadsheetImportDialog( mockedSpreadsheetOptions, ); }); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog.ts new file mode 100644 index 0000000000..3b11ca19c3 --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog.ts @@ -0,0 +1,19 @@ +import { useSetRecoilState } from 'recoil'; + +import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; +import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; + +export const useOpenSpreadsheetImportDialog = () => { + const setSpreadSheetImport = useSetRecoilState(spreadsheetImportDialogState); + + const openSpreadsheetImportDialog = ( + options: Omit, 'isOpen' | 'onClose'>, + ) => { + setSpreadSheetImport({ + isOpen: true, + options, + }); + }; + + return { openSpreadsheetImportDialog }; +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts deleted file mode 100644 index 0259faad60..0000000000 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useSetRecoilState } from 'recoil'; - -import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState'; -import { SpreadsheetOptions } from '@/spreadsheet-import/types'; - -export const useSpreadsheetImport = () => { - const setSpreadSheetImport = useSetRecoilState(spreadsheetImportState); - - const openSpreadsheetImport = ( - options: Omit, 'isOpen' | 'onClose'>, - ) => { - setSpreadSheetImport({ - isOpen: true, - options, - }); - }; - - return { openSpreadsheetImport }; -}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts index b9dccb62e2..0cf87b8138 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts @@ -3,12 +3,12 @@ import { SetRequired } from 'type-fest'; import { RsiContext } from '@/spreadsheet-import/components/Providers'; import { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport'; -import { SpreadsheetOptions } from '@/spreadsheet-import/types'; +import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; export const useSpreadsheetImportInternal = () => useContext< SetRequired< - SpreadsheetOptions, + SpreadsheetImportDialogOptions, keyof typeof defaultSpreadsheetImportProps > >(RsiContext); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx index 1b72f2d451..8237c14640 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx @@ -1,7 +1,7 @@ import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; import { Providers } from '@/spreadsheet-import/components/Providers'; import { Steps } from '@/spreadsheet-import/steps/components/Steps'; -import { SpreadsheetOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types'; +import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types'; export const defaultSpreadsheetImportProps: Partial< SpreadsheetImportProps @@ -10,7 +10,10 @@ export const defaultSpreadsheetImportProps: Partial< allowInvalidSubmit: true, autoMapDistance: 2, uploadStepHook: async (value) => value, - selectHeaderStepHook: async (headerValues, data) => ({ headerValues, data }), + selectHeaderStepHook: async (headerValues, data) => ({ + headerRow: headerValues, + importedRows: data, + }), matchColumnsStepHook: async (table) => table, dateFormat: 'yyyy-mm-dd', // ISO 8601, parseRaw: true, diff --git a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx index 5c5b7a1377..88041e2718 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useRecoilState } from 'recoil'; -import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState'; +import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; import { SpreadsheetImport } from './SpreadsheetImport'; @@ -10,12 +10,12 @@ type SpreadsheetImportProviderProps = React.PropsWithChildren; export const SpreadsheetImportProvider = ( props: SpreadsheetImportProviderProps, ) => { - const [spreadsheetImport, setSpreadsheetImport] = useRecoilState( - spreadsheetImportState, + const [spreadsheetImportDialog, setSpreadsheetImportDialog] = useRecoilState( + spreadsheetImportDialogState, ); const handleClose = () => { - setSpreadsheetImport({ + setSpreadsheetImportDialog({ isOpen: false, options: null, }); @@ -24,12 +24,12 @@ export const SpreadsheetImportProvider = ( return ( <> {props.children} - {spreadsheetImport.isOpen && spreadsheetImport.options && ( + {spreadsheetImportDialog.isOpen && spreadsheetImportDialog.options && ( )} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/states/spreadsheetImportDialogState.ts b/packages/twenty-front/src/modules/spreadsheet-import/states/spreadsheetImportDialogState.ts new file mode 100644 index 0000000000..fceda6736c --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/states/spreadsheetImportDialogState.ts @@ -0,0 +1,18 @@ +import { createState } from 'twenty-ui'; + +import { SpreadsheetImportDialogOptions } from '../types'; + +export type SpreadsheetImportDialogState = { + isOpen: boolean; + options: Omit, 'isOpen' | 'onClose'> | null; +}; + +export const spreadsheetImportDialogState = createState< + SpreadsheetImportDialogState +>({ + key: 'spreadsheetImportDialogState', + defaultValue: { + isOpen: false, + options: null, + }, +}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/states/spreadsheetImportState.ts b/packages/twenty-front/src/modules/spreadsheet-import/states/spreadsheetImportState.ts deleted file mode 100644 index c3eaf5534f..0000000000 --- a/packages/twenty-front/src/modules/spreadsheet-import/states/spreadsheetImportState.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createState } from 'twenty-ui'; - -import { SpreadsheetOptions } from '../types'; - -export type SpreadsheetImportState = { - isOpen: boolean; - options: Omit, 'isOpen' | 'onClose'> | null; -}; - -export const spreadsheetImportState = createState>({ - key: 'spreadsheetImportState', - defaultValue: { - isOpen: false, - options: null, - }, -}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx index b9f36e9697..866ad298e6 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; import styled from '@emotion/styled'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Heading } from '@/spreadsheet-import/components/Heading'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; -import { Field, RawData } from '@/spreadsheet-import/types'; +import { Field, ImportedRow } from '@/spreadsheet-import/types'; import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields'; import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns'; import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableData'; @@ -46,9 +46,13 @@ const StyledColumn = styled.span` `; export type MatchColumnsStepProps = { - data: RawData[]; - headerValues: RawData; - onContinue: (data: any[], rawData: RawData[], columns: Columns) => void; + data: ImportedRow[]; + headerValues: ImportedRow; + onContinue: ( + data: any[], + rawData: ImportedRow[], + columns: Columns, + ) => void; onBack: () => void; }; @@ -67,23 +71,27 @@ export type MatchedOptions = { }; type EmptyColumn = { type: ColumnType.empty; index: number; header: string }; + type IgnoredColumn = { type: ColumnType.ignored; index: number; header: string; }; + type MatchedColumn = { type: ColumnType.matched; index: number; header: string; value: T; }; + type MatchedSwitchColumn = { type: ColumnType.matchedCheckbox; index: number; header: string; value: T; }; + export type MatchedSelectColumn = { type: ColumnType.matchedSelect; index: number; @@ -91,6 +99,7 @@ export type MatchedSelectColumn = { value: T; matchedOptions: Partial>[]; }; + export type MatchedSelectOptionsColumn = { type: ColumnType.matchedSelectOptions; index: number; @@ -271,7 +280,7 @@ export const MatchColumnsStep = ({ renderUserColumn={(columns, columnIndex) => ( row[columns[columnIndex].index], )} /> diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UserTableColumn.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UserTableColumn.tsx index 8e5eca9c53..8c575097af 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UserTableColumn.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UserTableColumn.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { RawData } from '@/spreadsheet-import/types'; +import { ImportedRow } from '@/spreadsheet-import/types'; import { isDefined } from '~/utils/isDefined'; import { Column } from '../MatchColumnsStep'; @@ -31,20 +31,22 @@ const StyledExample = styled.span` type UserTableColumnProps = { column: Column; - entries: RawData; + importedRow: ImportedRow; }; export const UserTableColumn = ({ column, - entries, + importedRow, }: UserTableColumnProps) => { const { header } = column; - const entry = entries.find(isDefined); + const firstDefinedValue = importedRow.find(isDefined); return ( {header} - {entry && {`ex: ${entry}`}} + {firstDefinedValue && ( + {`ex: ${firstDefinedValue}`} + )} ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx index 754b3c6e4a..6958e32757 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx @@ -1,9 +1,9 @@ -import { useCallback, useState } from 'react'; import styled from '@emotion/styled'; +import { useCallback, useState } from 'react'; import { Heading } from '@/spreadsheet-import/components/Heading'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; -import { RawData } from '@/spreadsheet-import/types'; +import { ImportedRow } from '@/spreadsheet-import/types'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { SelectHeaderTable } from './components/SelectHeaderTable'; @@ -19,29 +19,36 @@ const StyledTableContainer = styled.div` `; type SelectHeaderStepProps = { - data: RawData[]; - onContinue: (headerValues: RawData, data: RawData[]) => Promise; + importedRows: ImportedRow[]; + onContinue: ( + headerValues: ImportedRow, + importedRows: ImportedRow[], + ) => Promise; onBack: () => void; }; export const SelectHeaderStep = ({ - data, + importedRows, onContinue, onBack, }: SelectHeaderStepProps) => { - const [selectedRows, setSelectedRows] = useState>( - new Set([0]), - ); + const [selectedRowIndexes, setSelectedRowIndexes] = useState< + ReadonlySet + >(new Set([0])); + const [isLoading, setIsLoading] = useState(false); const handleContinue = useCallback(async () => { - const [selectedRowIndex] = Array.from(new Set(selectedRows)); + const [selectedRowIndex] = Array.from(new Set(selectedRowIndexes)); // We consider data above header to be redundant - const trimmedData = data.slice(selectedRowIndex + 1); + const trimmedData = importedRows.slice(selectedRowIndex + 1); + setIsLoading(true); - await onContinue(data[selectedRowIndex], trimmedData); + + await onContinue(importedRows[selectedRowIndex], trimmedData); + setIsLoading(false); - }, [onContinue, data, selectedRows]); + }, [onContinue, importedRows, selectedRowIndexes]); return ( <> @@ -49,9 +56,9 @@ export const SelectHeaderStep = ({ diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx index bc49fb8ebd..da8761b370 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx @@ -1,7 +1,7 @@ // @ts-expect-error // Todo: remove usage of react-data-grid import { Column, FormatterProps, useRowSelection } from 'react-data-grid'; -import { RawData } from '@/spreadsheet-import/types'; +import { ImportedRow } from '@/spreadsheet-import/types'; import { Radio } from '@/ui/input/components/Radio'; const SELECT_COLUMN_KEY = 'select-row'; @@ -39,7 +39,7 @@ export const SelectColumn: Column = { formatter: SelectFormatter, }; -export const generateSelectionColumns = (data: RawData[]) => { +export const generateSelectionColumns = (data: ImportedRow[]) => { const longestRowLength = data.reduce( (acc, curr) => (acc > curr.length ? acc : curr.length), 0, diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx index aa1eb9f9fd..3d5124fb91 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx @@ -1,41 +1,44 @@ import { useMemo } from 'react'; import { Table } from '@/spreadsheet-import/components/Table'; -import { RawData } from '@/spreadsheet-import/types'; +import { ImportedRow } from '@/spreadsheet-import/types'; import { generateSelectionColumns } from './SelectColumn'; -interface SelectHeaderTableProps { - data: RawData[]; - selectedRows: ReadonlySet; - setSelectedRows: (rows: ReadonlySet) => void; -} +type SelectHeaderTableProps = { + importedRows: ImportedRow[]; + selectedRowIndexes: ReadonlySet; + setSelectedRowIndexes: (rowIndexes: ReadonlySet) => void; +}; export const SelectHeaderTable = ({ - data, - selectedRows, - setSelectedRows, + importedRows, + selectedRowIndexes, + setSelectedRowIndexes, }: SelectHeaderTableProps) => { - const columns = useMemo(() => generateSelectionColumns(data), [data]); + const columns = useMemo( + () => generateSelectionColumns(importedRows), + [importedRows], + ); return ( data.indexOf(row)} - rows={data} + rowKeyGetter={(row: any) => importedRows.indexOf(row)} + rows={importedRows} columns={columns} - selectedRows={selectedRows} - onSelectedRowsChange={(newRows: any) => { + selectedRowIndexes={selectedRowIndexes} + onSelectedRowIndexesChange={(newRowIndexes: number[]) => { // allow selecting only one row - newRows.forEach((value: any) => { - if (!selectedRows.has(value as number)) { - setSelectedRows(new Set([value as number])); + newRowIndexes.forEach((value: any) => { + if (!selectedRowIndexes.has(value as number)) { + setSelectedRowIndexes(new Set([value as number])); return; } }); }} onRowClick={(row: any) => { - setSelectedRows(new Set([data.indexOf(row)])); + setSelectedRowIndexes(new Set([importedRows.indexOf(row)])); }} headerRowHeight={0} /> diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx index 052082fe29..ae84f9d794 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx @@ -1,10 +1,10 @@ -import { useCallback, useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useCallback, useState } from 'react'; import { WorkBook } from 'xlsx-ugnis'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; -import { RawData } from '@/spreadsheet-import/types'; +import { ImportedRow } from '@/spreadsheet-import/types'; import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords'; import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook'; import { CircularProgressBar } from '@/ui/feedback/progress-bar/components/CircularProgressBar'; @@ -42,12 +42,12 @@ export type StepState = } | { type: StepType.selectHeader; - data: RawData[]; + data: ImportedRow[]; } | { type: StepType.matchColumns; - data: RawData[]; - headerValues: RawData; + data: ImportedRow[]; + headerValues: ImportedRow; } | { type: StepType.validateData; @@ -131,10 +131,8 @@ export const UploadFlow = ({ nextStep, prevStep }: UploadFlowProps) => { // Automatically select first row as header const trimmedData = mappedWorkbook.slice(1); - const { data, headerValues } = await selectHeaderStepHook( - mappedWorkbook[0], - trimmedData, - ); + const { importedRows: data, headerRow: headerValues } = + await selectHeaderStepHook(mappedWorkbook[0], trimmedData); setState({ type: StepType.matchColumns, @@ -186,12 +184,11 @@ export const UploadFlow = ({ nextStep, prevStep }: UploadFlowProps) => { case StepType.selectHeader: return ( { try { - const { data, headerValues } = await selectHeaderStepHook( - ...args, - ); + const { importedRows: data, headerRow: headerValues } = + await selectHeaderStepHook(...args); setState({ type: StepType.matchColumns, data, diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx index 653b3ac760..c368cdd66b 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx @@ -1,6 +1,6 @@ +import styled from '@emotion/styled'; import { useState } from 'react'; import { useDropzone } from 'react-dropzone'; -import styled from '@emotion/styled'; import * as XLSX from 'xlsx-ugnis'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; @@ -79,11 +79,7 @@ const StyledText = styled.span` font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.medium}; text-align: center; -`; - -const StyledButton = styled(MainButton)` - margin-top: ${({ theme }) => theme.spacing(2)}; - width: 200px; + padding: 15px; `; type DropZoneProps = { @@ -151,7 +147,7 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => { ) : ( <> Upload .xlsx, .xls or .csv file - + )} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx index ccf38c5be3..fb8f15f5d3 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx @@ -1,7 +1,7 @@ +import styled from '@emotion/styled'; import { useCallback, useMemo, useState } from 'react'; // @ts-expect-error Todo: remove usage of react-data-grid import { RowsChangeData } from 'react-data-grid'; -import styled from '@emotion/styled'; import { IconTrash } from 'twenty-ui'; import { Heading } from '@/spreadsheet-import/components/Heading'; @@ -12,7 +12,10 @@ import { Columns, ColumnType, } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; -import { Data } from '@/spreadsheet-import/types'; +import { + ImportedStructuredRow, + ImportValidationResult, +} from '@/spreadsheet-import/types'; import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations'; import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager'; import { Button } from '@/ui/input/button/components/Button'; @@ -21,7 +24,7 @@ import { Modal } from '@/ui/layout/modal/components/Modal'; import { isDefined } from '~/utils/isDefined'; import { generateColumns } from './components/columns'; -import { Meta } from './types'; +import { ImportedStructuredRowMetadata } from './types'; const StyledContent = styled(Modal.Content)` padding-left: ${({ theme }) => theme.spacing(6)}; @@ -65,7 +68,7 @@ const StyledNoRowsContainer = styled.div` `; type ValidationStepProps = { - initialData: Data[]; + initialData: ImportedStructuredRow[]; importedColumns: Columns; file: File; onSubmitStart?: () => void; @@ -83,7 +86,9 @@ export const ValidationStep = ({ const { fields, onClose, onSubmit, rowHook, tableHook } = useSpreadsheetImportInternal(); - const [data, setData] = useState<(Data & Meta)[]>( + const [data, setData] = useState< + (ImportedStructuredRow & ImportedStructuredRowMetadata)[] + >( useMemo( () => addErrorsAndRunHooks(initialData, fields, rowHook, tableHook), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -173,7 +178,11 @@ export const ValidationStep = ({ return data; }, [data, filterByErrors]); - const rowKeyGetter = useCallback((row: Data & Meta) => row.__index, []); + const rowKeyGetter = useCallback( + (row: ImportedStructuredRow & ImportedStructuredRowMetadata) => + row.__index, + [], + ); const submitData = async () => { const calculatedData = data.reduce( @@ -182,15 +191,23 @@ export const ValidationStep = ({ if (isDefined(__errors)) { for (const key in __errors) { if (__errors[key].level === 'error') { - acc.invalidData.push(values as unknown as Data); + acc.invalidStructuredRows.push( + values as unknown as ImportedStructuredRow, + ); return acc; } } } - acc.validData.push(values as unknown as Data); + acc.validStructuredRows.push( + values as unknown as ImportedStructuredRow, + ); return acc; }, - { validData: [] as Data[], invalidData: [] as Data[], all: data }, + { + validStructuredRows: [] as ImportedStructuredRow[], + invalidStructuredRows: [] as ImportedStructuredRow[], + allStructuredRows: data, + } satisfies ImportValidationResult, ); onSubmitStart?.(); await onSubmit(calculatedData, file); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx index 75e0a03da1..f2aa7983f4 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx @@ -1,17 +1,17 @@ +import styled from '@emotion/styled'; // @ts-expect-error // Todo: remove usage of react-data-grid import { Column, useRowSelection } from 'react-data-grid'; import { createPortal } from 'react-dom'; -import styled from '@emotion/styled'; import { AppTooltip } from 'twenty-ui'; import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect'; -import { Data, Fields } from '@/spreadsheet-import/types'; +import { Fields, ImportedStructuredRow } from '@/spreadsheet-import/types'; import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { TextInput } from '@/ui/input/components/TextInput'; import { Toggle } from '@/ui/input/components/Toggle'; import { isDefined } from '~/utils/isDefined'; -import { Meta } from '../types'; +import { ImportedStructuredRowMetadata } from '../types'; const StyledHeaderContainer = styled.div` align-items: center; @@ -63,7 +63,7 @@ const SELECT_COLUMN_KEY = 'select-row'; export const generateColumns = ( fields: Fields, -): Column & Meta>[] => [ +): Column & ImportedStructuredRowMetadata>[] => [ { key: SELECT_COLUMN_KEY, name: '', @@ -96,7 +96,9 @@ export const generateColumns = ( }, }, ...fields.map( - (column): Column & Meta> => ({ + ( + column, + ): Column & ImportedStructuredRowMetadata> => ({ key: column.key, name: column.label, minWidth: 150, @@ -120,7 +122,8 @@ export const generateColumns = ( editable: column.fieldType.type !== 'checkbox', // Todo: remove usage of react-data-grid editor: ({ row, onRowChange, onClose }: any) => { - const columnKey = column.key as keyof (Data & Meta); + const columnKey = column.key as keyof (ImportedStructuredRow & + ImportedStructuredRowMetadata); let component; switch (column.fieldType.type) { @@ -167,7 +170,8 @@ export const generateColumns = ( }, // Todo: remove usage of react-data-grid formatter: ({ row, onRowChange }: { row: any; onRowChange: any }) => { - const columnKey = column.key as keyof (Data & Meta); + const columnKey = column.key as keyof (ImportedStructuredRow & + ImportedStructuredRowMetadata); let component; switch (column.fieldType.type) { @@ -226,7 +230,7 @@ export const generateColumns = ( return component; }, - cellClass: (row: Meta) => { + cellClass: (row: ImportedStructuredRowMetadata) => { switch (row.__errors?.[column.key]?.level) { case 'error': return 'rdg-cell-error'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/types.ts b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/types.ts index d50ccdcdba..bf72b163db 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/types.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/types.ts @@ -1,5 +1,8 @@ import { Info } from '@/spreadsheet-import/types'; -export type Meta = { __index: string; __errors?: Error | null }; +export type ImportedStructuredRowMetadata = { + __index: string; + __errors?: Error | null; +}; export type Error = { [key: string]: Info }; export type Errors = { [id: string]: Error }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx index 3fd58030b1..c5b5f05242 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx @@ -24,7 +24,7 @@ export const Default = () => ( null}> Promise.resolve()} onBack={() => Promise.resolve()} /> diff --git a/packages/twenty-front/src/modules/spreadsheet-import/tests/mockRsiValues.ts b/packages/twenty-front/src/modules/spreadsheet-import/tests/mockRsiValues.ts index 27dc64dabd..bb478047e7 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/tests/mockRsiValues.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/tests/mockRsiValues.ts @@ -1,6 +1,9 @@ import { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport'; import { Columns } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; -import { Fields, SpreadsheetOptions } from '@/spreadsheet-import/types'; +import { + Fields, + SpreadsheetImportDialogOptions, +} from '@/spreadsheet-import/types'; import { sleep } from '~/utils/sleep'; const fields = [ @@ -13,7 +16,7 @@ const fields = [ type: 'input', }, example: 'Stephanie', - validations: [ + fieldValidationDefinitions: [ { rule: 'required', errorMessage: 'Name is required', @@ -29,7 +32,7 @@ const fields = [ type: 'input', }, example: 'McDonald', - validations: [ + fieldValidationDefinitions: [ { rule: 'unique', errorMessage: 'Last name must be unique', @@ -47,7 +50,7 @@ const fields = [ type: 'input', }, example: '23', - validations: [ + fieldValidationDefinitions: [ { rule: 'regex', value: '^\\d+$', @@ -69,7 +72,7 @@ const fields = [ ], }, example: 'Team one', - validations: [ + fieldValidationDefinitions: [ { rule: 'required', errorMessage: 'Team is required', @@ -117,7 +120,7 @@ export const importedColums: Columns = [ ]; const mockComponentBehaviourForTypes = ( - props: SpreadsheetOptions, + props: SpreadsheetImportDialogOptions, ) => props; export const mockRsiValues = mockComponentBehaviourForTypes({ @@ -142,8 +145,8 @@ export const mockRsiValues = mockComponentBehaviourForTypes({ }), ); return { - headerValues: hData, - data, + headerRow: hData, + importedRows: data, }; }, // Runs after column matching and on entry change, more performant diff --git a/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts b/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts index e85ccf299e..fa5cf6d975 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts @@ -3,34 +3,37 @@ import { ReadonlyDeep } from 'type-fest'; import { Columns } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; import { StepState } from '@/spreadsheet-import/steps/components/UploadFlow'; -import { Meta } from '@/spreadsheet-import/steps/components/ValidationStep/types'; +import { ImportedStructuredRowMetadata } from '@/spreadsheet-import/steps/components/ValidationStep/types'; -export type SpreadsheetOptions = { +export type SpreadsheetImportDialogOptions = { // Is modal visible. isOpen: boolean; // callback when RSI is closed before final submit onClose: () => void; // Field description for requested data - fields: Fields; + fields: Fields; // Runs after file upload step, receives and returns raw sheet data - uploadStepHook?: (data: RawData[]) => Promise; + uploadStepHook?: (importedRows: ImportedRow[]) => Promise; // Runs after header selection step, receives and returns raw sheet data selectHeaderStepHook?: ( - headerValues: RawData, - data: RawData[], - ) => Promise<{ headerValues: RawData; data: RawData[] }>; + headerRow: ImportedRow, + importedRows: ImportedRow[], + ) => Promise<{ headerRow: ImportedRow; importedRows: ImportedRow[] }>; // Runs once before validation step, used for data mutations and if you want to change how columns were matched matchColumnsStepHook?: ( - table: Data[], - rawData: RawData[], - columns: Columns, - ) => Promise[]>; + importedStructuredRows: ImportedStructuredRow[], + importedRows: ImportedRow[], + columns: Columns, + ) => Promise[]>; // Runs after column matching and on entry change - rowHook?: RowHook; + rowHook?: RowHook; // Runs after column matching and on entry change - tableHook?: TableHook; + tableHook?: TableHook; // Function called after user finishes the flow - onSubmit: (data: Result, file: File) => Promise; + onSubmit: ( + validationResult: ImportValidationResult, + file: File, + ) => Promise; // Allows submitting with errors. Default: true allowInvalidSubmit?: boolean; // Theme configuration passed to underlying Chakra-UI @@ -55,9 +58,9 @@ export type SpreadsheetOptions = { selectHeader?: boolean; }; -export type RawData = Array; +export type ImportedRow = Array; -export type Data = { +export type ImportedStructuredRow = { [key in T]: string | boolean | undefined; }; @@ -76,7 +79,7 @@ export type Field = { // Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName" alternateMatches?: string[]; // Validations used for field entries - validations?: Validation[]; + fieldValidationDefinitions?: FieldValidationDefinition[]; // Field entry component, default: Input fieldType: Checkbox | Select | Input; // UI-facing values shown to user as field examples pre-upload phase @@ -110,11 +113,19 @@ export type Input = { type: 'input'; }; -export type Validation = +export type FieldValidationDefinition = | RequiredValidation | UniqueValidation | RegexValidation - | FunctionValidation; + | FunctionValidation + | ObjectValidation; + +export type ObjectValidation = { + rule: 'object'; + isValid: (objectValue: any) => boolean; + errorMessage: string; + level?: ErrorLevel; +}; export type RequiredValidation = { rule: 'required'; @@ -145,14 +156,15 @@ export type FunctionValidation = { }; export type RowHook = ( - row: Data, + row: ImportedStructuredRow, addError: (fieldKey: T, error: Info) => void, - table: Data[], -) => Data; + table: ImportedStructuredRow[], +) => ImportedStructuredRow; + export type TableHook = ( - table: Data[], + table: ImportedStructuredRow[], addError: (rowIndex: number, fieldKey: T, error: Info) => void, -) => Data[]; +) => ImportedStructuredRow[]; export type ErrorLevel = 'info' | 'warning' | 'error'; @@ -161,8 +173,9 @@ export type Info = { level: ErrorLevel; }; -export type Result = { - validData: Data[]; - invalidData: Data[]; - all: (Data & Meta)[]; +export type ImportValidationResult = { + validStructuredRows: ImportedStructuredRow[]; + invalidStructuredRows: ImportedStructuredRow[]; + allStructuredRows: (ImportedStructuredRow & + ImportedStructuredRowMetadata)[]; }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/dataMutations.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/dataMutations.test.ts index 224adc1689..d03f91f3fa 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/dataMutations.test.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/dataMutations.test.ts @@ -1,6 +1,6 @@ import { - Data, Field, + ImportedStructuredRow, Info, RowHook, TableHook, @@ -8,11 +8,11 @@ import { import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations'; describe('addErrorsAndRunHooks', () => { - type FullData = Data<'name' | 'age' | 'country'>; + type FullData = ImportedStructuredRow<'name' | 'age' | 'country'>; const requiredField: Field<'name'> = { key: 'name', label: 'Name', - validations: [{ rule: 'required' }], + fieldValidationDefinitions: [{ rule: 'required' }], icon: null, fieldType: { type: 'input' }, }; @@ -20,7 +20,7 @@ describe('addErrorsAndRunHooks', () => { const regexField: Field<'age'> = { key: 'age', label: 'Age', - validations: [ + fieldValidationDefinitions: [ { rule: 'regex', value: '\\d+', errorMessage: 'Regex error' }, ], icon: null, @@ -30,7 +30,7 @@ describe('addErrorsAndRunHooks', () => { const uniqueField: Field<'country'> = { key: 'country', label: 'Country', - validations: [{ rule: 'unique' }], + fieldValidationDefinitions: [{ rule: 'unique' }], icon: null, fieldType: { type: 'input' }, }; @@ -38,7 +38,7 @@ describe('addErrorsAndRunHooks', () => { const functionValidationFieldTrue: Field<'email'> = { key: 'email', label: 'Email', - validations: [ + fieldValidationDefinitions: [ { rule: 'function', isValid: () => true, @@ -52,7 +52,7 @@ describe('addErrorsAndRunHooks', () => { const functionValidationFieldFalse: Field<'email'> = { key: 'email', label: 'Email', - validations: [ + fieldValidationDefinitions: [ { rule: 'function', isValid: () => false, @@ -63,8 +63,11 @@ describe('addErrorsAndRunHooks', () => { fieldType: { type: 'input' }, }; - const validData: Data<'name' | 'age'> = { name: 'John', age: '30' }; - const dataWithoutNameAndInvalidAge: Data<'name' | 'age'> = { + const validData: ImportedStructuredRow<'name' | 'age'> = { + name: 'John', + age: '30', + }; + const dataWithoutNameAndInvalidAge: ImportedStructuredRow<'name' | 'age'> = { name: '', age: 'Invalid', }; @@ -74,7 +77,7 @@ describe('addErrorsAndRunHooks', () => { country: 'Brazil', }; - const data: Data<'name' | 'age'>[] = [ + const data: ImportedStructuredRow<'name' | 'age'>[] = [ validData, dataWithoutNameAndInvalidAge, ]; @@ -180,7 +183,12 @@ describe('addErrorsAndRunHooks', () => { it('should not add errors for unique field with empty values if allowEmpty is true', () => { const result = addErrorsAndRunHooks( [{ country: '' }, { country: '' }], - [{ ...uniqueField, validations: [{ rule: 'unique', allowEmpty: true }] }], + [ + { + ...uniqueField, + fieldValidationDefinitions: [{ rule: 'unique', allowEmpty: true }], + }, + ], ); expect(result[0].__errors).toBeUndefined(); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findUnmatchedRequiredFields.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findUnmatchedRequiredFields.test.ts index e456f72f5a..cadb0f261a 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findUnmatchedRequiredFields.test.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findUnmatchedRequiredFields.test.ts @@ -2,7 +2,7 @@ import { Column, ColumnType, } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; -import { Field, Validation } from '@/spreadsheet-import/types'; +import { Field, FieldValidationDefinition } from '@/spreadsheet-import/types'; import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields'; const nameField: Field<'Name'> = { @@ -22,9 +22,15 @@ const ageField: Field<'Age'> = { type: 'input', }, }; -const validations: Validation[] = [{ rule: 'required' }]; -const nameFieldWithValidations: Field<'Name'> = { ...nameField, validations }; -const ageFieldWithValidations: Field<'Age'> = { ...ageField, validations }; +const validations: FieldValidationDefinition[] = [{ rule: 'required' }]; +const nameFieldWithValidations: Field<'Name'> = { + ...nameField, + fieldValidationDefinitions: validations, +}; +const ageFieldWithValidations: Field<'Age'> = { + ...ageField, + fieldValidationDefinitions: validations, +}; type ColumnValues = 'Name' | 'Age'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/dataMutations.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/dataMutations.ts index f9cb085d93..14ad0ed11f 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/dataMutations.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/dataMutations.ts @@ -3,11 +3,11 @@ import { v4 } from 'uuid'; import { Errors, - Meta, + ImportedStructuredRowMetadata, } from '@/spreadsheet-import/steps/components/ValidationStep/types'; import { - Data, Fields, + ImportedStructuredRow, Info, RowHook, TableHook, @@ -16,11 +16,11 @@ import { isDefined } from '~/utils/isDefined'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const addErrorsAndRunHooks = ( - data: (Data & Partial)[], + data: (ImportedStructuredRow & Partial)[], fields: Fields, rowHook?: RowHook, tableHook?: TableHook, -): (Data & Meta)[] => { +): (ImportedStructuredRow & ImportedStructuredRowMetadata)[] => { const errors: Errors = {}; const addHookError = (rowIndex: number, fieldKey: T, error: Info) => { @@ -41,8 +41,8 @@ export const addErrorsAndRunHooks = ( } fields.forEach((field) => { - field.validations?.forEach((validation) => { - switch (validation.rule) { + field.fieldValidationDefinitions?.forEach((fieldValidationDefinition) => { + switch (fieldValidationDefinition.rule) { case 'unique': { const values = data.map((entry) => entry[field.key as T]); @@ -51,7 +51,7 @@ export const addErrorsAndRunHooks = ( values.forEach((value) => { if ( - validation.allowEmpty === true && + fieldValidationDefinition.allowEmpty === true && (isUndefinedOrNull(value) || value === '' || !value) ) { // If allowEmpty is set, we will not validate falsy fields such as undefined or empty string. @@ -70,8 +70,10 @@ export const addErrorsAndRunHooks = ( errors[index] = { ...errors[index], [field.key]: { - level: validation.level || 'error', - message: validation.errorMessage || 'Field must be unique', + level: fieldValidationDefinition.level || 'error', + message: + fieldValidationDefinition.errorMessage || + 'Field must be unique', }, }; } @@ -88,8 +90,10 @@ export const addErrorsAndRunHooks = ( errors[index] = { ...errors[index], [field.key]: { - level: validation.level || 'error', - message: validation.errorMessage || 'Field is required', + level: fieldValidationDefinition.level || 'error', + message: + fieldValidationDefinition.errorMessage || + 'Field is required', }, }; } @@ -97,7 +101,10 @@ export const addErrorsAndRunHooks = ( break; } case 'regex': { - const regex = new RegExp(validation.value, validation.flags); + const regex = new RegExp( + fieldValidationDefinition.value, + fieldValidationDefinition.flags, + ); data.forEach((entry, index) => { const value = entry[field.key]?.toString(); @@ -105,10 +112,10 @@ export const addErrorsAndRunHooks = ( errors[index] = { ...errors[index], [field.key]: { - level: validation.level || 'error', + level: fieldValidationDefinition.level || 'error', message: - validation.errorMessage || - `Field did not match the regex /${validation.value}/${validation.flags} `, + fieldValidationDefinition.errorMessage || + `Field did not match the regex /${fieldValidationDefinition.value}/${fieldValidationDefinition.flags} `, }, }; } @@ -119,12 +126,17 @@ export const addErrorsAndRunHooks = ( data.forEach((entry, index) => { const value = entry[field.key]?.toString(); - if (isNonEmptyString(value) && !validation.isValid(value)) { + if ( + isNonEmptyString(value) && + !fieldValidationDefinition.isValid(value) + ) { errors[index] = { ...errors[index], [field.key]: { - level: validation.level || 'error', - message: validation.errorMessage || 'Field is invalid', + level: fieldValidationDefinition.level || 'error', + message: + fieldValidationDefinition.errorMessage || + 'Field is invalid', }, }; } @@ -140,7 +152,8 @@ export const addErrorsAndRunHooks = ( if (!('__index' in value)) { value.__index = v4(); } - const newValue = value as Data & Meta; + const newValue = value as ImportedStructuredRow & + ImportedStructuredRowMetadata; if (isDefined(errors[index])) { return { ...newValue, __errors: errors[index] }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts index 3dcd1333fd..e7219f70bd 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts @@ -8,7 +8,9 @@ export const findUnmatchedRequiredFields = ( fields .filter( (field) => - field.validations?.some((validation) => validation.rule === 'required'), + field.fieldValidationDefinitions?.some( + (validation) => validation.rule === 'required', + ), ) .filter( (field) => diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/normalizeTableData.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/normalizeTableData.ts index 0782ab69a8..7b40b64c37 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/normalizeTableData.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/normalizeTableData.ts @@ -2,13 +2,17 @@ import { Columns, ColumnType, } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; -import { Data, Fields, RawData } from '@/spreadsheet-import/types'; +import { + Fields, + ImportedRow, + ImportedStructuredRow, +} from '@/spreadsheet-import/types'; import { normalizeCheckboxValue } from './normalizeCheckboxValue'; export const normalizeTableData = ( columns: Columns, - data: RawData[], + data: ImportedRow[], fields: Fields, ) => data.map((row) => @@ -63,5 +67,5 @@ export const normalizeTableData = ( default: return acc; } - }, {} as Data), + }, {} as ImportedStructuredRow), ); diff --git a/packages/twenty-front/src/utils/__tests__/convert-currency-amount.test.ts b/packages/twenty-front/src/utils/__tests__/convert-currency-amount.test.ts index d4cf798c12..44849ab34e 100644 --- a/packages/twenty-front/src/utils/__tests__/convert-currency-amount.test.ts +++ b/packages/twenty-front/src/utils/__tests__/convert-currency-amount.test.ts @@ -1,35 +1,17 @@ import { - convertCurrencyMicrosToCurrency, - convertCurrencyToCurrencyMicros, -} from '~/utils/convert-currency-amount'; - -describe('convertCurrencyToCurrencyMicros', () => { - it('should return null if currencyAmount is null', () => { - expect(convertCurrencyToCurrencyMicros(null)).toBeNull(); - }); - - it('should throw an error if currencyAmount converted to micros is not a whole number', () => { - expect(() => convertCurrencyToCurrencyMicros(1.023)).toThrow( - 'Cannot convert 1.023 to micros', - ); - }); + convertCurrencyAmountToCurrencyMicros, + convertCurrencyMicrosToCurrencyAmount, +} from '~/utils/convertCurrencyToCurrencyMicros'; +describe('convertCurrencyAmountToCurrencyMicros', () => { it('should convert currencyAmount to micros', () => { - expect(convertCurrencyToCurrencyMicros(1)).toBe(1000000); - expect(convertCurrencyToCurrencyMicros(1.5)).toBe(1500000); + expect(convertCurrencyAmountToCurrencyMicros(1)).toBe(1000000); + expect(convertCurrencyAmountToCurrencyMicros(1.5)).toBe(1500000); }); }); -describe('convertCurrencyMicrosToCurrency', () => { - it('should return null if currencyAmountMicros is null', () => { - expect(convertCurrencyMicrosToCurrency(null)).toBeNull(); - }); - - it('should return null if currencyAmountMicros is undefined', () => { - expect(convertCurrencyMicrosToCurrency(undefined)).toBeNull(); - }); - +describe('convertCurrencyMicrosToCurrencyAmount', () => { it('should convert currency micros to currency', () => { - expect(convertCurrencyMicrosToCurrency(24000000)).toBe(24); + expect(convertCurrencyMicrosToCurrencyAmount(24000000)).toBe(24); }); }); diff --git a/packages/twenty-front/src/utils/castToString.ts b/packages/twenty-front/src/utils/castToString.ts new file mode 100644 index 0000000000..dd2c6853e5 --- /dev/null +++ b/packages/twenty-front/src/utils/castToString.ts @@ -0,0 +1,3 @@ +export const castToString = (value: any) => { + return String(value ?? ''); +}; diff --git a/packages/twenty-front/src/utils/convert-currency-amount.ts b/packages/twenty-front/src/utils/convert-currency-amount.ts deleted file mode 100644 index 72814c672b..0000000000 --- a/packages/twenty-front/src/utils/convert-currency-amount.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const convertCurrencyToCurrencyMicros = ( - currencyAmount: number | null | undefined, -) => { - if (currencyAmount == null) { - return null; - } - const currencyAmountAsNumber = +currencyAmount; - if (isNaN(currencyAmountAsNumber)) { - throw new Error(`Cannot convert ${currencyAmount} to micros`); - } - const currencyAmountAsMicros = currencyAmountAsNumber * 1000000; - if (currencyAmountAsMicros % 1 !== 0) { - throw new Error(`Cannot convert ${currencyAmount} to micros`); - } - return currencyAmountAsMicros; -}; - -export const convertCurrencyMicrosToCurrency = ( - currencyAmountMicros: number | null | undefined, -) => { - if (currencyAmountMicros == null) { - return null; - } - const currencyAmountMicrosAsNumber = +currencyAmountMicros; - if (isNaN(currencyAmountMicrosAsNumber)) { - throw new Error(`Cannot convert ${currencyAmountMicros} to currency`); - } - return currencyAmountMicrosAsNumber / 1000000; -}; diff --git a/packages/twenty-front/src/utils/convertCurrencyToCurrencyMicros.ts b/packages/twenty-front/src/utils/convertCurrencyToCurrencyMicros.ts new file mode 100644 index 0000000000..229e84b0cf --- /dev/null +++ b/packages/twenty-front/src/utils/convertCurrencyToCurrencyMicros.ts @@ -0,0 +1,13 @@ +export const convertCurrencyAmountToCurrencyMicros = ( + currencyAmount: number, +) => { + const currencyAmountAsMicros = currencyAmount * 1000000; + + return currencyAmountAsMicros; +}; + +export const convertCurrencyMicrosToCurrencyAmount = ( + currencyAmountMicros: number, +) => { + return currencyAmountMicros / 1000000; +}; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/util/isValidUuid.ts b/packages/twenty-front/src/utils/isValidUuid.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/util/isValidUuid.ts rename to packages/twenty-front/src/utils/isValidUuid.ts