5425 - Introducing support for all Composite Fields Import (#5470)

Adding support for all Composite Fields while using the "import"
functionality. This includes:
- Currency
- Address

Edit : 
- Refactored a lot of types in the spreadsheet import module
- Renamed a lot of functions, hooks and types that were not
self-explanatory enough

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Aryan Singh
2024-07-23 21:32:23 +05:30
committed by GitHub
parent 2cc0597ee4
commit 5c8fe027f9
46 changed files with 888 additions and 535 deletions

View File

@ -4,7 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { convertCurrencyToCurrencyMicros } from '~/utils/convert-currency-amount';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
import { FieldContext } from '../../contexts/FieldContext';
import { usePersistField } from '../../hooks/usePersistField';
@ -45,7 +45,7 @@ export const useCurrencyField = () => {
const newCurrencyValue = {
amountMicros: isNaN(amount)
? null
: convertCurrencyToCurrencyMicros(amount),
: convertCurrencyAmountToCurrencyMicros(amount),
currencyCode,
};

View File

@ -19,7 +19,7 @@ import {
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { useSpreadsheetRecordImport } from '@/object-record/spreadsheet-import/useSpreadsheetRecordImport';
import { useOpenObjectRecordsSpreasheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
@ -114,8 +114,8 @@ export const RecordIndexOptionsDropdownContent = ({
? handleBoardFieldVisibilityChange
: handleColumnVisibilityChange;
const { openRecordSpreadsheetImport } =
useSpreadsheetRecordImport(objectNameSingular);
const { openObjectRecordsSpreasheetImportDialog } =
useOpenObjectRecordsSpreasheetImportDialog(objectNameSingular);
const { progress, download } = useExportTableData({
delayMs: 100,
@ -135,7 +135,7 @@ export const RecordIndexOptionsDropdownContent = ({
hasSubMenu
/>
<MenuItem
onClick={() => openRecordSpreadsheetImport()}
onClick={() => openObjectRecordsSpreasheetImportDialog()}
LeftIcon={IconFileImport}
text="Import"
/>

View File

@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { json2csv } from 'json-2-csv';
import { useMemo } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useProcessRecordsForCSVExport } from '@/object-record/record-index/options/hooks/useProcessRecordsForCSVExport';
import {
useTableData,
UseTableDataOptions,
@ -66,12 +67,15 @@ export const generateCsv: GenerateExport = ({
.filter(isDefined)
.join(' '),
};
const fieldsWithSubFields = rows.find((row) => {
const fieldValue = (row as any)[column.field];
const hasSubFields =
fieldValue &&
typeof fieldValue === 'object' &&
!Array.isArray(fieldValue);
return hasSubFields;
});
@ -84,8 +88,10 @@ export const generateCsv: GenerateExport = ({
field: `${column.field}.${key}`,
title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`,
}));
return nestedFieldsWithoutTypename;
}
return [column];
});
@ -138,12 +144,17 @@ export const useExportTableData = ({
pageSize = 30,
recordIndexId,
}: UseExportTableDataOptions) => {
const { processRecordsForCSVExport } =
useProcessRecordsForCSVExport(objectNameSingular);
const downloadCsv = useMemo(
() =>
(rows: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
csvDownloader(filename, { rows, columns });
(records: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
const recordsProcessedForExport = processRecordsForCSVExport(records);
csvDownloader(filename, { rows: recordsProcessedForExport, columns });
},
[filename],
[filename, processRecordsForCSVExport],
);
const { getTableData: download, progress } = useTableData({

View File

@ -0,0 +1,39 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { convertCurrencyMicrosToCurrencyAmount } from '~/utils/convertCurrencyToCurrencyMicros';
export const useProcessRecordsForCSVExport = (objectNameSingular: string) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const processRecordsForCSVExport = (records: ObjectRecord[]) => {
return records.map((record) => {
const currencyFields = objectMetadataItem.fields.filter(
(field) => field.type === FieldMetadataType.Currency,
);
const processedRecord = {
...record,
};
for (const currencyField of currencyFields) {
if (isDefined(record[currencyField.name])) {
processedRecord[currencyField.name] = {
amountMicros: convertCurrencyMicrosToCurrencyAmount(
record[currencyField.name].amountMicros,
),
currencyCode: record[currencyField.name].currencyCode,
} satisfies FieldCurrencyValue;
}
}
return processedRecord;
});
};
return { processRecordsForCSVExport };
};

View File

@ -1,14 +1,14 @@
import { ReactNode } from 'react';
import { gql } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { useSpreadsheetRecordImport } from '../useSpreadsheetRecordImport';
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import { useOpenObjectRecordsSpreasheetImportDialog } from '../hooks/useOpenObjectRecordsSpreasheetImportDialog';
const companyId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a';
@ -62,7 +62,6 @@ const companyMocks = [
variables: {
data: [
{
address: 'test',
domainName: 'example.com',
employees: 0,
idealCustomerProfile: true,
@ -94,67 +93,81 @@ const fakeCsv = () => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={companyMocks} addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarManagerScopeInternalContext.Provider
value={{ scopeId: 'snack-bar-manager' }}
>
{children}
</SnackBarProviderScope>
</SnackBarManagerScopeInternalContext.Provider>
</MockedProvider>
</RecoilRoot>
);
// TODO: improve object metadata item seeds to have more field types to add tests on composite fields here
describe('useSpreadsheetCompanyImport', () => {
it('should work as expected', async () => {
const { result } = renderHook(
() => {
const spreadsheetImport = useRecoilValue(spreadsheetImportState);
const { openRecordSpreadsheetImport } = useSpreadsheetRecordImport(
const spreadsheetImportDialog = useRecoilValue(
spreadsheetImportDialogState,
);
const {
openObjectRecordsSpreasheetImportDialog: openRecordSpreadsheetImport,
} = useOpenObjectRecordsSpreasheetImportDialog(
CoreObjectNameSingular.Company,
);
return { openRecordSpreadsheetImport, spreadsheetImport };
return {
openRecordSpreadsheetImport,
spreadsheetImportDialog,
};
},
{
wrapper: Wrapper,
},
);
const { spreadsheetImport, openRecordSpreadsheetImport } = result.current;
const { spreadsheetImportDialog, openRecordSpreadsheetImport } =
result.current;
expect(spreadsheetImport.isOpen).toBe(false);
expect(spreadsheetImport.options).toBeNull();
expect(spreadsheetImportDialog.isOpen).toBe(false);
expect(spreadsheetImportDialog.options).toBeNull();
await act(async () => {
openRecordSpreadsheetImport();
});
const { spreadsheetImport: updatedImport } = result.current;
const { spreadsheetImportDialog: spreadsheetImportDialogAfterOpen } =
result.current;
expect(updatedImport.isOpen).toBe(true);
expect(updatedImport.options).toHaveProperty('onSubmit');
expect(updatedImport.options?.onSubmit).toBeInstanceOf(Function);
expect(updatedImport.options).toHaveProperty('fields');
expect(Array.isArray(updatedImport.options?.fields)).toBe(true);
expect(spreadsheetImportDialogAfterOpen.isOpen).toBe(true);
expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty('onSubmit');
expect(spreadsheetImportDialogAfterOpen.options?.onSubmit).toBeInstanceOf(
Function,
);
expect(spreadsheetImportDialogAfterOpen.options).toHaveProperty('fields');
expect(
Array.isArray(spreadsheetImportDialogAfterOpen.options?.fields),
).toBe(true);
act(() => {
updatedImport.options?.onSubmit(
spreadsheetImportDialogAfterOpen.options?.onSubmit(
{
validData: [
validStructuredRows: [
{
id: companyId,
name: 'Example Company',
domainName: 'example.com',
idealCustomerProfile: true,
address: 'test',
employees: '0',
},
],
invalidData: [],
all: [
invalidStructuredRows: [],
allStructuredRows: [
{
id: companyId,
name: 'Example Company',
domainName: 'example.com',
__index: 'cbc3985f-dde9-46d1-bae2-c124141700ac',
idealCustomerProfile: true,
address: 'test',
employees: '0',
},
],

View File

@ -0,0 +1,28 @@
import {
FieldAddressValue,
FieldCurrencyValue,
FieldFullNameValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { CompositeFieldLabels } from '@/object-record/spreadsheet-import/types/CompositeFieldLabels';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const COMPOSITE_FIELD_IMPORT_LABELS = {
[FieldMetadataType.FullName]: {
firstNameLabel: 'First Name',
lastNameLabel: 'Last Name',
} satisfies CompositeFieldLabels<FieldFullNameValue>,
[FieldMetadataType.Currency]: {
currencyCodeLabel: 'Currency Code',
amountMicrosLabel: 'Amount',
} satisfies CompositeFieldLabels<FieldCurrencyValue>,
[FieldMetadataType.Address]: {
addressStreet1Label: 'Address 1',
addressStreet2Label: 'Address 2',
addressCityLabel: 'City',
addressPostcodeLabel: 'Post Code',
addressStateLabel: 'State',
addressCountryLabel: 'Country',
addressLatLabel: 'Latitude',
addressLngLabel: 'Longitude',
} satisfies CompositeFieldLabels<FieldAddressValue>,
};

View File

@ -0,0 +1,128 @@
import { useIcons } from 'twenty-ui';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport';
import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/util/getSpreadSheetFieldValidationDefinitions';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useBuildAvailableFieldsForImport = () => {
const { getIcon } = useIcons();
const buildAvailableFieldsForImport = (
fieldMetadataItems: FieldMetadataItem[],
) => {
const availableFieldsForImport: AvailableFieldForImport[] = [];
for (const fieldMetadataItem of fieldMetadataItems) {
if (fieldMetadataItem.type === FieldMetadataType.FullName) {
const { firstNameLabel, lastNameLabel } =
COMPOSITE_FIELD_IMPORT_LABELS[FieldMetadataType.FullName];
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: `${firstNameLabel} (${fieldMetadataItem.label})`,
key: `${firstNameLabel} (${fieldMetadataItem.name})`,
fieldType: {
type: 'input',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
`${firstNameLabel} (${fieldMetadataItem.label})`,
),
});
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: `${lastNameLabel} (${fieldMetadataItem.label})`,
key: `${lastNameLabel} (${fieldMetadataItem.name})`,
fieldType: {
type: 'input',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
`${lastNameLabel} (${fieldMetadataItem.label})`,
),
});
} else if (fieldMetadataItem.type === FieldMetadataType.Relation) {
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label + ' (ID)',
key: fieldMetadataItem.name,
fieldType: {
type: 'input',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
fieldMetadataItem.label + ' (ID)',
),
});
} else if (fieldMetadataItem.type === FieldMetadataType.Currency) {
const { currencyCodeLabel, amountMicrosLabel } =
COMPOSITE_FIELD_IMPORT_LABELS[FieldMetadataType.Currency];
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: `${currencyCodeLabel} (${fieldMetadataItem.label})`,
key: `${currencyCodeLabel} (${fieldMetadataItem.name})`,
fieldType: {
type: 'input',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
`${currencyCodeLabel} (${fieldMetadataItem.label})`,
),
});
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: `${amountMicrosLabel} (${fieldMetadataItem.label})`,
key: `${amountMicrosLabel} (${fieldMetadataItem.name})`,
fieldType: {
type: 'input',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
FieldMetadataType.Number,
`${amountMicrosLabel} (${fieldMetadataItem.label})`,
),
});
} else if (fieldMetadataItem.type === FieldMetadataType.Address) {
Object.entries(
COMPOSITE_FIELD_IMPORT_LABELS[FieldMetadataType.Address],
).forEach(([_, fieldLabel]) => {
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: `${fieldLabel} (${fieldMetadataItem.label})`,
key: `${fieldLabel} (${fieldMetadataItem.name})`,
fieldType: {
type: 'input',
},
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
`${fieldLabel} (${fieldMetadataItem.label})`,
),
});
});
} else {
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label,
key: fieldMetadataItem.name,
fieldType: {
type: 'input',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
fieldMetadataItem.label,
),
});
}
}
return availableFieldsForImport;
};
return { buildAvailableFieldsForImport };
};

View File

@ -0,0 +1,78 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/util/buildRecordFromImportedStructuredRow';
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useOpenObjectRecordsSpreasheetImportDialog = (
objectNameSingular: string,
) => {
const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog<any>();
const { enqueueSnackBar } = useSnackBar();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { createManyRecords } = useCreateManyRecords({
objectNameSingular,
});
const { buildAvailableFieldsForImport } = useBuildAvailableFieldsForImport();
const openObjectRecordsSpreasheetImportDialog = (
options?: Omit<
SpreadsheetImportDialogOptions<any>,
'fields' | 'isOpen' | 'onClose'
>,
) => {
const availableFieldMetadataItems = objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isActive &&
(!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') &&
fieldMetadataItem.name !== 'createdAt' &&
(fieldMetadataItem.type !== FieldMetadataType.Relation ||
fieldMetadataItem.toRelationMetadata),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const availableFields = buildAvailableFieldsForImport(
availableFieldMetadataItems,
);
openSpreadsheetImportDialog({
...options,
onSubmit: async (data) => {
const createInputs = data.validStructuredRows.map((record) => {
const fieldMapping: Record<string, any> =
buildRecordFromImportedStructuredRow(
record,
availableFieldMetadataItems,
);
return fieldMapping;
});
try {
await createManyRecords(createInputs, true);
} catch (error: any) {
enqueueSnackBar(error?.message || 'Something went wrong', {
variant: SnackBarVariant.Error,
});
}
},
fields: availableFields,
});
};
return {
openObjectRecordsSpreasheetImportDialog,
};
};

View File

@ -0,0 +1,12 @@
import { FieldValidationDefinition } from '@/spreadsheet-import/types';
import { IconComponent } from 'twenty-ui';
export type AvailableFieldForImport = {
icon: IconComponent;
label: string;
key: string;
fieldType: {
type: 'input' | 'checkbox';
};
fieldValidationDefinitions?: FieldValidationDefinition[];
};

View File

@ -0,0 +1,5 @@
import { KeyOfCompositeField } from '@/object-record/spreadsheet-import/types/KeyOfCompositeField';
export type CompositeFieldLabels<T> = {
[key in `${KeyOfCompositeField<T>}Label`]: string;
};

View File

@ -0,0 +1,3 @@
export type KeyOfCompositeField<T> = keyof Omit<T, '__typename'> extends string
? keyof Omit<T, '__typename'>
: never;

View File

@ -1,202 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useIcons } from 'twenty-ui';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { getSpreadSheetValidation } from '@/object-record/spreadsheet-import/util/getSpreadSheetValidation';
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
import { Field, SpreadsheetOptions } from '@/spreadsheet-import/types';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
const firstName = 'Firstname';
const lastName = 'Lastname';
export const useSpreadsheetRecordImport = (objectNameSingular: string) => {
const { openSpreadsheetImport } = useSpreadsheetImport<any>();
const { enqueueSnackBar } = useSnackBar();
const { getIcon } = useIcons();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const fields = objectMetadataItem.fields
.filter(
(x) =>
x.isActive &&
(!x.isSystem || x.name === 'id') &&
x.name !== 'createdAt' &&
(x.type !== FieldMetadataType.Relation || x.toRelationMetadata),
)
.sort((a, b) => a.name.localeCompare(b.name));
const templateFields: Field<string>[] = [];
for (const field of fields) {
if (field.type === FieldMetadataType.FullName) {
templateFields.push({
icon: getIcon(field.icon),
label: `${firstName} (${field.label})`,
key: `${firstName} (${field.name})`,
fieldType: {
type: 'input',
},
validations: getSpreadSheetValidation(
field.type,
`${firstName} (${field.label})`,
),
});
templateFields.push({
icon: getIcon(field.icon),
label: `${lastName} (${field.label})`,
key: `${lastName} (${field.name})`,
fieldType: {
type: 'input',
},
validations: getSpreadSheetValidation(
field.type,
`${lastName} (${field.label})`,
),
});
} else if (field.type === FieldMetadataType.Relation) {
templateFields.push({
icon: getIcon(field.icon),
label: field.label + ' (ID)',
key: field.name,
fieldType: {
type: 'input',
},
validations: getSpreadSheetValidation(
field.type,
field.label + ' (ID)',
),
});
} else if (field.type === FieldMetadataType.Select) {
templateFields.push({
icon: getIcon(field.icon),
label: field.label,
key: field.name,
fieldType: {
type: 'select',
options:
field.options?.map((option) => ({
label: option.label,
value: option.value,
})) || [],
},
validations: getSpreadSheetValidation(
field.type,
field.label + ' (ID)',
),
});
} else if (field.type === FieldMetadataType.Boolean) {
templateFields.push({
icon: getIcon(field.icon),
label: field.label,
key: field.name,
fieldType: {
type: 'checkbox',
},
validations: getSpreadSheetValidation(field.type, field.label),
});
} else {
templateFields.push({
icon: getIcon(field.icon),
label: field.label,
key: field.name,
fieldType: {
type: 'input',
},
validations: getSpreadSheetValidation(field.type, field.label),
});
}
}
const { createManyRecords } = useCreateManyRecords({
objectNameSingular,
});
const openRecordSpreadsheetImport = (
options?: Omit<SpreadsheetOptions<any>, 'fields' | 'isOpen' | 'onClose'>,
) => {
openSpreadsheetImport({
...options,
onSubmit: async (data) => {
const createInputs = data.validData.map((record) => {
const fieldMapping: Record<string, any> = {};
for (const field of fields) {
const value = record[field.name];
switch (field.type) {
case FieldMetadataType.Boolean:
if (value !== undefined) {
fieldMapping[field.name] = value === 'true' || value === true;
}
break;
case FieldMetadataType.Number:
case FieldMetadataType.Numeric:
if (value !== undefined) {
fieldMapping[field.name] = Number(value);
}
break;
case FieldMetadataType.Currency:
if (value !== undefined) {
fieldMapping[field.name] = {
amountMicros: Number(value),
currencyCode: 'USD',
};
}
break;
case FieldMetadataType.Link:
if (value !== undefined) {
fieldMapping[field.name] = {
label: field.name,
url: value || null,
};
}
break;
case FieldMetadataType.Relation:
if (
isDefined(value) &&
(isNonEmptyString(value) || value !== false)
) {
fieldMapping[field.name + 'Id'] = value;
}
break;
case FieldMetadataType.FullName:
if (
isDefined(
record[`${firstName} (${field.name})`] ||
record[`${lastName} (${field.name})`],
)
) {
fieldMapping[field.name] = {
firstName: record[`${firstName} (${field.name})`] || '',
lastName: record[`${lastName} (${field.name})`] || '',
};
}
break;
default:
if (value !== undefined) {
fieldMapping[field.name] = value;
}
break;
}
}
return fieldMapping;
});
try {
await createManyRecords(createInputs, true);
} catch (error: any) {
enqueueSnackBar(error?.message || 'Something went wrong', {
variant: SnackBarVariant.Error,
});
}
},
fields: templateFields,
});
};
return { openRecordSpreadsheetImport };
};

View File

@ -0,0 +1,147 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { ImportedStructuredRow } from '@/spreadsheet-import/types';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { castToString } from '~/utils/castToString';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
export const buildRecordFromImportedStructuredRow = (
importedStructuredRow: ImportedStructuredRow<any>,
fields: FieldMetadataItem[],
) => {
const recordToBuild: Record<string, any> = {};
const {
ADDRESS: {
addressCityLabel,
addressCountryLabel,
addressLatLabel,
addressLngLabel,
addressPostcodeLabel,
addressStateLabel,
addressStreet1Label,
addressStreet2Label,
},
CURRENCY: { amountMicrosLabel, currencyCodeLabel },
FULL_NAME: { firstNameLabel, lastNameLabel },
} = COMPOSITE_FIELD_IMPORT_LABELS;
for (const field of fields) {
const importedFieldValue = importedStructuredRow[field.name];
switch (field.type) {
case FieldMetadataType.Boolean:
recordToBuild[field.name] =
importedFieldValue === 'true' || importedFieldValue === true;
break;
case FieldMetadataType.Number:
case FieldMetadataType.Numeric:
recordToBuild[field.name] = Number(importedFieldValue);
break;
case FieldMetadataType.Currency:
if (
isDefined(
importedStructuredRow[`${amountMicrosLabel} (${field.name})`],
) ||
isDefined(
importedStructuredRow[`${currencyCodeLabel} (${field.name})`],
)
) {
recordToBuild[field.name] = {
amountMicros: convertCurrencyAmountToCurrencyMicros(
Number(
importedStructuredRow[`${amountMicrosLabel} (${field.name})`],
),
),
currencyCode:
importedStructuredRow[`${currencyCodeLabel} (${field.name})`] ||
'USD',
};
}
break;
case FieldMetadataType.Address: {
if (
isDefined(
importedStructuredRow[`${addressStreet1Label} (${field.name})`] ||
importedStructuredRow[`${addressStreet2Label} (${field.name})`] ||
importedStructuredRow[`${addressCityLabel} (${field.name})`] ||
importedStructuredRow[
`${addressPostcodeLabel} (${field.name})`
] ||
importedStructuredRow[`${addressStateLabel} (${field.name})`] ||
importedStructuredRow[`${addressCountryLabel} (${field.name})`] ||
importedStructuredRow[`${addressLatLabel} (${field.name})`] ||
importedStructuredRow[`${addressLngLabel} (${field.name})`],
)
) {
recordToBuild[field.name] = {
addressStreet1: castToString(
importedStructuredRow[`${addressStreet1Label} (${field.name})`],
),
addressStreet2: castToString(
importedStructuredRow[`${addressStreet2Label} (${field.name})`],
),
addressCity: castToString(
importedStructuredRow[`${addressCityLabel} (${field.name})`],
),
addressPostcode: castToString(
importedStructuredRow[`${addressPostcodeLabel} (${field.name})`],
),
addressState: castToString(
importedStructuredRow[`${addressStateLabel} (${field.name})`],
),
addressCountry: castToString(
importedStructuredRow[`${addressCountryLabel} (${field.name})`],
),
addressLat: Number(
importedStructuredRow[`${addressLatLabel} (${field.name})`],
),
addressLng: Number(
importedStructuredRow[`${addressLngLabel} (${field.name})`],
),
} satisfies FieldAddressValue;
}
break;
}
case FieldMetadataType.Link:
if (importedFieldValue !== undefined) {
recordToBuild[field.name] = {
label: field.name,
url: importedFieldValue || null,
};
}
break;
case FieldMetadataType.Relation:
if (
isDefined(importedFieldValue) &&
(isNonEmptyString(importedFieldValue) || importedFieldValue !== false)
) {
recordToBuild[field.name + 'Id'] = importedFieldValue;
}
break;
case FieldMetadataType.FullName:
if (
isDefined(
importedStructuredRow[`${firstNameLabel} (${field.name})`] ??
importedStructuredRow[`${lastNameLabel} (${field.name})`],
)
) {
recordToBuild[field.name] = {
firstName:
importedStructuredRow[`${firstNameLabel} (${field.name})`] ?? '',
lastName:
importedStructuredRow[`${lastNameLabel} (${field.name})`] ?? '',
};
}
break;
default:
recordToBuild[field.name] = importedFieldValue;
break;
}
}
return recordToBuild;
};

View File

@ -1,14 +1,37 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { isValidUuid } from '@/object-record/spreadsheet-import/util/isValidUuid';
import { Validation } from '@/spreadsheet-import/types';
import { FieldValidationDefinition } from '@/spreadsheet-import/types';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isValidUuid } from '~/utils/isValidUuid';
export const getSpreadSheetValidation = (
export const getSpreadSheetFieldValidationDefinitions = (
type: FieldMetadataType,
fieldName: string,
): Validation[] => {
): FieldValidationDefinition[] => {
switch (type) {
case FieldMetadataType.FullName:
return [
{
rule: 'object',
isValid: ({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) => {
return (
isDefined(firstName) &&
isDefined(lastName) &&
typeof firstName === 'string' &&
typeof lastName === 'string'
);
},
errorMessage: fieldName + ' must be a full name',
level: 'error',
},
];
case FieldMetadataType.Number:
return [
{

View File

@ -1,5 +0,0 @@
export const isValidUuid = (value: string) => {
return /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(
value,
);
};