Import company and person from csv file (#1236)

* feat: wip implement back-end call csv import

* fix: rebase IconBrandTwitter missing

* feat: person and company csv import

* fix: test & clean

* fix: clean & test
This commit is contained in:
Jérémy M
2023-08-16 23:18:16 +02:00
committed by GitHub
parent 5890354d21
commit 8863bb0035
74 changed files with 950 additions and 312 deletions

View File

@ -0,0 +1,232 @@
import { useCallback, useMemo, useState } from 'react';
import type { RowsChangeData } from 'react-data-grid';
import styled from '@emotion/styled';
import { ContinueButton } from '@/spreadsheet-import/components/ContinueButton';
import { Heading } from '@/spreadsheet-import/components/Heading';
import { Table } from '@/spreadsheet-import/components/Table';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import type { Data } from '@/spreadsheet-import/types';
import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
import { useDialog } from '@/ui/dialog/hooks/useDialog';
import { IconTrash } from '@/ui/icon';
import { Toggle } from '@/ui/input/toggle/components/Toggle';
import { Modal } from '@/ui/modal/components/Modal';
import { generateColumns } from './components/columns';
import type { Meta } from './types';
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(8)};
`;
const ErrorToggle = styled.div`
align-items: center;
display: flex;
flex-direction: row;
`;
const ErrorToggleDescription = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const ScrollContainer = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
height: 0px;
width: 100%;
`;
const NoRowsContainer = styled.div`
display: flex;
grid-column: 1/-1;
justify-content: center;
margin-top: ${({ theme }) => theme.spacing(8)};
`;
type Props<T extends string> = {
initialData: Data<T>[];
file: File;
onSubmitStart?: () => void;
};
export const ValidationStep = <T extends string>({
initialData,
file,
onSubmitStart,
}: Props<T>) => {
const { enqueueDialog } = useDialog();
const { fields, onClose, onSubmit, rowHook, tableHook } =
useSpreadsheetImportInternal<T>();
const [data, setData] = useState<(Data<T> & Meta)[]>(
useMemo(
() => addErrorsAndRunHooks<T>(initialData, fields, rowHook, tableHook),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
),
);
const [selectedRows, setSelectedRows] = useState<
ReadonlySet<number | string>
>(new Set());
const [filterByErrors, setFilterByErrors] = useState(false);
const updateData = useCallback(
(rows: typeof data) => {
setData(addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook));
},
[setData, rowHook, tableHook, fields],
);
const deleteSelectedRows = () => {
if (selectedRows.size) {
const newData = data.filter((value) => !selectedRows.has(value.__index));
updateData(newData);
setSelectedRows(new Set());
}
};
const updateRow = useCallback(
(
rows: typeof data,
changedData?: RowsChangeData<(typeof data)[number]>,
) => {
const changes = changedData?.indexes.reduce((acc, index) => {
// when data is filtered val !== actual index in data
const realIndex = data.findIndex(
(value) => value.__index === rows[index].__index,
);
acc[realIndex] = rows[index];
return acc;
}, {} as Record<number, (typeof data)[number]>);
const newData = Object.assign([], data, changes);
updateData(newData);
},
[data, updateData],
);
const columns = useMemo(() => generateColumns(fields), [fields]);
const tableData = useMemo(() => {
if (filterByErrors) {
return data.filter((value) => {
if (value?.__errors) {
return Object.values(value.__errors)?.filter(
(err) => err.level === 'error',
).length;
}
return false;
});
}
return data;
}, [data, filterByErrors]);
const rowKeyGetter = useCallback((row: Data<T> & Meta) => row.__index, []);
const submitData = async () => {
const calculatedData = data.reduce(
(acc, value) => {
const { __index, __errors, ...values } = value;
if (__errors) {
for (const key in __errors) {
if (__errors[key].level === 'error') {
acc.invalidData.push(values as unknown as Data<T>);
return acc;
}
}
}
acc.validData.push(values as unknown as Data<T>);
return acc;
},
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data },
);
onSubmitStart?.();
await onSubmit(calculatedData, file);
onClose();
};
const onContinue = () => {
const invalidData = data.find((value) => {
if (value?.__errors) {
return !!Object.values(value.__errors)?.filter(
(err) => err.level === 'error',
).length;
}
return false;
});
if (!invalidData) {
submitData();
} else {
enqueueDialog({
title: 'Finish flow with errors',
message:
'There are still some rows that contain errors. Rows with errors will be ignored when submitting.',
buttons: [
{ title: 'Cancel' },
{
title: 'Submit',
variant: ButtonVariant.Primary,
onClick: submitData,
},
],
});
}
};
return (
<>
<Modal.Content>
<Heading
title="Review your import"
description="Correct the issues and fill the missing data."
/>
<Toolbar>
<ErrorToggle>
<Toggle
value={filterByErrors}
onChange={() => setFilterByErrors(!filterByErrors)}
/>
<ErrorToggleDescription>
Show only rows with errors
</ErrorToggleDescription>
</ErrorToggle>
<Button
icon={<IconTrash />}
title="Remove"
variant={ButtonVariant.Danger}
onClick={deleteSelectedRows}
disabled={selectedRows.size === 0}
/>
</Toolbar>
<ScrollContainer>
<Table
rowKeyGetter={rowKeyGetter}
rows={tableData}
onRowsChange={updateRow}
columns={columns}
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows}
components={{
noRowsFallback: (
<NoRowsContainer>
{filterByErrors
? 'No data containing errors'
: 'No data found'}
</NoRowsContainer>
),
}}
/>
</ScrollContainer>
</Modal.Content>
<ContinueButton onContinue={onContinue} title="Confirm" />
</>
);
};

View File

@ -0,0 +1,240 @@
import { Column, useRowSelection } from 'react-data-grid';
import { createPortal } from 'react-dom';
import styled from '@emotion/styled';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import type { Data, Fields } from '@/spreadsheet-import/types';
import {
Checkbox,
CheckboxVariant,
} from '@/ui/input/checkbox/components/Checkbox';
import { TextInput } from '@/ui/input/text/components/TextInput';
import { Toggle } from '@/ui/input/toggle/components/Toggle';
import { AppTooltip } from '@/ui/tooltip/AppTooltip';
import type { Meta } from '../types';
const HeaderContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
position: relative;
`;
const HeaderLabel = styled.span`
display: flex;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
`;
const CheckboxContainer = styled.div`
align-items: center;
box-sizing: content-box;
display: flex;
flex: 1;
height: 100%;
justify-content: center;
line-height: 0;
width: 100%;
`;
const ToggleContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
`;
const InputContainer = styled.div`
align-items: center;
display: flex;
min-height: 100%;
min-width: 100%;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
const DefaultContainer = styled.div`
min-height: 100%;
min-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
`;
const SELECT_COLUMN_KEY = 'select-row';
export const generateColumns = <T extends string>(
fields: Fields<T>,
): Column<Data<T> & Meta>[] => [
{
key: SELECT_COLUMN_KEY,
name: '',
width: 35,
minWidth: 35,
maxWidth: 35,
resizable: false,
sortable: false,
frozen: true,
formatter: (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [isRowSelected, onRowSelectionChange] = useRowSelection();
return (
<CheckboxContainer>
<Checkbox
aria-label="Select"
checked={isRowSelected}
variant={CheckboxVariant.Tertiary}
onChange={(event) => {
onRowSelectionChange({
row: props.row,
checked: event.target.checked,
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
});
}}
/>
</CheckboxContainer>
);
},
},
...fields.map(
(column): Column<Data<T> & Meta> => ({
key: column.key,
name: column.label,
minWidth: 150,
resizable: true,
headerRenderer: () => (
<HeaderContainer>
<HeaderLabel id={`${column.key}`}>{column.label}</HeaderLabel>
{column.description &&
createPortal(
<AppTooltip
anchorSelect={`#${column.key}`}
place="top"
content={column.description}
/>,
document.body,
)}
</HeaderContainer>
),
editable: column.fieldType.type !== 'checkbox',
editor: ({ row, onRowChange, onClose }) => {
const columnKey = column.key as keyof (Data<T> & Meta);
let component;
switch (column.fieldType.type) {
case 'select': {
const value = column.fieldType.options.find(
(option) => option.value === (row[columnKey] as string),
);
component = (
<MatchColumnSelect
value={
value
? ({
icon: null,
...value,
} as const)
: value
}
onChange={(value) => {
onRowChange({ ...row, [columnKey]: value?.value }, true);
}}
options={column.fieldType.options}
/>
);
break;
}
default:
component = (
<TextInput
value={row[columnKey] as string}
onChange={(value: string) => {
onRowChange({ ...row, [columnKey]: value });
}}
autoFocus={true}
onBlur={() => onClose(true)}
/>
);
}
return <InputContainer>{component}</InputContainer>;
},
editorOptions: {
editOnClick: true,
},
formatter: ({ row, onRowChange }) => {
const columnKey = column.key as keyof (Data<T> & Meta);
let component;
switch (column.fieldType.type) {
case 'checkbox':
component = (
<ToggleContainer
id={`${columnKey}-${row.__index}`}
onClick={(event) => {
event.stopPropagation();
}}
>
<Toggle
value={row[columnKey] as boolean}
onChange={() => {
onRowChange({
...row,
[columnKey]: !row[columnKey],
});
}}
/>
</ToggleContainer>
);
break;
case 'select':
component = (
<DefaultContainer id={`${columnKey}-${row.__index}`}>
{column.fieldType.options.find(
(option) => option.value === row[columnKey as T],
)?.label || null}
</DefaultContainer>
);
break;
default:
component = (
<DefaultContainer id={`${columnKey}-${row.__index}`}>
{row[columnKey]}
</DefaultContainer>
);
}
if (row.__errors?.[columnKey]) {
return (
<>
{component}
{createPortal(
<AppTooltip
anchorSelect={`#${columnKey}-${row.__index}`}
place="top"
content={row.__errors?.[columnKey]?.message}
/>,
document.body,
)}
</>
);
}
return component;
},
cellClass: (row: Meta) => {
switch (row.__errors?.[column.key]?.level) {
case 'error':
return 'rdg-cell-error';
case 'warning':
return 'rdg-cell-warning';
case 'info':
return 'rdg-cell-info';
default:
return '';
}
},
}),
),
];

View File

@ -0,0 +1,5 @@
import type { Info } from '@/spreadsheet-import/types';
export type Meta = { __index: string; __errors?: Error | null };
export type Error = { [key: string]: Info };
export type Errors = { [id: string]: Error };