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
new file mode 100644
index 000000000..c54a5498a
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx
@@ -0,0 +1,67 @@
+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 { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
+import { RawData, SpreadsheetOptions } 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,
+};
+
+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],
+ }),
+ {
+ wrapper: Wrapper,
+ },
+ );
+ expect(result.current.spreadsheetImportState).toStrictEqual({
+ isOpen: false,
+ options: null,
+ });
+ act(() => {
+ result.current.useSpreadsheetImport.openSpreadsheetImport(
+ mockedSpreadsheetOptions,
+ );
+ });
+ expect(result.current.spreadsheetImportState).toStrictEqual({
+ isOpen: true,
+ options: mockedSpreadsheetOptions,
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInitialStep.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInitialStep.test.ts
new file mode 100644
index 000000000..f47959b9d
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInitialStep.test.ts
@@ -0,0 +1,47 @@
+import { useState } from 'react';
+import { act, renderHook } from '@testing-library/react';
+
+import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep';
+import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
+
+describe('useSpreadsheetImportInitialStep', () => {
+ it('should return correct number for each step type', async () => {
+ const { result } = renderHook(() => {
+ const [step, setStep] = useState();
+ const { initialStep } = useSpreadsheetImportInitialStep(step);
+ return { initialStep, setStep };
+ });
+
+ expect(result.current.initialStep).toBe(-1);
+
+ act(() => {
+ result.current.setStep(StepType.upload);
+ });
+
+ expect(result.current.initialStep).toBe(0);
+
+ act(() => {
+ result.current.setStep(StepType.selectSheet);
+ });
+
+ expect(result.current.initialStep).toBe(0);
+
+ act(() => {
+ result.current.setStep(StepType.selectHeader);
+ });
+
+ expect(result.current.initialStep).toBe(0);
+
+ act(() => {
+ result.current.setStep(StepType.matchColumns);
+ });
+
+ expect(result.current.initialStep).toBe(2);
+
+ act(() => {
+ result.current.setStep(StepType.validateData);
+ });
+
+ expect(result.current.initialStep).toBe(3);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx
new file mode 100644
index 000000000..2e8e8f039
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx
@@ -0,0 +1,19 @@
+import { renderHook } from '@testing-library/react';
+
+import { Providers } from '@/spreadsheet-import/components/Providers';
+import { mockedSpreadsheetOptions } from '@/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test';
+import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('useSpreadsheetImportInternal', () => {
+ it('should return the value provided by provider component', async () => {
+ const { result } = renderHook(() => useSpreadsheetImportInternal(), {
+ wrapper: Wrapper,
+ });
+
+ expect(result.current).toBe(mockedSpreadsheetOptions);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.tsx b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts
similarity index 100%
rename from packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.tsx
rename to packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts
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
new file mode 100644
index 000000000..224adc168
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/dataMutations.test.ts
@@ -0,0 +1,209 @@
+import {
+ Data,
+ Field,
+ Info,
+ RowHook,
+ TableHook,
+} from '@/spreadsheet-import/types';
+import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations';
+
+describe('addErrorsAndRunHooks', () => {
+ type FullData = Data<'name' | 'age' | 'country'>;
+ const requiredField: Field<'name'> = {
+ key: 'name',
+ label: 'Name',
+ validations: [{ rule: 'required' }],
+ icon: null,
+ fieldType: { type: 'input' },
+ };
+
+ const regexField: Field<'age'> = {
+ key: 'age',
+ label: 'Age',
+ validations: [
+ { rule: 'regex', value: '\\d+', errorMessage: 'Regex error' },
+ ],
+ icon: null,
+ fieldType: { type: 'input' },
+ };
+
+ const uniqueField: Field<'country'> = {
+ key: 'country',
+ label: 'Country',
+ validations: [{ rule: 'unique' }],
+ icon: null,
+ fieldType: { type: 'input' },
+ };
+
+ const functionValidationFieldTrue: Field<'email'> = {
+ key: 'email',
+ label: 'Email',
+ validations: [
+ {
+ rule: 'function',
+ isValid: () => true,
+ errorMessage: 'Field is invalid',
+ },
+ ],
+ icon: null,
+ fieldType: { type: 'input' },
+ };
+
+ const functionValidationFieldFalse: Field<'email'> = {
+ key: 'email',
+ label: 'Email',
+ validations: [
+ {
+ rule: 'function',
+ isValid: () => false,
+ errorMessage: 'Field is invalid',
+ },
+ ],
+ icon: null,
+ fieldType: { type: 'input' },
+ };
+
+ const validData: Data<'name' | 'age'> = { name: 'John', age: '30' };
+ const dataWithoutNameAndInvalidAge: Data<'name' | 'age'> = {
+ name: '',
+ age: 'Invalid',
+ };
+ const dataWithDuplicatedValue: FullData = {
+ name: 'Alice',
+ age: '40',
+ country: 'Brazil',
+ };
+
+ const data: Data<'name' | 'age'>[] = [
+ validData,
+ dataWithoutNameAndInvalidAge,
+ ];
+
+ const basicError: Info = { message: 'Field is invalid', level: 'error' };
+ const nameError: Info = { message: 'Name Error', level: 'error' };
+ const ageError: Info = { message: 'Age Error', level: 'error' };
+ const regexError: Info = { message: 'Regex error', level: 'error' };
+ const requiredError: Info = { message: 'Field is required', level: 'error' };
+ const duplicatedError: Info = {
+ message: 'Field must be unique',
+ level: 'error',
+ };
+
+ const rowHook: RowHook<'name' | 'age'> = jest.fn((row, addError) => {
+ addError('name', nameError);
+ return row;
+ });
+ const tableHook: TableHook<'name' | 'age'> = jest.fn((table, addError) => {
+ addError(0, 'age', ageError);
+ return table;
+ });
+
+ it('should correctly call rowHook and tableHook and add errors', () => {
+ const result = addErrorsAndRunHooks(
+ data,
+ [requiredField, regexField],
+ rowHook,
+ tableHook,
+ );
+
+ expect(rowHook).toHaveBeenCalled();
+ expect(tableHook).toHaveBeenCalled();
+ expect(result[0].__errors).toStrictEqual({
+ name: nameError,
+ age: ageError,
+ });
+ });
+
+ it('should overwrite hook errors with validation errors', () => {
+ const result = addErrorsAndRunHooks(
+ data,
+ [requiredField, regexField],
+ rowHook,
+ tableHook,
+ );
+
+ expect(rowHook).toHaveBeenCalled();
+ expect(tableHook).toHaveBeenCalled();
+ expect(result[1].__errors).toStrictEqual({
+ name: requiredError,
+ age: regexError,
+ });
+ });
+
+ it('should add errors for required field', () => {
+ const result = addErrorsAndRunHooks(data, [requiredField]);
+
+ expect(result[1].__errors).toStrictEqual({
+ name: requiredError,
+ });
+ });
+
+ it('should add errors for regex field', () => {
+ const result = addErrorsAndRunHooks(data, [regexField]);
+
+ expect(result[1].__errors).toStrictEqual({
+ age: regexError,
+ });
+ });
+
+ it('should add errors for unique field', () => {
+ const result = addErrorsAndRunHooks(
+ [
+ dataWithDuplicatedValue,
+ dataWithDuplicatedValue,
+ ] as unknown as FullData[],
+ [uniqueField],
+ );
+
+ expect(result[0].__errors).toStrictEqual({
+ country: duplicatedError,
+ });
+ expect(result[1].__errors).toStrictEqual({
+ country: duplicatedError,
+ });
+ });
+
+ it('should add errors for unique field with empty values', () => {
+ const result = addErrorsAndRunHooks(
+ [{ country: '' }, { country: '' }],
+ [uniqueField],
+ );
+
+ expect(result[0].__errors).toStrictEqual({
+ country: duplicatedError,
+ });
+ expect(result[1].__errors).toStrictEqual({
+ country: duplicatedError,
+ });
+ });
+
+ 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 }] }],
+ );
+
+ expect(result[0].__errors).toBeUndefined();
+ expect(result[1].__errors).toBeUndefined();
+ });
+
+ it('should add errors for function validation if result is false', () => {
+ const result = addErrorsAndRunHooks(
+ [{ email: 'email' }],
+ [functionValidationFieldFalse],
+ );
+
+ expect(result[0].__errors).toStrictEqual({
+ email: basicError,
+ });
+ });
+
+ it('should not add errors for function validation if result is true', () => {
+ const result = addErrorsAndRunHooks(
+ [{ email: 'email' }],
+ [functionValidationFieldTrue],
+ );
+
+ expect(result[0].__errors).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/exceedsMaxRecords.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/exceedsMaxRecords.test.ts
new file mode 100644
index 000000000..7a2186011
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/exceedsMaxRecords.test.ts
@@ -0,0 +1,45 @@
+import { WorkSheet } from 'xlsx-ugnis';
+
+import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords';
+
+describe('exceedsMaxRecords', () => {
+ const maxRecords = 5;
+
+ it('should return true if the number of records exceeds the maximum limit', () => {
+ const workSheet: WorkSheet = {
+ '!ref': 'A1:A10',
+ };
+
+ const result = exceedsMaxRecords(workSheet, maxRecords);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the number of records does not exceed the maximum limit', () => {
+ const workSheet: WorkSheet = {
+ '!ref': 'A1:A4',
+ };
+
+ const result = exceedsMaxRecords(workSheet, maxRecords);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false if the number of records is equal to the maximum limit', () => {
+ const workSheet: WorkSheet = {
+ '!ref': 'A1:A5',
+ };
+
+ const result = exceedsMaxRecords(workSheet, maxRecords);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false if the worksheet does not have a defined range', () => {
+ const workSheet: WorkSheet = {};
+
+ const result = exceedsMaxRecords(workSheet, maxRecords);
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findMatch.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findMatch.test.ts
new file mode 100644
index 000000000..7514f0c0a
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findMatch.test.ts
@@ -0,0 +1,90 @@
+import { Field } from '@/spreadsheet-import/types';
+import { findMatch } from '@/spreadsheet-import/utils/findMatch';
+
+describe('findMatch', () => {
+ const defaultField: Field<'defaultField'> = {
+ key: 'defaultField',
+ icon: null,
+ label: 'label',
+ fieldType: {
+ type: 'input',
+ },
+ alternateMatches: ['Full Name', 'First Name'],
+ };
+
+ const secondaryField: Field<'secondaryField'> = {
+ key: 'secondaryField',
+ icon: null,
+ label: 'label',
+ fieldType: {
+ type: 'input',
+ },
+ };
+
+ const fields = [defaultField, secondaryField];
+
+ it('should return the matching field if the header matches exactly with the key', () => {
+ const autoMapDistance = 0;
+
+ const result = findMatch(defaultField.key, fields, autoMapDistance);
+
+ expect(result).toBe(defaultField.key);
+ });
+
+ it('should return the matching field if the header matches exactly one of the alternate matches', () => {
+ const autoMapDistance = 0;
+
+ const result = findMatch(
+ defaultField.alternateMatches?.[0] ?? '',
+ fields,
+ autoMapDistance,
+ );
+
+ expect(result).toBe(defaultField.key);
+ });
+
+ it('should return the matching field if the header matches partially one of the alternate matches', () => {
+ const header = 'First';
+ const autoMapDistance = 5;
+
+ const result = findMatch(header, fields, autoMapDistance);
+
+ expect(result).toBe(defaultField.key);
+ });
+
+ it('should return the matching field if the header matches partially both of the alternate matches', () => {
+ const header = 'Name';
+ const autoMapDistance = 5;
+
+ const result = findMatch(header, fields, autoMapDistance);
+
+ expect(result).toBe(defaultField.key);
+ });
+
+ it('should return undefined if no exact match or alternate match is found within the auto map distance', () => {
+ const header = 'Header';
+ const autoMapDistance = 2;
+
+ const result = findMatch(header, fields, autoMapDistance);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should return the matching field with the smallest Levenshtein distance if within auto map distance', () => {
+ const header = 'Name'.split('').reverse().join('');
+ const autoMapDistance = 100;
+
+ const result = findMatch(header, fields, autoMapDistance);
+
+ expect(result).toBe(defaultField.key);
+ });
+
+ it('should return undefined if no match is found within auto map distance', () => {
+ const header = 'Name'.split('').reverse().join('');
+ const autoMapDistance = 1;
+
+ const result = findMatch(header, fields, autoMapDistance);
+
+ expect(result).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
new file mode 100644
index 000000000..e456f72f5
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/findUnmatchedRequiredFields.test.ts
@@ -0,0 +1,89 @@
+import {
+ Column,
+ ColumnType,
+} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
+import { Field, Validation } from '@/spreadsheet-import/types';
+import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields';
+
+const nameField: Field<'Name'> = {
+ key: 'Name',
+ label: 'Name',
+ icon: null,
+ fieldType: {
+ type: 'input',
+ },
+};
+
+const ageField: Field<'Age'> = {
+ key: 'Age',
+ label: 'Age',
+ icon: null,
+ fieldType: {
+ type: 'input',
+ },
+};
+const validations: Validation[] = [{ rule: 'required' }];
+const nameFieldWithValidations: Field<'Name'> = { ...nameField, validations };
+const ageFieldWithValidations: Field<'Age'> = { ...ageField, validations };
+
+type ColumnValues = 'Name' | 'Age';
+
+const nameColumn: Column = {
+ type: ColumnType.matched,
+ index: 0,
+ header: '',
+ value: 'Name',
+};
+
+const ageColumn: Column = {
+ type: ColumnType.matched,
+ index: 0,
+ header: '',
+ value: 'Age',
+};
+
+const extraColumn: Column = {
+ type: ColumnType.matched,
+ index: 0,
+ header: '',
+ value: 'Age',
+};
+
+describe('findUnmatchedRequiredFields', () => {
+ it('should return an empty array if all required fields are matched', () => {
+ const fields = [nameFieldWithValidations, ageFieldWithValidations];
+ const columns = [nameColumn, ageColumn];
+
+ const result = findUnmatchedRequiredFields(fields, columns);
+
+ expect(result).toStrictEqual([]);
+ });
+
+ it('should return an array of labels for required fields that are not matched', () => {
+ const fields = [nameFieldWithValidations, ageFieldWithValidations];
+ const columns = [nameColumn];
+
+ const result = findUnmatchedRequiredFields(fields, columns);
+
+ expect(result).toStrictEqual(['Age']);
+ });
+
+ it('should return an empty array if there are no required fields', () => {
+ const fields = [nameField, ageField];
+ const columns = [nameColumn];
+
+ const result = findUnmatchedRequiredFields(fields, columns);
+
+ expect(result).toStrictEqual([]);
+ });
+
+ it('should return an empty array if all required fields are matched even if there are extra columns', () => {
+ const fields = [nameFieldWithValidations, ageFieldWithValidations];
+
+ const columns = [nameColumn, ageColumn, extraColumn];
+
+ const result = findUnmatchedRequiredFields(fields, columns);
+
+ expect(result).toStrictEqual([]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts
new file mode 100644
index 000000000..fc42f39c5
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts
@@ -0,0 +1,60 @@
+import { Field } from '@/spreadsheet-import/types';
+import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
+
+describe('generateExampleRow', () => {
+ const defaultField: Field<'defaultField'> = {
+ key: 'defaultField',
+ icon: null,
+ label: 'label',
+ fieldType: {
+ type: 'input',
+ },
+ };
+
+ it('should generate an example row from input field type', () => {
+ const fields: Field<'defaultField'>[] = [defaultField];
+
+ const result = generateExampleRow(fields);
+
+ expect(result).toStrictEqual([{ defaultField: 'Text' }]);
+ });
+
+ it('should generate an example row from checkbox field type', () => {
+ const fields: Field<'defaultField'>[] = [
+ {
+ ...defaultField,
+ fieldType: { type: 'checkbox' },
+ },
+ ];
+
+ const result = generateExampleRow(fields);
+
+ expect(result).toStrictEqual([{ defaultField: 'Boolean' }]);
+ });
+
+ it('should generate an example row from select field type', () => {
+ const fields: Field<'defaultField'>[] = [
+ {
+ ...defaultField,
+ fieldType: { type: 'select', options: [] },
+ },
+ ];
+
+ const result = generateExampleRow(fields);
+
+ expect(result).toStrictEqual([{ defaultField: 'Options' }]);
+ });
+
+ it('should generate an example row with provided example values for fields', () => {
+ const fields: Field<'defaultField'>[] = [
+ {
+ ...defaultField,
+ example: 'Example',
+ },
+ ];
+
+ const result = generateExampleRow(fields);
+
+ expect(result).toStrictEqual([{ defaultField: 'Example' }]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/getFieldOptions.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/getFieldOptions.test.ts
new file mode 100644
index 000000000..723c7497d
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/getFieldOptions.test.ts
@@ -0,0 +1,62 @@
+import { Field } from '@/spreadsheet-import/types';
+import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
+
+describe('getFieldOptions', () => {
+ const optionsArray = [
+ {
+ label: 'one',
+ value: 'One',
+ },
+ {
+ label: 'two',
+ value: 'Two',
+ },
+ {
+ label: 'three',
+ value: 'Three',
+ },
+ ];
+ const fields: Field<'Options' | 'Name'>[] = [
+ {
+ key: 'Options',
+ icon: null,
+ label: 'options',
+ fieldType: {
+ type: 'select',
+ options: optionsArray,
+ },
+ },
+ {
+ key: 'Name',
+ icon: null,
+ label: 'name',
+ fieldType: {
+ type: 'input',
+ },
+ },
+ ];
+
+ it('should return an empty array if the field does not exist in the fields list', () => {
+ const fieldKey = 'NonExistingField';
+
+ const result = getFieldOptions(fields, fieldKey);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return an empty array if the field is not of type select', () => {
+ const fieldKey = 'Name';
+
+ const result = getFieldOptions(fields, fieldKey);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return an array of options if the field is of type select', () => {
+ const fieldKey = 'Options';
+
+ const result = getFieldOptions(fields, fieldKey);
+
+ expect(result).toEqual(optionsArray);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/getMatchedColumns.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/getMatchedColumns.test.ts
new file mode 100644
index 000000000..538b39667
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/getMatchedColumns.test.ts
@@ -0,0 +1,132 @@
+import {
+ Column,
+ ColumnType,
+} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
+import { Field } from '@/spreadsheet-import/types';
+import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns';
+
+describe('getMatchedColumns', () => {
+ const columns: Column[] = [
+ { index: 0, header: 'Name', type: ColumnType.matched, value: 'Name' },
+ {
+ index: 1,
+ header: 'Location',
+ type: ColumnType.matched,
+ value: 'Location',
+ },
+ {
+ index: 2,
+ header: 'Age',
+ type: ColumnType.matched,
+ value: 'Age',
+ },
+ ];
+
+ const fields: Field[] = [
+ {
+ key: 'Name',
+ label: 'Name',
+ fieldType: { type: 'input' },
+ icon: null,
+ },
+ {
+ key: 'Location',
+ label: 'Location',
+ fieldType: { type: 'select', options: [] },
+ icon: null,
+ },
+ { key: 'Age', label: 'Age', fieldType: { type: 'input' }, icon: null },
+ ];
+
+ const data = [
+ ['John', 'New York'],
+ ['Alice', 'Los Angeles'],
+ ];
+
+ const autoMapDistance = 2;
+
+ it('should return matched columns for each field', () => {
+ const result = getMatchedColumns(columns, fields, data, autoMapDistance);
+ expect(result).toEqual([
+ { index: 0, header: 'Name', type: ColumnType.matched, value: 'Name' },
+ {
+ index: 1,
+ header: 'Location',
+ type: ColumnType.matchedSelect,
+ value: 'Location',
+ matchedOptions: [
+ {
+ entry: 'New York',
+ },
+ {
+ entry: 'Los Angeles',
+ },
+ ],
+ },
+ { index: 2, header: 'Age', type: ColumnType.matched, value: 'Age' },
+ ]);
+ });
+
+ it('should handle columns with duplicate values by choosing the closest match', () => {
+ const columnsWithDuplicates: Column[] = [
+ { index: 0, header: 'Name', type: ColumnType.matched, value: 'Name' },
+ { index: 1, header: 'Name', type: ColumnType.matched, value: 'Name' },
+ {
+ index: 2,
+ header: 'Location',
+ type: ColumnType.matched,
+ value: 'Location',
+ },
+ ];
+
+ const result = getMatchedColumns(
+ columnsWithDuplicates,
+ fields,
+ data,
+ autoMapDistance,
+ );
+
+ expect(result[0]).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.empty,
+ });
+ expect(result[1]).toEqual({
+ index: 1,
+ header: 'Name',
+ type: ColumnType.matched,
+ value: 'Name',
+ });
+ });
+
+ it('should return initial columns when no auto match is found', () => {
+ const unmatchedColumnsData: string[][] = [
+ ['John', 'New York', '30'],
+ ['Alice', 'Los Angeles', '25'],
+ ];
+
+ const unmatchedFields: Field[] = [
+ {
+ key: 'Hobby',
+ label: 'Hobby',
+ fieldType: { type: 'input' },
+ icon: null,
+ },
+ {
+ key: 'Interest',
+ label: 'Interest',
+ fieldType: { type: 'input' },
+ icon: null,
+ },
+ ];
+
+ const result = getMatchedColumns(
+ columns,
+ unmatchedFields,
+ unmatchedColumnsData,
+ autoMapDistance,
+ );
+
+ expect(result).toEqual(columns);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/mapWorkbook.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/mapWorkbook.test.ts
new file mode 100644
index 000000000..33d84f768
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/mapWorkbook.test.ts
@@ -0,0 +1,46 @@
+import * as XLSX from 'xlsx-ugnis';
+
+import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
+
+describe('mapWorkbook', () => {
+ it('should map the workbook to a 2D array of strings', () => {
+ const inputWorkbook = XLSX.utils.book_new();
+ const inputSheetData = [
+ ['Name', 'Age'],
+ ['John', '30'],
+ ['Alice', '25'],
+ ];
+ const expectedOutput = inputSheetData;
+
+ const worksheet = XLSX.utils.aoa_to_sheet(inputSheetData);
+ XLSX.utils.book_append_sheet(inputWorkbook, worksheet, 'Sheet1');
+
+ const result = mapWorkbook(inputWorkbook);
+
+ expect(result).toEqual(expectedOutput);
+ });
+
+ it('should map the specified sheet of the workbook to a 2D array of strings', () => {
+ const inputWorkbook = XLSX.utils.book_new();
+ const inputSheet1Data = [
+ ['Name', 'Age'],
+ ['John', '30'],
+ ['Alice', '25'],
+ ];
+ const inputSheet2Data = [
+ ['City', 'Population'],
+ ['New York', '8500000'],
+ ['Los Angeles', '4000000'],
+ ];
+ const expectedOutput = inputSheet2Data;
+
+ const worksheet1 = XLSX.utils.aoa_to_sheet(inputSheet1Data);
+ const worksheet2 = XLSX.utils.aoa_to_sheet(inputSheet2Data);
+ XLSX.utils.book_append_sheet(inputWorkbook, worksheet1, 'Sheet1');
+ XLSX.utils.book_append_sheet(inputWorkbook, worksheet2, 'Sheet2');
+
+ const result = mapWorkbook(inputWorkbook, 'Sheet2');
+
+ expect(result).toEqual(expectedOutput);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/normalizeCheckboxValue.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/normalizeCheckboxValue.test.ts
new file mode 100644
index 000000000..dad764d37
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/normalizeCheckboxValue.test.ts
@@ -0,0 +1,23 @@
+import { normalizeCheckboxValue } from '@/spreadsheet-import/utils/normalizeCheckboxValue';
+
+describe('normalizeCheckboxValue', () => {
+ const testCases = [
+ { value: 'yes', expected: true },
+ { value: 'Yes', expected: true },
+ { value: 'no', expected: false },
+ { value: 'No', expected: false },
+ { value: 'true', expected: true },
+ { value: 'True', expected: true },
+ { value: 'false', expected: false },
+ { value: 'False', expected: false },
+ { value: undefined, expected: false },
+ { value: 'invalid', expected: false },
+ ];
+
+ testCases.forEach(({ value, expected }) => {
+ it(`should return ${expected} for value "${value}"`, () => {
+ const result = normalizeCheckboxValue(value);
+ expect(result).toBe(expected);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/normalizeTableData.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/normalizeTableData.test.ts
new file mode 100644
index 000000000..9bb940267
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/normalizeTableData.test.ts
@@ -0,0 +1,147 @@
+import {
+ Column,
+ ColumnType,
+} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
+import { Field } from '@/spreadsheet-import/types';
+import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableData';
+
+describe('normalizeTableData', () => {
+ const columns: Column[] = [
+ { index: 0, header: 'Name', type: ColumnType.matched, value: 'name' },
+ { index: 1, header: 'Age', type: ColumnType.matched, value: 'age' },
+ {
+ index: 2,
+ header: 'Active',
+ type: ColumnType.matchedCheckbox,
+ value: 'active',
+ },
+ ];
+
+ const fields: Field[] = [
+ { key: 'name', label: 'Name', fieldType: { type: 'input' }, icon: null },
+ { key: 'age', label: 'Age', fieldType: { type: 'input' }, icon: null },
+ {
+ key: 'active',
+ label: 'Active',
+ fieldType: {
+ type: 'checkbox',
+ },
+ icon: null,
+ },
+ ];
+
+ const rawData = [
+ ['John', '30', 'Yes'],
+ ['Alice', '', 'No'],
+ ['Bob', '25', 'Maybe'],
+ ];
+
+ it('should normalize table data according to columns and fields', () => {
+ const result = normalizeTableData(columns, rawData, fields);
+
+ expect(result).toStrictEqual([
+ { name: 'John', age: '30', active: true },
+ { name: 'Alice', age: undefined, active: false },
+ { name: 'Bob', age: '25', active: false },
+ ]);
+ });
+
+ it('should normalize matchedCheckbox values and handle booleanMatches', () => {
+ const columns: Column[] = [
+ {
+ index: 0,
+ header: 'Active',
+ type: ColumnType.matchedCheckbox,
+ value: 'active',
+ },
+ ];
+
+ const fields: Field[] = [
+ {
+ key: 'active',
+ label: 'Active',
+ fieldType: {
+ type: 'checkbox',
+ booleanMatches: { yes: true, no: false },
+ },
+ icon: null,
+ },
+ ];
+
+ const rawData = [['Yes'], ['No'], ['OtherValue']];
+
+ const result = normalizeTableData(columns, rawData, fields);
+
+ expect(result).toStrictEqual([{ active: true }, { active: false }, {}]);
+ });
+
+ it('should map matchedSelect and matchedSelectOptions values correctly', () => {
+ const columns: Column[] = [
+ {
+ index: 0,
+ header: 'Number',
+ type: ColumnType.matchedSelect,
+ value: 'number',
+ matchedOptions: [
+ { entry: 'One', value: '1' },
+ { entry: 'Two', value: '2' },
+ ],
+ },
+ ];
+
+ const fields: Field[] = [
+ {
+ key: 'number',
+ label: 'Number',
+ fieldType: {
+ type: 'select',
+ options: [
+ { label: 'One', value: '1' },
+ { label: 'Two', value: '2' },
+ ],
+ },
+ icon: null,
+ },
+ ];
+
+ const rawData = [['One'], ['Two'], ['OtherValue']];
+
+ const result = normalizeTableData(columns, rawData, fields);
+
+ expect(result).toStrictEqual([
+ { number: '1' },
+ { number: '2' },
+ { number: undefined },
+ ]);
+ });
+
+ it('should handle empty and ignored columns', () => {
+ const columns: Column[] = [
+ { index: 0, header: 'Empty', type: ColumnType.empty },
+ { index: 1, header: 'Ignored', type: ColumnType.ignored },
+ ];
+
+ const rawData = [['Value1', 'Value2']];
+
+ const result = normalizeTableData(columns, rawData, []);
+
+ expect(result).toStrictEqual([{}]);
+ });
+
+ it('should handle unrecognized column types and return empty object', () => {
+ const columns: Column[] = [
+ {
+ index: 0,
+ header: 'Unrecognized',
+ type: 'Unknown' as unknown as ColumnType.matched,
+ value: '',
+ },
+ ];
+
+ const rawData = [['Value']];
+
+ const result = normalizeTableData(columns, rawData, []);
+
+ expect(result).toStrictEqual([{}]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/readFilesAsync.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/readFilesAsync.test.ts
new file mode 100644
index 000000000..4c846dd0d
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/readFilesAsync.test.ts
@@ -0,0 +1,9 @@
+import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync';
+
+describe('readFileAsync', () => {
+ it('should resolve with the file content as ArrayBuffer', async () => {
+ const file = new File(['Test content'], 'test.txt', { type: 'text/plain' });
+ const result = await readFileAsync(file);
+ expect(result).toBeInstanceOf(ArrayBuffer);
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setColumn.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setColumn.test.ts
new file mode 100644
index 000000000..012dfbabf
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setColumn.test.ts
@@ -0,0 +1,94 @@
+import {
+ Column,
+ ColumnType,
+} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
+import { Field } from '@/spreadsheet-import/types';
+import { setColumn } from '@/spreadsheet-import/utils/setColumn';
+
+describe('setColumn', () => {
+ const defaultField: Field<'Name'> = {
+ icon: null,
+ label: 'label',
+ key: 'Name',
+ fieldType: { type: 'input' },
+ };
+
+ const oldColumn: Column<'oldValue'> = {
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matched,
+ value: 'oldValue',
+ };
+
+ it('should return a matchedSelect column if field type is "select"', () => {
+ const field = {
+ ...defaultField,
+ fieldType: { type: 'select' },
+ } as Field<'Name'>;
+
+ const data = [['John'], ['Alice']];
+ const result = setColumn(oldColumn, field, data);
+
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matchedSelect,
+ value: 'Name',
+ matchedOptions: [
+ {
+ entry: 'John',
+ },
+ {
+ entry: 'Alice',
+ },
+ ],
+ });
+ });
+
+ it('should return a matchedCheckbox column if field type is "checkbox"', () => {
+ const field = {
+ ...defaultField,
+ fieldType: { type: 'checkbox' },
+ } as Field<'Name'>;
+
+ const result = setColumn(oldColumn, field);
+
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matchedCheckbox,
+ value: 'Name',
+ });
+ });
+
+ it('should return a matched column if field type is "input"', () => {
+ const field = {
+ ...defaultField,
+ fieldType: { type: 'input' },
+ } as Field<'Name'>;
+
+ const result = setColumn(oldColumn, field);
+
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matched,
+ value: 'Name',
+ });
+ });
+
+ it('should return an empty column if field type is not recognized', () => {
+ const field = {
+ ...defaultField,
+ fieldType: { type: 'unknown' },
+ } as unknown as Field<'Name'>;
+
+ const result = setColumn(oldColumn, field);
+
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.empty,
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setIgnoreColumn.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setIgnoreColumn.test.ts
new file mode 100644
index 000000000..ea6ed4a1d
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setIgnoreColumn.test.ts
@@ -0,0 +1,22 @@
+import {
+ Column,
+ ColumnType,
+} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
+import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn';
+
+describe('setIgnoreColumn', () => {
+ it('should return a column with type "ignored"', () => {
+ const column: Column<'John'> = {
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matched,
+ value: 'John',
+ };
+ const result = setIgnoreColumn(column);
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.ignored,
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setSubColumn.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setSubColumn.test.ts
new file mode 100644
index 000000000..785c89d34
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/setSubColumn.test.ts
@@ -0,0 +1,63 @@
+import {
+ Column,
+ ColumnType,
+} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
+import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn';
+
+describe('setSubColumn', () => {
+ it('should return a matchedSelectColumn with updated matchedOptions', () => {
+ const oldColumn: Column<'John' | ''> = {
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matchedSelect,
+ matchedOptions: [
+ { entry: 'Name1', value: 'John' },
+ { entry: 'Name2', value: '' },
+ ],
+ value: 'John',
+ };
+
+ const entry = 'Name1';
+ const value = 'John Doe';
+ const result = setSubColumn(oldColumn, entry, value);
+
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matchedSelect,
+ matchedOptions: [
+ { entry: 'Name1', value: 'John Doe' },
+ { entry: 'Name2', value: '' },
+ ],
+ value: 'John',
+ });
+ });
+
+ it('should return a matchedSelectOptionsColumn with updated matchedOptions', () => {
+ const oldColumn: Column<'John' | 'Jane'> = {
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matchedSelectOptions,
+ matchedOptions: [
+ { entry: 'Name1', value: 'John' },
+ { entry: 'Name2', value: 'Jane' },
+ ],
+ value: 'John',
+ };
+
+ const entry = 'Name1';
+ const value = 'John Doe';
+ const result = setSubColumn(oldColumn, entry, value);
+
+ expect(result).toEqual({
+ index: 0,
+ header: 'Name',
+ type: ColumnType.matchedSelectOptions,
+ matchedOptions: [
+ { entry: 'Name1', value: 'John Doe' },
+ { entry: 'Name2', value: 'Jane' },
+ ],
+ value: 'John',
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/uniqueEntries.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/uniqueEntries.test.ts
new file mode 100644
index 000000000..6329a2684
--- /dev/null
+++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/uniqueEntries.test.ts
@@ -0,0 +1,12 @@
+import { uniqueEntries } from '@/spreadsheet-import/utils/uniqueEntries';
+
+describe('uniqueEntries', () => {
+ it('should return unique entries from the specified column index', () => {
+ const data = [['John'], ['Alice'], ['John']];
+ const columnIndex = 0;
+
+ const result = uniqueEntries(data, columnIndex);
+
+ expect(result).toStrictEqual([{ entry: 'John' }, { entry: 'Alice' }]);
+ });
+});