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:
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 '';
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
];
|
||||
@ -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 };
|
||||
Reference in New Issue
Block a user