Connect - Import Relation (#13419)

re-opened https://github.com/twentyhq/twenty/pull/13213
This commit is contained in:
Etienne
2025-07-25 09:48:17 +02:00
committed by GitHub
parent ca27995ca6
commit 741924751b
92 changed files with 1612 additions and 1153 deletions

View File

@ -0,0 +1,503 @@
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useIcons } from 'twenty-ui/display';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useBuildSpreadsheetImportFields } from '@/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const Wrapper = ({ children }: { children: ReactNode }) => {
return (
<RecoilRoot>
<JestObjectMetadataItemSetter>{children}</JestObjectMetadataItemSetter>
</RecoilRoot>
);
};
jest.mock('twenty-ui/display', () => ({
useIcons: jest.fn(),
}));
describe('useBuildSpreadSheetImportFields', () => {
const mockGetIcon = jest.fn().mockReturnValue('MockIcon');
const mockUseIcons = useIcons as jest.MockedFunction<typeof useIcons>;
beforeEach(() => {
mockUseIcons.mockReturnValue({
getIcon: mockGetIcon,
getIcons: () => ({}),
});
jest.clearAllMocks();
});
const createMockFieldMetadataItem = (
overrides: Partial<FieldMetadataItem> = {},
): FieldMetadataItem => ({
id: 'test-field-id',
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
icon: 'IconTest',
isActive: true,
isCustom: false,
isSystem: false,
isNullable: true,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
...overrides,
});
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
description: 'Test object description',
icon: 'IconTest',
isCustom: false,
isSystem: false,
isActive: true,
isLabelSyncedWithName: false,
isRemote: false,
isSearchable: true,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
fields: [],
...overrides,
}) as ObjectMetadataItem;
it('should build importFields for basic field types', () => {
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
setObjectMetadataItems([]);
return useBuildSpreadsheetImportFields();
},
{ wrapper: Wrapper },
);
const fieldMetadataItems: FieldMetadataItem[] = [
createMockFieldMetadataItem({
type: FieldMetadataType.TEXT,
name: 'textField',
label: 'Text Field',
}),
createMockFieldMetadataItem({
type: FieldMetadataType.NUMBER,
name: 'numberField',
label: 'Number Field',
}),
createMockFieldMetadataItem({
type: FieldMetadataType.BOOLEAN,
name: 'booleanField',
label: 'Boolean Field',
}),
];
const spreadsheetImportFields =
result.current.buildSpreadsheetImportFields(fieldMetadataItems);
expect(spreadsheetImportFields).toHaveLength(3);
expect(spreadsheetImportFields[0]).toMatchObject({
label: 'Text Field',
key: 'textField',
fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.TEXT,
isNestedField: false,
});
expect(spreadsheetImportFields[1]).toMatchObject({
label: 'Number Field',
key: 'numberField',
fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.NUMBER,
isNestedField: false,
});
expect(spreadsheetImportFields[2]).toMatchObject({
label: 'Boolean Field',
key: 'booleanField',
fieldType: { type: 'checkbox' },
fieldMetadataType: FieldMetadataType.BOOLEAN,
isNestedField: false,
});
});
it('should build importFields for select types', () => {
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
setObjectMetadataItems([]);
return useBuildSpreadsheetImportFields();
},
{ wrapper: Wrapper },
);
const fieldMetadataItems: FieldMetadataItem[] = [
createMockFieldMetadataItem({
type: FieldMetadataType.SELECT,
name: 'selectField',
label: 'Select Field',
options: [
{
id: '1',
label: 'Option 1',
value: 'opt1',
color: 'red',
position: 0,
},
{
id: '2',
label: 'Option 2',
value: 'opt2',
color: 'blue',
position: 1,
},
],
}),
createMockFieldMetadataItem({
type: FieldMetadataType.MULTI_SELECT,
name: 'multiSelectField',
label: 'Multi Select Field',
options: [
{
id: '1',
label: 'Tag 1',
value: 'tag1',
color: 'green',
position: 0,
},
{
id: '2',
label: 'Tag 2',
value: 'tag2',
color: 'yellow',
position: 1,
},
],
}),
];
const spreadsheetImportFields =
result.current.buildSpreadsheetImportFields(fieldMetadataItems);
expect(spreadsheetImportFields).toHaveLength(2);
expect(spreadsheetImportFields[0]).toMatchObject({
label: 'Select Field',
key: 'selectField',
fieldType: {
type: 'select',
options: [
{ label: 'Option 1', value: 'opt1', color: 'red' },
{ label: 'Option 2', value: 'opt2', color: 'blue' },
],
},
fieldMetadataType: FieldMetadataType.SELECT,
});
expect(spreadsheetImportFields[1]).toMatchObject({
label: 'Multi Select Field',
key: 'multiSelectField',
fieldType: {
type: 'multiSelect',
options: [
{ label: 'Tag 1', value: 'tag1', color: 'green' },
{ label: 'Tag 2', value: 'tag2', color: 'yellow' },
],
},
fieldMetadataType: FieldMetadataType.MULTI_SELECT,
});
});
it('should build importFields for composite types (full name)', () => {
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
setObjectMetadataItems([]);
return useBuildSpreadsheetImportFields();
},
{ wrapper: Wrapper },
);
const fieldMetadataItems: FieldMetadataItem[] = [
createMockFieldMetadataItem({
type: FieldMetadataType.FULL_NAME,
name: 'fullName',
label: 'Full Name',
}),
];
const spreadsheetImportFields =
result.current.buildSpreadsheetImportFields(fieldMetadataItems);
expect(spreadsheetImportFields.length).toBe(2);
const firstNameField = spreadsheetImportFields.find((field) =>
field.key.includes('First Name'),
);
const lastNameField = spreadsheetImportFields.find((field) =>
field.key.includes('Last Name'),
);
expect(firstNameField).toBeDefined();
expect(lastNameField).toBeDefined();
expect(firstNameField?.isNestedField).toBe(true);
expect(firstNameField?.isCompositeSubField).toBe(true);
expect(lastNameField?.isNestedField).toBe(true);
expect(lastNameField?.isCompositeSubField).toBe(true);
});
it('should filter out ACTOR fields', () => {
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
setObjectMetadataItems([]);
return useBuildSpreadsheetImportFields();
},
{ wrapper: Wrapper },
);
const fieldMetadataItems: FieldMetadataItem[] = [
createMockFieldMetadataItem({
type: FieldMetadataType.ACTOR,
name: 'actorField',
label: 'Actor Field',
}),
createMockFieldMetadataItem({
type: FieldMetadataType.TEXT,
name: 'textField',
label: 'Text Field',
}),
];
const spreadsheetImportFields =
result.current.buildSpreadsheetImportFields(fieldMetadataItems);
expect(spreadsheetImportFields).toHaveLength(1);
expect(spreadsheetImportFields[0].fieldMetadataType).toBe(
FieldMetadataType.TEXT,
);
});
it('should return empty array for unsupported field types', () => {
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
setObjectMetadataItems([]);
return useBuildSpreadsheetImportFields();
},
{ wrapper: Wrapper },
);
const fieldMetadataItems: FieldMetadataItem[] = [
createMockFieldMetadataItem({
type: FieldMetadataType.POSITION,
name: 'positionField',
label: 'Position Field',
}),
createMockFieldMetadataItem({
type: FieldMetadataType.TS_VECTOR,
name: 'tsVectorField',
label: 'TS Vector Field',
}),
];
const spreadsheetImportFields =
result.current.buildSpreadsheetImportFields(fieldMetadataItems);
expect(spreadsheetImportFields).toHaveLength(0);
});
it('should build importFields for relation field type', () => {
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
const targetObjectMetadata = createMockObjectMetadataItem({
id: 'target-object-id',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
fields: [
createMockFieldMetadataItem({
id: 'company-id-field',
name: 'id',
label: 'ID',
type: FieldMetadataType.UUID,
}),
createMockFieldMetadataItem({
id: 'company-name-field',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
}),
createMockFieldMetadataItem({
id: 'company-email-field',
name: 'emails',
label: 'Emails',
type: FieldMetadataType.EMAILS,
}),
],
indexMetadatas: [
{
id: 'primary-key-index',
name: 'primaryKeyIndex',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
isUnique: true,
indexFieldMetadatas: [
{
id: 'index-field-1',
fieldMetadataId: 'company-id-field',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
order: 0,
},
],
},
{
id: 'unique-name-index',
name: 'uniqueNameIndex',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
isUnique: true,
indexFieldMetadatas: [
{
id: 'index-field-2',
fieldMetadataId: 'company-name-field',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
order: 0,
},
],
},
{
id: 'unique-email-index',
name: 'uniqueEmailIndex',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
isUnique: true,
indexFieldMetadatas: [
{
id: 'index-field-3',
fieldMetadataId: 'company-email-field',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
order: 0,
},
],
},
] as IndexMetadataItem[],
});
setObjectMetadataItems([targetObjectMetadata]);
return useBuildSpreadsheetImportFields();
},
{ wrapper: Wrapper },
);
const fieldMetadataItems: FieldMetadataItem[] = [
createMockFieldMetadataItem({
type: FieldMetadataType.RELATION,
name: 'company',
label: 'Company',
relation: {
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: 'target-object-id',
nameSingular: 'company',
namePlural: 'companies',
},
} as any,
}),
];
const spreadsheetImportFields =
result.current.buildSpreadsheetImportFields(fieldMetadataItems);
expect(spreadsheetImportFields).toHaveLength(4);
const idField = spreadsheetImportFields.find((field) =>
field.key.includes('id (company)'),
);
expect(idField).toBeDefined();
expect(idField).toMatchObject({
label: 'Company / ID',
key: 'id (company)',
fieldMetadataItemId: 'test-field-id',
fieldMetadataType: FieldMetadataType.RELATION,
isNestedField: true,
isRelationConnectField: true,
uniqueFieldMetadataItem: {
id: 'company-id-field',
name: 'id',
type: FieldMetadataType.UUID,
},
});
const nameField = spreadsheetImportFields.find((field) =>
field.key.includes('name (company)'),
);
expect(nameField).toBeDefined();
expect(nameField).toMatchObject({
label: 'Company / Name',
key: 'name (company)',
fieldMetadataItemId: 'test-field-id',
fieldMetadataType: FieldMetadataType.RELATION,
isNestedField: true,
isRelationConnectField: true,
uniqueFieldMetadataItem: {
id: 'company-name-field',
name: 'name',
type: FieldMetadataType.TEXT,
},
});
const primaryEmailField = spreadsheetImportFields.find((field) =>
field.key.includes('primaryEmail-emails (company)'),
);
expect(primaryEmailField).toBeDefined();
expect(primaryEmailField).toMatchObject({
isNestedField: true,
isCompositeSubField: true,
isRelationConnectField: true,
compositeSubFieldKey: 'primaryEmail',
uniqueFieldMetadataItem: {
id: 'company-email-field',
name: 'emails',
type: FieldMetadataType.EMAILS,
},
});
});
});

View File

@ -383,9 +383,13 @@ describe('useSpreadsheetCompanyImport', () => {
expect(spreadsheetImportDialogAfterOpen.options?.onSubmit).toBeInstanceOf( expect(spreadsheetImportDialogAfterOpen.options?.onSubmit).toBeInstanceOf(
Function, Function,
); );
expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty('fields'); expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty(
'spreadsheetImportFields',
);
expect( expect(
Array.isArray(spreadsheetImportDialogAfterOpen.options?.fields), Array.isArray(
spreadsheetImportDialogAfterOpen.options?.spreadsheetImportFields,
),
).toBe(true); ).toBe(true);
act(() => { act(() => {

View File

@ -1,177 +0,0 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport';
import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useBuildAvailableFieldsForImport = () => {
const { getIcon } = useIcons();
const buildAvailableFieldsForImport = (
fieldMetadataItems: FieldMetadataItem[],
) => {
const availableFieldsForImport: AvailableFieldForImport[] = [];
const createBaseField = (
fieldMetadataItem: FieldMetadataItem,
overrides: Partial<AvailableFieldForImport> = {},
customLabel?: string,
): AvailableFieldForImport => ({
Icon: getIcon(fieldMetadataItem.icon),
label: customLabel ?? fieldMetadataItem.label,
key: fieldMetadataItem.name,
fieldType: { type: 'input' },
fieldMetadataType: fieldMetadataItem.type,
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
customLabel ?? fieldMetadataItem.label,
),
...overrides,
});
const handleCompositeFieldWithLabels = (
fieldMetadataItem: FieldMetadataItem,
fieldType: CompositeFieldType,
) => {
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldType].subFields.forEach(
({ subFieldName, subFieldLabel, isImportable }) => {
if (!isImportable) return;
const label = `${fieldMetadataItem.label} / ${subFieldLabel}`;
availableFieldsForImport.push(
createBaseField(fieldMetadataItem, {
label,
key: `${subFieldLabel} (${fieldMetadataItem.name})`,
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
label,
subFieldName,
),
}),
);
},
);
};
const handleSelectField = (
fieldMetadataItem: FieldMetadataItem,
isMulti = false,
) => {
availableFieldsForImport.push(
createBaseField(fieldMetadataItem, {
fieldType: {
type: isMulti ? 'multiSelect' : 'select',
options:
fieldMetadataItem.options?.map((option) => ({
label: option.label,
value: option.value,
color: option.color,
})) || [],
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
`${fieldMetadataItem.label} (ID)`,
),
}),
);
};
const fieldTypeHandlers: Record<
string,
(fieldMetadataItem: FieldMetadataItem) => void
> = {
[FieldMetadataType.FULL_NAME]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.FULL_NAME,
);
},
[FieldMetadataType.ADDRESS]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.ADDRESS,
);
},
[FieldMetadataType.LINKS]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.LINKS,
);
},
[FieldMetadataType.EMAILS]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.EMAILS,
);
},
[FieldMetadataType.PHONES]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.PHONES,
);
},
[FieldMetadataType.RICH_TEXT_V2]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.RICH_TEXT_V2,
);
},
[FieldMetadataType.CURRENCY]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.CURRENCY,
);
},
[FieldMetadataType.ACTOR]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.ACTOR,
);
},
[FieldMetadataType.RELATION]: (fieldMetadataItem) => {
const label = `${fieldMetadataItem.label} (ID)`;
availableFieldsForImport.push(
createBaseField(fieldMetadataItem, {
label,
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
label,
),
}),
);
},
[FieldMetadataType.SELECT]: (fieldMetadataItem) => {
handleSelectField(fieldMetadataItem, false);
},
[FieldMetadataType.MULTI_SELECT]: (fieldMetadataItem) => {
handleSelectField(fieldMetadataItem, true);
},
[FieldMetadataType.BOOLEAN]: (fieldMetadataItem) => {
availableFieldsForImport.push(
createBaseField(fieldMetadataItem, {
fieldType: { type: 'checkbox' },
}),
);
},
default: (fieldMetadataItem) => {
availableFieldsForImport.push(createBaseField(fieldMetadataItem));
},
};
for (const fieldMetadataItem of fieldMetadataItems) {
const handler =
fieldTypeHandlers[fieldMetadataItem.type] || fieldTypeHandlers.default;
handler(fieldMetadataItem);
}
return availableFieldsForImport;
};
return { buildAvailableFieldsForImport };
};

View File

@ -0,0 +1,285 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions';
import { getRelationConnectSubFieldKey } from '@/object-record/spreadsheet-import/utils/spreadSheetGetRelationConnectSubFieldKey';
import { getCompositeSubFieldKey } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetCompositeSubFieldKey';
import { getCompositeSubFieldLabelWithFieldLabel } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetCompositeSubFieldLabelWithFieldLabel';
import { getRelationConnectSubFieldLabel } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetRelationConnectSubFieldLabel';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import {
SpreadsheetImportField,
SpreadsheetImportFields,
} from '@/spreadsheet-import/types';
import { useRecoilValue } from 'recoil';
import {
assertUnreachable,
getUniqueConstraintsFields,
isDefined,
} from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
export const useBuildSpreadsheetImportFields = () => {
const { getIcon } = useIcons();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const buildSpreadsheetImportFields = (
fieldMetadataItems: FieldMetadataItem[],
): SpreadsheetImportFields => {
return fieldMetadataItems
.filter((field) => field.type !== FieldMetadataType.ACTOR)
.flatMap((fieldMetadataItem) =>
buildSpreadsheetImportField(fieldMetadataItem),
);
};
const buildSpreadsheetImportField = (
fieldMetadataItem: FieldMetadataItem,
relationConnectFieldOverrides?: Partial<SpreadsheetImportField>,
) => {
switch (fieldMetadataItem.type) {
case FieldMetadataType.ADDRESS:
case FieldMetadataType.CURRENCY:
case FieldMetadataType.EMAILS:
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.LINKS:
case FieldMetadataType.PHONES:
case FieldMetadataType.RICH_TEXT_V2:
return handleCompositeFields({
fieldMetadataItem,
fieldType: fieldMetadataItem.type,
});
case FieldMetadataType.RELATION:
return handleRelationField(fieldMetadataItem);
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
return [
handleSelectField(
fieldMetadataItem,
fieldMetadataItem.type === FieldMetadataType.MULTI_SELECT,
relationConnectFieldOverrides,
),
];
case FieldMetadataType.BOOLEAN:
return [
createBaseField(fieldMetadataItem, {
fieldType: { type: 'checkbox' },
...(isDefined(relationConnectFieldOverrides)
? relationConnectFieldOverrides
: {}),
}),
];
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
case FieldMetadataType.TEXT:
case FieldMetadataType.UUID:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RATING:
case FieldMetadataType.RAW_JSON:
return [
createBaseField(fieldMetadataItem, relationConnectFieldOverrides),
];
case FieldMetadataType.POSITION:
case FieldMetadataType.MORPH_RELATION:
case FieldMetadataType.ACTOR:
case FieldMetadataType.TS_VECTOR:
case FieldMetadataType.RICH_TEXT:
return [];
default:
return assertUnreachable(fieldMetadataItem.type);
}
};
const createBaseField = (
fieldMetadataItem: FieldMetadataItem,
overrides: Partial<SpreadsheetImportField> = {},
): SpreadsheetImportField => {
return {
Icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label,
key: fieldMetadataItem.name,
fieldMetadataItemId: fieldMetadataItem.id,
fieldType: { type: 'input' },
fieldMetadataType: fieldMetadataItem.type,
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
fieldMetadataItem.label,
),
isNestedField: false,
...overrides,
};
};
const handleCompositeFields = ({
fieldMetadataItem,
fieldType,
}: {
fieldMetadataItem: FieldMetadataItem;
fieldType: CompositeFieldType;
}) => {
const spreadsheetImportFields: SpreadsheetImportField[] = [];
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldType].subFields.forEach(
({ subFieldName, isImportable, subFieldLabel }) => {
if (!isImportable) return;
const label = getCompositeSubFieldLabelWithFieldLabel(
fieldMetadataItem,
subFieldLabel,
);
spreadsheetImportFields.push(
createBaseField(fieldMetadataItem, {
label,
key: getCompositeSubFieldKey(fieldMetadataItem, subFieldName),
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
label,
subFieldName,
),
isNestedField: true,
isCompositeSubField: true,
compositeSubFieldKey: subFieldName,
}),
);
},
);
return spreadsheetImportFields;
};
const handleCompositeFieldFromRelationConnectField = ({
fieldMetadataItem,
uniqueConstraintField,
uniqueConstraintType,
}: {
fieldMetadataItem: FieldMetadataItem;
uniqueConstraintField: FieldMetadataItem;
uniqueConstraintType: CompositeFieldType;
}) => {
const spreadsheetImportFields: SpreadsheetImportField[] = [];
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[
uniqueConstraintType
].subFields.forEach(
({ subFieldName, isImportable, isIncludedInUniqueConstraint }) => {
if (!isImportable || !isIncludedInUniqueConstraint) return;
spreadsheetImportFields.push(
createBaseField(fieldMetadataItem, {
label: getRelationConnectSubFieldLabel(
fieldMetadataItem,
uniqueConstraintField,
subFieldName,
),
key: getRelationConnectSubFieldKey(
fieldMetadataItem,
uniqueConstraintField,
subFieldName,
),
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(
uniqueConstraintField.type,
uniqueConstraintField.name,
subFieldName,
),
isNestedField: true,
isCompositeSubField: true,
compositeSubFieldKey: subFieldName,
uniqueFieldMetadataItem: uniqueConstraintField,
isRelationConnectField: true,
}),
);
},
);
return spreadsheetImportFields;
};
const handleSelectField = (
fieldMetadataItem: FieldMetadataItem,
isMulti = false,
subFieldOverrides?: Record<string, any>,
) =>
createBaseField(fieldMetadataItem, {
fieldType: {
type: isMulti ? 'multiSelect' : 'select',
options:
fieldMetadataItem.options?.map((option) => ({
label: option.label,
value: option.value,
color: option.color,
})) || [],
...(isDefined(subFieldOverrides) ? subFieldOverrides : {}),
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
`${fieldMetadataItem.label} (ID)`,
),
});
const handleRelationField = (fieldMetadataItem: FieldMetadataItem) => {
const spreadsheetImportFields: SpreadsheetImportField[] = [];
const isManyToOneRelation =
fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE;
const targetObjectMetadataItem = objectMetadataItems?.find(
(objectMetadataItem) =>
objectMetadataItem.id ===
fieldMetadataItem.relation?.targetObjectMetadata.id,
);
if (isManyToOneRelation && isDefined(targetObjectMetadataItem)) {
const uniqueConstraintFields = getUniqueConstraintsFields<
FieldMetadataItem,
ObjectMetadataItem
>(targetObjectMetadataItem);
//todo - update logic when composite unique indexes will be supported
for (const uniqueConstraintField of uniqueConstraintFields.flat()) {
if (isCompositeFieldType(uniqueConstraintField.type)) {
spreadsheetImportFields.push(
...handleCompositeFieldFromRelationConnectField({
fieldMetadataItem,
uniqueConstraintField,
uniqueConstraintType: uniqueConstraintField.type,
}),
);
} else {
spreadsheetImportFields.push(
...buildSpreadsheetImportField(uniqueConstraintField, {
Icon: getIcon(fieldMetadataItem.icon),
isNestedField: true,
isCompositeSubField: false,
isRelationConnectField: true,
fieldMetadataItemId: fieldMetadataItem.id,
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: uniqueConstraintField,
label: getRelationConnectSubFieldLabel(
fieldMetadataItem,
uniqueConstraintField,
),
key: getRelationConnectSubFieldKey(
fieldMetadataItem,
uniqueConstraintField,
),
}),
);
}
}
}
return spreadsheetImportFields;
};
return { buildSpreadsheetImportFields };
};

View File

@ -1,8 +1,8 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useBatchCreateManyRecords } from '@/object-record/hooks/useBatchCreateManyRecords'; import { useBatchCreateManyRecords } from '@/object-record/hooks/useBatchCreateManyRecords';
import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport'; import { useBuildSpreadsheetImportFields } from '@/object-record/spreadsheet-import/hooks/useBuildSpreadSheetImportFields';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow'; import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow';
import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts'; import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems';
import { spreadsheetImportGetUnicityRowHook } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook'; import { spreadsheetImportGetUnicityRowHook } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityRowHook';
import { SpreadsheetImportCreateRecordsBatchSize } from '@/spreadsheet-import/constants/SpreadsheetImportCreateRecordsBatchSize'; import { SpreadsheetImportCreateRecordsBatchSize } from '@/spreadsheet-import/constants/SpreadsheetImportCreateRecordsBatchSize';
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
@ -10,12 +10,13 @@ import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-impo
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useOpenObjectRecordsSpreadsheetImportDialog = ( export const useOpenObjectRecordsSpreadsheetImportDialog = (
objectNameSingular: string, objectNameSingular: string,
) => { ) => {
const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog<any>(); const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog();
const { buildSpreadsheetImportFields } = useBuildSpreadsheetImportFields();
const { enqueueErrorSnackBar } = useSnackBar(); const { enqueueErrorSnackBar } = useSnackBar();
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
@ -35,28 +36,19 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
abortController, abortController,
}); });
const { buildAvailableFieldsForImport } = useBuildAvailableFieldsForImport();
const openObjectRecordsSpreadsheetImportDialog = ( const openObjectRecordsSpreadsheetImportDialog = (
options?: Omit< options?: Omit<
SpreadsheetImportDialogOptions<any>, SpreadsheetImportDialogOptions,
'fields' | 'isOpen' | 'onClose' 'fields' | 'isOpen' | 'onClose'
>, >,
) => { ) => {
//All fields that can be imported (included matchable and auto-filled)
const availableFieldMetadataItemsToImport = const availableFieldMetadataItemsToImport =
spreadsheetImportFilterAvailableFieldMetadataItems( spreadsheetImportFilterAvailableFieldMetadataItems(
objectMetadataItem.fields, objectMetadataItem.fields,
); );
const availableFieldMetadataItemsForMatching = const spreadsheetImportFields = buildSpreadsheetImportFields(
availableFieldMetadataItemsToImport.filter( availableFieldMetadataItemsToImport,
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.ACTOR,
);
const availableFieldsForMatching = buildAvailableFieldsForImport(
availableFieldMetadataItemsForMatching,
); );
openSpreadsheetImportDialog({ openSpreadsheetImportDialog({
@ -66,7 +58,8 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
const fieldMapping: Record<string, any> = const fieldMapping: Record<string, any> =
buildRecordFromImportedStructuredRow({ buildRecordFromImportedStructuredRow({
importedStructuredRow: record, importedStructuredRow: record,
fields: availableFieldMetadataItemsToImport, fieldMetadataItems: availableFieldMetadataItemsToImport,
spreadsheetImportFields,
}); });
return fieldMapping; return fieldMapping;
@ -83,7 +76,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
}); });
} }
}, },
fields: availableFieldsForMatching, spreadsheetImportFields,
availableFieldMetadataItems: availableFieldMetadataItemsToImport, availableFieldMetadataItems: availableFieldMetadataItemsToImport,
onAbortSubmit: () => { onAbortSubmit: () => {
abortController.abort(); abortController.abort();

View File

@ -1,15 +0,0 @@
import {
SpreadsheetImportFieldType,
SpreadsheetImportFieldValidationDefinition,
} from '@/spreadsheet-import/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { IconComponent } from 'twenty-ui/display';
export type AvailableFieldForImport = {
Icon: IconComponent;
label: string;
key: string;
fieldType: SpreadsheetImportFieldType;
fieldValidationDefinitions?: SpreadsheetImportFieldValidationDefinition[];
fieldMetadataType: FieldMetadataType;
};

View File

@ -1,15 +1,20 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow'; import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow';
import { ImportedStructuredRow } from '@/spreadsheet-import/types'; import {
ImportedStructuredRow,
SpreadsheetImportField,
} from '@/spreadsheet-import/types';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { RelationType } from '~/generated/graphql';
describe('buildRecordFromImportedStructuredRow', () => { describe('buildRecordFromImportedStructuredRow', () => {
it('should successfully build a record from imported structured row', () => { it('should successfully build a record from imported structured row', () => {
const importedStructuredRow: ImportedStructuredRow<string> = { const importedStructuredRow: ImportedStructuredRow = {
booleanField: 'true', booleanField: 'true',
numberField: '30', numberField: '30',
multiSelectField: '["tag1", "tag2", "tag3"]', multiSelectField: '["tag1", "tag2", "tag3"]',
relationField: 'company-123', 'nameField (relationField)': 'John Doe',
selectField: 'option1', selectField: 'option1',
arrayField: '["item1", "item2", "item3"]', arrayField: '["item1", "item2", "item3"]',
jsonField: '{"key": "value", "nested": {"prop": "data"}}', jsonField: '{"key": "value", "nested": {"prop": "data"}}',
@ -122,6 +127,9 @@ describe('buildRecordFromImportedStructuredRow', () => {
updatedAt: '2023-01-01', updatedAt: '2023-01-01',
icon: 'IconBuilding', icon: 'IconBuilding',
description: null, description: null,
relation: {
type: RelationType.MANY_TO_ONE,
} as FieldMetadataItemRelation,
}, },
{ {
id: '7', id: '7',
@ -337,9 +345,25 @@ describe('buildRecordFromImportedStructuredRow', () => {
}, },
]; ];
const spreadsheetImportFields = [
{
fieldMetadataItemId: '6',
isNestedField: false,
isRelationConnectField: true,
label: 'Relation Field / Name Field',
key: 'nameField (relationField)',
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: {
name: 'nameField',
type: FieldMetadataType.TEXT,
},
},
] as SpreadsheetImportField[];
const result = buildRecordFromImportedStructuredRow({ const result = buildRecordFromImportedStructuredRow({
importedStructuredRow, importedStructuredRow,
fields, fieldMetadataItems: fields,
spreadsheetImportFields,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -350,7 +374,14 @@ describe('buildRecordFromImportedStructuredRow', () => {
booleanField: true, booleanField: true,
numberField: 30, numberField: 30,
multiSelectField: ['tag1', 'tag2', 'tag3'], multiSelectField: ['tag1', 'tag2', 'tag3'],
relationFieldId: 'company-123', relationField: {
connect: {
where: {
nameField: 'John Doe',
},
},
},
relationFieldId: undefined,
selectField: 'option1', selectField: 'option1',
arrayField: ['item1', 'item2', 'item3'], arrayField: ['item1', 'item2', 'item3'],
jsonField: { key: 'value', nested: { prop: 'data' } }, jsonField: { key: 'value', nested: { prop: 'data' } },
@ -406,8 +437,8 @@ describe('buildRecordFromImportedStructuredRow', () => {
}); });
}); });
it('should handle case where user provides only a primaryPhoneNumber without calling code', () => { it('should successfully build a record from imported structured row with primary phone number (without calling code)', () => {
const importedStructuredRow: ImportedStructuredRow<string> = { const importedStructuredRow: ImportedStructuredRow = {
'Primary Phone Number (phoneField)': '5550123', 'Primary Phone Number (phoneField)': '5550123',
}; };
@ -430,7 +461,8 @@ describe('buildRecordFromImportedStructuredRow', () => {
const result = buildRecordFromImportedStructuredRow({ const result = buildRecordFromImportedStructuredRow({
importedStructuredRow, importedStructuredRow,
fields, fieldMetadataItems: fields,
spreadsheetImportFields: [],
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -440,4 +472,64 @@ describe('buildRecordFromImportedStructuredRow', () => {
}, },
}); });
}); });
it('should successfully build a record from imported structured row with relation composite subfield', () => {
const importedStructuredRow: ImportedStructuredRow = {
'emailField (relationField)': 'john.doe@example.com',
};
const fields: FieldMetadataItem[] = [
{
id: '6',
name: 'relationField',
label: 'Relation Field',
type: FieldMetadataType.RELATION,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBuilding',
description: null,
relation: {
type: RelationType.MANY_TO_ONE,
} as FieldMetadataItemRelation,
},
];
const spreadsheetImportFields = [
{
fieldMetadataItemId: '6',
isNestedField: false,
isRelationConnectField: true,
label: 'Relation Field / Email Field',
key: 'emailField (relationField)',
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: {
name: 'emailField',
type: FieldMetadataType.EMAILS,
},
compositeSubFieldKey: 'primaryEmail',
},
] as SpreadsheetImportField[];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fieldMetadataItems: fields,
spreadsheetImportFields,
});
expect(result).toEqual({
relationField: {
connect: {
where: {
emailField: {
primaryEmail: 'john.doe@example.com',
},
},
},
},
});
});
}); });

View File

@ -79,7 +79,7 @@ describe('spreadsheetImportGetUnicityRowHook', () => {
it('should return row with error if row is not unique - index on composite field', () => { it('should return row with error if row is not unique - index on composite field', () => {
const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem);
const testData: ImportedStructuredRow<string>[] = [ const testData: ImportedStructuredRow[] = [
{ 'Link URL (domainName)': 'https://duplicaTe.com' }, { 'Link URL (domainName)': 'https://duplicaTe.com' },
{ 'Link URL (domainName)': 'https://duplicate.com' }, { 'Link URL (domainName)': 'https://duplicate.com' },
{ 'Link URL (domainName)': 'https://other.com' }, { 'Link URL (domainName)': 'https://other.com' },
@ -100,7 +100,7 @@ describe('spreadsheetImportGetUnicityRowHook', () => {
it('should return row with error if row is not unique - index on id', () => { it('should return row with error if row is not unique - index on id', () => {
const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem);
const testData: ImportedStructuredRow<string>[] = [ const testData: ImportedStructuredRow[] = [
{ 'Link URL (domainName)': 'test.com', id: '1' }, { 'Link URL (domainName)': 'test.com', id: '1' },
{ 'Link URL (domainName)': 'test2.com', id: '1' }, { 'Link URL (domainName)': 'test2.com', id: '1' },
{ 'Link URL (domainName)': 'test3.com', id: '3' }, { 'Link URL (domainName)': 'test3.com', id: '3' },
@ -120,7 +120,7 @@ describe('spreadsheetImportGetUnicityRowHook', () => {
it('should return row with error if row is not unique - multi fields index', () => { it('should return row with error if row is not unique - multi fields index', () => {
const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem);
const testData: ImportedStructuredRow<string>[] = [ const testData: ImportedStructuredRow[] = [
{ name: 'test', employees: '100', id: '1' }, { name: 'test', employees: '100', id: '1' },
{ name: 'test', employees: '100', id: '2' }, { name: 'test', employees: '100', id: '2' },
{ name: 'test', employees: '101', id: '3' }, { name: 'test', employees: '101', id: '3' },
@ -143,7 +143,7 @@ describe('spreadsheetImportGetUnicityRowHook', () => {
it('should not add error if row values are unique', () => { it('should not add error if row values are unique', () => {
const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem);
const testData: ImportedStructuredRow<string>[] = [ const testData: ImportedStructuredRow[] = [
{ {
name: 'test', name: 'test',
'Link URL (domainName)': 'test.com', 'Link URL (domainName)': 'test.com',

View File

@ -1,31 +1,38 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; import { getCompositeSubFieldKey } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetCompositeSubFieldKey';
import { ImportedStructuredRow } from '@/spreadsheet-import/types'; import {
ImportedStructuredRow,
SpreadsheetImportFields,
} from '@/spreadsheet-import/types';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js'; import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js';
import { isDefined } from 'twenty-shared/utils'; import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod'; import { z } from 'zod';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { castToString } from '~/utils/castToString'; import { castToString } from '~/utils/castToString';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
import { isEmptyObject } from '~/utils/isEmptyObject'; import { isEmptyObject } from '~/utils/isEmptyObject';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type BuildRecordFromImportedStructuredRowArgs = { type BuildRecordFromImportedStructuredRowArgs = {
importedStructuredRow: ImportedStructuredRow<any>; importedStructuredRow: ImportedStructuredRow;
fields: FieldMetadataItem[]; fieldMetadataItems: FieldMetadataItem[];
spreadsheetImportFields: SpreadsheetImportFields;
}; };
const buildCompositeFieldRecord = ( const buildCompositeFieldRecord = (
field: FieldMetadataItem, field: FieldMetadataItem,
importedStructuredRow: ImportedStructuredRow<any>, importedStructuredRow: ImportedStructuredRow,
compositeFieldConfig: Record<string, ((value: any) => any) | undefined>, compositeFieldConfig: Record<string, ((value: any) => any) | undefined>,
): Record<string, any> | undefined => { ): Record<string, any> | undefined => {
const compositeFieldRecord = Object.entries(compositeFieldConfig).reduce( const compositeFieldRecord = Object.entries(compositeFieldConfig).reduce(
(acc, [compositeFieldKey, transform]) => { (acc, [compositeFieldKey, transform]) => {
const value = const value =
importedStructuredRow[getSubFieldOptionKey(field, compositeFieldKey)]; importedStructuredRow[
getCompositeSubFieldKey(field, compositeFieldKey)
];
return isDefined(value) return isDefined(value)
? { ...acc, [compositeFieldKey]: transform?.(value) || value } ? { ...acc, [compositeFieldKey]: transform?.(value) || value }
@ -37,9 +44,59 @@ const buildCompositeFieldRecord = (
return isEmptyObject(compositeFieldRecord) ? undefined : compositeFieldRecord; return isEmptyObject(compositeFieldRecord) ? undefined : compositeFieldRecord;
}; };
const buildRelationConnectFieldRecord = (
fieldMetadataItem: FieldMetadataItem,
importedStructuredRow: ImportedStructuredRow,
spreadsheetImportFields: SpreadsheetImportFields,
) => {
if (fieldMetadataItem.relation?.type !== RelationType.MANY_TO_ONE)
return undefined;
const relationConnectFields = spreadsheetImportFields.filter(
(field) =>
field.fieldMetadataItemId === fieldMetadataItem.id &&
isDefined(importedStructuredRow[field.key]) &&
isNonEmptyString(importedStructuredRow[field.key]),
);
if (relationConnectFields.length === 0) return undefined;
const relationConnectFieldValue = relationConnectFields.reduce(
(acc, field) => {
const uniqueFieldMetadataItem = field.uniqueFieldMetadataItem;
if (!isDefined(uniqueFieldMetadataItem)) return acc;
if (
isCompositeFieldType(uniqueFieldMetadataItem.type) &&
isDefined(field.compositeSubFieldKey)
) {
return {
...acc,
[uniqueFieldMetadataItem.name]: {
...(isDefined(acc?.[uniqueFieldMetadataItem.name])
? acc[uniqueFieldMetadataItem.name]
: {}),
[field.compositeSubFieldKey]: importedStructuredRow[field.key],
},
};
}
return {
...acc,
[uniqueFieldMetadataItem.name]: importedStructuredRow[field.key],
};
},
{} as Record<string, any>,
);
return isEmptyObject(relationConnectFieldValue)
? undefined
: { connect: { where: relationConnectFieldValue } };
};
export const buildRecordFromImportedStructuredRow = ({ export const buildRecordFromImportedStructuredRow = ({
fields, fieldMetadataItems,
importedStructuredRow, importedStructuredRow,
spreadsheetImportFields,
}: BuildRecordFromImportedStructuredRowArgs) => { }: BuildRecordFromImportedStructuredRowArgs) => {
const stringArrayJSONSchema = z const stringArrayJSONSchema = z
.preprocess((value) => { .preprocess((value) => {
@ -145,7 +202,7 @@ export const buildRecordFromImportedStructuredRow = ({
}, },
}; };
for (const field of fields) { for (const field of fieldMetadataItems) {
const importedFieldValue = importedStructuredRow[field.name]; const importedFieldValue = importedStructuredRow[field.name];
switch (field.type) { switch (field.type) {
@ -178,12 +235,12 @@ export const buildRecordFromImportedStructuredRow = ({
const primaryPhoneNumber = const primaryPhoneNumber =
importedStructuredRow[ importedStructuredRow[
getSubFieldOptionKey(field, 'primaryPhoneNumber') getCompositeSubFieldKey(field, 'primaryPhoneNumber')
]; ];
const primaryPhoneCallingCode = const primaryPhoneCallingCode =
importedStructuredRow[ importedStructuredRow[
getSubFieldOptionKey(field, 'primaryPhoneCallingCode') getCompositeSubFieldKey(field, 'primaryPhoneCallingCode')
]; ];
const hasUserProvidedPrimaryPhoneNumberWithoutCallingCode = const hasUserProvidedPrimaryPhoneNumberWithoutCallingCode =
@ -195,7 +252,7 @@ export const buildRecordFromImportedStructuredRow = ({
if (hasUserProvidedPrimaryPhoneNumberWithoutCallingCode) { if (hasUserProvidedPrimaryPhoneNumberWithoutCallingCode) {
const primaryPhoneCountryCode = const primaryPhoneCountryCode =
importedStructuredRow[ importedStructuredRow[
getSubFieldOptionKey(field, 'primaryPhoneCountryCode') getCompositeSubFieldKey(field, 'primaryPhoneCountryCode')
]; ];
const hasUserProvidedPrimaryPhoneCountryCode = const hasUserProvidedPrimaryPhoneCountryCode =
@ -237,22 +294,14 @@ export const buildRecordFromImportedStructuredRow = ({
case FieldMetadataType.NUMERIC: case FieldMetadataType.NUMERIC:
recordToBuild[field.name] = Number(importedFieldValue); recordToBuild[field.name] = Number(importedFieldValue);
break; break;
case FieldMetadataType.UUID: case FieldMetadataType.RELATION: {
if ( recordToBuild[field.name] = buildRelationConnectFieldRecord(
isDefined(importedFieldValue) && field,
isNonEmptyString(importedFieldValue) importedStructuredRow,
) { spreadsheetImportFields,
recordToBuild[field.name] = importedFieldValue; );
break;
} }
break;
case FieldMetadataType.RELATION:
if (
isDefined(importedFieldValue) &&
isNonEmptyString(importedFieldValue)
)
recordToBuild[field.name + 'Id'] = importedFieldValue;
break;
case FieldMetadataType.ACTOR: case FieldMetadataType.ACTOR:
recordToBuild[field.name] = { recordToBuild[field.name] = {
source: 'IMPORT', source: 'IMPORT',
@ -275,11 +324,30 @@ export const buildRecordFromImportedStructuredRow = ({
} }
break; break;
} }
default: case FieldMetadataType.UUID:
case FieldMetadataType.DATE:
case FieldMetadataType.DATE_TIME:
if (
isDefined(importedFieldValue) &&
isNonEmptyString(importedFieldValue)
) {
recordToBuild[field.name] = importedFieldValue;
}
break;
case FieldMetadataType.SELECT:
case FieldMetadataType.RATING:
case FieldMetadataType.TEXT:
if (isDefined(importedFieldValue)) { if (isDefined(importedFieldValue)) {
recordToBuild[field.name] = importedFieldValue; recordToBuild[field.name] = importedFieldValue;
} }
break; break;
case FieldMetadataType.MORPH_RELATION:
case FieldMetadataType.POSITION:
case FieldMetadataType.RICH_TEXT:
case FieldMetadataType.TS_VECTOR:
break;
default:
assertUnreachable(field.type);
} }
} }

View File

@ -0,0 +1,10 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const getRelationConnectSubFieldKey = (
fieldMetadataItem: FieldMetadataItem,
uniqueConstraintField: FieldMetadataItem,
compositeSubFieldKey?: string,
) => {
return `${isDefined(compositeSubFieldKey) ? `${compositeSubFieldKey}-${uniqueConstraintField.name}` : uniqueConstraintField.name} (${fieldMetadataItem.name})`;
};

View File

@ -2,20 +2,18 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
export const getSubFieldOptionKey = ( export const getCompositeSubFieldKey = (
fieldMetadataItem: FieldMetadataItem, fieldMetadataItem: FieldMetadataItem,
subFieldName: string, subFieldName: string,
) => { ) => {
if (!isCompositeFieldType(fieldMetadataItem.type)) { if (!isCompositeFieldType(fieldMetadataItem.type)) {
throw new Error( throw new Error(
`getSubFieldOptionKey can only be called for composite field types. Received: ${fieldMetadataItem.type}`, `getCompositeSubFieldKey can only be called for composite field types. Received: ${fieldMetadataItem.type}`,
); );
} }
const subFieldLabel = const subFieldLabel =
COMPOSITE_FIELD_SUB_FIELD_LABELS[fieldMetadataItem.type][subFieldName]; COMPOSITE_FIELD_SUB_FIELD_LABELS[fieldMetadataItem.type][subFieldName];
const subFieldKey = `${subFieldLabel} (${fieldMetadataItem.name})`; return `${subFieldLabel} (${fieldMetadataItem.name})`;
return subFieldKey;
}; };

View File

@ -0,0 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
export const getCompositeSubFieldLabelWithFieldLabel = (
fieldMetadataItem: FieldMetadataItem,
subFieldLabel: string,
) => {
return `${fieldMetadataItem.label} / ${subFieldLabel}`;
};

View File

@ -0,0 +1,20 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { isDefined } from 'twenty-shared/utils';
export const getRelationConnectSubFieldLabel = (
fieldMetadataItem: FieldMetadataItem,
uniqueFieldMetadataItem: FieldMetadataItem,
compositeSubFieldKey?: string,
) => {
const compositeSubFieldLabel =
isCompositeFieldType(fieldMetadataItem.type) &&
isDefined(compositeSubFieldKey)
? COMPOSITE_FIELD_SUB_FIELD_LABELS[fieldMetadataItem.type][
compositeSubFieldKey
]
: undefined;
return `${fieldMetadataItem.label} / ${uniqueFieldMetadataItem.label}${compositeSubFieldLabel ? ` / ${compositeSubFieldLabel}` : ''}`;
};

View File

@ -1,6 +1,7 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; import { getCompositeSubFieldKey } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetCompositeSubFieldKey';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { import {
@ -11,6 +12,7 @@ import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { import {
getUniqueConstraintsFields,
isDefined, isDefined,
lowercaseUrlOriginAndRemoveTrailingSlash, lowercaseUrlOriginAndRemoveTrailingSlash,
} from 'twenty-shared/utils'; } from 'twenty-shared/utils';
@ -23,22 +25,14 @@ type Column = {
export const spreadsheetImportGetUnicityRowHook = ( export const spreadsheetImportGetUnicityRowHook = (
objectMetadataItem: ObjectMetadataItem, objectMetadataItem: ObjectMetadataItem,
) => { ) => {
const uniqueConstraints = objectMetadataItem.indexMetadatas.filter( const uniqueConstraintsFields = getUniqueConstraintsFields<
(indexMetadata) => indexMetadata.isUnique, FieldMetadataItem,
); ObjectMetadataItem
>(objectMetadataItem);
const uniqueConstraintsWithColumnNames: Column[][] = [
[{ columnName: 'id', fieldType: FieldMetadataType.UUID }],
...uniqueConstraints.map((indexMetadata) =>
indexMetadata.indexFieldMetadatas.flatMap((indexField) => {
const field = objectMetadataItem.fields.find(
(objectField) => objectField.id === indexField.fieldMetadataId,
);
if (!field) {
return [];
}
const uniqueConstraintsWithColumnNames: Column[][] =
uniqueConstraintsFields.map((uniqueConstraintFields) =>
uniqueConstraintFields.flatMap((field) => {
if (isCompositeFieldType(field.type)) { if (isCompositeFieldType(field.type)) {
const compositeTypeFieldConfig = const compositeTypeFieldConfig =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type]; SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type];
@ -48,18 +42,16 @@ export const spreadsheetImportGetUnicityRowHook = (
); );
return uniqueSubFields.map((subField) => ({ return uniqueSubFields.map((subField) => ({
columnName: getSubFieldOptionKey(field, subField.subFieldName), columnName: getCompositeSubFieldKey(field, subField.subFieldName),
fieldType: field.type, fieldType: field.type,
})); }));
} }
return [{ columnName: field.name, fieldType: field.type }]; return [{ columnName: field.name, fieldType: field.type }];
}), }),
), );
]; const rowHook: SpreadsheetImportRowHook = (row, addError, table) => {
if (uniqueConstraintsFields.length === 0) {
const rowHook: SpreadsheetImportRowHook<string> = (row, addError, table) => {
if (uniqueConstraints.length === 0) {
return row; return row;
} }
@ -95,7 +87,7 @@ export const spreadsheetImportGetUnicityRowHook = (
}; };
const getUniqueValues = ( const getUniqueValues = (
row: ImportedStructuredRow<string>, row: ImportedStructuredRow,
uniqueConstraint: Column[], uniqueConstraint: Column[],
) => { ) => {
return uniqueConstraint return uniqueConstraint

View File

@ -42,18 +42,10 @@ export const sanitizeRecordInput = ({
if ( if (
isDefined(fieldMetadataItem) && isDefined(fieldMetadataItem) &&
fieldMetadataItem.type === FieldMetadataType.RELATION && fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE &&
!isDefined(recordInput[fieldMetadataItem.name]?.connect?.where)
) { ) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`; return undefined;
const relationIdFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.name === relationIdFieldName,
);
const relationIdFieldValue = recordInput[relationIdFieldName];
return relationIdFieldMetadataItem
? [relationIdFieldName, relationIdFieldValue ?? null]
: undefined;
} }
if ( if (

View File

@ -27,7 +27,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
//TODO : isIncludedInUniqueConstraint refactor - https://github.com/twentyhq/core-team-issues/issues/1097 //TODO : isIncludedInUniqueConstraint refactor - https://github.com/twentyhq/core-team-issues/issues/1097
type CompositeSubFieldConfig<T> = { export type CompositeSubFieldConfig<T> = {
subFieldName: keyof T; subFieldName: keyof T;
subFieldLabel: string; subFieldLabel: string;
isImportable: boolean; isImportable: boolean;
@ -258,7 +258,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
.firstName, .firstName,
isImportable: true, isImportable: true,
isFilterable: true, isFilterable: true,
isIncludedInUniqueConstraint: true, isIncludedInUniqueConstraint: false,
}, },
{ {
subFieldName: 'lastName', subFieldName: 'lastName',
@ -267,7 +267,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
.lastName, .lastName,
isImportable: true, isImportable: true,
isFilterable: true, isFilterable: true,
isIncludedInUniqueConstraint: true, isIncludedInUniqueConstraint: false,
}, },
], ],
exampleValues: [ exampleValues: [

View File

@ -16,7 +16,6 @@ const fields = [
fieldType: { fieldType: {
type: 'input', type: 'input',
}, },
example: 'Stephanie',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
{ {
rule: 'required', rule: 'required',
@ -24,6 +23,8 @@ const fields = [
}, },
], ],
fieldMetadataType: FieldMetadataType.TEXT, fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '1',
isNestedField: false,
}, },
{ {
Icon: null, Icon: null,
@ -33,7 +34,6 @@ const fields = [
fieldType: { fieldType: {
type: 'input', type: 'input',
}, },
example: 'McDonald',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
{ {
rule: 'unique', rule: 'unique',
@ -42,6 +42,9 @@ const fields = [
}, },
], ],
description: 'Family / Last name', description: 'Family / Last name',
fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '2',
isNestedField: false,
}, },
{ {
Icon: null, Icon: null,
@ -51,7 +54,6 @@ const fields = [
fieldType: { fieldType: {
type: 'input', type: 'input',
}, },
example: '23',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
{ {
rule: 'regex', rule: 'regex',
@ -60,12 +62,14 @@ const fields = [
level: 'warning', level: 'warning',
}, },
], ],
fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '3',
isNestedField: false,
}, },
{ {
Icon: null, Icon: null,
label: 'Team', label: 'Team',
key: 'team', key: 'team',
alternateMatches: ['department'],
fieldType: { fieldType: {
type: 'select', type: 'select',
options: [ options: [
@ -73,28 +77,31 @@ const fields = [
{ label: 'Team Two', value: 'two' }, { label: 'Team Two', value: 'two' },
], ],
}, },
example: 'Team one',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
{ {
rule: 'required', rule: 'required',
errorMessage: 'Team is required', errorMessage: 'Team is required',
}, },
], ],
fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '4',
isNestedField: false,
}, },
{ {
Icon: null, Icon: null,
label: 'Is manager', label: 'Is manager',
key: 'is_manager', key: 'is_manager',
alternateMatches: ['manages'],
fieldType: { fieldType: {
type: 'checkbox', type: 'checkbox',
booleanMatches: {}, booleanMatches: {},
}, },
example: 'true', fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '5',
isNestedField: false,
}, },
] as SpreadsheetImportFields<string>; ] as SpreadsheetImportFields;
export const importedColums: SpreadsheetColumns<string> = [ export const importedColums: SpreadsheetColumns = [
{ {
header: 'Name', header: 'Name',
index: 0, index: 0,
@ -121,13 +128,13 @@ export const importedColums: SpreadsheetColumns<string> = [
}, },
]; ];
const mockComponentBehaviourForTypes = <T extends string>( const mockComponentBehaviourForTypes = (
props: SpreadsheetImportDialogOptions<T>, props: SpreadsheetImportDialogOptions,
) => props; ) => props;
export const mockRsiValues = mockComponentBehaviourForTypes({ export const mockRsiValues = mockComponentBehaviourForTypes({
...defaultSpreadsheetImportProps, ...defaultSpreadsheetImportProps,
fields: fields, spreadsheetImportFields: fields,
onSubmit: async () => { onSubmit: async () => {
return; return;
}, },

View File

@ -1,8 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel'; import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey'; import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { hasNestedFields } from '@/spreadsheet-import/utils/spreadsheetImportHasNestedFields';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
@ -139,7 +139,7 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({
LeftIcon={getIcon(field.icon)} LeftIcon={getIcon(field.icon)}
text={field.label} text={field.label}
contextualText={getFieldMetadataTypeLabel(field.type)} contextualText={getFieldMetadataTypeLabel(field.type)}
hasSubMenu={isCompositeFieldType(field.type)} hasSubMenu={hasNestedFields(field)}
/> />
))} ))}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>

View File

@ -1,9 +1,7 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { SpreadsheetImportFieldOption } from '@/spreadsheet-import/types/SpreadsheetImportFieldOption';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { getSubFieldOptions } from '@/spreadsheet-import/utils/spreadsheetImportGetSubFieldOptions';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; import { hasNestedFields } from '@/spreadsheet-import/utils/spreadsheetImportHasNestedFields';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
@ -12,15 +10,8 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useState } from 'react'; import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils'; import { IconChevronLeft, OverflowingTextWithTooltip } from 'twenty-ui/display';
import {
IconChevronLeft,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation'; import { MenuItem } from 'twenty-ui/navigation';
import { ReadonlyDeep } from 'type-fest';
export const MatchColumnSelectSubFieldSelectDropdownContent = ({ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
fieldMetadataItem, fieldMetadataItem,
@ -30,13 +21,11 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
}: { }: {
fieldMetadataItem: FieldMetadataItem; fieldMetadataItem: FieldMetadataItem;
onSubFieldSelect: (subFieldNameSelected: string) => void; onSubFieldSelect: (subFieldNameSelected: string) => void;
options: readonly ReadonlyDeep<SelectOption>[]; options: readonly Readonly<SpreadsheetImportFieldOption>[];
onBack: () => void; onBack: () => void;
}) => { }) => {
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
const { getIcon } = useIcons();
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value; const value = event.currentTarget.value;
@ -52,30 +41,14 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
onBack(); onBack();
}; };
if (!isCompositeFieldType(fieldMetadataItem.type)) { if (!hasNestedFields(fieldMetadataItem)) {
return <></>; return <></>;
} }
const fieldMetadataItemSettings = const subFieldOptions = getSubFieldOptions(
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataItem.type]; fieldMetadataItem,
options,
const subFieldsThatExistInOptions = fieldMetadataItemSettings.subFields searchFilter,
.filter(({ subFieldName }) => {
const optionKey = getSubFieldOptionKey(fieldMetadataItem, subFieldName);
const correspondingOption = options.find(
(option) => option.value === optionKey,
);
return isDefined(correspondingOption);
})
.filter(({ subFieldName }) =>
getCompositeSubFieldLabel(
fieldMetadataItem.type as CompositeFieldType,
subFieldName,
)
.toLowerCase()
.includes(searchFilter.toLowerCase()),
); );
return ( return (
@ -97,24 +70,17 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight> <DropdownMenuItemsContainer hasMaxHeight>
{subFieldsThatExistInOptions.map(({ subFieldName }) => ( {subFieldOptions.map(
({ value, shortLabelForNestedField, Icon, disabled }) => (
<MenuItem <MenuItem
key={subFieldName} key={value}
onClick={() => handleSubFieldSelect(subFieldName)} onClick={() => handleSubFieldSelect(value)}
LeftIcon={getIcon(fieldMetadataItem.icon)} LeftIcon={Icon}
text={getCompositeSubFieldLabel( text={shortLabelForNestedField}
fieldMetadataItem.type as CompositeFieldType, disabled={disabled}
subFieldName,
)}
disabled={
options.find(
(option) =>
option.value ===
getSubFieldOptionKey(fieldMetadataItem, subFieldName),
)?.disabled
}
/> />
))} ),
)}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
</DropdownContent> </DropdownContent>
); );

View File

@ -3,10 +3,11 @@ import { ReadonlyDeep } from 'type-fest';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
import { MatchColumnSelectFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent'; import { MatchColumnSelectFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent';
import { MatchColumnSelectSubFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent'; import { MatchColumnSelectSubFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey'; import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
import { SpreadsheetImportFieldOption } from '@/spreadsheet-import/types/SpreadsheetImportFieldOption';
import { hasNestedFields } from '@/spreadsheet-import/utils/spreadsheetImportHasNestedFields';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
@ -19,7 +20,7 @@ interface MatchColumnToFieldSelectProps {
columnIndex: string; columnIndex: string;
onChange: (value: ReadonlyDeep<SelectOption> | null) => void; onChange: (value: ReadonlyDeep<SelectOption> | null) => void;
value?: ReadonlyDeep<SelectOption>; value?: ReadonlyDeep<SelectOption>;
options: readonly ReadonlyDeep<SelectOption>[]; options: readonly Readonly<SpreadsheetImportFieldOption>[];
suggestedOptions: readonly ReadonlyDeep<SelectOption>[]; suggestedOptions: readonly ReadonlyDeep<SelectOption>[];
placeholder?: string; placeholder?: string;
} }
@ -70,12 +71,7 @@ export const MatchColumnToFieldSelect = ({
} }
const correspondingOption = options.find((option) => { const correspondingOption = options.find((option) => {
const optionKey = getSubFieldOptionKey( return option.value === subFieldNameSelected;
selectedFieldMetadataItem,
subFieldNameSelected,
);
return option.value === optionKey;
}); });
if (isDefined(correspondingOption)) { if (isDefined(correspondingOption)) {
@ -112,9 +108,9 @@ export const MatchColumnToFieldSelect = ({
closeDropdown(dropdownId); closeDropdown(dropdownId);
}; };
const shouldShowSubField = const shouldShowNestedField =
isDefined(selectedFieldMetadataItem) && isDefined(selectedFieldMetadataItem) &&
isCompositeFieldType(selectedFieldMetadataItem.type); hasNestedFields(selectedFieldMetadataItem);
return ( return (
<Dropdown <Dropdown
@ -129,7 +125,7 @@ export const MatchColumnToFieldSelect = ({
/> />
} }
dropdownComponents={ dropdownComponents={
shouldShowSubField ? ( shouldShowNestedField ? (
<MatchColumnSelectSubFieldSelectDropdownContent <MatchColumnSelectSubFieldSelectDropdownContent
fieldMetadataItem={selectedFieldMetadataItem} fieldMetadataItem={selectedFieldMetadataItem}
onSubFieldSelect={handleSubFieldSelect} onSubFieldSelect={handleSubFieldSelect}

View File

@ -5,16 +5,16 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const RsiContext = createContext({} as any); export const RsiContext = createContext({} as any);
type ReactSpreadsheetImportContextProviderProps<T extends string> = { type ReactSpreadsheetImportContextProviderProps = {
children: React.ReactNode; children: React.ReactNode;
values: SpreadsheetImportDialogOptions<T>; values: SpreadsheetImportDialogOptions;
}; };
export const ReactSpreadsheetImportContextProvider = <T extends string>({ export const ReactSpreadsheetImportContextProvider = ({
children, children,
values, values,
}: ReactSpreadsheetImportContextProviderProps<T>) => { }: ReactSpreadsheetImportContextProviderProps) => {
if (isUndefinedOrNull(values.fields)) { if (isUndefinedOrNull(values.spreadsheetImportFields)) {
throw new Error('Fields must be provided to spreadsheet-import'); throw new Error('Fields must be provided to spreadsheet-import');
} }

View File

@ -13,12 +13,10 @@ import { act } from 'react';
const Wrapper = ({ children }: { children: React.ReactNode }) => ( const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot> <RecoilRoot>{children}</RecoilRoot>
); );
type SpreadsheetKey = 'spreadsheet_key';
export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions<SpreadsheetKey> = export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions = {
{
onClose: () => {}, onClose: () => {},
fields: [], spreadsheetImportFields: [],
uploadStepHook: async () => [], uploadStepHook: async () => [],
selectHeaderStepHook: async ( selectHeaderStepHook: async (
headerValues: ImportedRow, headerValues: ImportedRow,
@ -45,13 +43,13 @@ export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions<Spreadshee
rtl: false, rtl: false,
selectHeader: true, selectHeader: true,
availableFieldMetadataItems: [], availableFieldMetadataItems: [],
}; };
describe('useSpreadsheetImport', () => { describe('useSpreadsheetImport', () => {
it('should set isOpen to true, and update the options in the Recoil state', async () => { it('should set isOpen to true, and update the options in the Recoil state', async () => {
const { result } = renderHook( const { result } = renderHook(
() => ({ () => ({
useSpreadsheetImport: useOpenSpreadsheetImportDialog<SpreadsheetKey>(), useSpreadsheetImport: useOpenSpreadsheetImportDialog(),
spreadsheetImportState: useRecoilState(spreadsheetImportDialogState)[0], spreadsheetImportState: useRecoilState(spreadsheetImportDialogState)[0],
}), }),
{ {

View File

@ -8,8 +8,9 @@ import { ImportedRow } from '@/spreadsheet-import/types';
import { getMatchedColumnsWithFuse } from '@/spreadsheet-import/utils/getMatchedColumnsWithFuse'; import { getMatchedColumnsWithFuse } from '@/spreadsheet-import/utils/getMatchedColumnsWithFuse';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
export const useComputeColumnSuggestionsAndAutoMatch = <T extends string>() => { export const useComputeColumnSuggestionsAndAutoMatch = () => {
const { fields, autoMapHeaders } = useSpreadsheetImportInternal<T>(); const { spreadsheetImportFields: fields, autoMapHeaders } =
useSpreadsheetImportInternal();
const computeColumnSuggestionsAndAutoMatch = useRecoilCallback( const computeColumnSuggestionsAndAutoMatch = useRecoilCallback(
({ set, snapshot }) => ({ set, snapshot }) =>

View File

@ -4,13 +4,13 @@ import { SPREADSHEET_IMPORT_MODAL_ID } from '@/spreadsheet-import/constants/Spre
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal';
export const useOpenSpreadsheetImportDialog = <T extends string>() => { export const useOpenSpreadsheetImportDialog = () => {
const setSpreadSheetImport = useSetRecoilState(spreadsheetImportDialogState); const setSpreadSheetImport = useSetRecoilState(spreadsheetImportDialogState);
const { openModal } = useModal(); const { openModal } = useModal();
const openSpreadsheetImportDialog = ( const openSpreadsheetImportDialog = (
options: Omit<SpreadsheetImportDialogOptions<T>, 'isOpen' | 'onClose'>, options: Omit<SpreadsheetImportDialogOptions, 'isOpen' | 'onClose'>,
) => { ) => {
openModal(SPREADSHEET_IMPORT_MODAL_ID); openModal(SPREADSHEET_IMPORT_MODAL_ID);
setSpreadSheetImport({ setSpreadSheetImport({

View File

@ -5,10 +5,10 @@ import { RsiContext } from '@/spreadsheet-import/components/ReactSpreadsheetImpo
import { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport'; import { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport';
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
export const useSpreadsheetImportInternal = <T extends string>() => export const useSpreadsheetImportInternal = () =>
useContext< useContext<
SetRequired< SetRequired<
SpreadsheetImportDialogOptions<T>, SpreadsheetImportDialogOptions,
keyof typeof defaultSpreadsheetImportProps keyof typeof defaultSpreadsheetImportProps
> >
>(RsiContext); >(RsiContext);

View File

@ -10,9 +10,7 @@ import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogMa
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar'; import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
export const defaultSpreadsheetImportProps: Partial< export const defaultSpreadsheetImportProps: Partial<SpreadsheetImportProps> = {
SpreadsheetImportProps<any>
> = {
autoMapHeaders: true, autoMapHeaders: true,
allowInvalidSubmit: true, allowInvalidSubmit: true,
autoMapDistance: 2, autoMapDistance: 2,
@ -28,13 +26,11 @@ export const defaultSpreadsheetImportProps: Partial<
maxRecords: SpreadsheetMaxRecordImportCapacity, maxRecords: SpreadsheetMaxRecordImportCapacity,
} as const; } as const;
export const SpreadsheetImport = <T extends string>( export const SpreadsheetImport = (props: SpreadsheetImportProps) => {
props: SpreadsheetImportProps<T>,
) => {
const mergedProps = { const mergedProps = {
...defaultSpreadsheetImportProps, ...defaultSpreadsheetImportProps,
...props, ...props,
} as SpreadsheetImportProps<T>; } as SpreadsheetImportProps;
const { enqueueDialog } = useDialogManager(); const { enqueueDialog } = useDialogManager();

View File

@ -1,19 +1,18 @@
import { createState } from 'twenty-ui/utilities'; import { createState } from 'twenty-ui/utilities';
import { SpreadsheetImportDialogOptions } from '../types'; import { SpreadsheetImportDialogOptions } from '../types';
export type SpreadsheetImportDialogState<T extends string> = { export type SpreadsheetImportDialogState = {
isOpen: boolean; isOpen: boolean;
isStepBarVisible: boolean; isStepBarVisible: boolean;
options: Omit<SpreadsheetImportDialogOptions<T>, 'isOpen' | 'onClose'> | null; options: Omit<SpreadsheetImportDialogOptions, 'isOpen' | 'onClose'> | null;
}; };
export const spreadsheetImportDialogState = createState< export const spreadsheetImportDialogState =
SpreadsheetImportDialogState<any> createState<SpreadsheetImportDialogState>({
>({
key: 'spreadsheetImportDialogState', key: 'spreadsheetImportDialogState',
defaultValue: { defaultValue: {
isOpen: false, isOpen: false,
isStepBarVisible: true, isStepBarVisible: true,
options: null, options: null,
}, },
}); });

View File

@ -1,7 +1,6 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
import { useHideStepBar } from '@/spreadsheet-import/hooks/useHideStepBar';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState'; import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
@ -38,9 +37,6 @@ type ImportDataStepProps = {
export const ImportDataStep = ({ export const ImportDataStep = ({
recordsToImportCount, recordsToImportCount,
}: ImportDataStepProps) => { }: ImportDataStepProps) => {
const hideStepBar = useHideStepBar();
hideStepBar();
const { onClose } = useSpreadsheetImportInternal(); const { onClose } = useSpreadsheetImportInternal();
const spreadsheetImportCreatedRecordsProgress = useRecoilValue( const spreadsheetImportCreatedRecordsProgress = useRecoilValue(
spreadsheetImportCreatedRecordsProgressState, spreadsheetImportCreatedRecordsProgressState,

View File

@ -64,7 +64,7 @@ export type MatchColumnsStepProps = {
onError: (message: string) => void; onError: (message: string) => void;
}; };
export const MatchColumnsStep = <T extends string>({ export const MatchColumnsStep = ({
data, data,
headerValues, headerValues,
onBack, onBack,
@ -76,7 +76,7 @@ export const MatchColumnsStep = <T extends string>({
}: MatchColumnsStepProps) => { }: MatchColumnsStepProps) => {
const { enqueueDialog } = useDialogManager(); const { enqueueDialog } = useDialogManager();
const dataExample = data.slice(0, 2); const dataExample = data.slice(0, 2);
const { fields } = useSpreadsheetImportInternal<T>(); const { spreadsheetImportFields: fields } = useSpreadsheetImportInternal();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [columns, setColumns] = useRecoilState( const [columns, setColumns] = useRecoilState(
initialComputedColumnsSelector(headerValues), initialComputedColumnsSelector(headerValues),
@ -90,7 +90,7 @@ export const MatchColumnsStep = <T extends string>({
(columnIndex: number) => { (columnIndex: number) => {
setColumns( setColumns(
columns.map((column, index) => columns.map((column, index) =>
columnIndex === index ? setIgnoreColumn<string>(column) : column, columnIndex === index ? setIgnoreColumn(column) : column,
), ),
); );
}, },
@ -109,7 +109,7 @@ export const MatchColumnsStep = <T extends string>({
); );
const onChange = useCallback( const onChange = useCallback(
(value: T, columnIndex: number) => { (value: string, columnIndex: number) => {
if (value === DO_NOT_IMPORT_OPTION_KEY) { if (value === DO_NOT_IMPORT_OPTION_KEY) {
if (columns[columnIndex].type === SpreadsheetColumnType.ignored) { if (columns[columnIndex].type === SpreadsheetColumnType.ignored) {
onRevertIgnore(columnIndex); onRevertIgnore(columnIndex);
@ -119,12 +119,12 @@ export const MatchColumnsStep = <T extends string>({
} else { } else {
const field = fields.find( const field = fields.find(
(field) => field.key === value, (field) => field.key === value,
) as unknown as SpreadsheetImportField<T>; ) as unknown as SpreadsheetImportField;
const existingFieldIndex = columns.findIndex( const existingFieldIndex = columns.findIndex(
(column) => 'value' in column && column.value === field.key, (column) => 'value' in column && column.value === field.key,
); );
setColumns( setColumns(
columns.map<SpreadsheetColumn<string>>((column, index) => { columns.map<SpreadsheetColumn>((column, index) => {
if (columnIndex === index) { if (columnIndex === index) {
return setColumn(column, field, data); return setColumn(column, field, data);
} else if (index === existingFieldIndex) { } else if (index === existingFieldIndex) {
@ -141,9 +141,9 @@ export const MatchColumnsStep = <T extends string>({
const handleContinue = useCallback( const handleContinue = useCallback(
async ( async (
values: ImportedStructuredRow<string>[], values: ImportedStructuredRow[],
rawData: ImportedRow[], rawData: ImportedRow[],
columns: SpreadsheetColumns<string>, columns: SpreadsheetColumns,
) => { ) => {
try { try {
const data = await matchColumnsStepHook(values, rawData, columns); const data = await matchColumnsStepHook(values, rawData, columns);

View File

@ -82,28 +82,28 @@ const StyledGridHeader = styled.div<PositionProps>`
padding-right: ${({ theme }) => theme.spacing(4)}; padding-right: ${({ theme }) => theme.spacing(4)};
`; `;
type ColumnGridProps<T extends string> = { type ColumnGridProps = {
columns: SpreadsheetColumns<T>; columns: SpreadsheetColumns;
renderUserColumn: ( renderUserColumn: (
columns: SpreadsheetColumns<T>, columns: SpreadsheetColumns,
columnIndex: number, columnIndex: number,
) => React.ReactNode; ) => React.ReactNode;
renderTemplateColumn: ( renderTemplateColumn: (
columns: SpreadsheetColumns<T>, columns: SpreadsheetColumns,
columnIndex: number, columnIndex: number,
) => React.ReactNode; ) => React.ReactNode;
renderUnmatchedColumn: ( renderUnmatchedColumn: (
columns: SpreadsheetColumns<T>, columns: SpreadsheetColumns,
columnIndex: number, columnIndex: number,
) => React.ReactNode; ) => React.ReactNode;
}; };
export const ColumnGrid = <T extends string>({ export const ColumnGrid = ({
columns, columns,
renderUserColumn, renderUserColumn,
renderTemplateColumn, renderTemplateColumn,
renderUnmatchedColumn, renderUnmatchedColumn,
}: ColumnGridProps<T>) => { }: ColumnGridProps) => {
return ( return (
<> <>
<StyledGridContainer> <StyledGridContainer>

View File

@ -16,20 +16,20 @@ const StyledIconChevronDown = styled(IconChevronDown)`
color: ${({ theme }) => theme.font.color.tertiary}; color: ${({ theme }) => theme.font.color.tertiary};
`; `;
export type SubMatchingSelectDropdownButtonProps<T> = { export type SubMatchingSelectDropdownButtonProps = {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>; option: SpreadsheetMatchedOptions | Partial<SpreadsheetMatchedOptions>;
column: column:
| SpreadsheetMatchedSelectColumn<T> | SpreadsheetMatchedSelectColumn
| SpreadsheetMatchedSelectOptionsColumn<T>; | SpreadsheetMatchedSelectOptionsColumn;
placeholder: string; placeholder: string;
}; };
export const SubMatchingSelectDropdownButton = <T extends string>({ export const SubMatchingSelectDropdownButton = ({
option, option,
column, column,
placeholder, placeholder,
}: SubMatchingSelectDropdownButtonProps<T>) => { }: SubMatchingSelectDropdownButtonProps) => {
const { fields } = useSpreadsheetImportInternal<T>(); const { spreadsheetImportFields: fields } = useSpreadsheetImportInternal();
const options = getFieldOptions(fields, column.value) as SelectOption[]; const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value); const value = options.find((opt) => opt.value === option.value);

View File

@ -15,23 +15,23 @@ const StyledRowContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(1)}; padding-bottom: ${({ theme }) => theme.spacing(1)};
`; `;
interface SubMatchingSelectRowProps<T> { interface SubMatchingSelectRowProps {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>; option: SpreadsheetMatchedOptions | Partial<SpreadsheetMatchedOptions>;
column: column:
| SpreadsheetMatchedSelectColumn<T> | SpreadsheetMatchedSelectColumn
| SpreadsheetMatchedSelectOptionsColumn<T>; | SpreadsheetMatchedSelectOptionsColumn;
onSubChange: (val: T, index: number, option: string) => void; onSubChange: (val: string, index: number, option: string) => void;
placeholder: string; placeholder: string;
selectedOption?: selectedOption?:
| SpreadsheetMatchedOptions<T> | SpreadsheetMatchedOptions
| Partial<SpreadsheetMatchedOptions<T>>; | Partial<SpreadsheetMatchedOptions>;
} }
export const SubMatchingSelectRow = <T extends string>({ export const SubMatchingSelectRow = ({
option, option,
column, column,
onSubChange, onSubChange,
placeholder, placeholder,
}: SubMatchingSelectRowProps<T>) => { }: SubMatchingSelectRowProps) => {
return ( return (
<StyledRowContainer> <StyledRowContainer>
<SubMatchingSelectRowLeftSelect option={option} /> <SubMatchingSelectRowLeftSelect option={option} />

View File

@ -15,13 +15,13 @@ const StyledControlLabel = styled.div`
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
`; `;
export type SubMatchingSelectRowLeftSelectProps<T> = { export type SubMatchingSelectRowLeftSelectProps = {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>; option: SpreadsheetMatchedOptions | Partial<SpreadsheetMatchedOptions>;
}; };
export const SubMatchingSelectRowLeftSelect = <T extends string>({ export const SubMatchingSelectRowLeftSelect = ({
option, option,
}: SubMatchingSelectRowLeftSelectProps<T>) => { }: SubMatchingSelectRowLeftSelectProps) => {
return ( return (
<SubMatchingSelectControlContainer cursor="default"> <SubMatchingSelectControlContainer cursor="default">
<StyledControlLabel> <StyledControlLabel>

View File

@ -18,34 +18,34 @@ const StyledDropdownContainer = styled.div`
width: 100%; width: 100%;
`; `;
interface SubMatchingSelectRowRightDropdownProps<T> { interface SubMatchingSelectRowRightDropdownProps {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>; option: SpreadsheetMatchedOptions | Partial<SpreadsheetMatchedOptions>;
column: column:
| SpreadsheetMatchedSelectColumn<T> | SpreadsheetMatchedSelectColumn
| SpreadsheetMatchedSelectOptionsColumn<T>; | SpreadsheetMatchedSelectOptionsColumn;
onSubChange: (val: T, index: number, option: string) => void; onSubChange: (val: string, index: number, option: string) => void;
placeholder: string; placeholder: string;
selectedOption?: selectedOption?:
| SpreadsheetMatchedOptions<T> | SpreadsheetMatchedOptions
| Partial<SpreadsheetMatchedOptions<T>>; | Partial<SpreadsheetMatchedOptions>;
} }
export const SubMatchingSelectRowRightDropdown = <T extends string>({ export const SubMatchingSelectRowRightDropdown = ({
option, option,
column, column,
onSubChange, onSubChange,
placeholder, placeholder,
}: SubMatchingSelectRowRightDropdownProps<T>) => { }: SubMatchingSelectRowRightDropdownProps) => {
const dropdownId = `sub-matching-select-dropdown-${option.entry}`; const dropdownId = `sub-matching-select-dropdown-${option.entry}`;
const { closeDropdown } = useCloseDropdown(); const { closeDropdown } = useCloseDropdown();
const { fields } = useSpreadsheetImportInternal<T>(); const { spreadsheetImportFields: fields } = useSpreadsheetImportInternal();
const options = getFieldOptions(fields, column.value) as SelectOption[]; const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value); const value = options.find((opt) => opt.value === option.value);
const handleSelect = (selectedOption: SelectOption) => { const handleSelect = (selectedOption: SelectOption) => {
onSubChange(selectedOption.value as T, column.index, option.entry ?? ''); onSubChange(selectedOption.value, column.index, option.entry ?? '');
closeDropdown(dropdownId); closeDropdown(dropdownId);
}; };

View File

@ -6,7 +6,7 @@ import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpre
import { suggestedFieldsByColumnHeaderState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState'; import { suggestedFieldsByColumnHeaderState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState';
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns'; import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { spreadsheetBuildFieldOptions } from '@/spreadsheet-import/utils/spreadsheetBuildFieldOptions'; import { spreadsheetImportBuildFieldOptions } from '@/spreadsheet-import/utils/spreadsheetImportBuildFieldOptions';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { IconForbid } from 'twenty-ui/display'; import { IconForbid } from 'twenty-ui/display';
@ -25,18 +25,18 @@ const StyledErrorMessage = styled.span`
margin-top: ${({ theme }) => theme.spacing(1)}; margin-top: ${({ theme }) => theme.spacing(1)};
`; `;
type TemplateColumnProps<T extends string> = { type TemplateColumnProps = {
columns: SpreadsheetColumns<string>; columns: SpreadsheetColumns;
columnIndex: number; columnIndex: number;
onChange: (val: T, index: number) => void; onChange: (val: string, index: number) => void;
}; };
export const TemplateColumn = <T extends string>({ export const TemplateColumn = ({
columns, columns,
columnIndex, columnIndex,
onChange, onChange,
}: TemplateColumnProps<T>) => { }: TemplateColumnProps) => {
const { fields } = useSpreadsheetImportInternal<T>(); const { spreadsheetImportFields: fields } = useSpreadsheetImportInternal();
const suggestedFieldsByColumnHeader = useRecoilValue( const suggestedFieldsByColumnHeader = useRecoilValue(
suggestedFieldsByColumnHeaderState, suggestedFieldsByColumnHeaderState,
); );
@ -46,8 +46,8 @@ export const TemplateColumn = <T extends string>({
const { t } = useLingui(); const { t } = useLingui();
const fieldOptions = spreadsheetBuildFieldOptions(fields, columns); const fieldOptions = spreadsheetImportBuildFieldOptions(fields, columns);
const suggestedFieldOptions = spreadsheetBuildFieldOptions( const suggestedFieldOptions = spreadsheetImportBuildFieldOptions(
suggestedFieldsByColumnHeader[column.header] ?? [], suggestedFieldsByColumnHeader[column.header] ?? [],
columns, columns,
); );
@ -74,7 +74,7 @@ export const TemplateColumn = <T extends string>({
<MatchColumnToFieldSelect <MatchColumnToFieldSelect
placeholder={t`Select column...`} placeholder={t`Select column...`}
value={isIgnored ? ignoreValue : selectValue} value={isIgnored ? ignoreValue : selectValue}
onChange={(value) => onChange(value?.value as T, column.index)} onChange={(value) => onChange(value?.value as string, column.index)}
options={selectOptions} options={selectOptions}
suggestedOptions={suggestedFieldOptions} suggestedOptions={suggestedFieldOptions}
columnIndex={column.index.toString()} columnIndex={column.index.toString()}

View File

@ -11,9 +11,9 @@ import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { AnimatedExpandableContainer } from 'twenty-ui/layout'; import { AnimatedExpandableContainer } from 'twenty-ui/layout';
const getExpandableContainerTitle = <T extends string>( const getExpandableContainerTitle = (
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields,
column: SpreadsheetColumn<T>, column: SpreadsheetColumn,
) => { ) => {
const fieldLabel = fields.find( const fieldLabel = fields.find(
(field) => 'value' in column && field.key === column.value, (field) => 'value' in column && field.key === column.value,
@ -25,10 +25,10 @@ const getExpandableContainerTitle = <T extends string>(
} Unmatched)`; } Unmatched)`;
}; };
type UnmatchColumnProps<T extends string> = { type UnmatchColumnProps = {
columns: SpreadsheetColumns<T>; columns: SpreadsheetColumns;
columnIndex: number; columnIndex: number;
onSubChange: (val: T, index: number, option: string) => void; onSubChange: (val: string, index: number, option: string) => void;
}; };
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -44,12 +44,12 @@ const StyledContentWrapper = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(4)}; padding-bottom: ${({ theme }) => theme.spacing(4)};
`; `;
export const UnmatchColumn = <T extends string>({ export const UnmatchColumn = ({
columns, columns,
columnIndex, columnIndex,
onSubChange, onSubChange,
}: UnmatchColumnProps<T>) => { }: UnmatchColumnProps) => {
const { fields } = useSpreadsheetImportInternal<T>(); const { spreadsheetImportFields: fields } = useSpreadsheetImportInternal();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const column = columns[columnIndex]; const column = columns[columnIndex];
const isSelect = 'matchedOptions' in column; const isSelect = 'matchedOptions' in column;

View File

@ -29,15 +29,15 @@ const StyledExample = styled.span`
white-space: nowrap; white-space: nowrap;
`; `;
type UserTableColumnProps<T extends string> = { type UserTableColumnProps = {
column: SpreadsheetColumn<T>; column: SpreadsheetColumn;
importedRow: ImportedRow; importedRow: ImportedRow;
}; };
export const UserTableColumn = <T extends string>({ export const UserTableColumn = ({
column, column,
importedRow, importedRow,
}: UserTableColumnProps<T>) => { }: UserTableColumnProps) => {
const { header } = column; const { header } = column;
const firstDefinedValue = importedRow.find(isDefined); const firstDefinedValue = importedRow.find(isDefined);

View File

@ -5,18 +5,18 @@ import { atom, selectorFamily } from 'recoil';
export const matchColumnsState = atom({ export const matchColumnsState = atom({
key: 'MatchColumnsState', key: 'MatchColumnsState',
default: [] as SpreadsheetColumns<string>, default: [] as SpreadsheetColumns,
}); });
export const initialComputedColumnsSelector = selectorFamily< export const initialComputedColumnsSelector = selectorFamily<
SpreadsheetColumns<string>, SpreadsheetColumns,
ImportedRow ImportedRow
>({ >({
key: 'initialComputedColumnsSelector', key: 'initialComputedColumnsSelector',
get: get:
(headerValues: ImportedRow) => (headerValues: ImportedRow) =>
({ get }) => { ({ get }) => {
const currentState = get(matchColumnsState) as SpreadsheetColumns<string>; const currentState = get(matchColumnsState) as SpreadsheetColumns;
if (currentState.length === 0) { if (currentState.length === 0) {
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them // Do not remove spread, it indexes empty array elements, otherwise map() skips over them
const initialState = ([...headerValues] as string[]).map( const initialState = ([...headerValues] as string[]).map(
@ -26,7 +26,7 @@ export const initialComputedColumnsSelector = selectorFamily<
header: value ?? '', header: value ?? '',
}), }),
); );
return initialState as SpreadsheetColumns<string>; return initialState as SpreadsheetColumns;
} else { } else {
return currentState; return currentState;
} }
@ -34,6 +34,6 @@ export const initialComputedColumnsSelector = selectorFamily<
set: set:
() => () =>
({ set }, newValue) => { ({ set }, newValue) => {
set(matchColumnsState, newValue as SpreadsheetColumns<string>); set(matchColumnsState, newValue as SpreadsheetColumns);
}, },
}); });

View File

@ -3,5 +3,5 @@ import { createState } from 'twenty-ui/utilities';
export const suggestedFieldsByColumnHeaderState = createState({ export const suggestedFieldsByColumnHeaderState = createState({
key: 'suggestedFieldsByColumnHeaderState', key: 'suggestedFieldsByColumnHeaderState',
defaultValue: {} as Record<string, SpreadsheetImportField<string>[]>, defaultValue: {} as Record<string, SpreadsheetImportField[]>,
}); });

View File

@ -1,58 +0,0 @@
import styled from '@emotion/styled';
// @ts-expect-error // Todo: remove usage of react-data-grid
import { Column } from 'react-data-grid';
import { createPortal } from 'react-dom';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { AppTooltip } from 'twenty-ui/display';
const StyledHeaderContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
position: relative;
`;
const StyledHeaderLabel = styled.span`
display: flex;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledDefaultContainer = styled.div`
min-height: 100%;
min-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
`;
export const generateColumns = <T extends string>(
fields: SpreadsheetImportFields<T>,
) =>
fields.map(
(column): Column<any> => ({
key: column.key,
name: column.label,
minWidth: 150,
headerRenderer: () => (
<StyledHeaderContainer>
<StyledHeaderLabel id={`${column.key}`}>
{column.label}
</StyledHeaderLabel>
{column.description &&
createPortal(
<AppTooltip
anchorSelect={`#${column.key}`}
place="top"
content={column.description}
/>,
document.body,
)}
</StyledHeaderContainer>
),
formatter: ({ row }: any) => (
<StyledDefaultContainer>{row[column.key]}</StyledDefaultContainer>
),
}),
);

View File

@ -1,5 +1,6 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts'; import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems';
import { getCompositeSubFieldLabelWithFieldLabel } from '@/object-record/spreadsheet-import/utils/spreadsheetImportGetCompositeSubFieldLabelWithFieldLabel';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs'; import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs';
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue'; import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
@ -58,8 +59,8 @@ export const useDownloadFakeRecords = () => {
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues; SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues;
headerRow.push( headerRow.push(
...subFields.map( ...subFields.map(({ subFieldLabel }) =>
({ subFieldLabel }) => `${field.label} / ${subFieldLabel}`, getCompositeSubFieldLabelWithFieldLabel(field, subFieldLabel),
), ),
); );

View File

@ -1,5 +1,6 @@
import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable'; import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable';
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
import { useHideStepBar } from '@/spreadsheet-import/hooks/useHideStepBar';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
@ -91,30 +92,36 @@ const StyledNoRowsWithErrorsContainer = styled.div`
margin: auto 0; margin: auto 0;
`; `;
type ValidationStepProps<T extends string> = { type ValidationStepProps = {
initialData: ImportedStructuredRow<T>[]; initialData: ImportedStructuredRow[];
importedColumns: SpreadsheetColumns<string>; importedColumns: SpreadsheetColumns;
file: File; file: File;
onBack: () => void; onBack: () => void;
setCurrentStepState: Dispatch<SetStateAction<SpreadsheetImportStep>>; setCurrentStepState: Dispatch<SetStateAction<SpreadsheetImportStep>>;
}; };
export const ValidationStep = <T extends string>({ export const ValidationStep = ({
initialData, initialData,
importedColumns, importedColumns,
file, file,
setCurrentStepState, setCurrentStepState,
onBack, onBack,
}: ValidationStepProps<T>) => { }: ValidationStepProps) => {
const hideStepBar = useHideStepBar();
const { enqueueDialog } = useDialogManager(); const { enqueueDialog } = useDialogManager();
const { fields, onClose, onSubmit, rowHook, tableHook } = const {
useSpreadsheetImportInternal<T>(); spreadsheetImportFields: fields,
onClose,
onSubmit,
rowHook,
tableHook,
} = useSpreadsheetImportInternal();
const [data, setData] = useState< const [data, setData] = useState<
(ImportedStructuredRow<T> & ImportedStructuredRowMetadata)[] (ImportedStructuredRow & ImportedStructuredRowMetadata)[]
>( >(
useMemo( useMemo(
() => addErrorsAndRunHooks<T>(initialData, fields, rowHook, tableHook), () => addErrorsAndRunHooks(initialData, fields, rowHook, tableHook),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[], [],
), ),
@ -126,7 +133,7 @@ export const ValidationStep = <T extends string>({
const updateData = useCallback( const updateData = useCallback(
(rows: typeof data) => { (rows: typeof data) => {
setData(addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook)); setData(addErrorsAndRunHooks(rows, fields, rowHook, tableHook));
}, },
[setData, rowHook, tableHook, fields], [setData, rowHook, tableHook, fields],
); );
@ -205,8 +212,7 @@ export const ValidationStep = <T extends string>({
}, [data, filterByErrors]); }, [data, filterByErrors]);
const rowKeyGetter = useCallback( const rowKeyGetter = useCallback(
(row: ImportedStructuredRow<T> & ImportedStructuredRowMetadata) => (row: ImportedStructuredRow & ImportedStructuredRowMetadata) => row.__index,
row.__index,
[], [],
); );
@ -218,28 +224,29 @@ export const ValidationStep = <T extends string>({
for (const key in __errors) { for (const key in __errors) {
if (__errors[key].level === 'error') { if (__errors[key].level === 'error') {
acc.invalidStructuredRows.push( acc.invalidStructuredRows.push(
values as unknown as ImportedStructuredRow<T>, values as unknown as ImportedStructuredRow,
); );
return acc; return acc;
} }
} }
} }
acc.validStructuredRows.push( acc.validStructuredRows.push(
values as unknown as ImportedStructuredRow<T>, values as unknown as ImportedStructuredRow,
); );
return acc; return acc;
}, },
{ {
validStructuredRows: [] as ImportedStructuredRow<T>[], validStructuredRows: [] as ImportedStructuredRow[],
invalidStructuredRows: [] as ImportedStructuredRow<T>[], invalidStructuredRows: [] as ImportedStructuredRow[],
allStructuredRows: data, allStructuredRows: data,
} satisfies SpreadsheetImportImportValidationResult<T>, } satisfies SpreadsheetImportImportValidationResult,
); );
setCurrentStepState({ setCurrentStepState({
type: SpreadsheetImportStepType.importData, type: SpreadsheetImportStepType.importData,
recordsToImportCount: calculatedData.validStructuredRows.length, recordsToImportCount: calculatedData.validStructuredRows.length,
}); });
hideStepBar();
await onSubmit(calculatedData, file); await onSubmit(calculatedData, file);
onClose(); onClose();

View File

@ -71,9 +71,9 @@ const formatSafeId = (columnKey: string) => {
return camelCase(columnKey.replace('(', '').replace(')', '')); return camelCase(columnKey.replace('(', '').replace(')', ''));
}; };
export const generateColumns = <T extends string>( export const generateColumns = (
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields,
): Column<ImportedStructuredRow<T> & ImportedStructuredRowMetadata>[] => [ ): Column<ImportedStructuredRow & ImportedStructuredRowMetadata>[] => [
{ {
key: SELECT_COLUMN_KEY, key: SELECT_COLUMN_KEY,
name: '', name: '',
@ -108,7 +108,7 @@ export const generateColumns = <T extends string>(
...fields.map( ...fields.map(
( (
column, column,
): Column<ImportedStructuredRow<T> & ImportedStructuredRowMetadata> => ({ ): Column<ImportedStructuredRow & ImportedStructuredRowMetadata> => ({
key: column.key, key: column.key,
name: column.label, name: column.label,
minWidth: 150, minWidth: 150,
@ -132,7 +132,7 @@ export const generateColumns = <T extends string>(
editable: column.fieldType.type !== 'checkbox', editable: column.fieldType.type !== 'checkbox',
// Todo: remove usage of react-data-grid // Todo: remove usage of react-data-grid
editor: ({ row, onRowChange, onClose }: any) => { editor: ({ row, onRowChange, onClose }: any) => {
const columnKey = column.key as keyof (ImportedStructuredRow<T> & const columnKey = column.key as keyof (ImportedStructuredRow &
ImportedStructuredRowMetadata); ImportedStructuredRowMetadata);
let component; let component;
@ -166,7 +166,7 @@ export const generateColumns = <T extends string>(
}, },
// Todo: remove usage of react-data-grid // Todo: remove usage of react-data-grid
formatter: ({ row, onRowChange }: { row: any; onRowChange: any }) => { formatter: ({ row, onRowChange }: { row: any; onRowChange: any }) => {
const columnKey = column.key as keyof (ImportedStructuredRow<T> & const columnKey = column.key as keyof (ImportedStructuredRow &
ImportedStructuredRowMetadata); ImportedStructuredRowMetadata);
let component; let component;
@ -197,7 +197,7 @@ export const generateColumns = <T extends string>(
id={formatSafeId(`${columnKey}-${row.__index}`)} id={formatSafeId(`${columnKey}-${row.__index}`)}
> >
{column.fieldType.options.find( {column.fieldType.options.find(
(option) => option.value === row[columnKey as T], (option) => option.value === row[columnKey],
)?.label || null} )?.label || null}
</StyledDefaultContainer> </StyledDefaultContainer>
); );

View File

@ -23,7 +23,7 @@ export type SpreadsheetImportStep =
| { | {
type: SpreadsheetImportStepType.validateData; type: SpreadsheetImportStepType.validateData;
data: any[]; data: any[];
importedColumns: SpreadsheetColumns<string>; importedColumns: SpreadsheetColumns;
} }
| { | {
type: SpreadsheetImportStepType.loading; type: SpreadsheetImportStepType.loading;

View File

@ -13,49 +13,49 @@ type SpreadsheetIgnoredColumn = {
header: string; header: string;
}; };
type SpreadsheetMatchedColumn<T> = { type SpreadsheetMatchedColumn = {
type: SpreadsheetColumnType.matched; type: SpreadsheetColumnType.matched;
index: number; index: number;
header: string; header: string;
value: T; value: string;
}; };
type SpreadsheetMatchedSwitchColumn<T> = { type SpreadsheetMatchedSwitchColumn = {
type: SpreadsheetColumnType.matchedCheckbox; type: SpreadsheetColumnType.matchedCheckbox;
index: number; index: number;
header: string; header: string;
value: T; value: string;
}; };
export type SpreadsheetMatchedSelectColumn<T> = { export type SpreadsheetMatchedSelectColumn = {
type: SpreadsheetColumnType.matchedSelect; type: SpreadsheetColumnType.matchedSelect;
index: number; index: number;
header: string; header: string;
value: T; value: string;
matchedOptions: Partial<SpreadsheetMatchedOptions<T>>[]; matchedOptions: Partial<SpreadsheetMatchedOptions>[];
}; };
export type SpreadsheetMatchedSelectOptionsColumn<T> = { export type SpreadsheetMatchedSelectOptionsColumn = {
type: SpreadsheetColumnType.matchedSelectOptions; type: SpreadsheetColumnType.matchedSelectOptions;
index: number; index: number;
header: string; header: string;
value: T; value: string;
matchedOptions: SpreadsheetMatchedOptions<T>[]; matchedOptions: SpreadsheetMatchedOptions[];
}; };
export type SpreadsheetErrorColumn<T> = { export type SpreadsheetErrorColumn = {
type: SpreadsheetColumnType.matchedError; type: SpreadsheetColumnType.matchedError;
index: number; index: number;
header: string; header: string;
value: T; value: string;
errorMessage: string; errorMessage: string;
}; };
export type SpreadsheetColumn<T extends string> = export type SpreadsheetColumn =
| SpreadsheetEmptyColumn | SpreadsheetEmptyColumn
| SpreadsheetIgnoredColumn | SpreadsheetIgnoredColumn
| SpreadsheetMatchedColumn<T> | SpreadsheetMatchedColumn
| SpreadsheetMatchedSwitchColumn<T> | SpreadsheetMatchedSwitchColumn
| SpreadsheetMatchedSelectColumn<T> | SpreadsheetMatchedSelectColumn
| SpreadsheetMatchedSelectOptionsColumn<T> | SpreadsheetMatchedSelectOptionsColumn
| SpreadsheetErrorColumn<T>; | SpreadsheetErrorColumn;

View File

@ -1,3 +1,3 @@
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn'; import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
export type SpreadsheetColumns<T extends string> = SpreadsheetColumn<T>[]; export type SpreadsheetColumns = SpreadsheetColumn[];

View File

@ -8,11 +8,11 @@ import { SpreadsheetImportRowHook } from '@/spreadsheet-import/types/Spreadsheet
import { SpreadsheetImportTableHook } from '@/spreadsheet-import/types/SpreadsheetImportTableHook'; import { SpreadsheetImportTableHook } from '@/spreadsheet-import/types/SpreadsheetImportTableHook';
import { SpreadsheetImportStep } from '../steps/types/SpreadsheetImportStep'; import { SpreadsheetImportStep } from '../steps/types/SpreadsheetImportStep';
export type SpreadsheetImportDialogOptions<FieldNames extends string> = { export type SpreadsheetImportDialogOptions = {
// callback when RSI is closed before final submit // callback when RSI is closed before final submit
onClose: () => void; onClose: () => void;
// Field description for requested data // Field description for requested data
fields: SpreadsheetImportFields<FieldNames>; spreadsheetImportFields: SpreadsheetImportFields;
// Runs after file upload step, receives and returns raw sheet data // Runs after file upload step, receives and returns raw sheet data
uploadStepHook?: (importedRows: ImportedRow[]) => Promise<ImportedRow[]>; uploadStepHook?: (importedRows: ImportedRow[]) => Promise<ImportedRow[]>;
// Runs after header selection step, receives and returns raw sheet data // Runs after header selection step, receives and returns raw sheet data
@ -22,17 +22,17 @@ export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
) => Promise<{ 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 // Runs once before validation step, used for data mutations and if you want to change how columns were matched
matchColumnsStepHook?: ( matchColumnsStepHook?: (
importedStructuredRows: ImportedStructuredRow<FieldNames>[], importedStructuredRows: ImportedStructuredRow[],
importedRows: ImportedRow[], importedRows: ImportedRow[],
columns: SpreadsheetColumns<FieldNames>, columns: SpreadsheetColumns,
) => Promise<ImportedStructuredRow<FieldNames>[]>; ) => Promise<ImportedStructuredRow[]>;
// Runs after column matching and on entry change // Runs after column matching and on entry change
rowHook?: SpreadsheetImportRowHook<FieldNames>; rowHook?: SpreadsheetImportRowHook;
// Runs after column matching and on entry change // Runs after column matching and on entry change
tableHook?: SpreadsheetImportTableHook<FieldNames>; tableHook?: SpreadsheetImportTableHook;
// Function called after user finishes the flow // Function called after user finishes the flow
onSubmit: ( onSubmit: (
validationResult: SpreadsheetImportImportValidationResult<FieldNames>, validationResult: SpreadsheetImportImportValidationResult,
file: File, file: File,
) => Promise<void>; ) => Promise<void>;
// Function called when user aborts the importing flow // Function called when user aborts the importing flow
@ -59,5 +59,6 @@ export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
rtl?: boolean; rtl?: boolean;
// Allow header selection // Allow header selection
selectHeader?: boolean; selectHeader?: boolean;
// Available field for import
availableFieldMetadataItems: FieldMetadataItem[]; availableFieldMetadataItems: FieldMetadataItem[];
}; };

View File

@ -1,25 +1,34 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SpreadsheetImportFieldType } from '@/spreadsheet-import/types/SpreadsheetImportFieldType'; import { SpreadsheetImportFieldType } from '@/spreadsheet-import/types/SpreadsheetImportFieldType';
import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types/SpreadsheetImportFieldValidationDefinition'; import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types/SpreadsheetImportFieldValidationDefinition';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { IconComponent } from 'twenty-ui/display'; import { IconComponent } from 'twenty-ui/display';
export type SpreadsheetImportField<T extends string> = { export type SpreadsheetImportField = {
// Icon // Icon
Icon: IconComponent | null | undefined; Icon: IconComponent | null | undefined;
// UI-facing field label // UI-facing field label
label: string; label: string;
// Field's unique identifier // Field's unique identifier
key: T; key: string;
// Field's metadata item id - same for all associated nested fields
fieldMetadataItemId: string;
// UI-facing additional information displayed via tooltip and ? icon // UI-facing additional information displayed via tooltip and ? icon
description?: string; description?: string;
// Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName"
alternateMatches?: string[];
// Validations used for field entries // Validations used for field entries
fieldValidationDefinitions?: SpreadsheetImportFieldValidationDefinition[]; fieldValidationDefinitions?: SpreadsheetImportFieldValidationDefinition[];
// Field entry component, default: Input // Field entry component, default: Input
fieldType: SpreadsheetImportFieldType; fieldType: SpreadsheetImportFieldType;
// Field metadata type // Field metadata type
fieldMetadataType: FieldMetadataType; fieldMetadataType: FieldMetadataType;
// UI-facing values shown to user as field examples pre-upload phase // if true, it can be a composite sub-field or a relation connect field (or both)
example?: string; isNestedField: boolean;
// can be true only if isNestedField is true
isCompositeSubField?: boolean;
// defined only if isCompositeSubField is true
compositeSubFieldKey?: string;
// can be true only if isNestedField is true
isRelationConnectField?: boolean;
// defined only if isRelationConnectField is true
uniqueFieldMetadataItem?: FieldMetadataItem;
}; };

View File

@ -0,0 +1,12 @@
import { IconComponent } from 'twenty-ui/display';
export type SpreadsheetImportFieldOption = {
Icon: IconComponent | null | undefined;
value: string;
label: string;
shortLabelForNestedField?: string;
disabled?: boolean;
fieldMetadataTypeLabel?: string;
isNestedField?: boolean;
fieldMetadataItemId?: string;
};

View File

@ -1,6 +1,4 @@
import { SpreadsheetImportField } from '@/spreadsheet-import/types/SpreadsheetImportField'; import { SpreadsheetImportField } from '@/spreadsheet-import/types/SpreadsheetImportField';
import { ReadonlyDeep } from 'type-fest'; import { ReadonlyDeep } from 'type-fest';
export type SpreadsheetImportFields<T extends string> = ReadonlyDeep< export type SpreadsheetImportFields = ReadonlyDeep<SpreadsheetImportField[]>;
SpreadsheetImportField<T>[]
>;

View File

@ -1,9 +1,8 @@
import { ImportedStructuredRowMetadata } from '@/spreadsheet-import/steps/components/ValidationStep/types'; import { ImportedStructuredRowMetadata } from '@/spreadsheet-import/steps/components/ValidationStep/types';
import { ImportedStructuredRow } from './SpreadsheetImportImportedStructuredRow'; import { ImportedStructuredRow } from './SpreadsheetImportImportedStructuredRow';
export type SpreadsheetImportImportValidationResult<T extends string> = { export type SpreadsheetImportImportValidationResult = {
validStructuredRows: ImportedStructuredRow<T>[]; validStructuredRows: ImportedStructuredRow[];
invalidStructuredRows: ImportedStructuredRow<T>[]; invalidStructuredRows: ImportedStructuredRow[];
allStructuredRows: (ImportedStructuredRow<T> & allStructuredRows: (ImportedStructuredRow & ImportedStructuredRowMetadata)[];
ImportedStructuredRowMetadata)[];
}; };

View File

@ -1,3 +1,3 @@
export type ImportedStructuredRow<T extends string> = { export type ImportedStructuredRow = {
[key in T]: string | boolean | undefined; [key: string]: string | boolean | undefined;
}; };

View File

@ -1,8 +1,8 @@
import { ImportedStructuredRow } from './SpreadsheetImportImportedStructuredRow'; import { ImportedStructuredRow } from './SpreadsheetImportImportedStructuredRow';
import { SpreadsheetImportInfo } from './SpreadsheetImportInfo'; import { SpreadsheetImportInfo } from './SpreadsheetImportInfo';
export type SpreadsheetImportRowHook<T extends string> = ( export type SpreadsheetImportRowHook = (
row: ImportedStructuredRow<T>, row: ImportedStructuredRow,
addError: (fieldKey: T, error: SpreadsheetImportInfo) => void, addError: (fieldKey: string, error: SpreadsheetImportInfo) => void,
table: ImportedStructuredRow<T>[], table: ImportedStructuredRow[],
) => ImportedStructuredRow<T>; ) => ImportedStructuredRow;

View File

@ -1,11 +1,11 @@
import { ImportedStructuredRow } from './SpreadsheetImportImportedStructuredRow'; import { ImportedStructuredRow } from './SpreadsheetImportImportedStructuredRow';
import { SpreadsheetImportInfo } from './SpreadsheetImportInfo'; import { SpreadsheetImportInfo } from './SpreadsheetImportInfo';
export type SpreadsheetImportTableHook<T extends string> = ( export type SpreadsheetImportTableHook = (
table: ImportedStructuredRow<T>[], table: ImportedStructuredRow[],
addError: ( addError: (
rowIndex: number, rowIndex: number,
fieldKey: T, fieldKey: string,
error: SpreadsheetImportInfo, error: SpreadsheetImportInfo,
) => void, ) => void,
) => ImportedStructuredRow<T>[]; ) => ImportedStructuredRow[];

View File

@ -1,4 +1,4 @@
export type SpreadsheetMatchedOptions<T> = { export type SpreadsheetMatchedOptions = {
entry: string; entry: string;
value?: T; value?: string;
}; };

View File

@ -9,17 +9,16 @@ import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
describe('addErrorsAndRunHooks', () => { describe('addErrorsAndRunHooks', () => {
type FullData = ImportedStructuredRow<'name' | 'age' | 'country'>; const requiredField = {
const requiredField: SpreadsheetImportField<'name'> = {
key: 'name', key: 'name',
label: 'Name', label: 'Name',
fieldValidationDefinitions: [{ rule: 'required' }], fieldValidationDefinitions: [{ rule: 'required' }],
Icon: null, Icon: null,
fieldType: { type: 'input' }, fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.TEXT, fieldMetadataType: FieldMetadataType.TEXT,
}; } as SpreadsheetImportField;
const regexField: SpreadsheetImportField<'age'> = { const regexField = {
key: 'age', key: 'age',
label: 'Age', label: 'Age',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
@ -28,18 +27,20 @@ describe('addErrorsAndRunHooks', () => {
Icon: null, Icon: null,
fieldType: { type: 'input' }, fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.NUMBER, fieldMetadataType: FieldMetadataType.NUMBER,
}; } as SpreadsheetImportField;
const uniqueField: SpreadsheetImportField<'country'> = { const uniqueField = {
key: 'country', key: 'country',
label: 'Country', label: 'Country',
fieldValidationDefinitions: [{ rule: 'unique' }], fieldValidationDefinitions: [{ rule: 'unique' }],
Icon: null, Icon: null,
fieldType: { type: 'input' }, fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.SELECT, fieldMetadataType: FieldMetadataType.SELECT,
}; fieldMetadataItemId: '2',
isNestedField: false,
} as SpreadsheetImportField;
const functionValidationFieldTrue: SpreadsheetImportField<'email'> = { const functionValidationFieldTrue = {
key: 'email', key: 'email',
label: 'Email', label: 'Email',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
@ -52,9 +53,11 @@ describe('addErrorsAndRunHooks', () => {
Icon: null, Icon: null,
fieldType: { type: 'input' }, fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.EMAILS, fieldMetadataType: FieldMetadataType.EMAILS,
}; fieldMetadataItemId: '1',
isNestedField: false,
} as SpreadsheetImportField;
const functionValidationFieldFalse: SpreadsheetImportField<'email'> = { const functionValidationFieldFalse = {
key: 'email', key: 'email',
label: 'Email', label: 'Email',
fieldValidationDefinitions: [ fieldValidationDefinitions: [
@ -67,23 +70,25 @@ describe('addErrorsAndRunHooks', () => {
Icon: null, Icon: null,
fieldType: { type: 'input' }, fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.EMAILS, fieldMetadataType: FieldMetadataType.EMAILS,
}; fieldMetadataItemId: '3',
isNestedField: false,
} as SpreadsheetImportField;
const validData: ImportedStructuredRow<'name' | 'age'> = { const validData: ImportedStructuredRow = {
name: 'John', name: 'John',
age: '30', age: '30',
}; };
const dataWithoutNameAndInvalidAge: ImportedStructuredRow<'name' | 'age'> = { const dataWithoutNameAndInvalidAge: ImportedStructuredRow = {
name: '', name: '',
age: 'Invalid', age: 'Invalid',
}; };
const dataWithDuplicatedValue: FullData = { const dataWithDuplicatedValue: ImportedStructuredRow = {
name: 'Alice', name: 'Alice',
age: '40', age: '40',
country: 'Brazil', country: 'Brazil',
}; };
const data: ImportedStructuredRow<'name' | 'age'>[] = [ const data: ImportedStructuredRow[] = [
validData, validData,
dataWithoutNameAndInvalidAge, dataWithoutNameAndInvalidAge,
]; ];
@ -113,18 +118,14 @@ describe('addErrorsAndRunHooks', () => {
level: 'error', level: 'error',
}; };
const rowHook: SpreadsheetImportRowHook<'name' | 'age'> = jest.fn( const rowHook: SpreadsheetImportRowHook = jest.fn((row, addError) => {
(row, addError) => {
addError('name', nameError); addError('name', nameError);
return row; return row;
}, });
); const tableHook: SpreadsheetImportTableHook = jest.fn((table, addError) => {
const tableHook: SpreadsheetImportTableHook<'name' | 'age'> = jest.fn(
(table, addError) => {
addError(0, 'age', ageError); addError(0, 'age', ageError);
return table; return table;
}, });
);
it('should correctly call rowHook and tableHook and add errors', () => { it('should correctly call rowHook and tableHook and add errors', () => {
const result = addErrorsAndRunHooks( const result = addErrorsAndRunHooks(
@ -179,7 +180,7 @@ describe('addErrorsAndRunHooks', () => {
[ [
dataWithDuplicatedValue, dataWithDuplicatedValue,
dataWithDuplicatedValue, dataWithDuplicatedValue,
] as unknown as FullData[], ] as unknown as ImportedStructuredRow[],
[uniqueField], [uniqueField],
); );

View File

@ -1,93 +0,0 @@
import { SpreadsheetImportField } from '@/spreadsheet-import/types';
import { findMatch } from '@/spreadsheet-import/utils/findMatch';
import { FieldMetadataType } from 'twenty-shared/types';
describe('findMatch', () => {
const defaultField: SpreadsheetImportField<'defaultField'> = {
key: 'defaultField',
Icon: null,
label: 'label',
fieldType: {
type: 'input',
},
fieldMetadataType: FieldMetadataType.TEXT,
alternateMatches: ['Full Name', 'First Name'],
};
const secondaryField: SpreadsheetImportField<'secondaryField'> = {
key: 'secondaryField',
Icon: null,
label: 'label',
fieldType: {
type: 'input',
},
fieldMetadataType: FieldMetadataType.TEXT,
};
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();
});
});

View File

@ -7,7 +7,7 @@ import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetCol
import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields'; import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
const nameField: SpreadsheetImportField<'Name'> = { const nameField: SpreadsheetImportField = {
key: 'Name', key: 'Name',
label: 'Name', label: 'Name',
Icon: null, Icon: null,
@ -15,9 +15,11 @@ const nameField: SpreadsheetImportField<'Name'> = {
type: 'input', type: 'input',
}, },
fieldMetadataType: FieldMetadataType.TEXT, fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '1',
isNestedField: false,
}; };
const ageField: SpreadsheetImportField<'Age'> = { const ageField: SpreadsheetImportField = {
key: 'Age', key: 'Age',
label: 'Age', label: 'Age',
Icon: null, Icon: null,
@ -25,37 +27,37 @@ const ageField: SpreadsheetImportField<'Age'> = {
type: 'input', type: 'input',
}, },
fieldMetadataType: FieldMetadataType.NUMBER, fieldMetadataType: FieldMetadataType.NUMBER,
fieldMetadataItemId: '2',
isNestedField: false,
}; };
const validations: SpreadsheetImportFieldValidationDefinition[] = [ const validations: SpreadsheetImportFieldValidationDefinition[] = [
{ rule: 'required' }, { rule: 'required' },
]; ];
const nameFieldWithValidations: SpreadsheetImportField<'Name'> = { const nameFieldWithValidations: SpreadsheetImportField = {
...nameField, ...nameField,
fieldValidationDefinitions: validations, fieldValidationDefinitions: validations,
}; };
const ageFieldWithValidations: SpreadsheetImportField<'Age'> = { const ageFieldWithValidations: SpreadsheetImportField = {
...ageField, ...ageField,
fieldValidationDefinitions: validations, fieldValidationDefinitions: validations,
}; };
type ColumnValues = 'Name' | 'Age'; const nameColumn: SpreadsheetColumn = {
const nameColumn: SpreadsheetColumn<ColumnValues> = {
type: SpreadsheetColumnType.matched, type: SpreadsheetColumnType.matched,
index: 0, index: 0,
header: '', header: '',
value: 'Name', value: 'Name',
}; };
const ageColumn: SpreadsheetColumn<ColumnValues> = { const ageColumn: SpreadsheetColumn = {
type: SpreadsheetColumnType.matched, type: SpreadsheetColumnType.matched,
index: 0, index: 0,
header: '', header: '',
value: 'Age', value: 'Age',
}; };
const extraColumn: SpreadsheetColumn<ColumnValues> = { const extraColumn: SpreadsheetColumn = {
type: SpreadsheetColumnType.matched, type: SpreadsheetColumnType.matched,
index: 0, index: 0,
header: '', header: '',

View File

@ -17,7 +17,7 @@ describe('getFieldOptions', () => {
value: 'Three', value: 'Three',
}, },
]; ];
const fields: SpreadsheetImportField<'Options' | 'Name'>[] = [ const fields: SpreadsheetImportField[] = [
{ {
key: 'Options', key: 'Options',
Icon: null, Icon: null,
@ -27,6 +27,8 @@ describe('getFieldOptions', () => {
options: optionsArray, options: optionsArray,
}, },
fieldMetadataType: FieldMetadataType.SELECT, fieldMetadataType: FieldMetadataType.SELECT,
fieldMetadataItemId: '1',
isNestedField: false,
}, },
{ {
key: 'Name', key: 'Name',
@ -36,6 +38,8 @@ describe('getFieldOptions', () => {
type: 'input', type: 'input',
}, },
fieldMetadataType: FieldMetadataType.TEXT, fieldMetadataType: FieldMetadataType.TEXT,
fieldMetadataItemId: '2',
isNestedField: false,
}, },
]; ];

View File

@ -1,166 +0,0 @@
import { SpreadsheetImportField } from '@/spreadsheet-import/types';
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns';
import { FieldMetadataType } from 'twenty-shared/types';
describe('getMatchedColumns', () => {
const columns: SpreadsheetColumn<string>[] = [
{
index: 0,
header: 'Name',
type: SpreadsheetColumnType.matched,
value: 'Name',
},
{
index: 1,
header: 'Location',
type: SpreadsheetColumnType.matched,
value: 'Location',
},
{
index: 2,
header: 'Age',
type: SpreadsheetColumnType.matched,
value: 'Age',
},
];
const fields: SpreadsheetImportField<string>[] = [
{
key: 'Name',
label: 'Name',
fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.TEXT,
Icon: null,
},
{
key: 'Location',
label: 'Location',
fieldType: { type: 'select', options: [] },
fieldMetadataType: FieldMetadataType.POSITION,
Icon: null,
},
{
key: 'Age',
label: 'Age',
fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.NUMBER,
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: SpreadsheetColumnType.matched,
value: 'Name',
},
{
index: 1,
header: 'Location',
type: SpreadsheetColumnType.matchedSelect,
value: 'Location',
matchedOptions: [
{
entry: 'New York',
},
{
entry: 'Los Angeles',
},
],
},
{
index: 2,
header: 'Age',
type: SpreadsheetColumnType.matched,
value: 'Age',
},
]);
});
it('should handle columns with duplicate values by choosing the closest match', () => {
const columnsWithDuplicates: SpreadsheetColumn<string>[] = [
{
index: 0,
header: 'Name',
type: SpreadsheetColumnType.matched,
value: 'Name',
},
{
index: 1,
header: 'Name',
type: SpreadsheetColumnType.matched,
value: 'Name',
},
{
index: 2,
header: 'Location',
type: SpreadsheetColumnType.matched,
value: 'Location',
},
];
const result = getMatchedColumns(
columnsWithDuplicates,
fields,
data,
autoMapDistance,
);
expect(result[0]).toEqual({
index: 0,
header: 'Name',
type: SpreadsheetColumnType.empty,
});
expect(result[1]).toEqual({
index: 1,
header: 'Name',
type: SpreadsheetColumnType.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: SpreadsheetImportField<string>[] = [
{
key: 'Hobby',
label: 'Hobby',
fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.TEXT,
Icon: null,
},
{
key: 'Interest',
label: 'Interest',
fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.TEXT,
Icon: null,
},
];
const result = getMatchedColumns(
columns,
unmatchedFields,
unmatchedColumnsData,
autoMapDistance,
);
expect(result).toEqual(columns);
});
});

View File

@ -6,7 +6,7 @@ import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableDat
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
describe('normalizeTableData', () => { describe('normalizeTableData', () => {
const columns: SpreadsheetColumn<string>[] = [ const columns: SpreadsheetColumn[] = [
{ {
index: 0, index: 0,
header: 'Name', header: 'Name',
@ -27,7 +27,7 @@ describe('normalizeTableData', () => {
}, },
]; ];
const fields: SpreadsheetImportField<string>[] = [ const fields = [
{ {
key: 'name', key: 'name',
label: 'Name', label: 'Name',
@ -51,7 +51,7 @@ describe('normalizeTableData', () => {
fieldMetadataType: FieldMetadataType.BOOLEAN, fieldMetadataType: FieldMetadataType.BOOLEAN,
Icon: null, Icon: null,
}, },
]; ] as SpreadsheetImportField[];
const rawData = [ const rawData = [
['John', '30', 'Yes'], ['John', '30', 'Yes'],
@ -70,7 +70,7 @@ describe('normalizeTableData', () => {
}); });
it('should normalize matchedCheckbox values and handle booleanMatches', () => { it('should normalize matchedCheckbox values and handle booleanMatches', () => {
const columns: SpreadsheetColumn<string>[] = [ const columns: SpreadsheetColumn[] = [
{ {
index: 0, index: 0,
header: 'Active', header: 'Active',
@ -79,7 +79,7 @@ describe('normalizeTableData', () => {
}, },
]; ];
const fields: SpreadsheetImportField<string>[] = [ const fields = [
{ {
key: 'active', key: 'active',
label: 'Active', label: 'Active',
@ -89,8 +89,10 @@ describe('normalizeTableData', () => {
}, },
fieldMetadataType: FieldMetadataType.BOOLEAN, fieldMetadataType: FieldMetadataType.BOOLEAN,
Icon: null, Icon: null,
fieldMetadataItemId: '1',
isNestedField: false,
}, },
]; ] as SpreadsheetImportField[];
const rawData = [['Yes'], ['No'], ['OtherValue']]; const rawData = [['Yes'], ['No'], ['OtherValue']];
@ -100,7 +102,7 @@ describe('normalizeTableData', () => {
}); });
it('should map matchedSelect and matchedSelectOptions values correctly', () => { it('should map matchedSelect and matchedSelectOptions values correctly', () => {
const columns: SpreadsheetColumn<string>[] = [ const columns: SpreadsheetColumn[] = [
{ {
index: 0, index: 0,
header: 'Number', header: 'Number',
@ -113,7 +115,7 @@ describe('normalizeTableData', () => {
}, },
]; ];
const fields: SpreadsheetImportField<string>[] = [ const fields = [
{ {
key: 'number', key: 'number',
label: 'Number', label: 'Number',
@ -127,7 +129,7 @@ describe('normalizeTableData', () => {
fieldMetadataType: FieldMetadataType.SELECT, fieldMetadataType: FieldMetadataType.SELECT,
Icon: null, Icon: null,
}, },
]; ] as SpreadsheetImportField[];
const rawData = [['One'], ['Two'], ['OtherValue']]; const rawData = [['One'], ['Two'], ['OtherValue']];
@ -141,7 +143,7 @@ describe('normalizeTableData', () => {
}); });
it('should handle empty and ignored columns', () => { it('should handle empty and ignored columns', () => {
const columns: SpreadsheetColumn<string>[] = [ const columns: SpreadsheetColumn[] = [
{ index: 0, header: 'Empty', type: SpreadsheetColumnType.empty }, { index: 0, header: 'Empty', type: SpreadsheetColumnType.empty },
{ index: 1, header: 'Ignored', type: SpreadsheetColumnType.ignored }, { index: 1, header: 'Ignored', type: SpreadsheetColumnType.ignored },
]; ];
@ -154,7 +156,7 @@ describe('normalizeTableData', () => {
}); });
it('should handle unrecognized column types and return empty object', () => { it('should handle unrecognized column types and return empty object', () => {
const columns: SpreadsheetColumns<string> = [ const columns: SpreadsheetColumns = [
{ {
index: 0, index: 0,
header: 'Unrecognized', header: 'Unrecognized',

View File

@ -5,15 +5,15 @@ import { setColumn } from '@/spreadsheet-import/utils/setColumn';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
describe('setColumn', () => { describe('setColumn', () => {
const defaultField: SpreadsheetImportField<'Name'> = { const defaultField = {
Icon: null, Icon: null,
label: 'label', label: 'label',
key: 'Name', key: 'Name',
fieldType: { type: 'input' }, fieldType: { type: 'input' },
fieldMetadataType: FieldMetadataType.TEXT, fieldMetadataType: FieldMetadataType.TEXT,
}; } as SpreadsheetImportField;
const oldColumn: SpreadsheetColumn<'oldValue'> = { const oldColumn: SpreadsheetColumn = {
index: 0, index: 0,
header: 'Name', header: 'Name',
type: SpreadsheetColumnType.matched, type: SpreadsheetColumnType.matched,
@ -27,7 +27,7 @@ describe('setColumn', () => {
type: 'select', type: 'select',
options: [{ value: 'John' }, { value: 'Alice' }], options: [{ value: 'John' }, { value: 'Alice' }],
}, },
} as SpreadsheetImportField<'Name'>; } as SpreadsheetImportField;
const data = [['John'], ['Alice']]; const data = [['John'], ['Alice']];
const result = setColumn(oldColumn, field, data); const result = setColumn(oldColumn, field, data);
@ -54,7 +54,7 @@ describe('setColumn', () => {
const field = { const field = {
...defaultField, ...defaultField,
fieldType: { type: 'checkbox' }, fieldType: { type: 'checkbox' },
} as SpreadsheetImportField<'Name'>; } as SpreadsheetImportField;
const result = setColumn(oldColumn, field); const result = setColumn(oldColumn, field);
@ -70,7 +70,7 @@ describe('setColumn', () => {
const field = { const field = {
...defaultField, ...defaultField,
fieldType: { type: 'input' }, fieldType: { type: 'input' },
} as SpreadsheetImportField<'Name'>; } as SpreadsheetImportField;
const result = setColumn(oldColumn, field); const result = setColumn(oldColumn, field);
@ -86,7 +86,7 @@ describe('setColumn', () => {
const field = { const field = {
...defaultField, ...defaultField,
fieldType: { type: 'unknown' }, fieldType: { type: 'unknown' },
} as unknown as SpreadsheetImportField<'Name'>; } as unknown as SpreadsheetImportField;
const result = setColumn(oldColumn, field); const result = setColumn(oldColumn, field);

View File

@ -4,7 +4,7 @@ import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn';
describe('setIgnoreColumn', () => { describe('setIgnoreColumn', () => {
it('should return a column with type "ignored"', () => { it('should return a column with type "ignored"', () => {
const column: SpreadsheetColumn<'John'> = { const column: SpreadsheetColumn = {
index: 0, index: 0,
header: 'Name', header: 'Name',
type: SpreadsheetColumnType.matched, type: SpreadsheetColumnType.matched,

View File

@ -4,7 +4,7 @@ import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn';
describe('setSubColumn', () => { describe('setSubColumn', () => {
it('should return a matchedSelectColumn with updated matchedOptions', () => { it('should return a matchedSelectColumn with updated matchedOptions', () => {
const oldColumn: SpreadsheetColumn<'John' | ''> = { const oldColumn: SpreadsheetColumn = {
index: 0, index: 0,
header: 'Name', header: 'Name',
type: SpreadsheetColumnType.matchedSelect, type: SpreadsheetColumnType.matchedSelect,
@ -32,7 +32,7 @@ describe('setSubColumn', () => {
}); });
it('should return a matchedSelectOptionsColumn with updated matchedOptions', () => { it('should return a matchedSelectOptionsColumn with updated matchedOptions', () => {
const oldColumn: SpreadsheetColumn<'John' | 'Jane'> = { const oldColumn: SpreadsheetColumn = {
index: 0, index: 0,
header: 'Name', header: 'Name',
type: SpreadsheetColumnType.matchedSelectOptions, type: SpreadsheetColumnType.matchedSelectOptions,

View File

@ -15,17 +15,17 @@ import {
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const addErrorsAndRunHooks = <T extends string>( export const addErrorsAndRunHooks = (
data: (ImportedStructuredRow<T> & Partial<ImportedStructuredRowMetadata>)[], data: (ImportedStructuredRow & Partial<ImportedStructuredRowMetadata>)[],
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields,
rowHook?: SpreadsheetImportRowHook<T>, rowHook?: SpreadsheetImportRowHook,
tableHook?: SpreadsheetImportTableHook<T>, tableHook?: SpreadsheetImportTableHook,
): (ImportedStructuredRow<T> & ImportedStructuredRowMetadata)[] => { ): (ImportedStructuredRow & ImportedStructuredRowMetadata)[] => {
const errors: Errors = {}; const errors: Errors = {};
const addHookError = ( const addHookError = (
rowIndex: number, rowIndex: number,
fieldKey: T, fieldKey: string,
error: SpreadsheetImportInfo, error: SpreadsheetImportInfo,
) => { ) => {
errors[rowIndex] = { errors[rowIndex] = {
@ -48,7 +48,7 @@ export const addErrorsAndRunHooks = <T extends string>(
field.fieldValidationDefinitions?.forEach((fieldValidationDefinition) => { field.fieldValidationDefinitions?.forEach((fieldValidationDefinition) => {
switch (fieldValidationDefinition.rule) { switch (fieldValidationDefinition.rule) {
case 'unique': { case 'unique': {
const values = data.map((entry) => entry[field.key as T]); const values = data.map((entry) => entry[field.key]);
const taken = new Set(); // Set of items used at least once const taken = new Set(); // Set of items used at least once
const duplicates = new Set(); // Set of items used multiple times const duplicates = new Set(); // Set of items used multiple times
@ -87,9 +87,9 @@ export const addErrorsAndRunHooks = <T extends string>(
case 'required': { case 'required': {
data.forEach((entry, index) => { data.forEach((entry, index) => {
if ( if (
entry[field.key as T] === null || entry[field.key] === null ||
entry[field.key as T] === undefined || entry[field.key] === undefined ||
entry[field.key as T] === '' entry[field.key] === ''
) { ) {
errors[index] = { errors[index] = {
...errors[index], ...errors[index],
@ -156,14 +156,17 @@ export const addErrorsAndRunHooks = <T extends string>(
if (!('__index' in value)) { if (!('__index' in value)) {
value.__index = v4(); value.__index = v4();
} }
const newValue = value as ImportedStructuredRow<T> & const newValue = value as ImportedStructuredRow &
ImportedStructuredRowMetadata; ImportedStructuredRowMetadata;
if (isDefined(errors[index])) { if (isDefined(errors[index])) {
return { ...newValue, __errors: errors[index] }; return { ...newValue, __errors: errors[index] } as ImportedStructuredRow &
ImportedStructuredRowMetadata;
} }
if (isUndefinedOrNull(errors[index]) && isDefined(value?.__errors)) { if (isUndefinedOrNull(errors[index]) && isDefined(value?.__errors)) {
return { ...newValue, __errors: null }; return { ...newValue, __errors: null } as ImportedStructuredRow &
ImportedStructuredRowMetadata;
} }
return newValue; return newValue;
}); });

View File

@ -1,30 +0,0 @@
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import lavenstein from 'js-levenshtein';
type AutoMatchAccumulator<T> = {
distance: number;
value: T;
};
export const findMatch = <T extends string>(
header: string,
fields: SpreadsheetImportFields<T>,
autoMapDistance: number,
): T | undefined => {
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((acc, field) => {
const distance = Math.min(
...[
lavenstein(field.key, header),
...(field.alternateMatches?.map((alternate) =>
lavenstein(alternate, header),
) || []),
],
);
return distance < acc.distance || acc.distance === undefined
? ({ value: field.key, distance } as AutoMatchAccumulator<T>)
: acc;
}, {} as AutoMatchAccumulator<T>);
return smallestValue.distance <= autoMapDistance
? smallestValue.value
: undefined;
};

View File

@ -1,9 +1,9 @@
import { SpreadsheetImportFields } from '@/spreadsheet-import/types'; import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns'; import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
export const findUnmatchedRequiredFields = <T extends string>( export const findUnmatchedRequiredFields = (
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields,
columns: SpreadsheetColumns<T>, columns: SpreadsheetColumns,
) => ) =>
fields fields
.filter((field) => .filter((field) =>

View File

@ -1,7 +1,7 @@
import { SpreadsheetImportFields } from '@/spreadsheet-import/types'; import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
export const getFieldOptions = <T extends string>( export const getFieldOptions = (
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields,
fieldKey: string, fieldKey: string,
) => { ) => {
const field = fields.find(({ key }) => fieldKey === key); const field = fields.find(({ key }) => fieldKey === key);

View File

@ -1,52 +0,0 @@
import lavenstein from 'js-levenshtein';
import { MatchColumnsStepProps } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import {
SpreadsheetImportField,
SpreadsheetImportFields,
} from '@/spreadsheet-import/types';
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { isDefined } from 'twenty-shared/utils';
import { findMatch } from './findMatch';
import { setColumn } from './setColumn';
export const getMatchedColumns = <T extends string>(
columns: SpreadsheetColumns<T>,
fields: SpreadsheetImportFields<T>,
data: MatchColumnsStepProps['data'],
autoMapDistance: number,
) =>
columns.reduce<SpreadsheetColumn<T>[]>((arr, column) => {
const autoMatch = findMatch(column.header, fields, autoMapDistance);
if (isDefined(autoMatch)) {
const field = fields.find(
(field) => field.key === autoMatch,
) as SpreadsheetImportField<T>;
const duplicateIndex = arr.findIndex(
(column) => 'value' in column && column.value === field.key,
);
const duplicate = arr[duplicateIndex];
if (duplicate && 'value' in duplicate) {
return lavenstein(duplicate.value, duplicate.header) <
lavenstein(autoMatch, column.header)
? [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex], field, data),
...arr.slice(duplicateIndex + 1),
setColumn(column),
]
: [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex]),
...arr.slice(duplicateIndex + 1),
setColumn(column, field, data),
];
} else {
return [...arr, setColumn(column, field, data)];
}
} else {
return [...arr, column];
}
}, []);

View File

@ -11,16 +11,16 @@ import { setColumn } from '@/spreadsheet-import/utils/setColumn';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
export const getMatchedColumnsWithFuse = <T extends string>({ export const getMatchedColumnsWithFuse = ({
columns, columns,
fields, fields,
data, data,
}: { }: {
columns: SpreadsheetColumns<T>; columns: SpreadsheetColumns;
fields: SpreadsheetImportFields<T>; fields: SpreadsheetImportFields;
data: MatchColumnsStepProps['data']; data: MatchColumnsStepProps['data'];
}) => { }) => {
const matchedColumns: SpreadsheetColumn<T>[] = []; const matchedColumns: SpreadsheetColumn[] = [];
const fieldsToSearch = new Fuse(fields, { const fieldsToSearch = new Fuse(fields, {
keys: ['label'], keys: ['label'],
@ -30,8 +30,8 @@ export const getMatchedColumnsWithFuse = <T extends string>({
}); });
const suggestedFieldsByColumnHeader: Record< const suggestedFieldsByColumnHeader: Record<
SpreadsheetColumn<T>['header'], SpreadsheetColumn['header'],
SpreadsheetImportField<T>[] SpreadsheetImportField[]
> = {}; > = {};
for (const column of columns) { for (const column of columns) {
@ -58,7 +58,7 @@ export const getMatchedColumnsWithFuse = <T extends string>({
); );
suggestedFieldsByColumnHeader[column.header] = fieldsThatMatch.map( suggestedFieldsByColumnHeader[column.header] = fieldsThatMatch.map(
(match) => match.item as SpreadsheetImportField<T>, (match) => match.item as SpreadsheetImportField,
); );
if (isFirstMatchValid && isFieldStillUnmatched) { if (isFirstMatchValid && isFieldStillUnmatched) {

View File

@ -0,0 +1,3 @@
export const getShortNestedFieldLabel = (label: string) => {
return label.split(' / ').slice(1).join(' / ');
};

View File

@ -9,10 +9,10 @@ import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod'; import { z } from 'zod';
import { normalizeCheckboxValue } from './normalizeCheckboxValue'; import { normalizeCheckboxValue } from './normalizeCheckboxValue';
export const normalizeTableData = <T extends string>( export const normalizeTableData = (
columns: SpreadsheetColumns<T>, columns: SpreadsheetColumns,
data: ImportedRow[], data: ImportedRow[],
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields,
) => ) =>
data.map((row) => data.map((row) =>
columns.reduce((acc, column, index) => { columns.reduce((acc, column, index) => {
@ -101,5 +101,5 @@ export const normalizeTableData = <T extends string>(
default: default:
return acc; return acc;
} }
}, {} as ImportedStructuredRow<T>), }, {} as ImportedStructuredRow),
); );

View File

@ -9,17 +9,17 @@ import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod'; import { z } from 'zod';
import { uniqueEntries } from './uniqueEntries'; import { uniqueEntries } from './uniqueEntries';
export const setColumn = <T extends string>( export const setColumn = (
oldColumn: SpreadsheetColumn<T>, oldColumn: SpreadsheetColumn,
field?: SpreadsheetImportField<T>, field?: SpreadsheetImportField,
data?: MatchColumnsStepProps['data'], data?: MatchColumnsStepProps['data'],
): SpreadsheetColumn<T> => { ): SpreadsheetColumn => {
if (field?.fieldType.type === 'select') { if (field?.fieldType.type === 'select') {
const fieldOptions = field.fieldType.options; const fieldOptions = field.fieldType.options;
const uniqueData = uniqueEntries( const uniqueData = uniqueEntries(
data || [], data || [],
oldColumn.index, oldColumn.index,
) as SpreadsheetMatchedOptions<T>[]; ) as SpreadsheetMatchedOptions[];
const matchedOptions = uniqueData.map((record) => { const matchedOptions = uniqueData.map((record) => {
const value = fieldOptions.find( const value = fieldOptions.find(
@ -28,8 +28,8 @@ export const setColumn = <T extends string>(
fieldOption.label === record.entry, fieldOption.label === record.entry,
)?.value; )?.value;
return value return value
? ({ ...record, value } as SpreadsheetMatchedOptions<T>) ? ({ ...record, value } as SpreadsheetMatchedOptions)
: (record as SpreadsheetMatchedOptions<T>); : (record as SpreadsheetMatchedOptions);
}); });
const allMatched = const allMatched =
matchedOptions.filter((o) => o.value).length === uniqueData?.length; matchedOptions.filter((o) => o.value).length === uniqueData?.length;
@ -77,8 +77,8 @@ export const setColumn = <T extends string>(
fieldOption.value === entry || fieldOption.label === entry, fieldOption.value === entry || fieldOption.label === entry,
)?.value; )?.value;
return value return value
? ({ entry, value } as SpreadsheetMatchedOptions<T>) ? ({ entry, value } as SpreadsheetMatchedOptions)
: ({ entry } as SpreadsheetMatchedOptions<T>); : ({ entry } as SpreadsheetMatchedOptions);
}); });
const areAllMatched = const areAllMatched =
matchedOptions.filter((option) => option.value).length === matchedOptions.filter((option) => option.value).length ===

View File

@ -1,10 +1,10 @@
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn'; import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
export const setIgnoreColumn = <T extends string>({ export const setIgnoreColumn = ({
header, header,
index, index,
}: SpreadsheetColumn<T>): SpreadsheetColumn<T> => ({ }: SpreadsheetColumn): SpreadsheetColumn => ({
header, header,
index, index,
type: SpreadsheetColumnType.ignored, type: SpreadsheetColumnType.ignored,

View File

@ -5,15 +5,13 @@ import {
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions'; import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
export const setSubColumn = <T>( export const setSubColumn = (
oldColumn: oldColumn:
| SpreadsheetMatchedSelectColumn<T> | SpreadsheetMatchedSelectColumn
| SpreadsheetMatchedSelectOptionsColumn<T>, | SpreadsheetMatchedSelectOptionsColumn,
entry: string, entry: string,
value: string, value: string,
): ): SpreadsheetMatchedSelectColumn | SpreadsheetMatchedSelectOptionsColumn => {
| SpreadsheetMatchedSelectColumn<T>
| SpreadsheetMatchedSelectOptionsColumn<T> => {
const shouldUnselectValue = const shouldUnselectValue =
oldColumn.matchedOptions.find((option) => option.entry === entry)?.value === oldColumn.matchedOptions.find((option) => option.entry === entry)?.value ===
value; value;
@ -28,13 +26,13 @@ export const setSubColumn = <T>(
if (allMatched) { if (allMatched) {
return { return {
...oldColumn, ...oldColumn,
matchedOptions: options as SpreadsheetMatchedOptions<T>[], matchedOptions: options as SpreadsheetMatchedOptions[],
type: SpreadsheetColumnType.matchedSelectOptions, type: SpreadsheetColumnType.matchedSelectOptions,
}; };
} else { } else {
return { return {
...oldColumn, ...oldColumn,
matchedOptions: options as SpreadsheetMatchedOptions<T>[], matchedOptions: options as SpreadsheetMatchedOptions[],
type: SpreadsheetColumnType.matchedSelect, type: SpreadsheetColumnType.matchedSelect,
}; };
} }

View File

@ -1,29 +0,0 @@
import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { FieldMetadataType } from 'twenty-shared/types';
export const spreadsheetBuildFieldOptions = <T extends string>(
fields: SpreadsheetImportFields<T>,
columns: SpreadsheetColumns<string>,
) => {
return fields
.filter((field) => field.fieldMetadataType !== FieldMetadataType.RICH_TEXT)
.map(({ Icon, label, key, fieldMetadataType }) => {
const isSelected =
columns.findIndex((column) => {
if ('value' in column) {
return column.value === key;
}
return false;
}) !== -1;
return {
Icon: Icon,
value: key,
label: label,
disabled: isSelected,
fieldMetadataTypeLabel: getFieldMetadataTypeLabel(fieldMetadataType),
} as const;
});
};

View File

@ -0,0 +1,42 @@
import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { SpreadsheetImportFieldOption } from '@/spreadsheet-import/types/SpreadsheetImportFieldOption';
import { getShortNestedFieldLabel } from '@/spreadsheet-import/utils/getShortNestedFieldLabel';
import { ReadonlyDeep } from 'type-fest';
export const spreadsheetImportBuildFieldOptions = (
fields: SpreadsheetImportFields,
columns: SpreadsheetColumns,
): readonly ReadonlyDeep<SpreadsheetImportFieldOption>[] => {
return fields.map(
({
Icon,
label,
key,
fieldMetadataType,
isNestedField,
fieldMetadataItemId,
}) => {
const isSelected = columns.some((column) => {
if ('value' in column) {
return column.value === key;
}
return false;
});
return {
Icon: Icon,
value: key,
label,
shortLabelForNestedField: isNestedField
? getShortNestedFieldLabel(label)
: undefined,
disabled: isSelected,
fieldMetadataTypeLabel: getFieldMetadataTypeLabel(fieldMetadataType),
isNestedField: isNestedField,
fieldMetadataItemId: fieldMetadataItemId,
};
},
);
};

View File

@ -0,0 +1,14 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SpreadsheetImportFieldOption } from '@/spreadsheet-import/types/SpreadsheetImportFieldOption';
export const getSubFieldOptions = (
fieldMetadataItem: FieldMetadataItem,
options: readonly Readonly<SpreadsheetImportFieldOption>[],
searchFilter: string,
): readonly Readonly<SpreadsheetImportFieldOption>[] => {
return options.filter(
(option) =>
option.fieldMetadataItemId === fieldMetadataItem.id &&
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
};

View File

@ -0,0 +1,12 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated/graphql';
export const hasNestedFields = (fieldMetadata: FieldMetadataItem) => {
return (
(fieldMetadata.type === FieldMetadataType.RELATION &&
fieldMetadata.relation?.type === RelationType.MANY_TO_ONE) ||
isCompositeFieldType(fieldMetadata.type)
);
};

View File

@ -3,10 +3,10 @@ import uniqBy from 'lodash.uniqby';
import { MatchColumnsStepProps } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; import { MatchColumnsStepProps } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions'; import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
export const uniqueEntries = <T extends string>( export const uniqueEntries = (
data: MatchColumnsStepProps['data'], data: MatchColumnsStepProps['data'],
index: number, index: number,
): Partial<SpreadsheetMatchedOptions<T>>[] => ): Partial<SpreadsheetMatchedOptions>[] =>
uniqBy( uniqBy(
data.map((row) => ({ entry: row[index] })), data.map((row) => ({ entry: row[index] })),
'entry', 'entry',

View File

@ -8,6 +8,7 @@ import {
GraphQLString, GraphQLString,
} from 'graphql'; } from 'graphql';
import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants'; import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';
import { getUniqueConstraintsFields } from 'twenty-shared/utils';
import { import {
InputTypeDefinition, InputTypeDefinition,
@ -17,7 +18,6 @@ import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-build
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case'; import { pascalCase } from 'src/utils/pascal-case';

View File

@ -10,14 +10,14 @@ export const fullNameCompositeType: CompositeType = {
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
hidden: false, hidden: false,
isRequired: false, isRequired: false,
isIncludedInUniqueConstraint: true, isIncludedInUniqueConstraint: false,
}, },
{ {
name: 'lastName', name: 'lastName',
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
hidden: false, hidden: false,
isRequired: false, isRequired: false,
isIncludedInUniqueConstraint: true, isIncludedInUniqueConstraint: false,
}, },
], ],
}; };

View File

@ -15,6 +15,7 @@ import {
TwentyORMException, TwentyORMException,
TwentyORMExceptionCode, TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util'; import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util';
import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils'; import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils';
@ -196,12 +197,19 @@ export class RelationNestedQueries {
); );
if (recordToConnect.length !== 1) { if (recordToConnect.length !== 1) {
const recordToConnectTotal = recordToConnect.length; const { errorMessage, userFriendlyMessage } =
const connectFieldName = connectQueryConfig.connectFieldName; formatConnectRecordNotFoundErrorMessage(
connectQueryConfig.connectFieldName,
recordToConnect.length,
connectQueryConfig.recordToConnectConditionByEntityIndex[index],
);
throw new TwentyORMException( throw new TwentyORMException(
`Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal}.`, errorMessage,
TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND, TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND,
{
userFriendlyMessage,
},
); );
} }

View File

@ -0,0 +1,21 @@
import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util';
describe('formatConnectRecordNotFoundErrorMessage', () => {
it('should format the error message correctly', () => {
const result = formatConnectRecordNotFoundErrorMessage(
'connectFieldName',
0,
[
['field1', 'value1'],
['field2', 'value2'],
],
);
expect(result).toEqual({
errorMessage:
'Expected 1 record to connect to connectFieldName, but found 0 for field1 = value1 and field2 = value2',
userFriendlyMessage:
'Expected 1 record to connect to connectFieldName, but found 0 for field1 = value1 and field2 = value2',
});
});
});

View File

@ -0,0 +1,18 @@
import { t } from '@lingui/core/macro';
import { UniqueConstraintCondition } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
export const formatConnectRecordNotFoundErrorMessage = (
connectFieldName: string,
recordToConnectTotal: number,
uniqueConstraint: UniqueConstraintCondition,
) => {
const formattedConnectCondition = uniqueConstraint
.map(([field, value]) => `${field} = ${value}`)
.join(' and ');
return {
errorMessage: `Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal} for ${formattedConnectCondition}`,
userFriendlyMessage: t`Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal} for ${formattedConnectCondition}`,
};
};

View File

@ -1,13 +1,12 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { getUniqueConstraintsFields, isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';

View File

@ -336,7 +336,7 @@ describe('relation connect in workspace createOne/createMany resolvers (e2e)',
expect(response.body.errors).toBeDefined(); expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe( expect(response.body.errors[0].message).toBe(
'Expected 1 record to connect to company, but found 0.', 'Expected 1 record to connect to company, but found 0 for domainNamePrimaryLinkUrl = not-existing-company',
); );
expect(response.body.errors[0].extensions.code).toBe( expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT, ErrorCode.BAD_USER_INPUT,

View File

@ -15,6 +15,7 @@ export {
sanitizeURL, sanitizeURL,
getLogoUrlFromDomainName, getLogoUrlFromDomainName,
} from './image/getLogoUrlFromDomainName'; } from './image/getLogoUrlFromDomainName';
export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraintsFields';
export { parseJson } from './parseJson'; export { parseJson } from './parseJson';
export { removeUndefinedFields } from './removeUndefinedFields'; export { removeUndefinedFields } from './removeUndefinedFields';
export { getGenericOperationName } from './sentry/getGenericOperationName'; export { getGenericOperationName } from './sentry/getGenericOperationName';

View File

@ -1,6 +1,5 @@
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from '@/types';
import { getUniqueConstraintsFields } from '@/utils/indexMetadata/getUniqueConstraintsFields';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
describe('getUniqueConstraintsFields', () => { describe('getUniqueConstraintsFields', () => {
const mockIdField = { const mockIdField = {

View File

@ -1,4 +1,4 @@
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from '@/utils/validation/isDefined';
export const getUniqueConstraintsFields = < export const getUniqueConstraintsFields = <
K extends { K extends {

View File

@ -0,0 +1 @@
export * from './getUniqueConstraintsFields';