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:
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
@ -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>,
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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[];
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { KeyOfCompositeField } from '@/object-record/spreadsheet-import/types/KeyOfCompositeField';
|
||||
|
||||
export type CompositeFieldLabels<T> = {
|
||||
[key in `${KeyOfCompositeField<T>}Label`]: string;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export type KeyOfCompositeField<T> = keyof Omit<T, '__typename'> extends string
|
||||
? keyof Omit<T, '__typename'>
|
||||
: never;
|
||||
@ -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 };
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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 [
|
||||
{
|
||||
@ -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,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user