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,291 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { ContinueButton } from '@/spreadsheet-import/components/ContinueButton';
import { Heading } from '@/spreadsheet-import/components/Heading';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import type { Field, RawData } from '@/spreadsheet-import/types';
import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields';
import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns';
import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableData';
import { setColumn } from '@/spreadsheet-import/utils/setColumn';
import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn';
import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn';
import { ButtonVariant } from '@/ui/button/components/Button';
import { useDialog } from '@/ui/dialog/hooks/useDialog';
import { Modal } from '@/ui/modal/components/Modal';
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
import { ColumnGrid } from './components/ColumnGrid';
import { TemplateColumn } from './components/TemplateColumn';
import { UserTableColumn } from './components/UserTableColumn';
const StyledContent = styled(Modal.Content)`
align-items: center;
`;
const StyledColumnsContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledColumns = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledColumn = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
export type MatchColumnsProps<T extends string> = {
data: RawData[];
headerValues: RawData;
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>) => void;
};
export enum ColumnType {
empty,
ignored,
matched,
matchedCheckbox,
matchedSelect,
matchedSelectOptions,
}
export type MatchedOptions<T> = {
entry: string;
value: T;
};
type EmptyColumn = { type: ColumnType.empty; index: number; header: string };
type IgnoredColumn = {
type: ColumnType.ignored;
index: number;
header: string;
};
type MatchedColumn<T> = {
type: ColumnType.matched;
index: number;
header: string;
value: T;
};
type MatchedSwitchColumn<T> = {
type: ColumnType.matchedCheckbox;
index: number;
header: string;
value: T;
};
export type MatchedSelectColumn<T> = {
type: ColumnType.matchedSelect;
index: number;
header: string;
value: T;
matchedOptions: Partial<MatchedOptions<T>>[];
};
export type MatchedSelectOptionsColumn<T> = {
type: ColumnType.matchedSelectOptions;
index: number;
header: string;
value: T;
matchedOptions: MatchedOptions<T>[];
};
export type Column<T extends string> =
| EmptyColumn
| IgnoredColumn
| MatchedColumn<T>
| MatchedSwitchColumn<T>
| MatchedSelectColumn<T>
| MatchedSelectOptionsColumn<T>;
export type Columns<T extends string> = Column<T>[];
export const MatchColumnsStep = <T extends string>({
data,
headerValues,
onContinue,
}: MatchColumnsProps<T>) => {
const { enqueueDialog } = useDialog();
const { enqueueSnackBar } = useSnackBar();
const dataExample = data.slice(0, 2);
const { fields, autoMapHeaders, autoMapDistance } =
useSpreadsheetImportInternal<T>();
const [isLoading, setIsLoading] = useState(false);
const [columns, setColumns] = useState<Columns<T>>(
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty,
index,
header: value ?? '',
})),
);
const onIgnore = useCallback(
(columnIndex: number) => {
setColumns(
columns.map((column, index) =>
columnIndex === index ? setIgnoreColumn<T>(column) : column,
),
);
},
[columns, setColumns],
);
const onRevertIgnore = useCallback(
(columnIndex: number) => {
setColumns(
columns.map((column, index) =>
columnIndex === index ? setColumn(column) : column,
),
);
},
[columns, setColumns],
);
const onChange = useCallback(
(value: T, columnIndex: number) => {
if (value === 'do-not-import') {
if (columns[columnIndex].type === ColumnType.ignored) {
onRevertIgnore(columnIndex);
} else {
onIgnore(columnIndex);
}
} else {
const field = fields.find(
(field) => field.key === value,
) as unknown as Field<T>;
const existingFieldIndex = columns.findIndex(
(column) => 'value' in column && column.value === field.key,
);
setColumns(
columns.map<Column<T>>((column, index) => {
if (columnIndex === index) {
return setColumn(column, field, data);
} else if (index === existingFieldIndex) {
enqueueSnackBar('Columns cannot duplicate', {
title: 'Another column unselected',
variant: 'error',
});
return setColumn(column);
} else {
return column;
}
}),
);
}
},
[columns, onRevertIgnore, onIgnore, fields, data, enqueueSnackBar],
);
const onSubChange = useCallback(
(value: string, columnIndex: number, entry: string) => {
setColumns(
columns.map((column, index) =>
columnIndex === index && 'matchedOptions' in column
? setSubColumn(column, entry, value)
: column,
),
);
},
[columns, setColumns],
);
const unmatchedRequiredFields = useMemo(
() => findUnmatchedRequiredFields(fields, columns),
[fields, columns],
);
const handleAlertOnContinue = useCallback(async () => {
setIsLoading(true);
await onContinue(normalizeTableData(columns, data, fields), data, columns);
setIsLoading(false);
}, [onContinue, columns, data, fields]);
const handleOnContinue = useCallback(async () => {
if (unmatchedRequiredFields.length > 0) {
enqueueDialog({
title: 'Not all columns matched',
message:
'There are required columns that are not matched or ignored. Do you want to continue?',
children: (
<StyledColumnsContainer>
<StyledColumns>Columns not matched:</StyledColumns>
{unmatchedRequiredFields.map((field) => (
<StyledColumn key={field}>{field}</StyledColumn>
))}
</StyledColumnsContainer>
),
buttons: [
{ title: 'Cancel' },
{
title: 'Continue',
onClick: handleAlertOnContinue,
variant: ButtonVariant.Primary,
},
],
});
} else {
setIsLoading(true);
await onContinue(
normalizeTableData(columns, data, fields),
data,
columns,
);
setIsLoading(false);
}
}, [
unmatchedRequiredFields,
enqueueDialog,
handleAlertOnContinue,
onContinue,
columns,
data,
fields,
]);
useEffect(() => {
if (autoMapHeaders) {
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<StyledContent>
<Heading
title="Match Columns"
description="Select the correct field for each column you'd like to import."
/>
<ColumnGrid
columns={columns}
renderUserColumn={(columns, columnIndex) => (
<UserTableColumn
column={columns[columnIndex]}
entries={dataExample.map(
(row) => row[columns[columnIndex].index],
)}
/>
)}
renderTemplateColumn={(columns, columnIndex) => (
<TemplateColumn
columns={columns}
columnIndex={columnIndex}
onChange={onChange}
onSubChange={onSubChange}
/>
)}
/>
</StyledContent>
<ContinueButton
isLoading={isLoading}
onContinue={handleOnContinue}
title="Next"
/>
</>
);
};

View File

@ -0,0 +1,126 @@
import React from 'react';
import styled from '@emotion/styled';
import type { Columns } from '../MatchColumnsStep';
const GridContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
flex-grow: 1;
height: 0px;
width: 100%;
`;
const Grid = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
margin-top: ${({ theme }) => theme.spacing(8)};
width: 75%;
`;
type HeightProps = {
height?: `${number}px`;
};
const GridRow = styled.div<HeightProps>`
box-sizing: border-box;
display: flex;
flex-direction: row;
min-height: ${({ height = '64px' }) => height};
`;
type PositionProps = {
position: 'left' | 'right';
};
const GridCell = styled.div<PositionProps>`
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1;
overflow-x: auto;
padding-bottom: ${({ theme }) => theme.spacing(4)};
padding-top: ${({ theme }) => theme.spacing(4)};
${({ position, theme }) => {
if (position === 'left') {
return `
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(2)};
`;
}
return `
padding-left: ${theme.spacing(2)};
padding-right: ${theme.spacing(4)};
`;
}};
`;
const GridHeader = styled.div<PositionProps>`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.light};
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding-left: ${({ theme }) => theme.spacing(4)};
padding-right: ${({ theme }) => theme.spacing(4)};
${({ position, theme }) => {
if (position === 'left') {
return `border-top-left-radius: calc(${theme.border.radius.md} - 1px);`;
}
return `border-top-right-radius: calc(${theme.border.radius.md} - 1px);`;
}};
text-transform: uppercase;
`;
type ColumnGridProps<T extends string> = {
columns: Columns<T>;
renderUserColumn: (
columns: Columns<T>,
columnIndex: number,
) => React.ReactNode;
renderTemplateColumn: (
columns: Columns<T>,
columnIndex: number,
) => React.ReactNode;
};
export const ColumnGrid = <T extends string>({
columns,
renderUserColumn,
renderTemplateColumn,
}: ColumnGridProps<T>) => {
return (
<>
<GridContainer>
<Grid>
<GridRow height="29px">
<GridHeader position="left">Imported data</GridHeader>
<GridHeader position="right">Twenty fields</GridHeader>
</GridRow>
{columns.map((column, index) => {
const userColumn = renderUserColumn(columns, index);
const templateColumn = renderTemplateColumn(columns, index);
if (React.isValidElement(userColumn)) {
return (
<GridRow key={index}>
<GridCell position="left">{userColumn}</GridCell>
<GridCell position="right">{templateColumn}</GridCell>
</GridRow>
);
}
return null;
})}
</Grid>
</GridContainer>
</>
);
};

View File

@ -0,0 +1,56 @@
import styled from '@emotion/styled';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SelectOption } from '@/spreadsheet-import/types';
import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
import type {
MatchedOptions,
MatchedSelectColumn,
MatchedSelectOptionsColumn,
} from '../MatchColumnsStep';
const Container = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const SelectLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
`;
interface Props<T> {
option: MatchedOptions<T> | Partial<MatchedOptions<T>>;
column: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>;
onSubChange: (val: T, index: number, option: string) => void;
}
export const SubMatchingSelect = <T extends string>({
option,
column,
onSubChange,
}: Props<T>) => {
const { fields } = useSpreadsheetImportInternal<T>();
const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value);
return (
<Container>
<SelectLabel>{option.entry}</SelectLabel>
<MatchColumnSelect
value={value}
placeholder="Select..."
onChange={(value) =>
onSubChange(value?.value as T, column.index, option.entry ?? '')
}
options={options}
name={option.entry}
/>
</Container>
);
};

View File

@ -0,0 +1,160 @@
// TODO: We should create our own accordion component
import {
Accordion,
AccordionButton as ChakraAccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
} from '@chakra-ui/accordion';
import styled from '@emotion/styled';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import type { Fields } from '@/spreadsheet-import/types';
import { IconChevronDown, IconForbid } from '@/ui/icon';
import type { Column, Columns } from '../MatchColumnsStep';
import { ColumnType } from '../MatchColumnsStep';
import { SubMatchingSelect } from './SubMatchingSelect';
const Container = styled.div`
display: flex;
flex-direction: column;
min-height: 10px;
width: 100%;
`;
const AccordionButton = styled(ChakraAccordionButton)`
align-items: center;
background-color: ${({ theme }) => theme.accent.secondary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
margin-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
width: 100%;
&:hover {
background-color: ${({ theme }) => theme.accent.primary};
}
`;
const AccordionContainer = styled.div`
display: flex;
width: 100%;
`;
const AccordionLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
padding-left: ${({ theme }) => theme.spacing(1)};
text-align: left;
`;
const getAccordionTitle = <T extends string>(
fields: Fields<T>,
column: Column<T>,
) => {
const fieldLabel = fields.find(
(field) => 'value' in column && field.key === column.value,
)?.label;
return `Match ${fieldLabel} (${
'matchedOptions' in column && column.matchedOptions.length
} Unmatched)`;
};
type TemplateColumnProps<T extends string> = {
columns: Columns<T>;
columnIndex: number;
onChange: (val: T, index: number) => void;
onSubChange: (val: T, index: number, option: string) => void;
};
export const TemplateColumn = <T extends string>({
columns,
columnIndex,
onChange,
onSubChange,
}: TemplateColumnProps<T>) => {
const { fields } = useSpreadsheetImportInternal<T>();
const column = columns[columnIndex];
const isIgnored = column.type === ColumnType.ignored;
const isSelect = 'matchedOptions' in column;
const fieldOptions = fields.map(({ icon, label, key }) => {
const isSelected =
columns.findIndex((column) => {
if ('value' in column) {
return column.value === key;
}
return false;
}) !== -1;
return {
icon,
value: key,
label,
disabled: isSelected,
} as const;
});
const selectOptions = [
{
icon: <IconForbid />,
value: 'do-not-import',
label: 'Do not import',
},
...fieldOptions,
];
const selectValue = fieldOptions.find(
({ value }) => 'value' in column && column.value === value,
);
const ignoreValue = selectOptions.find(
({ value }) => value === 'do-not-import',
);
return (
<Container>
<MatchColumnSelect
placeholder="Select column..."
value={isIgnored ? ignoreValue : selectValue}
onChange={(value) => onChange(value?.value as T, column.index)}
options={selectOptions}
name={column.header}
/>
{isSelect && (
<AccordionContainer>
<Accordion allowMultiple width="100%">
<AccordionItem border="none" py={1}>
<AccordionButton data-testid="accordion-button">
<AccordionLabel>
{getAccordionTitle<T>(fields, column)}
</AccordionLabel>
<AccordionIcon as={IconChevronDown} />
</AccordionButton>
<AccordionPanel pb={4} pr={3} display="flex" flexDir="column">
{column.matchedOptions.map((option) => (
<SubMatchingSelect
option={option}
column={column}
onSubChange={onSubChange}
key={option.entry}
/>
))}
</AccordionPanel>
</AccordionItem>
</Accordion>
</AccordionContainer>
)}
</Container>
);
};

View File

@ -0,0 +1,50 @@
import styled from '@emotion/styled';
import type { RawData } from '@/spreadsheet-import/types';
import { assertNotNull } from '~/utils/assert';
import type { Column } from '../MatchColumnsStep';
const Container = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const Value = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Example = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
type UserTableColumnProps<T extends string> = {
column: Column<T>;
entries: RawData;
};
export const UserTableColumn = <T extends string>({
column,
entries,
}: UserTableColumnProps<T>) => {
const { header } = column;
const entry = entries.find(assertNotNull);
return (
<Container>
<Value>{header}</Value>
{entry && <Example>{`ex: ${entry}`}</Example>}
</Container>
);
};