diff --git a/package.json b/package.json index 62efd225..fde33f99 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "pb-util": "^1.0.3", "quicktype-core": "^6.0.71", "react": "^18.0.0", + "react-beautiful-dnd": "^13.1.0", "react-color-palette": "^6.2.0", "react-data-grid": "7.0.0-beta.5", "react-div-100vh": "^0.7.0", @@ -152,6 +153,7 @@ "@types/lodash-es": "^4.17.6", "@types/node": "^17.0.23", "@types/react": "^18.0.5", + "@types/react-beautiful-dnd": "^13.1.2", "@types/react-div-100vh": "^0.4.0", "@types/react-dom": "^18.0.0", "@types/react-router-dom": "^5.3.3", diff --git a/src/atoms/tableScope/columnActions.ts b/src/atoms/tableScope/columnActions.ts index f123627f..4b879601 100644 --- a/src/atoms/tableScope/columnActions.ts +++ b/src/atoms/tableScope/columnActions.ts @@ -15,7 +15,7 @@ export interface IAddColumnOptions { index?: number; } /** - * Store function to add a column to tableSchema, to the end or by index. + * Set function adds a column to tableSchema, to the end or by index. * Also fixes any issues with column indexes, so they go from 0 to length - 1 * @param options - {@link IAddColumnOptions} * @@ -53,7 +53,7 @@ export interface IUpdateColumnOptions { index?: number; } /** - * Store function to update a column in tableSchema + * Set function updates a column in tableSchema * @throws Error if column not found * @param options - {@link IUpdateColumnOptions} * @@ -100,7 +100,7 @@ export const updateColumnAtom = atom( ); /** - * Store function to delete a column in tableSchema + * Set function deletes a column in tableSchema * @param key - Unique key of column to delete * * @example Basic usage: diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts index 9d197b17..48808350 100644 --- a/src/atoms/tableScope/rowActions.ts +++ b/src/atoms/tableScope/rowActions.ts @@ -36,7 +36,7 @@ export interface IAddRowOptions { setId?: "random" | "decrement"; } /** - * Adds a row or an array of rows. + * Set function adds a row or an array of rows. * Adds to rowsDb if it has no missing required fields, otherwise to rowsLocal. * @param options - {@link IAddRowOptions} * @@ -171,7 +171,7 @@ export const addRowAtom = atom( ); /** - * Deletes a row or an array of rows from rowsDb or rowsLocal. + * Set function deletes a row or an array of rows from rowsDb or rowsLocal. * @param path - A single path or array of paths of rows to delete * * @example Basic usage: @@ -222,7 +222,7 @@ export interface IUpdateFieldOptions { disableCheckEquality?: boolean; } /** - * Updates or deletes a field in a row. + * Set function updates or deletes a field in a row. * Adds to rowsDb if it has no missing required fields, * otherwise keeps in rowsLocal. * @param options - {@link IUpdateFieldOptions} diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 39e05c57..f3deeb2b 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -11,6 +11,7 @@ import useMonacoCustomizations, { IUseMonacoCustomizationsProps, } from "./useMonacoCustomizations"; import FullScreenButton from "./FullScreenButton"; +import { spreadSx } from "@src/utils/ui"; export interface ICodeEditorProps extends Partial, @@ -72,14 +73,7 @@ export default function CodeEditor({ return ( {fullScreen && fullScreenTitle && ( diff --git a/src/components/CodeEditor/DiffEditor.tsx b/src/components/CodeEditor/DiffEditor.tsx index 591585c1..834f7a68 100644 --- a/src/components/CodeEditor/DiffEditor.tsx +++ b/src/components/CodeEditor/DiffEditor.tsx @@ -14,6 +14,7 @@ import useMonacoCustomizations, { IUseMonacoCustomizationsProps, } from "./useMonacoCustomizations"; import FullScreenButton from "./FullScreenButton"; +import { spreadSx } from "@src/utils/ui"; export interface IDiffEditorProps extends Partial, @@ -63,14 +64,7 @@ export default function DiffEditor({ return ( ) { stroke: (theme) => theme.palette.background.default, }, }, - ...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : []), + ...spreadSx(props.sx), ]} > 0 ? "visible" : "hidden", }} - sx={[ - ...(Array.isArray(dividerSx) ? dividerSx : [dividerSx]), - ...(Array.isArray(topDividerSx) ? topDividerSx : [topDividerSx]), - ]} + sx={[...spreadSx(dividerSx), ...spreadSx(topDividerSx)]} /> )} @@ -54,12 +52,7 @@ export default function ScrollableDialogContent({ style={{ visibility: scrollInfo.y.percentage < 1 ? "visible" : "hidden", }} - sx={[ - ...(Array.isArray(dividerSx) ? dividerSx : [dividerSx]), - ...(Array.isArray(bottomDividerSx) - ? bottomDividerSx - : [bottomDividerSx]), - ]} + sx={[...spreadSx(dividerSx), ...spreadSx(bottomDividerSx)]} /> )} diff --git a/src/components/SideDrawer/SideDrawer.tsx b/src/components/SideDrawer/SideDrawer.tsx index 78cd302f..3b48f046 100644 --- a/src/components/SideDrawer/SideDrawer.tsx +++ b/src/components/SideDrawer/SideDrawer.tsx @@ -15,7 +15,6 @@ import ChevronDownIcon from "@mui/icons-material/KeyboardArrowDown"; import ErrorFallback from "@src/components/ErrorFallback"; import StyledDrawer from "./StyledDrawer"; import SideDrawerFields from "./SideDrawerFields"; -// import Form from "./Form"; import { globalScope, userSettingsAtom } from "@src/atoms/globalScope"; import { diff --git a/src/components/Table/Breadcrumbs.tsx b/src/components/Table/Breadcrumbs.tsx index dcdf9116..5e7a9f9a 100644 --- a/src/components/Table/Breadcrumbs.tsx +++ b/src/components/Table/Breadcrumbs.tsx @@ -13,7 +13,6 @@ import { Typography, Tooltip, } from "@mui/material"; -import ArrowRightIcon from "@mui/icons-material/ChevronRight"; import ReadOnlyIcon from "@mui/icons-material/EditOffOutlined"; import InfoTooltip from "@src/components/InfoTooltip"; @@ -26,6 +25,7 @@ import { tablesAtom, } from "@src/atoms/globalScope"; import { ROUTES } from "@src/constants/routes"; +import { spreadSx } from "@src/utils/ui"; export default function Breadcrumbs({ sx = [], ...props }: BreadcrumbsProps) { const [userRoles] = useAtom(userRolesAtom, globalScope); @@ -65,7 +65,7 @@ export default function Breadcrumbs({ sx = [], ...props }: BreadcrumbsProps) { }, "& .MuiBreadcrumbs-li": { display: "flex" }, }, - ...(Array.isArray(sx) ? sx : [sx]), + ...spreadSx(sx), ]} > {/* Section name */} diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx index 31fa90c4..118b25e3 100644 --- a/src/components/Table/EmptyTable.tsx +++ b/src/components/Table/EmptyTable.tsx @@ -13,7 +13,7 @@ import { import { APP_BAR_HEIGHT } from "@src/layouts/Navigation"; // FIXME: -// import ImportWizard from "@src/components/Wizards/ImportWizard"; +// import ImportWizard from "@src/components/TableWizards/ImportWizard"; // import ImportCSV from "@src/components/TableToolbar/ImportCsv"; export default function EmptyTable() { diff --git a/src/components/TableToolbar/ColumnSelect.tsx b/src/components/TableToolbar/ColumnSelect.tsx index 1264d67c..11a6ad07 100644 --- a/src/components/TableToolbar/ColumnSelect.tsx +++ b/src/components/TableToolbar/ColumnSelect.tsx @@ -8,6 +8,7 @@ import { tableScope, tableColumnsOrderedAtom } from "@src/atoms/tableScope"; import { ColumnConfig } from "@src/types/table"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; +import { spreadSx } from "@src/utils/ui"; export type ColumnOption = { value: string; @@ -96,10 +97,7 @@ export function ColumnItem({ alignItems="center" spacing={1} {...props} - sx={[ - { color: "text.secondary", width: "100%" }, - ...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : []), - ]} + sx={[{ color: "text.secondary", width: "100%" }, ...spreadSx(props.sx)]} > {getFieldProp("icon", option.type)} diff --git a/src/components/TableToolbar/ImportCsv.tsx b/src/components/TableToolbar/ImportCsv.tsx index 9e1a4393..955b2707 100644 --- a/src/components/TableToolbar/ImportCsv.tsx +++ b/src/components/TableToolbar/ImportCsv.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef } from "react"; import { useAtom } from "jotai"; -// FIXME: import { parse } from "csv-parse"; +import { parse } from "csv-parse/browser/esm"; import { useDropzone } from "react-dropzone"; import { useDebouncedCallback } from "use-debounce"; import { useSnackbar } from "notistack"; @@ -31,7 +31,7 @@ import { analytics, logEvent } from "@src/analytics"; // FIXME: // import ImportCsvWizard, { // IImportCsvWizardProps, -// } from "@src/components/Wizards/ImportCsvWizard"; +// } from "@src/components/TableWizards/ImportCsvWizard"; export enum ImportType { csv = "csv", @@ -76,27 +76,26 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) { }; const popoverId = open ? "csv-popover" : undefined; - const parseCsv = (csvString: string) => {}; - // FIXME: - // parse(csvString, { delimiter: [",", "\t"] }, (err, rows) => { - // if (err) { - // setError(err.message); - // } else { - // const columns = rows.shift() ?? []; - // if (columns.length === 0) { - // setError("No columns detected"); - // } else { - // const mappedRows = rows.map((row: any) => - // row.reduce( - // (a: any, c: any, i: number) => ({ ...a, [columns[i]]: c }), - // {} - // ) - // ); - // setCsvData({ columns, rows: mappedRows }); - // setError(""); - // } - // } - // }); + const parseCsv = (csvString: string) => + parse(csvString, { delimiter: [",", "\t"] }, (err, rows) => { + if (err) { + setError(err.message); + } else { + const columns = rows.shift() ?? []; + if (columns.length === 0) { + setError("No columns detected"); + } else { + const mappedRows = rows.map((row: any) => + row.reduce( + (a: any, c: any, i: number) => ({ ...a, [columns[i]]: c }), + {} + ) + ); + setCsvData({ columns, rows: mappedRows }); + setError(""); + } + } + }); const onDrop = useCallback( async (acceptedFiles: File[]) => { @@ -130,7 +129,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) { function setDataTypeRef(data: string) { const getFirstLine = data?.match(/^(.*)/)?.[0]; - /** + /* * Catching edge case with regex * EG: "hello\tworld"\tFirst * - find \t between quotes, and replace with '\s' @@ -180,8 +179,6 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) { title="Import CSV or TSV" onClick={handleOpen} icon={} - // FIXME: - disabled /> )} diff --git a/src/components/TableWizards/Cell.tsx b/src/components/TableWizards/Cell.tsx new file mode 100644 index 00000000..b614e9ce --- /dev/null +++ b/src/components/TableWizards/Cell.tsx @@ -0,0 +1,99 @@ +import { createElement } from "react"; + +import { styled } from "@mui/material"; +import EmptyState from "@src/components/EmptyState"; + +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; + +const Root = styled("div")(({ theme }) => ({ + width: "100%", + height: 43, + position: "relative", + overflow: "hidden", + whiteSpace: "nowrap", + + pointerEvents: "none", + + border: `1px solid ${theme.palette.divider}`, + borderTopWidth: 0, + backgroundColor: theme.palette.background.paper, + + display: "flex", + alignItems: "center", + padding: theme.spacing(0, 1.25), + + ...theme.typography.body2, + fontSize: "0.75rem", + lineHeight: "inherit", + color: theme.palette.text.secondary, + + "& .cell-collapse-padding": { + margin: theme.spacing(0, -1.5), + width: `calc(100% + ${theme.spacing(3)})`, + }, +})); + +const Value = styled("div")(({ theme }) => ({ + width: "100%", + height: 43, + display: "flex", + justifyContent: "flex-start", + alignItems: "center", +})); + +export interface ICellProps + extends Partial< + React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > + > { + field: string; + type: FieldType; + value: any; + name?: string; +} + +export default function Cell({ + field, + type, + value, + name, + ...props +}: ICellProps) { + const formatter = type ? getFieldProp("TableCell", type) : null; + + return ( + + + {formatter ? ( + createElement(formatter, { + value, + rowIdx: 0, + column: { + type, + key: field, + name, + config: { options: [] }, + editable: false, + } as any, + row: { [field]: value }, + isRowSelected: false, + onRowSelectionChange: () => {}, + isSummaryRow: false, + } as any) + ) : typeof value === "string" || + typeof value === "number" || + value === undefined || + value === null ? ( + value + ) : typeof value === "boolean" ? ( + value.toString() + ) : ( + + )} + + + ); +} diff --git a/src/components/TableWizards/ImportCsvWizard/ImportCsvWizard.tsx b/src/components/TableWizards/ImportCsvWizard/ImportCsvWizard.tsx new file mode 100644 index 00000000..356032ce --- /dev/null +++ b/src/components/TableWizards/ImportCsvWizard/ImportCsvWizard.tsx @@ -0,0 +1,199 @@ +import { useState, useMemo, useCallback } from "react"; +import useMemoValue from "use-memo-value"; +import { useAtom, useSetAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { mergeWith, find, isEqual } from "lodash-es"; + +import { + useTheme, + useMediaQuery, + Typography, + Link, + Alert, + AlertTitle, +} from "@mui/material"; + +import WizardDialog from "@src/components/TableWizards/WizardDialog"; +import Step1Columns from "./Step1Columns"; +import Step2NewColumns from "./Step2NewColumns"; +import Step3Preview from "./Step3Preview"; + +import { + tableScope, + tableSchemaAtom, + addColumnAtom, + addRowAtom, +} from "@src/atoms/tableScope"; +import { ColumnConfig } from "@src/types/table"; +import { getFieldProp } from "@src/components/fields"; +import { analytics, logEvent } from "@src/analytics"; +import { ImportType } from "@src/components/TableToolbar/ImportCsv"; + +export type CsvConfig = { + pairs: { csvKey: string; columnKey: string }[]; + newColumns: ColumnConfig[]; +}; + +export interface IStepProps { + csvData: NonNullable; + config: CsvConfig; + setConfig: React.Dispatch>; + updateConfig: (value: Partial) => void; + isXs: boolean; +} + +export interface IImportCsvWizardProps { + importType: ImportType; + handleClose: () => void; + csvData: { + columns: string[]; + rows: Record[]; + } | null; +} + +export default function ImportCsvWizard({ + importType, + handleClose, + csvData, +}: IImportCsvWizardProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const addColumn = useSetAtom(addColumnAtom, tableScope); + // const addRow = useSetAtom(addRowAtom, tableScope); + const { enqueueSnackbar } = useSnackbar(); + const theme = useTheme(); + const isXs = useMediaQuery(theme.breakpoints.down("sm")); + + const [open, setOpen] = useState(true); + const columns = useMemoValue(tableSchema.columns ?? {}, isEqual); + + const [config, setConfig] = useState({ + pairs: [], + newColumns: [], + }); + const updateConfig: IStepProps["updateConfig"] = useCallback((value) => { + setConfig((prev) => ({ + ...mergeWith(prev, value, (objValue, srcValue) => + Array.isArray(objValue) ? objValue.concat(srcValue) : undefined + ), + })); + }, []); + + const parsedRows: any[] = useMemo(() => { + if (!columns || !csvData) return []; + return csvData.rows.map((row) => + config.pairs.reduce((a, pair) => { + const matchingColumn = + columns[pair.columnKey] ?? + find(config.newColumns, { key: pair.columnKey }); + const csvFieldParser = getFieldProp( + "csvImportParser", + matchingColumn.type + ); + const value = csvFieldParser + ? csvFieldParser(row[pair.csvKey], matchingColumn.config) + : row[pair.csvKey]; + return { ...a, [pair.columnKey]: value }; + }, {}) + ); + }, [csvData, columns, config]); + + const handleFinish = () => { + if (!columns || !parsedRows) return; + enqueueSnackbar("Importing data…"); + // Add all new rows — synchronous + // FIXME: + // addRows(parsedRows.map((r) => ({ data: r })).reverse(), true); + + // Add any new columns to the end + for (const col of config.newColumns) addColumn({ config: col }); + logEvent(analytics, "import_success", { type: importType }); + + // Close wizard + setOpen(false); + setTimeout(handleClose, 300); + }; + + if (!csvData) return null; + + return ( + { + setOpen(false); + setTimeout(handleClose, 300); + }} + title="Import CSV or TSV" + steps={ + [ + { + title: "Choose columns", + description: ( + <> + + Select or add the columns to be imported to your table. + + + Importing dates? + Make sure they’re in UTC time and{" "} + + a supported format + + . If they’re not, you’ll need to re-import your CSV data. + + + ), + content: ( + + ), + disableNext: config.pairs.length === 0, + }, + config.newColumns.length > 0 && { + title: "Set column types", + description: + "Set the type of each column to display your data correctly. Some column types have been suggested based on your data.", + content: ( + + ), + disableNext: config.newColumns.reduce( + (a, c) => a || (c.type as any) === "", + false + ), + }, + { + title: "Preview", + description: + "Preview your data with your configured columns. You can change column types by clicking “Edit type” from the column menu at any time.", + content: ( + + ), + }, + ].filter((x) => x) as any + } + onFinish={handleFinish} + /> + ); +} diff --git a/src/components/TableWizards/ImportCsvWizard/Step1Columns.tsx b/src/components/TableWizards/ImportCsvWizard/Step1Columns.tsx new file mode 100644 index 00000000..9d8c8968 --- /dev/null +++ b/src/components/TableWizards/ImportCsvWizard/Step1Columns.tsx @@ -0,0 +1,274 @@ +import { useState } from "react"; +import { useAtom } from "jotai"; +import useMemoValue from "use-memo-value"; +import { find, findIndex, camelCase, isEqual } from "lodash-es"; + +import { + Grid, + Typography, + Divider, + FormControlLabel, + Checkbox, + Chip, +} from "@mui/material"; +import ArrowIcon from "@mui/icons-material/ArrowForward"; + +import { IStepProps } from "."; +import FadeList from "@src/components/TableWizards/ScrollableList"; +import Column from "@src/components/Table/Column"; +import MultiSelect from "@rowy/multiselect"; + +import { + tableScope, + tableSchemaAtom, + tableColumnsOrderedAtom, +} from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { suggestType } from "@src/components/TableWizards/ImportWizard/utils"; + +export default function Step1Columns({ + csvData, + config, + updateConfig, + setConfig, + isXs, +}: IStepProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + + const tableColumns = useMemoValue( + tableColumnsOrdered + .filter((column) => column.type !== FieldType.id) + .map((column) => ({ label: column.name, value: column.fieldName })), + isEqual + ); + + const [selectedFields, setSelectedFields] = useState( + config.pairs.map((pair) => pair.csvKey) + ); + + const handleSelect = + (field: string) => (e: React.ChangeEvent) => { + const checked = e.target.checked; + + if (checked) { + setSelectedFields((x) => [...x, field]); + + // Try to match the field to a column in the table + const match = + find(tableColumns, (column) => + column.label.toLowerCase().includes(field.toLowerCase()) + )?.value ?? null; + if (match) { + setConfig((config) => ({ + ...config, + pairs: [...config.pairs, { csvKey: field, columnKey: match }], + })); + } + } else { + const newValue = [...selectedFields]; + newValue.splice(newValue.indexOf(field), 1); + setSelectedFields(newValue); + + // Check if this pair was already pushed to main config + const configPair = find(config.pairs, { csvKey: field }); + const configIndex = findIndex(config.pairs, { csvKey: field }); + + // Delete matching newColumn if it was created + if (configPair) { + const newColumnIndex = findIndex(config.newColumns, { + key: configPair.columnKey, + }); + if (newColumnIndex > -1) { + const newColumns = [...config.newColumns]; + newColumns.splice(newColumnIndex, 1); + setConfig((config) => ({ ...config, newColumns })); + } + } + + // Delete pair from main config + if (configIndex > -1) { + const newConfig = [...config.pairs]; + newConfig.splice(configIndex, 1); + setConfig((config) => ({ ...config, pairs: newConfig })); + } + } + }; + + const handleChange = (csvKey: string) => (value: string) => { + const columnKey = !!tableSchema.columns?.[value] ? value : camelCase(value); + + // Check if this pair already exists in config + const configIndex = findIndex(config.pairs, { csvKey }); + if (configIndex > -1) { + const pairs = [...config.pairs]; + pairs[configIndex].columnKey = columnKey; + setConfig((config) => ({ ...config, pairs })); + } else { + updateConfig({ + pairs: [{ csvKey, columnKey }], + }); + } + + if (!tableSchema.columns?.[value]) { + updateConfig({ + newColumns: [ + { + name: value, + fieldName: columnKey, + key: columnKey, + type: suggestType(csvData.rows, csvKey) || FieldType.shortText, + index: -1, + config: {}, + }, + ], + }); + } + }; + + return ( +
+ + {!isXs && ( + + + Select columns ({config.pairs.length} of {csvData.columns.length}) + + + )} + + + Table columns + + + + + + + + {csvData.columns.map((field) => { + const selected = selectedFields.indexOf(field) > -1; + const columnKey = + find(config.pairs, { csvKey: field })?.columnKey ?? null; + const matchingColumn = columnKey + ? tableSchema.columns?.[columnKey] ?? + find(config.newColumns, { key: columnKey }) ?? + null + : null; + const isNewColumn = !!find(config.newColumns, { key: columnKey }); + + return ( + + + + } + label={} + sx={{ + marginRight: 0, + flex: 1, + alignItems: "center", + "& .MuiFormControlLabel-label": { mt: 0, flex: 1 }, + }} + /> + + + theme.spacing(7), + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + + + + + {selected && ( + { + if (!columnKey) return "Select or add column"; + else + return ( + <> + {matchingColumn?.name} + {isNewColumn && ( + + theme.spacing(1) + " !important", + backgroundColor: "action.focus", + pointerEvents: "none", + }} + /> + )} + + ); + }, + }, + InputProps: { + sx: [ + { + backgroundColor: "background.default", + border: (theme) => + `1px solid ${theme.palette.divider}`, + borderRadius: 0, + boxShadow: "none", + height: 42, + + "& > *": { + typography: "caption", + fontWeight: "medium", + }, + + color: "text.secondary", + "&:hover": { + backgroundColor: "background.default", + color: "text.primary", + boxShadow: "none", + }, + + "&::before": { content: "none" }, + "&::after": { pointerEvents: "none" }, + }, + !columnKey && { color: "text.disabled" }, + ], + }, + }} + clearable={false} + displayEmpty + labelPlural="columns" + freeText + AddButtonProps={{ children: "Add new column…" }} + AddDialogProps={{ + title: "Add new column", + textFieldLabel: "Column name", + }} + /> + )} + + + ); + })} + +
+ ); +} diff --git a/src/components/TableWizards/ImportCsvWizard/Step2NewColumns.tsx b/src/components/TableWizards/ImportCsvWizard/Step2NewColumns.tsx new file mode 100644 index 00000000..e4f54eb4 --- /dev/null +++ b/src/components/TableWizards/ImportCsvWizard/Step2NewColumns.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { find } from "lodash-es"; +import { parseJSON } from "date-fns"; + +import { Grid, Typography, Divider, ButtonBase } from "@mui/material"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; + +import { IStepProps } from "."; +import ScrollableList from "@src/components/TableWizards/ScrollableList"; +import Column from "@src/components/Table/Column"; +import Cell from "@src/components/TableWizards/Cell"; +import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; + +import { FieldType } from "@src/constants/fields"; +import { SELECTABLE_TYPES } from "@src/components/TableWizards/ImportWizard/utils"; + +export default function Step2NewColumns({ + csvData, + config, + setConfig, + isXs, +}: IStepProps) { + const [fieldToEdit, setFieldToEdit] = useState(0); + + const handleChange = (v: FieldType) => { + const newColumns = [...config.newColumns]; + newColumns[fieldToEdit].type = v; + + setConfig((config) => ({ ...config, newColumns })); + }; + + const currentPair = find(config.pairs, { + columnKey: config.newColumns[fieldToEdit]?.key, + }); + const rowData = csvData.rows.map((row) => row[currentPair?.csvKey ?? ""]); + + return ( + <> +
+ + + + New table columns + + + + + {config.newColumns.map(({ key, name, type }, i) => ( +
  • + setFieldToEdit(i)} + aria-label={`Edit column ${key}`} + focusRipple + > + } + /> + +
  • + ))} +
    +
    + + + Column type: {config.newColumns[fieldToEdit].name} + + + + +
    +
    + +
    + + {!isXs && ( + + + Raw data + + + )} + + + Column preview + + + + + + + {!isXs && ( + + + + )} + + + + + + {rowData.slice(0, 20).map((cell, i) => ( + + {!isXs && ( + + + + )} + + {!isXs && ( + theme.spacing(3) }} /> + )} + + + + + + ))} + +
    + + ); +} diff --git a/src/components/TableWizards/ImportCsvWizard/Step3Preview.tsx b/src/components/TableWizards/ImportCsvWizard/Step3Preview.tsx new file mode 100644 index 00000000..a8c51bbe --- /dev/null +++ b/src/components/TableWizards/ImportCsvWizard/Step3Preview.tsx @@ -0,0 +1,84 @@ +import { useAtom } from "jotai"; +import { find } from "lodash-es"; +import { parseJSON } from "date-fns"; + +import { styled, Grid } from "@mui/material"; +import Column from "@src/components/Table/Column"; +import Cell from "@src/components/TableWizards/Cell"; + +import { IStepProps } from "."; +import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; + +const Spacer = styled(Grid)(({ theme }) => ({ + width: theme.spacing(3), + height: theme.spacing(3), + flexShrink: 0, +})); + +const ColumnWrapper = styled(Grid)(() => ({ + width: 200, + flexShrink: 0, + marginLeft: -1, + "&:first-of-type": { marginLeft: 0 }, +})); + +export default function Step3Preview({ csvData, config }: IStepProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + const columns = config.pairs.map(({ csvKey, columnKey }) => ({ + csvKey, + columnKey, + ...(tableSchema.columns?.[columnKey] ?? + find(config.newColumns, { key: columnKey }) ?? + {}), + })); + + return ( +
    +
    + + {columns.map(({ key, name, type }) => ( + + + + ))} + + + + + {columns.map(({ csvKey, name, columnKey, type }) => ( + + {csvData.rows.slice(0, 50).map((row, i) => ( + + ))} + + + ))} + + +
    +
    + ); +} diff --git a/src/components/TableWizards/ImportCsvWizard/index.ts b/src/components/TableWizards/ImportCsvWizard/index.ts new file mode 100644 index 00000000..0eaa7750 --- /dev/null +++ b/src/components/TableWizards/ImportCsvWizard/index.ts @@ -0,0 +1,2 @@ +export * from "./ImportCsvWizard"; +export { default } from "./ImportCsvWizard"; diff --git a/src/components/TableWizards/ImportWizard/ImportWizard.tsx b/src/components/TableWizards/ImportWizard/ImportWizard.tsx new file mode 100644 index 00000000..15f330d7 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/ImportWizard.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect, useCallback } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { merge } from "lodash-es"; + +import { useTheme, useMediaQuery, Typography } from "@mui/material"; + +import WizardDialog from "@src/components/TableWizards/WizardDialog"; +import Step1Columns from "./Step1Columns"; +import Step2Rename from "./Step2Rename"; +import Step3Types from "./Step3Types"; +import Step4Preview from "./Step4Preview"; + +import { + tableScope, + tableFiltersAtom, + tableOrdersAtom, + tableRowsAtom, +} from "@src/atoms/tableScope"; +import { TableSchema, ColumnConfig } from "@src/types/table"; + +export type TableColumnsConfig = NonNullable; + +export type ImportWizardRef = { + open: boolean; + setOpen: React.Dispatch>; +}; + +export interface IStepProps { + config: TableColumnsConfig; + setConfig: React.Dispatch>; + updateConfig: (value: Partial) => void; + isXs: boolean; +} + +export default function ImportWizard() { + const setTableFilters = useSetAtom(tableFiltersAtom, tableScope); + const setTableOrders = useSetAtom(tableOrdersAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + + const theme = useTheme(); + const isXs = useMediaQuery(theme.breakpoints.down("sm")); + + const [open, setOpen] = useState(false); + // if (importWizardRef) importWizardRef.current = { open, setOpen }; + + const [config, setConfig] = useState({}); + const updateConfig: IStepProps["updateConfig"] = useCallback((value) => { + setConfig((prev) => ({ ...merge(prev, value) })); + }, []); + + // Reset table filters and orders on open + useEffect(() => { + if (!open) return; + setTableFilters([]); + setTableOrders([]); + }, [open, setTableFilters, setTableOrders]); + + if (tableRows.length === 0) return null; + + const handleFinish = () => { + // FIXME: Investigate if this overwrites + // tableActions?.table.updateConfig("columns", config); + setOpen(false); + }; + + return ( + setOpen(false)} + title="Import" + steps={[ + { + title: "Choose columns", + description: ( + <> + + It looks like you already have data in this table. You can + import and view the data by setting up columns for this table. + + + Start by choosing which columns you want to display, then sort + your columns. + + + ), + content: ( + + ), + disableNext: Object.keys(config).length === 0, + }, + { + title: "Rename columns", + description: + "Rename your table columns with user-friendly names. These changes will not update the field names in your database.", + content: ( + + ), + }, + { + title: "Set column types", + description: + "Set the type of each column to display your data correctly. Some column types have been suggested based on your data.", + content: ( + + ), + }, + { + title: "Preview", + description: + "Preview your data with your configured columns. You can change column types by clicking “Edit type” from the column menu at any time.", + content: ( + + ), + }, + ]} + onFinish={handleFinish} + /> + ); +} diff --git a/src/components/TableWizards/ImportWizard/Step1Columns.tsx b/src/components/TableWizards/ImportWizard/Step1Columns.tsx new file mode 100644 index 00000000..d794fe16 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/Step1Columns.tsx @@ -0,0 +1,199 @@ +import { useMemo, useState, useEffect } from "react"; +import { useAtom } from "jotai"; +import { + DragDropContext, + DropResult, + Droppable, + Draggable, +} from "react-beautiful-dnd"; +import { sortBy, startCase } from "lodash-es"; +import { IStepProps } from "."; + +import { + Grid, + Typography, + Divider, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import DragHandleIcon from "@mui/icons-material/DragHandle"; +import { AddColumn as AddColumnIcon } from "@src/assets/icons"; + +import ScrollableList from "@src/components/TableWizards/ScrollableList"; +import Column from "@src/components/Table/Column"; +import EmptyState from "@src/components/EmptyState"; + +import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { suggestType } from "./utils"; + +export default function Step1Columns({ config, setConfig }: IStepProps) { + // Get a list of fields from first 50 documents + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const tableRowsSample = tableRows.slice(0, 50); + + const allFields = useMemo(() => { + const fields_ = new Set(); + tableRowsSample.forEach((doc) => + Object.keys(doc).forEach((key) => { + if (key !== "ref") fields_.add(key); + }) + ); + return Array.from(fields_).sort(); + }, [tableRowsSample]); + + // Store selected fields + const [selectedFields, setSelectedFields] = useState( + sortBy(Object.keys(config), "index") + ); + + const handleSelect = + (field: string) => (e: React.ChangeEvent) => { + const checked = e.target.checked; + + if (checked) { + setSelectedFields([...selectedFields, field]); + } else { + const newSelection = [...selectedFields]; + newSelection.splice(newSelection.indexOf(field), 1); + setSelectedFields(newSelection); + } + }; + + const handleSelectAll = () => { + if (selectedFields.length !== allFields.length) + setSelectedFields(allFields); + else setSelectedFields([]); + }; + + const handleDragEnd = (result: DropResult) => { + const newOrder = [...selectedFields]; + const [removed] = newOrder.splice(result.source.index, 1); + newOrder.splice(result.destination!.index, 0, removed); + setSelectedFields(newOrder); + }; + + useEffect(() => { + setConfig( + selectedFields.reduce( + (a, c, i) => ({ + ...a, + [c]: { + fieldName: c, + key: c, + name: config[c]?.name || startCase(c), + type: + config[c]?.type || + suggestType(tableRows, c) || + FieldType.shortText, + index: i, + config: {}, + }, + }), + {} + ) + ); + }, [selectedFields, setConfig]); + + return ( + + + + Select columns ({selectedFields.length} of {allFields.length}) + + + + +
  • + + } + label="Select all" + sx={{ + height: 42, + mr: 0, + alignItems: "center", + "& .MuiFormControlLabel-label": { mt: 0, flex: 1 }, + }} + /> +
  • + + {allFields.map((field) => ( +
  • + -1} + aria-label={`Select column ${field}`} + onChange={handleSelect(field)} + color="default" + /> + } + label={} + sx={{ + height: 42, + mr: 0, + alignItems: "center", + "& .MuiFormControlLabel-label": { mt: 0, flex: 1 }, + }} + /> +
  • + ))} +
    +
    + + + Sort table columns + + + + {selectedFields.length === 0 ? ( + + + + ) : ( + + + + {(provided) => ( +
    + {selectedFields.map((field, i) => ( +
  • + + {(provided, snapshot) => ( +
    + } + /> +
    + )} +
    +
  • + ))} + {provided.placeholder} +
    + )} +
    +
    +
    + )} +
    +
    + ); +} diff --git a/src/components/TableWizards/ImportWizard/Step2Rename.tsx b/src/components/TableWizards/ImportWizard/Step2Rename.tsx new file mode 100644 index 00000000..de9a6034 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/Step2Rename.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; + +import { + Grid, + Typography, + Divider, + IconButton, + ButtonBase, + TextField, + InputAdornment, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DoneIcon from "@mui/icons-material/Done"; + +import { IStepProps } from "."; +import ScrollableList from "@src/components/TableWizards/ScrollableList"; +import Column from "@src/components/Table/Column"; + +export default function Step2Rename({ + config, + updateConfig, + isXs, +}: IStepProps) { + const [fieldToRename, setFieldToRename] = useState(""); + const [renameTextField, setRenameTextField] = useState(""); + const handleRename = () => { + updateConfig({ [fieldToRename]: { name: renameTextField } }); + setFieldToRename(""); + setRenameTextField(""); + }; + + return ( +
    + + {!isXs && ( + + + Field names + + + )} + + + Set column names + + + + + + + + {Object.entries(config).map(([field, { name }]) => ( + + {!isXs && ( + + + + )} + {!isXs && theme.spacing(3) }} />} + + {fieldToRename === field ? ( + setRenameTextField(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + }} + InputProps={{ + endAdornment: ( + + + + + + ), + sx: { + paddingRight: "1px", + borderRadius: 0, + boxShadow: (theme) => + `0 0 0 1px inset ${theme.palette.divider}`, + backgroundColor: "background.default", + typography: "subtitle2", + "& .MuiFilledInput-inputHiddenLabel": { + pt: 15 / 8, + pb: 14 / 8, + pl: 17 / 8, + }, + }, + }} + hiddenLabel + fullWidth + autoFocus + style={{ margin: 0 }} + /> + ) : ( + { + setFieldToRename(field); + setRenameTextField(name); + }} + aria-label={`Rename column ${field}`} + focusRipple + > + } /> + + )} + + + ))} + +
    + ); +} diff --git a/src/components/TableWizards/ImportWizard/Step3Types.tsx b/src/components/TableWizards/ImportWizard/Step3Types.tsx new file mode 100644 index 00000000..740f7161 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/Step3Types.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { useAtom } from "jotai"; + +import { Grid, Typography, Divider, ButtonBase } from "@mui/material"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; + +import { IStepProps } from "."; +import ScrollableList from "@src/components/TableWizards/ScrollableList"; +import Column from "@src/components/Table/Column"; +import Cell from "@src/components/TableWizards/Cell"; +import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; + +import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { SELECTABLE_TYPES } from "./utils"; + +export default function Step3Types({ config, updateConfig, isXs }: IStepProps) { + const [tableRows] = useAtom(tableRowsAtom, tableScope); + + const [fieldToEdit, setFieldToEdit] = useState(Object.keys(config)[0]); + const handleChange = (v: FieldType) => + updateConfig({ [fieldToEdit]: { type: v } }); + + return ( +
    + + + + Table columns + + + + + {Object.entries(config).map(([field, { name, type }]) => ( +
  • + setFieldToEdit(field)} + aria-label={`Edit column ${field}`} + focusRipple + sx={{ width: "100%", textAlign: "left" }} + > + + } + /> + +
  • + ))} +
    +
    + + + Column type: {config[fieldToEdit].name} + + + + +
    + + + {!isXs && ( + + + Raw data + + + )} + + + Column preview + + + + + + + {!isXs && ( + + + + )} + + + + + + {tableRows.slice(0, 20).map((row) => ( + + {!isXs && ( + + + + )} + + {!isXs && theme.spacing(3) }} />} + + + + + + ))} + +
    + ); +} diff --git a/src/components/TableWizards/ImportWizard/Step4Preview.tsx b/src/components/TableWizards/ImportWizard/Step4Preview.tsx new file mode 100644 index 00000000..35b6df57 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/Step4Preview.tsx @@ -0,0 +1,73 @@ +import { useAtom } from "jotai"; +import { IStepProps } from "."; + +import { styled, Grid } from "@mui/material"; +import Column from "@src/components/Table/Column"; +import Cell from "@src/components/TableWizards/Cell"; + +import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; + +const Spacer = styled(Grid)(({ theme }) => ({ + width: theme.spacing(3), + height: theme.spacing(3), + flexShrink: 0, +})); + +const ColumnWrapper = styled(Grid)(() => ({ + width: 200, + flexShrink: 0, + marginLeft: -1, + "&:first-of-type": { marginLeft: 0 }, +})); + +export default function Step4Preview({ config }: IStepProps) { + const [tableRows] = useAtom(tableRowsAtom, tableScope); + + return ( +
    +
    + + {Object.entries(config).map(([field, { name, type }]) => ( + + + + ))} + + + + + {Object.entries(config).map(([field, { name, type }]) => ( + + {tableRows.slice(0, 20).map((row) => ( + + ))} + + + ))} + + +
    +
    + ); +} diff --git a/src/components/TableWizards/ImportWizard/index.ts b/src/components/TableWizards/ImportWizard/index.ts new file mode 100644 index 00000000..83a3c069 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/index.ts @@ -0,0 +1,2 @@ +export * from "./ImportWizard"; +export { default } from "./ImportWizard"; diff --git a/src/components/TableWizards/ImportWizard/utils.ts b/src/components/TableWizards/ImportWizard/utils.ts new file mode 100644 index 00000000..7b6d36e0 --- /dev/null +++ b/src/components/TableWizards/ImportWizard/utils.ts @@ -0,0 +1,93 @@ +import { isDate, sortBy } from "lodash-es"; +import { FieldType } from "@src/constants/fields"; + +export const SELECTABLE_TYPES = [ + FieldType.shortText, + FieldType.longText, + FieldType.richText, + FieldType.email, + FieldType.phone, + + FieldType.checkbox, + FieldType.number, + FieldType.percentage, + + FieldType.date, + FieldType.dateTime, + + FieldType.url, + FieldType.rating, + + FieldType.singleSelect, + FieldType.multiSelect, + + FieldType.json, + FieldType.code, + + FieldType.color, + FieldType.slider, +]; + +export const REGEX_EMAIL = + /([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)/; +export const REGEX_PHONE = + /(([+][(]?[0-9]{1,3}[)]?)|([(]?[0-9]{4}[)]?))\s*[)]?[-\s\.]?[(]?[0-9]{1,3}[)]?([-\s\.]?[0-9]{3})([-\s\.]?[0-9]{3,4})/; +export const REGEX_URL = + /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; +export const REGEX_HTML = /<\/?[a-z][\s\S]*>/; + +const inferTypeFromValue = (value: any) => { + if (!value || typeof value === "function") return; + + if (Array.isArray(value) && typeof value[0] === "string") + return FieldType.multiSelect; + if (typeof value === "boolean") return FieldType.checkbox; + if (isDate(value)) return FieldType.dateTime; + + if (typeof value === "object") { + if ("hex" in value && "rgb" in value) return FieldType.color; + if ("toDate" in value) return FieldType.dateTime; + return FieldType.json; + } + + if (typeof value === "number") { + if (Math.abs(value) > 0 && Math.abs(value) < 1) return FieldType.percentage; + return FieldType.number; + } + + if (typeof value === "string") { + if (REGEX_EMAIL.test(value)) return FieldType.email; + if (REGEX_PHONE.test(value)) return FieldType.phone; + if (REGEX_URL.test(value)) return FieldType.url; + if (REGEX_HTML.test(value)) return FieldType.richText; + if (value.length >= 50) return FieldType.longText; + return FieldType.shortText; + } + + return; +}; + +export const suggestType = (data: { [key: string]: any }[], field: string) => { + const results: Record = {}; + + data.forEach((row) => { + const result = inferTypeFromValue(row[field]); + if (!result) return; + if (results[result] === undefined) results[result] = 1; + else results[result] += 1; + }); + + const sortedResults = sortBy(Object.entries(results), 1).reverse(); + if (!sortedResults || !sortedResults[0]) return FieldType.json; + const bestMatch = sortedResults[0][0]; + + if (bestMatch === FieldType.shortText) { + const values = data.map((row) => row[field]); + const uniqueValues = new Set(values); + const hasDuplicates = values.length !== uniqueValues.size; + + if (hasDuplicates && uniqueValues.size < 30) return FieldType.singleSelect; + } + + return bestMatch; +}; diff --git a/src/components/TableWizards/ScrollableList.tsx b/src/components/TableWizards/ScrollableList.tsx new file mode 100644 index 00000000..08033fcc --- /dev/null +++ b/src/components/TableWizards/ScrollableList.tsx @@ -0,0 +1,62 @@ +import { memo } from "react"; +import useScrollInfo from "react-element-scroll-hook"; + +import { styled, Divider, DividerProps } from "@mui/material"; +import { spreadSx } from "@src/utils/ui"; + +const MemoizedList = memo( + styled("ul")(({ theme }) => ({ + listStyleType: "none", + margin: 0, + padding: theme.spacing(1.5, 0, 3), + + height: 400, + overflowY: "auto", + + "& li": { margin: theme.spacing(0.5, 0) }, + })) +); + +export interface IFadeListProps { + children?: React.ReactNode; + disableTopDivider?: boolean; + disableBottomDivider?: boolean; + dividerSx?: DividerProps["sx"]; + topDividerSx?: DividerProps["sx"]; + bottomDividerSx?: DividerProps["sx"]; + listSx?: DividerProps["sx"]; +} + +export default function FadeList({ + children, + disableTopDivider = true, + disableBottomDivider = false, + dividerSx = [], + topDividerSx = [], + bottomDividerSx = [], + listSx, +}: IFadeListProps) { + const [scrollInfo, setRef] = useScrollInfo(); + + return ( + <> + {!disableTopDivider && + scrollInfo.y.percentage !== null && + scrollInfo.y.percentage > 0 && ( + + )} + + + {children} + + + {!disableBottomDivider && + scrollInfo.y.percentage !== null && + scrollInfo.y.percentage < 1 && ( + + )} + + ); +} diff --git a/src/components/TableWizards/TableWizards.tsx b/src/components/TableWizards/TableWizards.tsx new file mode 100644 index 00000000..aa89afe2 --- /dev/null +++ b/src/components/TableWizards/TableWizards.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +export interface ITableWizardsProps {} + +export default function TableWizards(props: ITableWizardsProps) { + return
    ; +} diff --git a/src/components/TableWizards/WizardDialog.tsx b/src/components/TableWizards/WizardDialog.tsx new file mode 100644 index 00000000..c9517ca9 --- /dev/null +++ b/src/components/TableWizards/WizardDialog.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; + +import { + useTheme, + useMediaQuery, + Dialog, + DialogProps, + Stack, + DialogTitle, + Typography, + IconButton, + MobileStepper, + DialogActions, + Button, + Slide, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; + +import { SlideTransitionMui } from "@src/components/Modal/SlideTransition"; +import ScrollableDialogContent from "@src/components/Modal/ScrollableDialogContent"; + +import { spreadSx } from "@src/utils/ui"; + +export interface IWizardDialogProps extends DialogProps { + title: string; + steps: { + title: string; + description?: React.ReactNode; + content: React.ReactNode; + disableNext?: boolean; + }[]; + onFinish: () => void; + fullHeight?: boolean; +} + +export default function WizardDialog({ + title, + steps, + onFinish, + fullHeight = true, + ...props +}: IWizardDialogProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const [step, setStep] = useState(0); + const currentStep = steps[step]; + + const handleNext = () => + step < steps.length - 1 ? setStep((s) => s + 1) : onFinish(); + const handleBack = () => + step > 0 ? setStep((s) => s - 1) : props.onClose?.({}, "escapeKeyDown"); + + return ( + + + + {title} + {currentStep.title && `: ${currentStep.title}`} + + + + + + + } + backButton={ + + + + } + /> + + + + + + + + + {currentStep.description && ( + + {currentStep.description} + + )} + + {currentStep.content} + + + + + + + + ); +} diff --git a/src/components/TableWizards/index.ts b/src/components/TableWizards/index.ts new file mode 100644 index 00000000..9dbe943d --- /dev/null +++ b/src/components/TableWizards/index.ts @@ -0,0 +1,2 @@ +export * from "./TableWizards"; +export { default } from "./TableWizards"; diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx index b3566bde..cfee6bf4 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/Thumbnail.tsx @@ -6,6 +6,7 @@ import { Box, BoxProps, Skeleton } from "@mui/material"; import EmptyState from "./EmptyState"; import BrokenImageIcon from "@mui/icons-material/BrokenImageOutlined"; +import { spreadSx } from "@src/utils/ui"; export interface IThumbnailProps extends React.DetailedHTMLProps< @@ -79,7 +80,7 @@ export function Thumbnail({ border ? `0 0 0 1px ${theme.palette.divider} inset` : "none", }, }, - ...(Array.isArray(props.sx) ? props.sx : [props.sx]), + ...spreadSx(props.sx), ]} /> ); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 514bca02..8d3007ca 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -1,5 +1,10 @@ +import { SxProps, Theme } from "@mui/material"; + export const isTargetInsideBox = (target: Element, box: Element) => { const targetRect = target.getBoundingClientRect(); const boxRect = box.getBoundingClientRect(); return targetRect.y < boxRect.y + boxRect.height; }; + +export const spreadSx = (sx?: SxProps) => + Array.isArray(sx) ? sx : sx ? [sx] : []; diff --git a/yarn.lock b/yarn.lock index ef276b15..ea2c33a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3736,6 +3736,14 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -3909,6 +3917,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react-beautiful-dnd@^13.1.2": + version "13.1.2" + resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz#510405abb09f493afdfd898bf83995dc6385c130" + integrity sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg== + dependencies: + "@types/react" "*" + "@types/react-div-100vh@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@types/react-div-100vh/-/react-div-100vh-0.4.0.tgz#750e3ac45ee239ec2952089c1516f3b510bd103e" @@ -3930,6 +3945,16 @@ dependencies: "@types/react" "*" +"@types/react-redux@^7.1.20": + version "7.1.24" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0" + integrity sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-router-dom@*", "@types/react-router-dom@^5.3.3": version "5.3.3" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83" @@ -5586,6 +5611,13 @@ css-blank-pseudo@^3.0.3: dependencies: postcss-selector-parser "^6.0.9" +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-declaration-sorter@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.2.2.tgz#bfd2f6f50002d6a3ae779a87d3a0c5d5b10e0f02" @@ -5804,11 +5836,6 @@ csstype@^3.0.11, csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.11.tgz#d66700c5eacfac1940deb4e3ee5642792d85cd33" integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw== -csstype@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" - integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== - csv-parse@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.1.0.tgz#e587e969bf0385ecf4f36f584ed5ddebba0237ab" @@ -9299,6 +9326,11 @@ memfs@^3.1.2, memfs@^3.4.1: dependencies: fs-monkey "1.0.3" +memoize-one@^5.1.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -11063,6 +11095,11 @@ quote-stream@^1.0.1, quote-stream@~1.0.2: minimist "^1.1.3" through2 "^2.0.0" +raf-schd@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== + raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -11122,6 +11159,19 @@ react-base16-styling@^0.6.0: lodash.flow "^3.3.0" pure-color "^1.2.0" +react-beautiful-dnd@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz#ec97c81093593526454b0de69852ae433783844d" + integrity sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-color-palette@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/react-color-palette/-/react-color-palette-6.2.0.tgz#aa3be88f6953d57502c00f4433692129ffbad3e7" @@ -11316,6 +11366,18 @@ react-markdown@^8.0.3, react-markdown@~8.0.0: unist-util-visit "^4.0.0" vfile "^5.0.0" +react-redux@^7.2.0: + version "7.2.8" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de" + integrity sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -11486,7 +11548,7 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux@^4.1.1, redux@^4.2.0: +redux@^4.0.0, redux@^4.0.4, redux@^4.1.1, redux@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13" integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== @@ -12830,6 +12892,11 @@ tiny-inflate@^1.0.0: resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== +tiny-invariant@^1.0.6: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" + integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== + tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" @@ -13276,6 +13343,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-memo-one@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" + integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ== + use-memo-value@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/use-memo-value/-/use-memo-value-1.0.1.tgz#71ba4500d143a1cf3ad3f18cc98a59365955f4a1"