Spreadsheet import front module (#2862)

* Spreadsheet import front module

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Toledodev <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>

* Automatically update table

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Toledodev <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>

* Add company import

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Toledodev <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>

* Fixes

* Hide import options on custom objects

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Toledodev <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
gitstart-twenty 2023-12-09 15:46:01 +05:45 committed by GitHub
parent 7c40dc7b81
commit 306344a190
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 275 additions and 51 deletions

View File

@ -179,4 +179,4 @@
"msw": {
"workerDirectory": "public"
}
}
}

View File

@ -190,9 +190,7 @@ export const CommandMenu = () => {
selectableListId="command-menu-list"
selectableItemIds={[selectableItemIds]}
hotkeyScope={AppHotkeyScope.CommandMenu}
onEnter={(itemId) => {
console.log(itemId);
}}
onEnter={(_itemId) => {}}
>
{!matchingCreateCommand.length &&
!matchingNavigateCommand.length &&

View File

@ -0,0 +1,77 @@
import { Company } from '@/companies/types/Company';
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: '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,
}));
// 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 };
};

View File

@ -8,6 +8,7 @@ import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapTo
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
@ -93,6 +94,10 @@ export const useObjectMetadataItem = (
objectMetadataItem,
});
const createManyRecordsMutation = useGenerateCreateManyRecordMutation({
objectMetadataItem,
});
const updateOneRecordMutation = useGenerateUpdateOneRecordMutation({
objectMetadataItem,
});
@ -118,6 +123,7 @@ export const useObjectMetadataItem = (
createOneRecordMutation,
updateOneRecordMutation,
deleteOneRecordMutation,
createManyRecordsMutation,
mapToObjectRecordIdentifier,
getObjectOrderByField,
};

View File

@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
@ -8,6 +9,8 @@ import { RecordTable } from '@/object-record/record-table/components/RecordTable
import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown';
import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ViewBar } from '@/views/components/ViewBar';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
@ -44,6 +47,9 @@ export const RecordTableContainer = ({
objectNameSingular,
});
const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport();
const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport();
const recordTableId = objectNamePlural ?? '';
const viewBarId = objectNamePlural ?? '';
@ -67,26 +73,43 @@ export const RecordTableContainer = ({
});
};
const handleImport = () => {
const openImport =
recordTableId === 'companies'
? openCompanySpreadsheetImport
: openPersonSpreadsheetImport;
openImport();
};
return (
<StyledContainer>
<ViewBar
viewBarId={viewBarId}
optionsDropdownButton={
<TableOptionsDropdown recordTableId={recordTableId} />
}
optionsDropdownScopeId={TableOptionsDropdownId}
onViewFieldsChange={(viewFields) => {
setTableColumns(
mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions),
);
}}
onViewFiltersChange={(viewFilters) => {
setTableFilters(mapViewFiltersToFilters(viewFilters));
}}
onViewSortsChange={(viewSorts) => {
setTableSorts(mapViewSortsToSorts(viewSorts));
}}
/>
<SpreadsheetImportProvider>
<ViewBar
viewBarId={viewBarId}
optionsDropdownButton={
<TableOptionsDropdown
recordTableId={recordTableId}
onImport={
['companies', 'people'].includes(recordTableId)
? handleImport
: undefined
}
/>
}
optionsDropdownScopeId={TableOptionsDropdownId}
onViewFieldsChange={(viewFields) => {
setTableColumns(
mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions),
);
}}
onViewFiltersChange={(viewFilters) => {
setTableFilters(mapViewFiltersToFilters(viewFilters));
}}
onViewSortsChange={(viewSorts) => {
setTableSorts(mapViewSortsToSorts(viewSorts));
}}
/>
</SpreadsheetImportProvider>
<RecordTableEffect recordTableId={recordTableId} viewBarId={viewBarId} />
{
<RecordTable

View File

@ -0,0 +1,73 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateManyRecords = <T>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const { objectMetadataItem, createManyRecordsMutation } =
useObjectMetadataItem({
objectNameSingular,
});
const { generateEmptyRecord } = useGenerateEmptyRecord({
objectMetadataItem,
});
const apolloClient = useApolloClient();
const createManyRecords = async (data: Record<string, any>[]) => {
const withIds = data.map((record) => ({
...record,
id: (record.id as string) ?? v4(),
}));
withIds.forEach((record) => {
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
generateEmptyRecord({ id: record.id }),
);
});
const createdObjects = await apolloClient.mutate({
mutation: createManyRecordsMutation,
variables: {
data: withIds,
},
optimisticResponse: {
[`create${capitalize(objectMetadataItem.namePlural)}`]: withIds.map(
(record) => generateEmptyRecord({ id: record.id }),
),
},
});
if (!createdObjects.data) {
return null;
}
const createdRecords =
(createdObjects.data[
`create${capitalize(objectMetadataItem.namePlural)}`
] as T[]) ?? [];
createdRecords.forEach((record) => {
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
record,
);
});
return createdRecords;
};
return { createManyRecords };
};

View File

@ -0,0 +1,30 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateCreateManyRecordMutation = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
if (!objectMetadataItem) {
return EMPTY_MUTATION;
}
return gql`
mutation Create${capitalize(
objectMetadataItem.namePlural,
)}($data: [${capitalize(objectMetadataItem.nameSingular)}CreateInput!]!) {
create${capitalize(objectMetadataItem.namePlural)}(data: $data) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.join('\n')}
}
}`;
};

View File

@ -1,5 +1,10 @@
import { v4 } from 'uuid';
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';
@ -7,6 +12,11 @@ export type FieldPersonMapping = (typeof fieldsForPerson)[number]['key'];
export const useSpreadsheetPersonImport = () => {
const { openSpreadsheetImport } = useSpreadsheetImport<FieldPersonMapping>();
const { enqueueSnackBar } = useSnackBar();
const { createManyRecords: createManyPeople } = useCreateManyRecords<Person>({
objectNameSingular: 'person',
});
const openPersonSpreadsheetImport = (
options?: Omit<
@ -16,35 +26,43 @@ export const useSpreadsheetPersonImport = () => {
) => {
openSpreadsheetImport({
...options,
onSubmit: async (_data) => {
onSubmit: async (data) => {
// TODO: Add better type checking in spreadsheet import later
// const createInputs = data.validData.map((person) => ({
// id: uuidv4(),
// firstName: person.firstName as string | undefined,
// lastName: person.lastName as string | undefined,
// email: person.email as string | undefined,
// linkedinUrl: person.linkedinUrl as string | undefined,
// xUrl: person.xUrl as string | undefined,
// jobTitle: person.jobTitle as string | undefined,
// phone: person.phone as string | undefined,
// city: person.city as string | undefined,
// }));
// TODO : abstract this part for any object
// try {
// const result = await createManyPerson({
// variables: {
// data: createInputs,
// },
// refetchQueries: 'active',
// });
// if (result.errors) {
// throw result.errors;
// }
// } catch (error: any) {
// enqueueSnackBar(error?.message || 'Something went wrong', {
// variant: 'error',
// });
// }
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,
}));
// TODO: abstract this part for any object
try {
await createManyPeople(createInputs);
} catch (error: any) {
enqueueSnackBar(error?.message || 'Something went wrong', {
variant: 'error',
});
}
},
fields: fieldsForPerson,
});

View File

@ -74,7 +74,6 @@ export const useSetHotkeyScope = () =>
}
scopesToSet.push(newHotkeyScope.scope);
console.log(scopesToSet);
set(internalHotkeysEnabledScopesState, scopesToSet);
set(currentHotkeyScopeState, newHotkeyScope);
},