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,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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ContinueButton } from '@/spreadsheet-import/components/ContinueButton';
|
||||
import { Heading } from '@/spreadsheet-import/components/Heading';
|
||||
import type { RawData } from '@/spreadsheet-import/types';
|
||||
import { Modal } from '@/ui/modal/components/Modal';
|
||||
|
||||
import { SelectHeaderTable } from './components/SelectHeaderTable';
|
||||
|
||||
const StyledHeading = styled(Heading)`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const TableContainer = styled.div`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
height: 0px;
|
||||
`;
|
||||
|
||||
type SelectHeaderProps = {
|
||||
data: RawData[];
|
||||
onContinue: (headerValues: RawData, data: RawData[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const SelectHeaderStep = ({ data, onContinue }: SelectHeaderProps) => {
|
||||
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(
|
||||
new Set([0]),
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const [selectedRowIndex] = Array.from(new Set(selectedRows));
|
||||
// We consider data above header to be redundant
|
||||
const trimmedData = data.slice(selectedRowIndex + 1);
|
||||
setIsLoading(true);
|
||||
await onContinue(data[selectedRowIndex], trimmedData);
|
||||
setIsLoading(false);
|
||||
}, [onContinue, data, selectedRows]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Content>
|
||||
<StyledHeading title="Select header row" />
|
||||
<TableContainer>
|
||||
<SelectHeaderTable
|
||||
data={data}
|
||||
selectedRows={selectedRows}
|
||||
setSelectedRows={setSelectedRows}
|
||||
/>
|
||||
</TableContainer>
|
||||
</Modal.Content>
|
||||
<ContinueButton
|
||||
onContinue={handleContinue}
|
||||
title="Next"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
import { Column, FormatterProps, useRowSelection } from 'react-data-grid';
|
||||
|
||||
import type { RawData } from '@/spreadsheet-import/types';
|
||||
import { Radio } from '@/ui/input/radio/components/Radio';
|
||||
|
||||
const SELECT_COLUMN_KEY = 'select-row';
|
||||
|
||||
function SelectFormatter(props: FormatterProps<unknown>) {
|
||||
const [isRowSelected, onRowSelectionChange] = useRowSelection();
|
||||
|
||||
return (
|
||||
<Radio
|
||||
aria-label="Select"
|
||||
checked={isRowSelected}
|
||||
onChange={(event) => {
|
||||
onRowSelectionChange({
|
||||
row: props.row,
|
||||
checked: Boolean(event.target.checked),
|
||||
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const SelectColumn: Column<any, any> = {
|
||||
key: SELECT_COLUMN_KEY,
|
||||
name: '',
|
||||
width: 35,
|
||||
minWidth: 35,
|
||||
maxWidth: 35,
|
||||
resizable: false,
|
||||
sortable: false,
|
||||
frozen: true,
|
||||
cellClass: 'rdg-radio',
|
||||
formatter: SelectFormatter,
|
||||
};
|
||||
|
||||
export const generateSelectionColumns = (data: RawData[]) => {
|
||||
const longestRowLength = data.reduce(
|
||||
(acc, curr) => (acc > curr.length ? acc : curr.length),
|
||||
0,
|
||||
);
|
||||
return [
|
||||
SelectColumn,
|
||||
...Array.from(Array(longestRowLength), (_, index) => ({
|
||||
key: index.toString(),
|
||||
name: '',
|
||||
})),
|
||||
];
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Table } from '@/spreadsheet-import/components/Table';
|
||||
import type { RawData } from '@/spreadsheet-import/types';
|
||||
|
||||
import { generateSelectionColumns } from './SelectColumn';
|
||||
|
||||
interface Props {
|
||||
data: RawData[];
|
||||
selectedRows: ReadonlySet<number>;
|
||||
setSelectedRows: (rows: ReadonlySet<number>) => void;
|
||||
}
|
||||
|
||||
export const SelectHeaderTable = ({
|
||||
data,
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
}: Props) => {
|
||||
const columns = useMemo(() => generateSelectionColumns(data), [data]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKeyGetter={(row) => data.indexOf(row)}
|
||||
rows={data}
|
||||
columns={columns}
|
||||
selectedRows={selectedRows}
|
||||
onSelectedRowsChange={(newRows) => {
|
||||
// allow selecting only one row
|
||||
newRows.forEach((value) => {
|
||||
if (!selectedRows.has(value as number)) {
|
||||
setSelectedRows(new Set([value as number]));
|
||||
return;
|
||||
}
|
||||
});
|
||||
}}
|
||||
onRowClick={(row) => {
|
||||
setSelectedRows(new Set([data.indexOf(row)]));
|
||||
}}
|
||||
headerRowHeight={0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ContinueButton } from '@/spreadsheet-import/components/ContinueButton';
|
||||
import { Heading } from '@/spreadsheet-import/components/Heading';
|
||||
import { Radio } from '@/ui/input/radio/components/Radio';
|
||||
import { RadioGroup } from '@/ui/input/radio/components/RadioGroup';
|
||||
import { Modal } from '@/ui/modal/components/Modal';
|
||||
|
||||
const Content = styled(Modal.Content)`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled(Heading)`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const RadioContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
height: 0px;
|
||||
`;
|
||||
|
||||
type SelectSheetProps = {
|
||||
sheetNames: string[];
|
||||
onContinue: (sheetName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const SelectSheetStep = ({
|
||||
sheetNames,
|
||||
onContinue,
|
||||
}: SelectSheetProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [value, setValue] = useState(sheetNames[0]);
|
||||
|
||||
const handleOnContinue = useCallback(
|
||||
async (data: typeof value) => {
|
||||
setIsLoading(true);
|
||||
await onContinue(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
[onContinue],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Content>
|
||||
<StyledHeading title="Select the sheet to use" />
|
||||
<RadioContainer>
|
||||
<RadioGroup onValueChange={(value) => setValue(value)} value={value}>
|
||||
{sheetNames.map((sheetName) => (
|
||||
<Radio value={sheetName} key={sheetName} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</RadioContainer>
|
||||
</Content>
|
||||
<ContinueButton
|
||||
isLoading={isLoading}
|
||||
onContinue={() => handleOnContinue(value)}
|
||||
title="Next"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep';
|
||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||
import { Modal } from '@/ui/modal/components/Modal';
|
||||
import { StepBar } from '@/ui/step-bar/components/StepBar';
|
||||
import { useStepBar } from '@/ui/step-bar/hooks/useStepBar';
|
||||
|
||||
import { UploadFlow } from './UploadFlow';
|
||||
|
||||
const Header = styled(Modal.Header)`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
height: 60px;
|
||||
padding: 0px;
|
||||
padding-left: ${({ theme }) => theme.spacing(30)};
|
||||
padding-right: ${({ theme }) => theme.spacing(30)};
|
||||
`;
|
||||
|
||||
const stepTitles = {
|
||||
uploadStep: 'Upload file',
|
||||
matchColumnsStep: 'Match columns',
|
||||
validationStep: 'Validate data',
|
||||
} as const;
|
||||
|
||||
export const Steps = () => {
|
||||
const { initialStepState } = useSpreadsheetImportInternal();
|
||||
|
||||
const { steps, initialStep } = useSpreadsheetImportInitialStep(
|
||||
initialStepState?.type,
|
||||
);
|
||||
|
||||
const { nextStep, activeStep } = useStepBar({
|
||||
initialStep,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<StepBar activeStep={activeStep}>
|
||||
{steps.map((key) => (
|
||||
<StepBar.Step label={stepTitles[key]} key={key} />
|
||||
))}
|
||||
</StepBar>
|
||||
</Header>
|
||||
<UploadFlow nextStep={nextStep} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,221 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import type XLSX from 'xlsx-ugnis';
|
||||
|
||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||
import type { RawData } from '@/spreadsheet-import/types';
|
||||
import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords';
|
||||
import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
|
||||
import { Modal } from '@/ui/modal/components/Modal';
|
||||
import { CircularProgressBar } from '@/ui/progress-bar/components/CircularProgressBar';
|
||||
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
|
||||
|
||||
import { MatchColumnsStep } from './MatchColumnsStep/MatchColumnsStep';
|
||||
import { SelectHeaderStep } from './SelectHeaderStep/SelectHeaderStep';
|
||||
import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep';
|
||||
import { UploadStep } from './UploadStep/UploadStep';
|
||||
import { ValidationStep } from './ValidationStep/ValidationStep';
|
||||
|
||||
const ProgressBarContainer = styled(Modal.Content)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export enum StepType {
|
||||
upload = 'upload',
|
||||
selectSheet = 'selectSheet',
|
||||
selectHeader = 'selectHeader',
|
||||
matchColumns = 'matchColumns',
|
||||
validateData = 'validateData',
|
||||
loading = 'loading',
|
||||
}
|
||||
export type StepState =
|
||||
| {
|
||||
type: StepType.upload;
|
||||
}
|
||||
| {
|
||||
type: StepType.selectSheet;
|
||||
workbook: XLSX.WorkBook;
|
||||
}
|
||||
| {
|
||||
type: StepType.selectHeader;
|
||||
data: RawData[];
|
||||
}
|
||||
| {
|
||||
type: StepType.matchColumns;
|
||||
data: RawData[];
|
||||
headerValues: RawData;
|
||||
}
|
||||
| {
|
||||
type: StepType.validateData;
|
||||
data: any[];
|
||||
}
|
||||
| {
|
||||
type: StepType.loading;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
export const UploadFlow = ({ nextStep }: Props) => {
|
||||
const theme = useTheme();
|
||||
const { initialStepState } = useSpreadsheetImportInternal();
|
||||
const [state, setState] = useState<StepState>(
|
||||
initialStepState || { type: StepType.upload },
|
||||
);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const {
|
||||
maxRecords,
|
||||
uploadStepHook,
|
||||
selectHeaderStepHook,
|
||||
matchColumnsStepHook,
|
||||
} = useSpreadsheetImportInternal();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const errorToast = useCallback(
|
||||
(description: string) => {
|
||||
enqueueSnackBar(description, {
|
||||
title: 'Error',
|
||||
variant: 'error',
|
||||
});
|
||||
},
|
||||
[enqueueSnackBar],
|
||||
);
|
||||
|
||||
switch (state.type) {
|
||||
case StepType.upload:
|
||||
return (
|
||||
<UploadStep
|
||||
onContinue={async (workbook, file) => {
|
||||
setUploadedFile(file);
|
||||
const isSingleSheet = workbook.SheetNames.length === 1;
|
||||
if (isSingleSheet) {
|
||||
if (
|
||||
maxRecords &&
|
||||
exceedsMaxRecords(
|
||||
workbook.Sheets[workbook.SheetNames[0]],
|
||||
maxRecords,
|
||||
)
|
||||
) {
|
||||
errorToast(
|
||||
`Too many records. Up to ${maxRecords.toString()} allowed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mappedWorkbook = await uploadStepHook(
|
||||
mapWorkbook(workbook),
|
||||
);
|
||||
setState({
|
||||
type: StepType.selectHeader,
|
||||
data: mappedWorkbook,
|
||||
});
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message);
|
||||
}
|
||||
} else {
|
||||
setState({ type: StepType.selectSheet, workbook });
|
||||
}
|
||||
nextStep();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case StepType.selectSheet:
|
||||
return (
|
||||
<SelectSheetStep
|
||||
sheetNames={state.workbook.SheetNames}
|
||||
onContinue={async (sheetName) => {
|
||||
if (
|
||||
maxRecords &&
|
||||
exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)
|
||||
) {
|
||||
errorToast(
|
||||
`Too many records. Up to ${maxRecords.toString()} allowed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mappedWorkbook = await uploadStepHook(
|
||||
mapWorkbook(state.workbook, sheetName),
|
||||
);
|
||||
setState({
|
||||
type: StepType.selectHeader,
|
||||
data: mappedWorkbook,
|
||||
});
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case StepType.selectHeader:
|
||||
return (
|
||||
<SelectHeaderStep
|
||||
data={state.data}
|
||||
onContinue={async (...args) => {
|
||||
try {
|
||||
const { data, headerValues } = await selectHeaderStepHook(
|
||||
...args,
|
||||
);
|
||||
setState({
|
||||
type: StepType.matchColumns,
|
||||
data,
|
||||
headerValues,
|
||||
});
|
||||
nextStep();
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case StepType.matchColumns:
|
||||
return (
|
||||
<MatchColumnsStep
|
||||
data={state.data}
|
||||
headerValues={state.headerValues}
|
||||
onContinue={async (values, rawData, columns) => {
|
||||
try {
|
||||
const data = await matchColumnsStepHook(values, rawData, columns);
|
||||
setState({
|
||||
type: StepType.validateData,
|
||||
data,
|
||||
});
|
||||
nextStep();
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case StepType.validateData:
|
||||
if (!uploadedFile) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
return (
|
||||
<ValidationStep
|
||||
initialData={state.data}
|
||||
file={uploadedFile}
|
||||
onSubmitStart={() =>
|
||||
setState({
|
||||
type: StepType.loading,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
case StepType.loading:
|
||||
default:
|
||||
return (
|
||||
<ProgressBarContainer>
|
||||
<CircularProgressBar
|
||||
size={80}
|
||||
barWidth={8}
|
||||
barColor={theme.font.color.primary}
|
||||
/>
|
||||
</ProgressBarContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import type XLSX from 'xlsx-ugnis';
|
||||
|
||||
import { Modal } from '@/ui/modal/components/Modal';
|
||||
|
||||
import { DropZone } from './components/DropZone';
|
||||
|
||||
const Content = styled(Modal.Content)`
|
||||
padding: ${({ theme }) => theme.spacing(6)};
|
||||
`;
|
||||
|
||||
type UploadProps = {
|
||||
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export const UploadStep = ({ onContinue }: UploadProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleOnContinue = useCallback(
|
||||
async (data: XLSX.WorkBook, file: File) => {
|
||||
setIsLoading(true);
|
||||
await onContinue(data, file);
|
||||
setIsLoading(false);
|
||||
},
|
||||
[onContinue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,143 @@
|
||||
import { useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import styled from '@emotion/styled';
|
||||
import * as XLSX from 'xlsx-ugnis';
|
||||
|
||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||
import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync';
|
||||
import { MainButton } from '@/ui/button/components/MainButton';
|
||||
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
|
||||
|
||||
const Container = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => `
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
${theme.font.color.primary},
|
||||
${theme.font.color.primary} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px,
|
||||
${theme.font.color.primary} 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
${theme.font.color.primary},
|
||||
${theme.font.color.primary} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px,
|
||||
${theme.font.color.primary} 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
180deg,
|
||||
${theme.font.color.primary},
|
||||
${theme.font.color.primary} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px,
|
||||
${theme.font.color.primary} 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
270deg,
|
||||
${theme.font.color.primary},
|
||||
${theme.font.color.primary} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px,
|
||||
${theme.font.color.primary} 20px
|
||||
);
|
||||
`};
|
||||
background-position: 0 0, 0 0, 100% 0, 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 2px 100%, 100% 2px, 2px 100%, 100% 2px;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Overlay = styled.div`
|
||||
background: ${({ theme }) => theme.background.transparent.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
`;
|
||||
|
||||
const Text = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Button = styled(MainButton)`
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
width: 200px;
|
||||
`;
|
||||
|
||||
type DropZoneProps = {
|
||||
onContinue: (data: XLSX.WorkBook, file: File) => void;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
|
||||
const { maxFileSize, dateFormat, parseRaw } = useSpreadsheetImportInternal();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
maxFiles: 1,
|
||||
maxSize: maxFileSize,
|
||||
accept: {
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
|
||||
'.xlsx',
|
||||
],
|
||||
'text/csv': ['.csv'],
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
setLoading(false);
|
||||
fileRejections.forEach((fileRejection) => {
|
||||
enqueueSnackBar(fileRejection.errors[0].message, {
|
||||
title: `${fileRejection.file.name} upload rejected`,
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
},
|
||||
onDropAccepted: async ([file]) => {
|
||||
setLoading(true);
|
||||
const arrayBuffer = await readFileAsync(file);
|
||||
const workbook = XLSX.read(arrayBuffer, {
|
||||
cellDates: true,
|
||||
dateNF: dateFormat,
|
||||
raw: parseRaw,
|
||||
dense: true,
|
||||
});
|
||||
setLoading(false);
|
||||
onContinue(workbook, file);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container {...getRootProps()}>
|
||||
{isDragActive && <Overlay />}
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<Text>Drop file here...</Text>
|
||||
) : loading || isLoading ? (
|
||||
<Text>Processing...</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text>Upload .xlsx, .xls or .csv file</Text>
|
||||
<Button onClick={open} title="Select file" />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Table } from '@/spreadsheet-import/components/Table';
|
||||
import type { Fields } from '@/spreadsheet-import/types';
|
||||
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
|
||||
|
||||
import { generateColumns } from './columns';
|
||||
|
||||
interface Props<T extends string> {
|
||||
fields: Fields<T>;
|
||||
}
|
||||
|
||||
export const ExampleTable = <T extends string>({ fields }: Props<T>) => {
|
||||
const data = useMemo(() => generateExampleRow(fields), [fields]);
|
||||
const columns = useMemo(() => generateColumns(fields), [fields]);
|
||||
|
||||
return <Table rows={data} columns={columns} className={'rdg-example'} />;
|
||||
};
|
||||
@ -0,0 +1,53 @@
|
||||
import type { Column } from 'react-data-grid';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import type { Fields } from '@/spreadsheet-import/types';
|
||||
import { AppTooltip } from '@/ui/tooltip/AppTooltip';
|
||||
|
||||
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 DefaultContainer = styled.div`
|
||||
min-height: 100%;
|
||||
min-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export const generateColumns = <T extends string>(fields: Fields<T>) =>
|
||||
fields.map(
|
||||
(column): Column<any> => ({
|
||||
key: column.key,
|
||||
name: column.label,
|
||||
minWidth: 150,
|
||||
headerRenderer: () => (
|
||||
<HeaderContainer>
|
||||
<HeaderLabel id={`${column.key}`}>{column.label}</HeaderLabel>
|
||||
{column.description &&
|
||||
createPortal(
|
||||
<AppTooltip
|
||||
anchorSelect={`#${column.key}`}
|
||||
place="top"
|
||||
content={column.description}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</HeaderContainer>
|
||||
),
|
||||
formatter: ({ row }) => (
|
||||
<DefaultContainer>{row[column.key]}</DefaultContainer>
|
||||
),
|
||||
}),
|
||||
);
|
||||
@ -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 };
|
||||
@ -0,0 +1,71 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
||||
import { Providers } from '@/spreadsheet-import/components/Providers';
|
||||
import { MatchColumnsStep } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
||||
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
||||
|
||||
const meta: Meta<typeof MatchColumnsStep> = {
|
||||
title: 'Modules/SpreadsheetImport/MatchColumnsStep',
|
||||
component: MatchColumnsStep,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const mockData = [
|
||||
['id', 'first_name', 'last_name', 'email', 'gender', 'ip_address'],
|
||||
['2', 'Geno', 'Gencke', 'ggencke0@tinypic.com', 'Female', '17.204.180.40'],
|
||||
[
|
||||
'3',
|
||||
'Bertram',
|
||||
'Twyford',
|
||||
'btwyford1@seattletimes.com',
|
||||
'Genderqueer',
|
||||
'188.98.2.13',
|
||||
],
|
||||
[
|
||||
'4',
|
||||
'Tersina',
|
||||
'Isacke',
|
||||
'tisacke2@edublogs.org',
|
||||
'Non-binary',
|
||||
'237.69.180.31',
|
||||
],
|
||||
[
|
||||
'5',
|
||||
'Yoko',
|
||||
'Guilliland',
|
||||
'yguilliland3@elegantthemes.com',
|
||||
'Male',
|
||||
'179.123.237.119',
|
||||
],
|
||||
['6', 'Freida', 'Fearns', 'ffearns4@fotki.com', 'Male', '184.48.15.1'],
|
||||
['7', 'Mildrid', 'Mount', 'mmount5@last.fm', 'Male', '26.97.160.103'],
|
||||
[
|
||||
'8',
|
||||
'Jolene',
|
||||
'Darlington',
|
||||
'jdarlington6@jalbum.net',
|
||||
'Agender',
|
||||
'172.14.232.84',
|
||||
],
|
||||
['9', 'Craig', 'Dickie', 'cdickie7@virginia.edu', 'Male', '143.248.220.47'],
|
||||
['10', 'Jere', 'Shier', 'jshier8@comcast.net', 'Agender', '10.143.62.161'],
|
||||
];
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Providers values={mockRsiValues}>
|
||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||
<MatchColumnsStep
|
||||
headerValues={mockData[0] as string[]}
|
||||
data={mockData.slice(1)}
|
||||
onContinue={() => null}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
||||
import { Providers } from '@/spreadsheet-import/components/Providers';
|
||||
import { SelectHeaderStep } from '@/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep';
|
||||
import {
|
||||
headerSelectionTableFields,
|
||||
mockRsiValues,
|
||||
} from '@/spreadsheet-import/tests/mockRsiValues';
|
||||
|
||||
const meta: Meta<typeof SelectHeaderStep> = {
|
||||
title: 'Modules/SpreadsheetImport/SelectHeaderStep',
|
||||
component: SelectHeaderStep,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Providers values={mockRsiValues}>
|
||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||
<SelectHeaderStep
|
||||
data={headerSelectionTableFields}
|
||||
onContinue={() => Promise.resolve()}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
||||
import { Providers } from '@/spreadsheet-import/components/Providers';
|
||||
import { SelectSheetStep } from '@/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep';
|
||||
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
||||
|
||||
const meta: Meta<typeof SelectSheetStep> = {
|
||||
title: 'Modules/SpreadsheetImport/SelectSheetStep',
|
||||
component: SelectSheetStep,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const sheetNames = ['Sheet1', 'Sheet2', 'Sheet3'];
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Providers values={mockRsiValues}>
|
||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||
<SelectSheetStep
|
||||
sheetNames={sheetNames}
|
||||
onContinue={() => Promise.resolve()}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
||||
import { Providers } from '@/spreadsheet-import/components/Providers';
|
||||
import { UploadStep } from '@/spreadsheet-import/steps/components/UploadStep/UploadStep';
|
||||
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
||||
|
||||
const meta: Meta<typeof UploadStep> = {
|
||||
title: 'Modules/SpreadsheetImport/UploadStep',
|
||||
component: UploadStep,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Providers values={mockRsiValues}>
|
||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||
<UploadStep onContinue={() => Promise.resolve()} />
|
||||
</ModalWrapper>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
||||
import { Providers } from '@/spreadsheet-import/components/Providers';
|
||||
import { ValidationStep } from '@/spreadsheet-import/steps/components/ValidationStep/ValidationStep';
|
||||
import {
|
||||
editableTableInitialData,
|
||||
mockRsiValues,
|
||||
} from '@/spreadsheet-import/tests/mockRsiValues';
|
||||
|
||||
const meta: Meta<typeof ValidationStep> = {
|
||||
title: 'Modules/SpreadsheetImport/ValidationStep',
|
||||
component: ValidationStep,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const file = new File([''], 'file.csv');
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Providers values={mockRsiValues}>
|
||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||
<ValidationStep initialData={editableTableInitialData} file={file} />
|
||||
</ModalWrapper>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user