Files
twenty/front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx
Jérémy M 8863bb0035 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
2023-08-16 14:18:16 -07:00

292 lines
8.4 KiB
TypeScript

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"
/>
</>
);
};