diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index 762c99d8ffc..ec53ffc7794 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -13,9 +13,9 @@ import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRec import { encodeCursor } from '@/apollo/utils/encodeCursor'; import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; +import { ObjectPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; -import { ObjectPermissions } from 'twenty-shared/types'; /* TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are. diff --git a/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts index 416237c111a..92738028025 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts @@ -16,8 +16,8 @@ export const useBatchCreateManyRecords = < >({ objectNameSingular, recordGqlFields, - skipPostOptimisticEffect = false, shouldMatchRootQueryFilter, + skipPostOptimisticEffect = false, mutationBatchSize = DEFAULT_MUTATION_BATCH_SIZE, setBatchedRecordsCount, abortController, @@ -29,7 +29,7 @@ export const useBatchCreateManyRecords = < const { createManyRecords } = useCreateManyRecords({ objectNameSingular, recordGqlFields, - skipPostOptimisticEffect, + skipPostOptimisticEffect: skipPostOptimisticEffect, shouldMatchRootQueryFilter, shouldRefetchAggregateQueries: false, }); @@ -95,6 +95,7 @@ export const useBatchCreateManyRecords = < } await refetchAggregateQueries(); + return allCreatedRecords; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index 65079afad07..440dca6d80c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -91,10 +91,10 @@ export const useCreateManyRecords = < }: createManyRecordsProps) => { const sanitizedCreateManyRecordsInput: PartialObjectRecordWithOptionalId[] = []; + const shouldPerformOptimisticEffect = upsert !== true; const recordOptimisticRecordsInput: PartialObjectRecordWithId[] = []; recordsToCreate.forEach((recordToCreate) => { - const shouldDoOptimisticEffect = upsert !== true; - const idForCreation = shouldDoOptimisticEffect + const idForCreation = shouldPerformOptimisticEffect ? (recordToCreate?.id ?? v4()) : undefined; const sanitizedRecord = { @@ -117,7 +117,7 @@ export const useCreateManyRecords = < sanitizedCreateManyRecordsInput.push(sanitizedRecord); - if (shouldDoOptimisticEffect) { + if (shouldPerformOptimisticEffect) { const optimisticRecordInput = { ...computeOptimisticRecordFromInput({ cache: apolloCoreClient.cache, @@ -186,7 +186,12 @@ export const useCreateManyRecords = < update: (cache, { data }) => { const records = data?.[mutationResponseField]; - if (!isDefined(records?.length) || skipPostOptimisticEffect) return; + if ( + !isDefined(records?.length) || + skipPostOptimisticEffect || + !shouldPerformOptimisticEffect + ) + return; triggerCreateRecordsOptimisticEffect({ cache, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts index 8c9d3bfa211..29076df8a82 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts @@ -1,5 +1,4 @@ -import { gql } from '@apollo/client'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { act } from 'react'; import { useRecoilValue } from 'recoil'; @@ -8,15 +7,37 @@ import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spread import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog'; -import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata'; +import gql from 'graphql-tag'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +const mockBatchCreateManyRecords = jest.fn().mockResolvedValue([]); + +jest.mock('@/object-record/hooks/useBatchCreateManyRecords', () => ({ + useBatchCreateManyRecords: () => ({ + batchCreateManyRecords: mockBatchCreateManyRecords, + }), +})); + const companyId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a'; jest.mock('uuid', () => ({ v4: jest.fn(() => companyId), })); +const mockResult = jest.fn(() => ({ + data: { + createCompanies: [ + { + id: companyId, + name: 'Example Company', + employees: 0, + idealCustomerProfile: true, + __typename: 'Company', + }, + ], + }, +})); + const companyMocks = [ { request: { @@ -26,309 +47,17 @@ const companyMocks = [ $upsert: Boolean ) { createCompanies(data: $data, upsert: $upsert) { - __typename - accountOwner { - __typename - avatarUrl - colorScheme - createdAt - dateFormat - deletedAt - id - locale - name { - firstName - lastName - } - position - timeFormat - timeZone - updatedAt - userEmail - userId - } - accountOwnerId - address { - addressStreet1 - addressStreet2 - addressCity - addressState - addressCountry - addressPostcode - addressLat - addressLng - } - annualRecurringRevenue { - amountMicros - currencyCode - } - attachments { - edges { - node { - __typename - authorId - companyId - createdAt - deletedAt - fullPath - id - name - noteId - opportunityId - personId - petId - rocketId - surveyResultId - taskId - type - updatedAt - } - } - } - createdAt - createdBy { - source - workspaceMemberId - name - context - } - deletedAt - domainName { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - employees - favorites { - edges { - node { - __typename - companyId - createdAt - deletedAt - favoriteFolderId - forWorkspaceMemberId - id - noteId - opportunityId - personId - petId - position - rocketId - surveyResultId - taskId - updatedAt - viewId - workflowId - workflowRunId - workflowVersionId - } - } - } id - idealCustomerProfile - introVideo { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - linkedinLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } name - noteTargets { - edges { - node { - __typename - companyId - createdAt - deletedAt - id - noteId - opportunityId - personId - petId - rocketId - surveyResultId - updatedAt - } - } - } - opportunities { - edges { - node { - __typename - amount { - amountMicros - currencyCode - } - closeDate - companyId - createdAt - createdBy { - source - workspaceMemberId - name - context - } - deletedAt - id - name - pointOfContactId - position - stage - updatedAt - } - } - } - people { - edges { - node { - __typename - avatarUrl - city - companyId - createdAt - createdBy { - source - workspaceMemberId - name - context - } - deletedAt - emails { - primaryEmail - additionalEmails - } - id - intro - jobTitle - linkedinLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - name { - firstName - lastName - } - performanceRating - phones { - primaryPhoneNumber - primaryPhoneCountryCode - primaryPhoneCallingCode - additionalPhones - } - position - updatedAt - whatsapp { - primaryPhoneNumber - primaryPhoneCountryCode - primaryPhoneCallingCode - additionalPhones - } - workPreference - xLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - } - } - } - position - tagline - taskTargets { - edges { - node { - __typename - companyId - createdAt - deletedAt - id - opportunityId - personId - petId - rocketId - surveyResultId - taskId - updatedAt - } - } - } - timelineActivities { - edges { - node { - __typename - companyId - createdAt - deletedAt - happensAt - id - linkedObjectMetadataId - linkedRecordCachedName - linkedRecordId - name - noteId - opportunityId - personId - petId - properties - rocketId - surveyResultId - taskId - updatedAt - workflowId - workflowRunId - workflowVersionId - workspaceMemberId - } - } - } - updatedAt - visaSponsorship - workPolicy - xLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } + employees + idealCustomerProfile + __typename } } `, - variables: { - data: [ - { - createdBy: { - source: 'IMPORT', - context: {}, - } satisfies FieldActorForInputValue, - employees: 0, - idealCustomerProfile: true, - name: 'Example Company', - id: companyId, - visaSponsorship: false, - deletedAt: undefined, - workPolicy: [], - }, - ], - upsert: true, - }, }, - result: jest.fn(() => ({ - data: { - createCompanies: [ - { - id: companyId, - favorites: { - edges: [], - }, - }, - ], - }, - })), + variableMatcher: () => true, + result: mockResult, }, ]; @@ -342,84 +71,112 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: companyMocks, }); -// 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 () => { +describe('useOpenObjectRecordsSpreadsheetImportDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should open dialog and configure onSubmit function correctly', async () => { const { result } = renderHook( () => { const spreadsheetImportDialog = useRecoilValue( spreadsheetImportDialogState, ); - const { - openObjectRecordsSpreadsheetImportDialog: openRecordSpreadsheetImport, - } = useOpenObjectRecordsSpreadsheetImportDialog( - CoreObjectNameSingular.Company, - ); + const { openObjectRecordsSpreadsheetImportDialog } = + useOpenObjectRecordsSpreadsheetImportDialog( + CoreObjectNameSingular.Company, + ); return { - openRecordSpreadsheetImport, + openObjectRecordsSpreadsheetImportDialog, spreadsheetImportDialog, }; }, - { - wrapper: Wrapper, - }, + { wrapper: Wrapper }, ); - const { spreadsheetImportDialog, openRecordSpreadsheetImport } = - result.current; + const { + spreadsheetImportDialog, + openObjectRecordsSpreadsheetImportDialog, + } = result.current; expect(spreadsheetImportDialog.isOpen).toBe(false); expect(spreadsheetImportDialog.options).toBeNull(); await act(async () => { - openRecordSpreadsheetImport(); + openObjectRecordsSpreadsheetImportDialog(); }); - const { spreadsheetImportDialog: spreadsheetImportDialogAfterOpen } = - result.current; + const { spreadsheetImportDialog: dialogAfterOpen } = result.current; - expect(spreadsheetImportDialogAfterOpen.isOpen).toBe(true); - expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty('onSubmit'); - expect(spreadsheetImportDialogAfterOpen.options?.onSubmit).toBeInstanceOf( - Function, - ); - expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty( - 'spreadsheetImportFields', - ); + expect(dialogAfterOpen.isOpen).toBe(true); + expect(dialogAfterOpen.options).toHaveProperty('onSubmit'); + expect(dialogAfterOpen.options?.onSubmit).toBeInstanceOf(Function); + expect(dialogAfterOpen.options).toHaveProperty('spreadsheetImportFields'); expect( - Array.isArray( - spreadsheetImportDialogAfterOpen.options?.spreadsheetImportFields, - ), + Array.isArray(dialogAfterOpen.options?.spreadsheetImportFields), ).toBe(true); + }); - act(() => { - spreadsheetImportDialogAfterOpen.options?.onSubmit( + it('should call batchCreateManyRecords when onSubmit is executed', async () => { + const { result } = renderHook( + () => { + const spreadsheetImportDialog = useRecoilValue( + spreadsheetImportDialogState, + ); + const { openObjectRecordsSpreadsheetImportDialog } = + useOpenObjectRecordsSpreadsheetImportDialog( + CoreObjectNameSingular.Company, + ); + return { + openObjectRecordsSpreadsheetImportDialog, + spreadsheetImportDialog, + }; + }, + { wrapper: Wrapper }, + ); + + await act(async () => { + result.current.openObjectRecordsSpreadsheetImportDialog(); + }); + + const { spreadsheetImportDialog } = result.current; + + const submitData = { + validStructuredRows: [ { - validStructuredRows: [ - { - id: companyId, - name: 'Example Company', - idealCustomerProfile: true, - employees: '0', - }, - ], - invalidStructuredRows: [], - allStructuredRows: [ - { - id: companyId, - name: 'Example Company', - __index: 'cbc3985f-dde9-46d1-bae2-c124141700ac', - idealCustomerProfile: true, - employees: '0', - }, - ], + id: companyId, + name: 'Example Company', + idealCustomerProfile: true, + employees: '0', }, - fakeCsv(), - ); + ], + invalidStructuredRows: [], + allStructuredRows: [ + { + id: companyId, + name: 'Example Company', + __index: 'cbc3985f-dde9-46d1-bae2-c124141700ac', + idealCustomerProfile: true, + employees: '0', + }, + ], + }; + + await act(async () => { + await spreadsheetImportDialog.options?.onSubmit(submitData, fakeCsv()); }); - await waitFor(() => { - expect(companyMocks[0].result).toHaveBeenCalled(); - }); + expect(mockBatchCreateManyRecords).toHaveBeenCalledTimes(1); + + const callArgs = mockBatchCreateManyRecords.mock.calls[0][0]; + expect(callArgs).toHaveProperty('recordsToCreate'); + expect(callArgs).toHaveProperty('upsert', true); + expect(Array.isArray(callArgs.recordsToCreate)).toBe(true); + expect(callArgs.recordsToCreate).toHaveLength(1); + + const recordToCreate = callArgs.recordsToCreate[0]; + expect(recordToCreate).toHaveProperty('name', 'Example Company'); + expect(recordToCreate).toHaveProperty('idealCustomerProfile', true); + expect(recordToCreate).toHaveProperty('employees', 0); }); }); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts index 37988d6aa1e..693775a1ea1 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts @@ -1,9 +1,11 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { generateDepthOneWithoutRelationsRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneWithoutRelationsRecordGqlFields'; import { useBatchCreateManyRecords } from '@/object-record/hooks/useBatchCreateManyRecords'; import { useBuildSpreadsheetImportFields } from '@/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields'; import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow'; import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems'; -import { spreadsheetImportGetUnicityRowHook } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook'; +import { spreadsheetImportGetUnicityTableHook } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook'; import { SpreadsheetImportCreateRecordsBatchSize } from '@/spreadsheet-import/constants/SpreadsheetImportCreateRecordsBatchSize'; import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState'; @@ -14,6 +16,7 @@ import { useSetRecoilState } from 'recoil'; export const useOpenObjectRecordsSpreadsheetImportDialog = ( objectNameSingular: string, ) => { + const apolloCoreClient = useApolloCoreClient(); const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog(); const { buildSpreadsheetImportFields } = useBuildSpreadsheetImportFields(); @@ -31,6 +34,9 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( const { batchCreateManyRecords } = useBatchCreateManyRecords({ objectNameSingular, + recordGqlFields: generateDepthOneWithoutRelationsRecordGqlFields({ + objectMetadataItem, + }), mutationBatchSize: SpreadsheetImportCreateRecordsBatchSize, setBatchedRecordsCount: setCreatedRecordsProgress, abortController, @@ -70,6 +76,11 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( recordsToCreate: createInputs, upsert: true, }); + await apolloCoreClient.refetchQueries({ + updateCache: (cache) => { + cache.evict({ fieldName: objectMetadataItem.namePlural }); + }, + }); } catch (error: any) { enqueueErrorSnackBar({ apolloError: error, @@ -81,7 +92,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( onAbortSubmit: () => { abortController.abort(); }, - rowHook: spreadsheetImportGetUnicityRowHook(objectMetadataItem), + tableHook: spreadsheetImportGetUnicityTableHook(objectMetadataItem), }); }; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/spreadsheetImportGetUnicityRowHook.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/spreadsheetImportGetUnicityTableHook.test.ts similarity index 70% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/spreadsheetImportGetUnicityRowHook.test.ts rename to packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/spreadsheetImportGetUnicityTableHook.test.ts index 4431b282240..7f8a28688b5 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/spreadsheetImportGetUnicityRowHook.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/spreadsheetImportGetUnicityTableHook.test.ts @@ -1,11 +1,11 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { spreadsheetImportGetUnicityRowHook } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook'; +import { spreadsheetImportGetUnicityTableHook } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook'; import { ImportedStructuredRow } from '@/spreadsheet-import/types'; import { IndexType } from '~/generated-metadata/graphql'; import { getMockCompanyObjectMetadataItem } from '~/testing/mock-data/companies'; import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow'; -describe('spreadsheetImportGetUnicityRowHook', () => { +describe('spreadsheetImportGetUnicityTableHook', () => { const baseMockCompany = getMockCompanyObjectMetadataItem(); const nameField = getMockFieldMetadataItemOrThrow({ @@ -71,7 +71,7 @@ describe('spreadsheetImportGetUnicityRowHook', () => { }; it('should return row with error if row is not unique - index on composite field', () => { - const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); + const hook = spreadsheetImportGetUnicityTableHook(mockObjectMetadataItem); const testData: ImportedStructuredRow[] = [ { 'Link URL (domainName)': 'https://duplicaTe.com' }, { 'Link URL (domainName)': 'https://duplicate.com' }, @@ -80,18 +80,23 @@ describe('spreadsheetImportGetUnicityRowHook', () => { const addErrorMock = jest.fn(); - const result = hook(testData[1], addErrorMock, testData); + const result = hook(testData, addErrorMock); - expect(addErrorMock).toHaveBeenCalledWith('Link URL (domainName)', { + expect(addErrorMock).toHaveBeenCalledWith(0, 'Link URL (domainName)', { message: 'This Link URL (domainName) value already exists in your import data', level: 'error', }); - expect(result).toBe(testData[1]); + expect(addErrorMock).toHaveBeenCalledWith(1, 'Link URL (domainName)', { + message: + 'This Link URL (domainName) value already exists in your import data', + level: 'error', + }); + expect(result).toBe(testData); }); it('should return row with error if row is not unique - index on id', () => { - const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); + const hook = spreadsheetImportGetUnicityTableHook(mockObjectMetadataItem); const testData: ImportedStructuredRow[] = [ { 'Link URL (domainName)': 'test.com', id: '1' }, @@ -101,17 +106,21 @@ describe('spreadsheetImportGetUnicityRowHook', () => { const addErrorMock = jest.fn(); - const result = hook(testData[1], addErrorMock, testData); + const result = hook(testData, addErrorMock); - expect(addErrorMock).toHaveBeenCalledWith('id', { + expect(addErrorMock).toHaveBeenCalledWith(0, 'id', { message: 'This id value already exists in your import data', level: 'error', }); - expect(result).toBe(testData[1]); + expect(addErrorMock).toHaveBeenCalledWith(1, 'id', { + message: 'This id value already exists in your import data', + level: 'error', + }); + expect(result).toBe(testData); }); it('should return row with error if row is not unique - multi fields index', () => { - const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); + const hook = spreadsheetImportGetUnicityTableHook(mockObjectMetadataItem); const testData: ImportedStructuredRow[] = [ { name: 'test', employees: '100', id: '1' }, @@ -121,20 +130,29 @@ describe('spreadsheetImportGetUnicityRowHook', () => { const addErrorMock = jest.fn(); - const result = hook(testData[1], addErrorMock, testData); + const result = hook(testData, addErrorMock); - expect(addErrorMock).toHaveBeenCalledWith('name', { + expect(addErrorMock).toHaveBeenCalledWith(0, 'name', { message: 'This name value already exists in your import data', level: 'error', }); - expect(addErrorMock).toHaveBeenCalledWith('employees', { + expect(addErrorMock).toHaveBeenCalledWith(0, 'employees', { message: 'This employees value already exists in your import data', level: 'error', }); - expect(result).toBe(testData[1]); + expect(addErrorMock).toHaveBeenCalledWith(1, 'name', { + message: 'This name value already exists in your import data', + level: 'error', + }); + expect(addErrorMock).toHaveBeenCalledWith(1, 'employees', { + message: 'This employees value already exists in your import data', + level: 'error', + }); + expect(result).toBe(testData); }); + it('should not add error if row values are unique', () => { - const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); + const hook = spreadsheetImportGetUnicityTableHook(mockObjectMetadataItem); const testData: ImportedStructuredRow[] = [ { @@ -159,9 +177,9 @@ describe('spreadsheetImportGetUnicityRowHook', () => { const addErrorMock = jest.fn(); - const result = hook(testData[1], addErrorMock, testData); + const result = hook(testData, addErrorMock); expect(addErrorMock).not.toHaveBeenCalled(); - expect(result).toBe(testData[1]); + expect(result).toBe(testData); }); }); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts similarity index 71% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook.ts rename to packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts index 8d17f2eca6d..9933a141055 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts @@ -6,9 +6,8 @@ import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constant import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { ImportedStructuredRow, - SpreadsheetImportRowHook, + SpreadsheetImportTableHook, } from '@/spreadsheet-import/types'; -import { t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { FieldMetadataType } from 'twenty-shared/types'; import { @@ -22,7 +21,7 @@ type Column = { fieldType: FieldMetadataType; }; -export const spreadsheetImportGetUnicityRowHook = ( +export const spreadsheetImportGetUnicityTableHook = ( objectMetadataItem: ObjectMetadataItem, ) => { const uniqueConstraintsFields = getUniqueConstraintsFields< @@ -50,40 +49,45 @@ export const spreadsheetImportGetUnicityRowHook = ( return [{ columnName: field.name, fieldType: field.type }]; }), ); - const rowHook: SpreadsheetImportRowHook = (row, addError, table) => { + const tableHook: SpreadsheetImportTableHook = (table, addError) => { if (uniqueConstraintsFields.length === 0) { - return row; + return table; } - uniqueConstraintsWithColumnNames.forEach((uniqueConstraint) => { - const rowUniqueValues = getUniqueValues(row, uniqueConstraint); + for (const uniqueConstraint of uniqueConstraintsWithColumnNames) { + const uniqueValues: Record = {}; + const duplicateIndices: Set = new Set(); - if (!isNonEmptyString(rowUniqueValues)) { - return row; - } + table.forEach((row, index) => { + const uniqueValue = getUniqueValues(row, uniqueConstraint); - const duplicateRows = table.filter( - (r) => getUniqueValues(r, uniqueConstraint) === rowUniqueValues, - ); + if (!isNonEmptyString(uniqueValue)) { + return; + } - if (duplicateRows.length <= 1) { - return row; - } - - uniqueConstraint.forEach(({ columnName }) => { - if (isDefined(row[columnName])) { - addError(columnName, { - message: t`This ${columnName} value already exists in your import data`, - level: 'error', - }); + if (isDefined(uniqueValues[uniqueValue])) { + const originalIndex = uniqueValues[uniqueValue]; + duplicateIndices.add(originalIndex); + duplicateIndices.add(index); + } else { + uniqueValues[uniqueValue] = index; } }); - }); - return row; + duplicateIndices.forEach((duplicateIndex) => { + uniqueConstraint.forEach(({ columnName }) => { + addError(duplicateIndex, columnName, { + message: `This ${columnName} value already exists in your import data`, + level: 'error', + }); + }); + }); + } + + return table; }; - return rowHook; + return tableHook; }; const getUniqueValues = ( 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 f4bf87b719d..67e6a895019 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 @@ -146,6 +146,7 @@ export const MatchColumnsStep = ({ columns: SpreadsheetColumns, ) => { try { + setIsLoading(true); const data = await matchColumnsStepHook(values, rawData, columns); setCurrentStepState({ type: SpreadsheetImportStepType.validateData,