Custom object import csv (#3756)
* poc custom object import csv * fix fullname * lint * add relation Ids, fix label full name, add simple test * mock missing fields? * - fix test * validate uuid, fix key in column dropdown, don't save non set composite fields, allow only import relations where toRelationMetadata
This commit is contained in:
@ -1,83 +0,0 @@
|
|||||||
import { Company } from '@/companies/types/Company';
|
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
|
||||||
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
|
|
||||||
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
|
||||||
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
||||||
|
|
||||||
import { fieldsForCompany } from '../utils/fieldsForCompany';
|
|
||||||
|
|
||||||
export type FieldCompanyMapping = (typeof fieldsForCompany)[number]['key'];
|
|
||||||
|
|
||||||
export const useSpreadsheetCompanyImport = () => {
|
|
||||||
const { openSpreadsheetImport } = useSpreadsheetImport<FieldCompanyMapping>();
|
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
|
||||||
|
|
||||||
const { createManyRecords: createManyCompanies } =
|
|
||||||
useCreateManyRecords<Company>({
|
|
||||||
objectNameSingular: CoreObjectNameSingular.Company,
|
|
||||||
});
|
|
||||||
|
|
||||||
const openCompanySpreadsheetImport = (
|
|
||||||
options?: Omit<
|
|
||||||
SpreadsheetOptions<FieldCompanyMapping>,
|
|
||||||
'fields' | 'isOpen' | 'onClose'
|
|
||||||
>,
|
|
||||||
) => {
|
|
||||||
openSpreadsheetImport({
|
|
||||||
...options,
|
|
||||||
onSubmit: async (data) => {
|
|
||||||
// TODO: Add better type checking in spreadsheet import later
|
|
||||||
const createInputs = data.validData.map(
|
|
||||||
(company) =>
|
|
||||||
({
|
|
||||||
name: company.name as string | undefined,
|
|
||||||
domainName: company.domainName as string | undefined,
|
|
||||||
...(company.linkedinUrl
|
|
||||||
? {
|
|
||||||
linkedinLink: {
|
|
||||||
label: 'linkedinUrl',
|
|
||||||
url: company.linkedinUrl as string | undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(company.annualRecurringRevenue
|
|
||||||
? {
|
|
||||||
annualRecurringRevenue: {
|
|
||||||
amountMicros: Number(company.annualRecurringRevenue),
|
|
||||||
currencyCode: 'USD',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
idealCustomerProfile:
|
|
||||||
company.idealCustomerProfile &&
|
|
||||||
['true', true].includes(company.idealCustomerProfile),
|
|
||||||
...(company.xUrl
|
|
||||||
? {
|
|
||||||
xLink: {
|
|
||||||
label: 'xUrl',
|
|
||||||
url: company.xUrl as string | undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
address: company.address as string | undefined,
|
|
||||||
employees: company.employees
|
|
||||||
? Number(company.employees)
|
|
||||||
: undefined,
|
|
||||||
}) as Company,
|
|
||||||
);
|
|
||||||
// TODO: abstract this part for any object
|
|
||||||
try {
|
|
||||||
await createManyCompanies(createInputs);
|
|
||||||
} catch (error: any) {
|
|
||||||
enqueueSnackBar(error?.message || 'Something went wrong', {
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fields: fieldsForCompany,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { openCompanySpreadsheetImport };
|
|
||||||
};
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import {
|
|
||||||
IconBrandLinkedin,
|
|
||||||
IconBrandX,
|
|
||||||
IconBuildingSkyscraper,
|
|
||||||
IconMail,
|
|
||||||
IconMap,
|
|
||||||
IconMoneybag,
|
|
||||||
IconTarget,
|
|
||||||
IconUsers,
|
|
||||||
} from '@/ui/display/icon';
|
|
||||||
|
|
||||||
export const fieldsForCompany = [
|
|
||||||
{
|
|
||||||
icon: IconBuildingSkyscraper,
|
|
||||||
label: 'Name',
|
|
||||||
key: 'name',
|
|
||||||
alternateMatches: ['name', 'company name', 'company'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'Tim',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconMail,
|
|
||||||
label: 'Domain name',
|
|
||||||
key: 'domainName',
|
|
||||||
alternateMatches: ['domain', 'domain name'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'apple.dev',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconBrandLinkedin,
|
|
||||||
label: 'Linkedin URL',
|
|
||||||
key: 'linkedinUrl',
|
|
||||||
alternateMatches: ['linkedIn', 'linkedin', 'linkedin url'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'https://www.linkedin.com/in/apple',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconMoneybag,
|
|
||||||
label: 'ARR',
|
|
||||||
key: 'annualRecurringRevenue',
|
|
||||||
alternateMatches: [
|
|
||||||
'arr',
|
|
||||||
'annual revenue',
|
|
||||||
'revenue',
|
|
||||||
'recurring revenue',
|
|
||||||
'annual recurring revenue',
|
|
||||||
],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
validation: [
|
|
||||||
{
|
|
||||||
regex: /^(\d+)?$/,
|
|
||||||
errorMessage: 'Annual recurring revenue must be a number',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
example: '1000000',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconTarget,
|
|
||||||
label: 'ICP',
|
|
||||||
key: 'idealCustomerProfile',
|
|
||||||
alternateMatches: [
|
|
||||||
'icp',
|
|
||||||
'ideal profile',
|
|
||||||
'ideal customer profile',
|
|
||||||
'ideal customer',
|
|
||||||
],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
validation: [
|
|
||||||
{
|
|
||||||
regex: /^(true|false)?$/,
|
|
||||||
errorMessage: 'Ideal custoner profile must be a boolean',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
example: 'true/false',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconBrandX,
|
|
||||||
label: 'x URL',
|
|
||||||
key: 'xUrl',
|
|
||||||
alternateMatches: ['x', 'twitter', 'twitter url', 'x url'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'https://x.com/tim_cook',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconMap,
|
|
||||||
label: 'Address',
|
|
||||||
key: 'address',
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'Maple street',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconUsers,
|
|
||||||
label: 'Employees',
|
|
||||||
key: 'employees',
|
|
||||||
alternateMatches: ['employees', 'total employees', 'number of employees'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
validation: [
|
|
||||||
{
|
|
||||||
regex: /^\d+$/,
|
|
||||||
errorMessage: 'Employees must be a number',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
example: '150',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
@ -5,8 +5,8 @@ import { Key } from 'ts-key-enum';
|
|||||||
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
|
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
|
||||||
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
|
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
|
||||||
import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable';
|
import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable';
|
||||||
import { useRecordIndexOptionsImport } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsImport';
|
|
||||||
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
||||||
|
import { useSpreadsheetRecordImport } from '@/object-record/spreadsheet-import/useSpreadsheetRecordImport';
|
||||||
import {
|
import {
|
||||||
IconBaselineDensitySmall,
|
IconBaselineDensitySmall,
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
@ -116,7 +116,8 @@ export const RecordIndexOptionsDropdownContent = ({
|
|||||||
? handleBoardFieldVisibilityChange
|
? handleBoardFieldVisibilityChange
|
||||||
: handleColumnVisibilityChange;
|
: handleColumnVisibilityChange;
|
||||||
|
|
||||||
const { handleImport } = useRecordIndexOptionsImport({ objectNameSingular });
|
const { openRecordSpreadsheetImport } =
|
||||||
|
useSpreadsheetRecordImport(objectNameSingular);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -141,13 +142,11 @@ export const RecordIndexOptionsDropdownContent = ({
|
|||||||
LeftIcon={IconTag}
|
LeftIcon={IconTag}
|
||||||
text="Fields"
|
text="Fields"
|
||||||
/>
|
/>
|
||||||
{handleImport && (
|
<MenuItem
|
||||||
<MenuItem
|
onClick={() => openRecordSpreadsheetImport()}
|
||||||
onClick={() => handleImport()}
|
LeftIcon={IconFileImport}
|
||||||
LeftIcon={IconFileImport}
|
text="Import"
|
||||||
text="Import"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport';
|
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
|
||||||
import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport';
|
|
||||||
|
|
||||||
type useRecordIndexOptionsImportParams = {
|
|
||||||
objectNameSingular: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRecordIndexOptionsImport = ({
|
|
||||||
objectNameSingular,
|
|
||||||
}: useRecordIndexOptionsImportParams) => {
|
|
||||||
const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport();
|
|
||||||
const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport();
|
|
||||||
|
|
||||||
const handleImport =
|
|
||||||
CoreObjectNameSingular.Company === objectNameSingular
|
|
||||||
? openCompanySpreadsheetImport
|
|
||||||
: CoreObjectNameSingular.Person === objectNameSingular
|
|
||||||
? openPersonSpreadsheetImport
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { handleImport };
|
|
||||||
};
|
|
||||||
@ -4,10 +4,11 @@ import { MockedProvider } from '@apollo/client/testing';
|
|||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState';
|
import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState';
|
||||||
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
|
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
|
||||||
|
|
||||||
import { useSpreadsheetCompanyImport } from '../useSpreadsheetCompanyImport';
|
import { useSpreadsheetRecordImport } from '../useSpreadsheetRecordImport';
|
||||||
|
|
||||||
const companyId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a';
|
const companyId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a';
|
||||||
|
|
||||||
@ -32,11 +33,11 @@ const companyMocks = [
|
|||||||
variables: {
|
variables: {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
name: 'Example Company',
|
address: 'test',
|
||||||
domainName: 'example.com',
|
domainName: 'example.com',
|
||||||
|
employees: 0,
|
||||||
idealCustomerProfile: true,
|
idealCustomerProfile: true,
|
||||||
address: undefined,
|
name: 'Example Company',
|
||||||
employees: undefined,
|
|
||||||
id: companyId,
|
id: companyId,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -75,21 +76,23 @@ describe('useSpreadsheetCompanyImport', () => {
|
|||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => {
|
() => {
|
||||||
const spreadsheetImport = useRecoilValue(spreadsheetImportState);
|
const spreadsheetImport = useRecoilValue(spreadsheetImportState);
|
||||||
const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport();
|
const { openRecordSpreadsheetImport } = useSpreadsheetRecordImport(
|
||||||
return { openCompanySpreadsheetImport, spreadsheetImport };
|
CoreObjectNameSingular.Company,
|
||||||
|
);
|
||||||
|
return { openRecordSpreadsheetImport, spreadsheetImport };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { spreadsheetImport, openCompanySpreadsheetImport } = result.current;
|
const { spreadsheetImport, openRecordSpreadsheetImport } = result.current;
|
||||||
|
|
||||||
expect(spreadsheetImport.isOpen).toBe(false);
|
expect(spreadsheetImport.isOpen).toBe(false);
|
||||||
expect(spreadsheetImport.options).toBeNull();
|
expect(spreadsheetImport.options).toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
openCompanySpreadsheetImport();
|
openRecordSpreadsheetImport();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { spreadsheetImport: updatedImport } = result.current;
|
const { spreadsheetImport: updatedImport } = result.current;
|
||||||
@ -109,8 +112,8 @@ describe('useSpreadsheetCompanyImport', () => {
|
|||||||
name: 'Example Company',
|
name: 'Example Company',
|
||||||
domainName: 'example.com',
|
domainName: 'example.com',
|
||||||
idealCustomerProfile: true,
|
idealCustomerProfile: true,
|
||||||
address: undefined,
|
address: 'test',
|
||||||
employees: undefined,
|
employees: '0',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
invalidData: [],
|
invalidData: [],
|
||||||
@ -121,8 +124,8 @@ describe('useSpreadsheetCompanyImport', () => {
|
|||||||
domainName: 'example.com',
|
domainName: 'example.com',
|
||||||
__index: 'cbc3985f-dde9-46d1-bae2-c124141700ac',
|
__index: 'cbc3985f-dde9-46d1-bae2-c124141700ac',
|
||||||
idealCustomerProfile: true,
|
idealCustomerProfile: true,
|
||||||
address: undefined,
|
address: 'test',
|
||||||
employees: undefined,
|
employees: '0',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
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 { SpreadsheetOptions, Validation } from '@/spreadsheet-import/types';
|
||||||
|
import { useIcons } from '@/ui/display/icon/hooks/useIcons';
|
||||||
|
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
|
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 !== 'createdAt' &&
|
||||||
|
(x.type !== FieldMetadataType.Relation || x.toRelationMetadata),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const templateFields: {
|
||||||
|
icon: IconComponent;
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
fieldType: {
|
||||||
|
type: 'input' | 'checkbox';
|
||||||
|
};
|
||||||
|
validations?: Validation[];
|
||||||
|
}[] = [];
|
||||||
|
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 {
|
||||||
|
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:
|
||||||
|
fieldMapping[field.name] = value === 'true' || value === true;
|
||||||
|
break;
|
||||||
|
case FieldMetadataType.Number:
|
||||||
|
case FieldMetadataType.Numeric:
|
||||||
|
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 (value) {
|
||||||
|
fieldMapping[field.name + 'Id'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case FieldMetadataType.FullName:
|
||||||
|
if (
|
||||||
|
record[`${firstName} (${field.name})`] ||
|
||||||
|
record[`${lastName} (${field.name})`]
|
||||||
|
) {
|
||||||
|
fieldMapping[field.name] = {
|
||||||
|
firstName: record[`${firstName} (${field.name})`] || '',
|
||||||
|
lastName: record[`${lastName} (${field.name})`] || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fieldMapping[field.name] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fieldMapping;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await createManyRecords(createInputs);
|
||||||
|
} catch (error: any) {
|
||||||
|
enqueueSnackBar(error?.message || 'Something went wrong', {
|
||||||
|
variant: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fields: templateFields,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { openRecordSpreadsheetImport };
|
||||||
|
};
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
|
||||||
|
import { isValidUuid } from '@/object-record/spreadsheet-import/util/isValidUuid';
|
||||||
|
import { Validation } from '@/spreadsheet-import/types';
|
||||||
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
|
export const getSpreadSheetValidation = (
|
||||||
|
type: FieldMetadataType,
|
||||||
|
fieldName: string,
|
||||||
|
): Validation[] => {
|
||||||
|
switch (type) {
|
||||||
|
case FieldMetadataType.Number:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
rule: 'regex',
|
||||||
|
value: '^d+$',
|
||||||
|
errorMessage: fieldName + ' must be a number',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case FieldMetadataType.Phone:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
rule: 'function',
|
||||||
|
isValid: (value: string) => isValidPhoneNumber(value),
|
||||||
|
errorMessage: fieldName + ' is not valid',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case FieldMetadataType.Relation:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
rule: 'function',
|
||||||
|
isValid: (value: string) => isValidUuid(value),
|
||||||
|
errorMessage: fieldName + ' is not valid',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import { gql } from '@apollo/client';
|
|
||||||
|
|
||||||
export const query = gql`
|
|
||||||
mutation CreatePeople($data: [PersonCreateInput!]!) {
|
|
||||||
createPeople(data: $data) {
|
|
||||||
id
|
|
||||||
opportunities {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xLink {
|
|
||||||
label
|
|
||||||
url
|
|
||||||
}
|
|
||||||
id
|
|
||||||
pointOfContactForOpportunities {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
company {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
city
|
|
||||||
email
|
|
||||||
activityTargets {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobTitle
|
|
||||||
favorites {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
attachments {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
name {
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
}
|
|
||||||
phone
|
|
||||||
linkedinLink {
|
|
||||||
label
|
|
||||||
url
|
|
||||||
}
|
|
||||||
updatedAt
|
|
||||||
avatarUrl
|
|
||||||
companyId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const personId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a';
|
|
||||||
|
|
||||||
export const variables = {
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: personId,
|
|
||||||
name: { firstName: 'Sheldon', lastName: ' Cooper' },
|
|
||||||
email: undefined,
|
|
||||||
jobTitle: undefined,
|
|
||||||
phone: undefined,
|
|
||||||
city: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const responseData = [
|
|
||||||
{
|
|
||||||
opportunities: {
|
|
||||||
edges: [],
|
|
||||||
},
|
|
||||||
xLink: {
|
|
||||||
label: '',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
pointOfContactForOpportunities: {
|
|
||||||
edges: [],
|
|
||||||
},
|
|
||||||
createdAt: '',
|
|
||||||
company: {
|
|
||||||
id: '',
|
|
||||||
},
|
|
||||||
city: '',
|
|
||||||
email: '',
|
|
||||||
activityTargets: {
|
|
||||||
edges: [],
|
|
||||||
},
|
|
||||||
jobTitle: '',
|
|
||||||
favorites: {
|
|
||||||
edges: [],
|
|
||||||
},
|
|
||||||
attachments: {
|
|
||||||
edges: [],
|
|
||||||
},
|
|
||||||
name: variables.data[0].name,
|
|
||||||
phone: '',
|
|
||||||
linkedinLink: {
|
|
||||||
label: '',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
updatedAt: '',
|
|
||||||
avatarUrl: '',
|
|
||||||
companyId: '',
|
|
||||||
id: personId,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import { MockedProvider } from '@apollo/client/testing';
|
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
|
||||||
|
|
||||||
import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState';
|
|
||||||
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
|
|
||||||
|
|
||||||
import {
|
|
||||||
personId,
|
|
||||||
query,
|
|
||||||
responseData,
|
|
||||||
variables,
|
|
||||||
} from '../__mocks__/useSpreadsheetPersonImport';
|
|
||||||
import { useSpreadsheetPersonImport } from '../useSpreadsheetPersonImport';
|
|
||||||
|
|
||||||
jest.mock('uuid', () => ({
|
|
||||||
v4: jest.fn(() => personId),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mocks = [
|
|
||||||
{
|
|
||||||
request: {
|
|
||||||
query,
|
|
||||||
variables,
|
|
||||||
},
|
|
||||||
result: jest.fn(() => ({
|
|
||||||
data: {
|
|
||||||
createPeople: responseData,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
||||||
<RecoilRoot>
|
|
||||||
<MockedProvider mocks={mocks} addTypename={false}>
|
|
||||||
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
|
|
||||||
{children}
|
|
||||||
</SnackBarProviderScope>
|
|
||||||
</MockedProvider>
|
|
||||||
</RecoilRoot>
|
|
||||||
);
|
|
||||||
|
|
||||||
const fakeCsv = () => {
|
|
||||||
const csvContent = 'firstname, lastname\nSheldon, Cooper';
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
|
||||||
return new File([blob], 'fakeData.csv', { type: 'text/csv' });
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('useSpreadsheetPersonImport', () => {
|
|
||||||
it('should work as expected', async () => {
|
|
||||||
const { result } = renderHook(
|
|
||||||
() => {
|
|
||||||
const spreadsheetImport = useRecoilValue(spreadsheetImportState);
|
|
||||||
const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport();
|
|
||||||
return { openPersonSpreadsheetImport, spreadsheetImport };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
wrapper: Wrapper,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { spreadsheetImport, openPersonSpreadsheetImport } = result.current;
|
|
||||||
|
|
||||||
expect(spreadsheetImport.isOpen).toBe(false);
|
|
||||||
expect(spreadsheetImport.options).toBeNull();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
openPersonSpreadsheetImport();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { spreadsheetImport: updatedImport } = 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);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
updatedImport.options?.onSubmit(
|
|
||||||
{
|
|
||||||
validData: [
|
|
||||||
{
|
|
||||||
firstName: 'Sheldon',
|
|
||||||
lastName: ' Cooper',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
invalidData: [],
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
firstName: 'Sheldon',
|
|
||||||
lastName: ' Cooper',
|
|
||||||
__index: 'cbc3985f-dde9-46d1-bae2-c124141700ac',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fakeCsv(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mocks[0].result).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { v4 } from 'uuid';
|
|
||||||
|
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
|
||||||
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
|
|
||||||
import { Person } from '@/people/types/Person';
|
|
||||||
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
|
||||||
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
||||||
|
|
||||||
import { fieldsForPerson } from '../utils/fieldsForPerson';
|
|
||||||
|
|
||||||
export type FieldPersonMapping = (typeof fieldsForPerson)[number]['key'];
|
|
||||||
|
|
||||||
export const useSpreadsheetPersonImport = () => {
|
|
||||||
const { openSpreadsheetImport } = useSpreadsheetImport<FieldPersonMapping>();
|
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
|
||||||
|
|
||||||
const { createManyRecords: createManyPeople } = useCreateManyRecords<Person>({
|
|
||||||
objectNameSingular: CoreObjectNameSingular.Person,
|
|
||||||
});
|
|
||||||
|
|
||||||
const openPersonSpreadsheetImport = (
|
|
||||||
options?: Omit<
|
|
||||||
SpreadsheetOptions<FieldPersonMapping>,
|
|
||||||
'fields' | 'isOpen' | 'onClose'
|
|
||||||
>,
|
|
||||||
) => {
|
|
||||||
openSpreadsheetImport({
|
|
||||||
...options,
|
|
||||||
onSubmit: async (data) => {
|
|
||||||
// TODO: Add better type checking in spreadsheet import later
|
|
||||||
const createInputs = data.validData.map(
|
|
||||||
(person) =>
|
|
||||||
({
|
|
||||||
id: v4(),
|
|
||||||
name: {
|
|
||||||
firstName: person.firstName as string | undefined,
|
|
||||||
lastName: person.lastName as string | undefined,
|
|
||||||
},
|
|
||||||
email: person.email as string | undefined,
|
|
||||||
...(person.linkedinUrl
|
|
||||||
? {
|
|
||||||
linkedinLink: {
|
|
||||||
label: 'linkedinUrl',
|
|
||||||
url: person.linkedinUrl as string | undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(person.xUrl
|
|
||||||
? {
|
|
||||||
xLink: {
|
|
||||||
label: 'xUrl',
|
|
||||||
url: person.xUrl as string | undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
jobTitle: person.jobTitle as string | undefined,
|
|
||||||
phone: person.phone as string | undefined,
|
|
||||||
city: person.city as string | undefined,
|
|
||||||
}) as Person,
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: abstract this part for any object
|
|
||||||
try {
|
|
||||||
await createManyPeople(createInputs);
|
|
||||||
} catch (error: any) {
|
|
||||||
enqueueSnackBar(error?.message || 'Something went wrong', {
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fields: fieldsForPerson,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { openPersonSpreadsheetImport };
|
|
||||||
};
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
|
||||||
|
|
||||||
import { Fields } from '@/spreadsheet-import/types';
|
|
||||||
import {
|
|
||||||
IconBrandLinkedin,
|
|
||||||
IconBrandX,
|
|
||||||
IconBriefcase,
|
|
||||||
IconMail,
|
|
||||||
IconMap,
|
|
||||||
IconUser,
|
|
||||||
} from '@/ui/display/icon';
|
|
||||||
|
|
||||||
export const fieldsForPerson = [
|
|
||||||
{
|
|
||||||
icon: IconUser,
|
|
||||||
label: 'Firstname',
|
|
||||||
key: 'firstName',
|
|
||||||
alternateMatches: ['first name', 'first', 'firstname'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'Tim',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconUser,
|
|
||||||
label: 'Lastname',
|
|
||||||
key: 'lastName',
|
|
||||||
alternateMatches: ['last name', 'last', 'lastname'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'Cook',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconMail,
|
|
||||||
label: 'Email',
|
|
||||||
key: 'email',
|
|
||||||
alternateMatches: ['email', 'mail'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'tim@apple.dev',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconBrandLinkedin,
|
|
||||||
label: 'Linkedin URL',
|
|
||||||
key: 'linkedinUrl',
|
|
||||||
alternateMatches: ['linkedIn', 'linkedin', 'linkedin url'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'https://www.linkedin.com/in/timcook',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconBrandX,
|
|
||||||
label: 'X URL',
|
|
||||||
key: 'xUrl',
|
|
||||||
alternateMatches: ['x', 'x url'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'https://x.com/tim_cook',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconBriefcase,
|
|
||||||
label: 'Job title',
|
|
||||||
key: 'jobTitle',
|
|
||||||
alternateMatches: ['job', 'job title'],
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'CEO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconBriefcase,
|
|
||||||
label: 'Phone',
|
|
||||||
key: 'phone',
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: '+1234567890',
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'function',
|
|
||||||
isValid: (value: string) => isValidPhoneNumber(value),
|
|
||||||
errorMessage: 'phone is not valid',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconMap,
|
|
||||||
label: 'City',
|
|
||||||
key: 'city',
|
|
||||||
fieldType: {
|
|
||||||
type: 'input',
|
|
||||||
},
|
|
||||||
example: 'Seattle',
|
|
||||||
},
|
|
||||||
] as Fields<string>;
|
|
||||||
@ -129,9 +129,8 @@ export const MatchColumnSelect = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItemsContainer hasMaxHeight>
|
<DropdownMenuItemsContainer hasMaxHeight>
|
||||||
{options?.map((option) => (
|
{options?.map((option) => (
|
||||||
<>
|
<React.Fragment key={option.label}>
|
||||||
<MenuItemSelect
|
<MenuItemSelect
|
||||||
key={option.label}
|
|
||||||
selected={value?.label === option.label}
|
selected={value?.label === option.label}
|
||||||
onClick={() => handleChange(option)}
|
onClick={() => handleChange(option)}
|
||||||
disabled={
|
disabled={
|
||||||
@ -152,9 +151,11 @@ export const MatchColumnSelect = ({
|
|||||||
/>,
|
/>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{options?.length === 0 && <MenuItem text="No result" />}
|
{options?.length === 0 && (
|
||||||
|
<MenuItem key="No result" text="No result" />
|
||||||
|
)}
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</StyledFloatingDropdown>,
|
</StyledFloatingDropdown>,
|
||||||
|
|||||||
Reference in New Issue
Block a user