diff --git a/craco.config.js b/craco.config.js index e71c336e..e17da462 100644 --- a/craco.config.js +++ b/craco.config.js @@ -64,4 +64,15 @@ module.exports = { return jestConfig; }, }, + webpack: { + configure: { + resolve: { + // Need to add polyfill for csv-parse + fallback: { + stream: require.resolve("stream-browserify"), + buffer: require.resolve("buffer"), + }, + }, + }, + }, }; diff --git a/emulators/auth_export/accounts.json b/emulators/auth_export/accounts.json index a2e193a1..34127b9e 100644 --- a/emulators/auth_export/accounts.json +++ b/emulators/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1652237658250","displayName":"Admin User","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltWjasmDYtQJU3vEm0cdg9:password=adminUser","salt":"fakeSaltWjasmDYtQJU3vEm0cdg9","passwordUpdatedAt":1652169642047,"customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","displayName":"Admin User","email":"admin@example.com"},{"providerId":"password","email":"admin@example.com","federatedId":"admin@example.com","rawId":"admin@example.com","displayName":"Admin User","photoUrl":""}],"validSince":"1652169642","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-05-11T02:54:18.250Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651727720399","displayName":"Editor User","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltmNJg6BtcsUfyZKz6wZQY:password=editorUser","salt":"fakeSaltmNJg6BtcsUfyZKz6wZQY","passwordUpdatedAt":1652169642047,"providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"},{"providerId":"password","email":"editor@example.com","federatedId":"editor@example.com","rawId":"editor@example.com","displayName":"Editor User","photoUrl":""}],"validSince":"1652169642","email":"editor@example.com","emailVerified":true,"disabled":false}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1652766366467","displayName":"Admin User","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltWjasmDYtQJU3vEm0cdg9:password=adminUser","salt":"fakeSaltWjasmDYtQJU3vEm0cdg9","passwordUpdatedAt":1652670601103,"customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","displayName":"Admin User","email":"admin@example.com"},{"providerId":"password","email":"admin@example.com","federatedId":"admin@example.com","rawId":"admin@example.com","displayName":"Admin User","photoUrl":""}],"validSince":"1652670601","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-05-19T05:13:45.818Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651727720399","displayName":"Editor User","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltmNJg6BtcsUfyZKz6wZQY:password=editorUser","salt":"fakeSaltmNJg6BtcsUfyZKz6wZQY","passwordUpdatedAt":1652670601104,"providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"},{"providerId":"password","email":"editor@example.com","federatedId":"editor@example.com","rawId":"editor@example.com","displayName":"Editor User","photoUrl":""}],"validSince":"1652670601","email":"editor@example.com","emailVerified":true,"disabled":false}]} \ No newline at end of file diff --git a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 5bbf1600..fa0ff791 100644 Binary files a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/emulators/firestore_export/all_namespaces/all_kinds/output-0 b/emulators/firestore_export/all_namespaces/all_kinds/output-0 index eae31061..4d99baab 100644 Binary files a/emulators/firestore_export/all_namespaces/all_kinds/output-0 and b/emulators/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/package.json b/package.json index 3b622810..820fa843 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@mui/styles": "^5.6.2", "@rowy/form-builder": "^0.6.1", "@rowy/multiselect": "^0.3.0", + "buffer": "^6.0.3", "compare-versions": "^4.1.3", + "csv-parse": "^5.0.4", "date-fns": "^2.28.0", "dompurify": "^2.3.6", "firebase": "^9.6.11", @@ -34,7 +36,10 @@ "react-color-palette": "^6.2.0", "react-data-grid": "7.0.0-beta.5", "react-div-100vh": "^0.7.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.0.0", + "react-dropzone": "^10", "react-element-scroll-hook": "^1.1.0", "react-error-boundary": "^3.1.4", "react-helmet-async": "^1.3.0", @@ -44,6 +49,7 @@ "react-router-hash-link": "^2.4.3", "react-scripts": "^5.0.0", "remark-gfm": "^3.0.1", + "stream-browserify": "^3.0.0", "swr": "^1.3.0", "tss-react": "^3.6.2", "typescript": "^4.6.3", diff --git a/src/App.tsx b/src/App.tsx index e653e225..54dd2b49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,7 +36,7 @@ const TableSettingsDialog = lazy(() => import("@src/components/TableSettingsDial // prettier-ignore const TablesPage = lazy(() => import("@src/pages/Tables" /* webpackChunkName: "TablesPage" */)); // prettier-ignore -const TablePage = lazy(() => import("@src/pages/TableTest" /* webpackChunkName: "TablePage" */)); +const TablePage = lazy(() => import("@src/pages/Table" /* webpackChunkName: "TablePage" */)); // prettier-ignore const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */)); @@ -119,7 +119,7 @@ export default function App() { /> {/* } /> */} - } /> + } /> } /> diff --git a/src/atoms/globalScope/ui.ts b/src/atoms/globalScope/ui.ts index 0ee1ad3c..94e6039a 100644 --- a/src/atoms/globalScope/ui.ts +++ b/src/atoms/globalScope/ui.ts @@ -138,3 +138,18 @@ export const tableSettingsDialogSchemaAtom = atom(async (get) => { if (!tableId || !getTableSchema) return {} as TableSchema; return getTableSchema(tableId); }); + +/** Persist the state of the add row ID type */ +export const tableAddRowIdTypeAtom = atomWithStorage< + "decrement" | "random" | "custom" +>("__ROWY__ADD_ROW_ID_TYPE", "decrement"); +/** Persist when the user dismissed the row out of order warning */ +export const tableOutOfOrderDismissedAtom = atomWithStorage( + "__ROWY__OUT_OF_ORDER_TOOLTIP_DISMISSED", + false +); +/** Store tables where user has dismissed the description tooltip */ +export const tableDescriptionDismissedAtom = atomWithStorage( + "__ROWY__TABLE_DESCRIPTION_DISMISSED", + [] +); diff --git a/src/atoms/tableScope/columnActions.ts b/src/atoms/tableScope/columnActions.ts index 8a25456c..f123627f 100644 --- a/src/atoms/tableScope/columnActions.ts +++ b/src/atoms/tableScope/columnActions.ts @@ -1,25 +1,13 @@ import { atom } from "jotai"; -import { orderBy, findIndex } from "lodash-es"; +import { findIndex } from "lodash-es"; -import { tableSchemaAtom, updateTableSchemaAtom } from "./table"; +import { + tableColumnsOrderedAtom, + tableColumnsReducer, + updateTableSchemaAtom, +} from "./table"; import { ColumnConfig } from "@src/types/table"; -/** Store the table columns as an ordered array */ -export const tableColumnsOrderedAtom = atom((get) => { - const tableSchema = get(tableSchemaAtom); - if (!tableSchema || !tableSchema.columns) return []; - return orderBy(Object.values(tableSchema?.columns ?? {}), "index"); -}); -/** Reducer function to convert from array of columns to columns object */ -export const tableColumnsReducer = ( - a: Record, - c: ColumnConfig, - index: number -) => { - a[c.key] = { ...c, index }; - return a; -}; - export interface IAddColumnOptions { /** Column config to add. `config.index` is ignored */ config: Omit; diff --git a/src/atoms/tableScope/rowActions.test.ts b/src/atoms/tableScope/rowActions.test.ts index eab16fc5..946c4d14 100644 --- a/src/atoms/tableScope/rowActions.test.ts +++ b/src/atoms/tableScope/rowActions.test.ts @@ -233,6 +233,9 @@ describe("addRow", () => { }); }); + // TODO: NESTED FIELDS TESTS + // TODO: TEST _rowy_* fields are removed + describe("multiple", () => { test("adds multiple rows with pre-defined id", async () => { initRows(generatedRows); diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts index 29ecd434..18e19e1b 100644 --- a/src/atoms/tableScope/rowActions.ts +++ b/src/atoms/tableScope/rowActions.ts @@ -5,13 +5,13 @@ import { currentUserAtom } from "@src/atoms/globalScope"; import { auditChangeAtom, tableSettingsAtom, + tableColumnsOrderedAtom, tableFiltersAtom, tableRowsLocalAtom, tableRowsAtom, _updateRowDbAtom, _deleteRowDbAtom, } from "./table"; -import { tableColumnsOrderedAtom } from "./columnActions"; import { TableRow } from "@src/types/table"; import { rowyUser, @@ -216,7 +216,7 @@ export interface IUpdateFieldOptions { * 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 IAddRowOptions} + * @param options - {@link IUpdateFieldOptions} * * @example Basic usage: * ``` diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index 353d2905..87fb7706 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -1,10 +1,18 @@ import { atom } from "jotai"; import { atomWithReducer } from "jotai/utils"; -import { uniqBy, sortBy, findIndex, cloneDeep, unset } from "lodash-es"; +import { + uniqBy, + sortBy, + findIndex, + cloneDeep, + unset, + orderBy, +} from "lodash-es"; import { TableSettings, TableSchema, + ColumnConfig, TableFilter, TableOrder, TableRow, @@ -15,15 +23,37 @@ import { import { updateRowData } from "@src/utils/table"; /** Root atom from which others are derived */ -export const tableIdAtom = atom(undefined); +export const tableIdAtom = atom(""); /** Store tableSettings from project settings document */ -export const tableSettingsAtom = atom(undefined); +export const tableSettingsAtom = atom({ + id: "", + collection: "", + name: "", + roles: [], + section: "", + tableType: "primaryCollection", +}); /** Store tableSchema from schema document */ export const tableSchemaAtom = atom({}); /** Store function to update tableSchema */ export const updateTableSchemaAtom = atom< UpdateDocFunction | undefined >(undefined); +/** Store the table columns as an ordered array */ +export const tableColumnsOrderedAtom = atom((get) => { + const tableSchema = get(tableSchemaAtom); + if (!tableSchema || !tableSchema.columns) return []; + return orderBy(Object.values(tableSchema?.columns ?? {}), "index"); +}); +/** Reducer function to convert from array of columns to columns object */ +export const tableColumnsReducer = ( + a: Record, + c: ColumnConfig, + index: number +) => { + a[c.key] = { ...c, index }; + return a; +}; /** Filters applied to the local view */ export const tableFiltersAtom = atom([]); diff --git a/src/components/ButtonWithStatus.tsx b/src/components/ButtonWithStatus.tsx new file mode 100644 index 00000000..39805b2b --- /dev/null +++ b/src/components/ButtonWithStatus.tsx @@ -0,0 +1,60 @@ +import React, { forwardRef } from "react"; + +import { Button, ButtonProps } from "@mui/material"; +import { alpha } from "@mui/material/styles"; + +export interface IButtonWithStatusProps extends ButtonProps { + active?: boolean; +} + +export const ButtonWithStatus = forwardRef(function ButtonWithStatus_( + { active = false, className, ...props }: IButtonWithStatusProps, + ref: React.Ref +) { + return ( + + ) : ( + + Click to dismiss + + )} + + } + PopperProps={{ + modifiers: [ + { + name: "preventOverflow", + enabled: true, + options: { + altAxis: true, + altBoundary: true, + tether: false, + rootBoundary: "document", + padding: 8, + }, + }, + ], + }} + {...props} + > + {render({ openTooltip, closeTooltip, toggleTooltip })} + + ); +} diff --git a/src/components/Table/Breadcrumbs.tsx b/src/components/Table/Breadcrumbs.tsx new file mode 100644 index 00000000..20802209 --- /dev/null +++ b/src/components/Table/Breadcrumbs.tsx @@ -0,0 +1,176 @@ +import { useAtom } from "jotai"; +import { + useLocation, + useSearchParams, + Link as RouterLink, +} from "react-router-dom"; +import { find, camelCase, uniq } from "lodash-es"; + +import { + Breadcrumbs as MuiBreadcrumbs, + BreadcrumbsProps, + Link, + 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"; +import RenderedMarkdown from "@src/components/RenderedMarkdown"; + +import { + globalScope, + userRolesAtom, + tableDescriptionDismissedAtom, + tablesAtom, +} from "@src/atoms/globalScope"; +import { ROUTES } from "@src/constants/routes"; + +export default function Breadcrumbs({ sx = [], ...props }: BreadcrumbsProps) { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [dismissed, setDismissed] = useAtom( + tableDescriptionDismissedAtom, + globalScope + ); + const [tables] = useAtom(tablesAtom, globalScope); + + const { pathname } = useLocation(); + const id = pathname.replace(ROUTES.table + "/", ""); + const [searchParams] = useSearchParams(); + + const tableSettings = find(tables, ["id", id]); + if (!tableSettings) return null; + + const collection = id || tableSettings.collection; + const parentLabel = decodeURIComponent(searchParams.get("parentLabel") || ""); + const breadcrumbs = collection.split("/"); + const section = tableSettings.section; + const getLabel = (id: string) => find(tables, ["id", id])?.name || id; + + return ( + } + aria-label="Sub-table breadcrumbs" + {...props} + sx={[ + { + "& .MuiBreadcrumbs-ol": { + userSelect: "none", + flexWrap: "nowrap", + whiteSpace: "nowrap", + }, + }, + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {/* Section name */} + {section && ( + + {section} + + )} + + {breadcrumbs.map((crumb: string, index) => { + // If it’s the first breadcrumb, show with specific style + const crumbProps = { + key: index, + variant: "h6", + component: index === 0 ? "h1" : "div", + color: + index === breadcrumbs.length - 1 ? "textPrimary" : "textSecondary", + } as const; + + // If it’s the last crumb, just show the label without linking + if (index === breadcrumbs.length - 1) + return ( +
+ + {getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")} + + {crumb === tableSettings.id && tableSettings.readOnly && ( + + + + )} + + {crumb === tableSettings.id && tableSettings.description && ( + + +
+ } + buttonLabel="Table info" + tooltipProps={{ + componentsProps: { + popper: { sx: { zIndex: "appBar" } }, + tooltip: { sx: { maxWidth: "75vw" } }, + } as any, + }} + defaultOpen={!dismissed.includes(tableSettings.id)} + onClose={() => + setDismissed((d) => uniq([...d, tableSettings.id])) + } + /> + )} + + ); + + // If odd: breadcrumb points to a document — link to rowRef + // FUTURE: show a picker here to switch between sub tables + if (index % 2 === 1) + return ( + + {getLabel( + parentLabel.split(",")[Math.ceil(index / 2) - 1] || crumb + )} + + ); + + // Otherwise, even: breadcrumb points to a Firestore collection + return ( + + {getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")} + + ); + })} +
+ ); +} diff --git a/src/components/Table/CellValidation.tsx b/src/components/Table/CellValidation.tsx new file mode 100644 index 00000000..ac6a9764 --- /dev/null +++ b/src/components/Table/CellValidation.tsx @@ -0,0 +1,98 @@ +import { styled } from "@mui/material/styles"; +import ErrorIcon from "@mui/icons-material/ErrorOutline"; +import WarningIcon from "@mui/icons-material/WarningAmber"; + +import RichTooltip from "@src/components/RichTooltip"; + +const Root = styled("div", { shouldForwardProp: (prop) => prop !== "error" })( + ({ theme, ...props }) => ({ + width: "100%", + height: "100%", + padding: "var(--cell-padding)", + position: "relative", + + overflow: "hidden", + contain: "strict", + display: "flex", + alignItems: "center", + + ...((props as any).error + ? { + ".rdg-cell:not([aria-selected=true]) &": { + boxShadow: `inset 0 0 0 2px ${theme.palette.error.main}`, + }, + } + : {}), + }) +); + +const Dot = styled("div")(({ theme }) => ({ + position: "absolute", + right: -5, + top: "50%", + transform: "translateY(-50%)", + zIndex: 1, + + width: 12, + height: 12, + + borderRadius: "50%", + backgroundColor: theme.palette.error.main, + + boxShadow: `0 0 0 4px var(--background-color)`, + ".rdg-row:hover &": { + boxShadow: `0 0 0 4px var(--row-hover-background-color)`, + }, +})); + +export interface ICellValidationProps + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > { + value: any; + required?: boolean; + validationRegex?: string; +} + +export default function CellValidation({ + value, + required, + validationRegex, + children, +}: ICellValidationProps) { + const isInvalid = validationRegex && !new RegExp(validationRegex).test(value); + const isMissing = required && value === undefined; + + if (isInvalid) + return ( + <> + } + title="Invalid data" + message="This row will not be saved until all the required fields contain valid data" + placement="right" + render={({ openTooltip }) => } + /> + + {children} + + ); + + if (isMissing) + return ( + <> + } + title="Required field" + message="This row will not be saved until all the required fields contain valid data" + placement="right" + render={({ openTooltip }) => } + /> + + {children} + + ); + + return {children}; +} diff --git a/src/components/Table/ColumnHeader.tsx b/src/components/Table/ColumnHeader.tsx new file mode 100644 index 00000000..890c09ea --- /dev/null +++ b/src/components/Table/ColumnHeader.tsx @@ -0,0 +1,305 @@ +import { useRef } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { HeaderRendererProps } from "react-data-grid"; +import { useDrag, useDrop } from "react-dnd"; +import useCombinedRefs from "@src/hooks/useCombinedRefs"; + +import { + alpha, + Tooltip, + Fade, + Grid, + IconButton, + Typography, +} from "@mui/material"; +import SortDescIcon from "@mui/icons-material/ArrowDownward"; +import DropdownIcon from "@mui/icons-material/MoreHoriz"; +import LockIcon from "@mui/icons-material/LockOutlined"; + +import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; +import { + tableScope, + tableOrdersAtom, + updateColumnAtom, +} from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; +import { TableOrder } from "@src/types/table"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +export default function DraggableHeaderRenderer({ + column, +}: HeaderRendererProps & { + onColumnsReorder: (sourceKey: string, targetKey: string) => void; +}) { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [tableOrders, setTableOrders] = useAtom(tableOrdersAtom, tableScope); + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + + const [{ isDragging }, drag] = useDrag({ + type: "COLUMN_DRAG", + item: { key: column.key }, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + }); + + // FIXME: + // const [{ isOver }, drop] = useDrop({ + // accept: "COLUMN_DRAG", + // drop: ({ key }) => { + // tableActions?.column.reorder(key, column.key); + // }, + // collect: (monitor) => ({ + // isOver: !!monitor.isOver(), + // canDrop: !!monitor.canDrop(), + // }), + // }); + const isOver = false; + + const headerRef = useCombinedRefs(drag, drag /** FIXME: drop */); + const buttonRef = useRef(null); + + const handleOpenMenu = (e: React.MouseEvent) => { + e.preventDefault(); + // FIXME: + // columnMenuRef?.current?.setSelectedColumnHeader({ + // column, + // anchorEl: buttonRef.current, + // }); + }; + const _sortKey = getFieldProp("sortKey", (column as any).type); + const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; + + const isSorted = tableOrders[0]?.key === sortKey; + const isAsc = isSorted && tableOrders[0]?.direction === "asc"; + const isDesc = isSorted && tableOrders[0]?.direction === "desc"; + + const handleSortClick = () => { + let newOrders: TableOrder[] = []; + + if (!isSorted) newOrders = [{ key: sortKey, direction: "desc" }]; + else if (isDesc) newOrders = [{ key: sortKey, direction: "asc" }]; + else newOrders = []; + + setTableOrders(newOrders); + }; + + return ( + + theme.transitions.create("color", { + duration: theme.transitions.duration.short, + }), + "&:hover": { color: "text.primary" }, + + cursor: "move", + + py: 0, + pr: 0.5, + pl: 1, + width: "100%", + }, + isDragging + ? { opacity: 0.5 } + : isOver + ? { + backgroundColor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.action.focusOpacity + ), + color: "primary.main", + } + : {}, + ]} + > + {(column.width as number) > 140 && ( + + Click to copy field key: +
+ {column.key} + + } + enterDelay={1000} + placement="bottom-start" + > + { + navigator.clipboard.writeText(column.key); + }} + > + {column.editable === false ? ( + + ) : ( + getFieldProp("icon", (column as any).type) + )} + +
+ )} + + + + {column.name as string} + + } + enterDelay={1000} + placement="bottom-start" + disableInteractive + // PopperProps={{ + // modifiers: [ + // { + // name: "flip", + // options: { + // enabled: false, + // }, + // }, + // { + // name: "preventOverflow", + // options: { + // enabled: false, + // boundariesElement: "scrollParent", + // }, + // }, + // { + // name: "hide", + // options: { + // enabled: false, + // }, + // }, + // ], + // }} + TransitionComponent={Fade} + sx={{ + "& .MuiTooltip-tooltip": { + background: "background.default", + color: "text.primary", + + margin: `-${DEFAULT_ROW_HEIGHT}px 0 0 !important`, + p: 0, + pr: 1.5, + + "& *": { lineHeight: "40px" }, + }, + }} + > + + {column.name as string} + + + + + {(column as any).type !== FieldType.id && ( + + theme.transitions.create("opacity", { + duration: theme.transitions.duration.shortest, + }), + + "$root:hover &": { opacity: 1 }, + }} + > + + + theme.transitions.create(["background-color", "transform"], { + duration: theme.transitions.duration.short, + }), + transform: isAsc ? "rotate(180deg)" : "none", + }} + > + + + + + )} + + {(userRoles.includes("ADMIN") || + (userRoles.includes("OPS") && + [FieldType.multiSelect, FieldType.singleSelect].includes( + (column as any).type + ))) && ( + + + + theme.transitions.create("color", { + duration: theme.transitions.duration.short, + }), + + color: "text.disabled", + "$root:hover &": { color: "text.primary" }, + }} + > + + + + + )} +
+ ); +} diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx new file mode 100644 index 00000000..315f6314 --- /dev/null +++ b/src/components/Table/EmptyTable.tsx @@ -0,0 +1,154 @@ +import { useAtom } from "jotai"; + +import { Grid, Stack, Typography, Button, Divider } from "@mui/material"; +import ImportIcon from "@src/assets/icons/Import"; +import AddColumnIcon from "@src/assets/icons/AddColumn"; + +import { + tableScope, + tableSettingsAtom, + tableRowsAtom, +} from "@src/atoms/tableScope"; +import { APP_BAR_HEIGHT } from "@src/layouts/Navigation"; + +// FIXME: +// import ColumnMenu from "./ColumnMenu"; +// import ImportWizard from "@src/components/Wizards/ImportWizard"; +// import ImportCSV from "@src/components/TableHeader/ImportCsv"; + +export default function EmptyTable() { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + // const { tableState, importWizardRef, columnMenuRef } = useProjectContext(); + + let contents = <>; + + if (tableRows.length > 0) { + contents = ( + <> +
+ + Get started + + + There is existing data in the Firestore collection: +
+ {tableSettings.collection} +
+
+ +
+ + You can import that existing data to this table. + + + + + {/* */} +
+ + ); + } else { + contents = ( + <> +
+ + Get started + + + There is no data in the Firestore collection: +
+ {tableSettings.collection} +
+
+ + + + + You can import data from an external CSV file: + + + {/* ( */} + + {/* )} + PopoverProps={{ + anchorOrigin: { + vertical: "bottom", + horizontal: "center", + }, + transformOrigin: { + vertical: "top", + horizontal: "center", + }, + }} + /> */} + + + + + or + + + + + + You can manually add new columns and rows: + + + + + {/* */} + + + + ); + } + + return ( + + {contents} + + ); +} diff --git a/src/components/Table/FinalColumnHeader.tsx b/src/components/Table/FinalColumnHeader.tsx new file mode 100644 index 00000000..9b227cc0 --- /dev/null +++ b/src/components/Table/FinalColumnHeader.tsx @@ -0,0 +1,38 @@ +import { useAtom } from "jotai"; +import { Column } from "react-data-grid"; + +import { Button } from "@mui/material"; +import AddColumnIcon from "@src/assets/icons/AddColumn"; + +import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; + +const FinalColumnHeader: Column["headerRenderer"] = ({ column }) => { + const [userRoles] = useAtom(userRolesAtom, globalScope); + // FIXME: const { columnMenuRef } = useProjectContext(); + // if (!columnMenuRef) return null; + + if (!userRoles.includes("ADMIN")) return null; + + const handleClick = ( + event: React.MouseEvent + ) => { + // columnMenuRef?.current?.setSelectedColumnHeader({ + // column, + // anchorEl: event.currentTarget, + // }); + }; + + return ( + + ); +}; + +export default FinalColumnHeader; diff --git a/src/components/Table/OutOfOrderIndicator.tsx b/src/components/Table/OutOfOrderIndicator.tsx new file mode 100644 index 00000000..3b9367b6 --- /dev/null +++ b/src/components/Table/OutOfOrderIndicator.tsx @@ -0,0 +1,63 @@ +import { useAtom } from "jotai"; + +import { styled } from "@mui/material/styles"; +import RichTooltip from "@src/components/RichTooltip"; +import WarningIcon from "@mui/icons-material/WarningAmber"; +import { OUT_OF_ORDER_MARGIN } from "./TableContainer"; + +import { + globalScope, + tableOutOfOrderDismissedAtom, +} from "@src/atoms/globalScope"; + +const Dot = styled("div")(({ theme }) => ({ + position: "absolute", + left: -6, + top: "50%", + transform: "translateY(-50%)", + zIndex: 1, + + width: 12, + height: 12, + + borderRadius: "50%", + backgroundColor: theme.palette.warning.main, +})); + +export interface IOutOfOrderIndicatorProps { + top: number; + height: number; +} + +export default function OutOfOrderIndicator({ + top, + height, +}: IOutOfOrderIndicatorProps) { + const [dismissed, setDismissed] = useAtom( + tableOutOfOrderDismissedAtom, + globalScope + ); + + return ( +
+ } + title="Row out of order" + message="This row will not appear on the top of the table after you reload this page" + placement="right" + render={({ openTooltip }) => } + defaultOpen={!dismissed} + onClose={() => setDismissed(true)} + /> +
+ ); +} diff --git a/src/components/Table/Skeleton/TableHeaderSkeleton.tsx b/src/components/Table/Skeleton/TableHeaderSkeleton.tsx index 02708be9..34ca7936 100644 --- a/src/components/Table/Skeleton/TableHeaderSkeleton.tsx +++ b/src/components/Table/Skeleton/TableHeaderSkeleton.tsx @@ -1,7 +1,7 @@ import { Fade, Stack, Button, Skeleton, SkeletonProps } from "@mui/material"; import AddRowIcon from "@src/assets/icons/AddRow"; -// TODO: +// FIXME: // import { TABLE_HEADER_HEIGHT } from "@src/components/TableHeader"; const TABLE_HEADER_HEIGHT = 44; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 00000000..90a30380 --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,291 @@ +import React, { useRef, useMemo, useState } from "react"; +import { find, difference, get } from "lodash-es"; +import { useAtom, useSetAtom } from "jotai"; +import { useDebouncedCallback } from "use-debounce"; + +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; + +// import "react-data-grid/dist/react-data-grid.css"; +import DataGrid, { + Column, + // SelectColumn as _SelectColumn, +} from "react-data-grid"; + +import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer"; +import TableHeader from "@src/components/TableHeader"; +import ColumnHeader from "./ColumnHeader"; +// import ColumnMenu from "./ColumnMenu"; +// import ContextMenu from "./ContextMenu"; +import FinalColumnHeader from "./FinalColumnHeader"; +import FinalColumn from "./formatters/FinalColumn"; +import TableRow from "./TableRow"; +// import BulkActions from "./BulkActions"; + +import { + globalScope, + userRolesAtom, + userSettingsAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableIdAtom, + tableSettingsAtom, + tableSchemaAtom, + tableColumnsOrderedAtom, + tableRowsAtom, + tableLoadingMoreAtom, + tablePageAtom, + updateColumnAtom, + updateFieldAtom, +} from "@src/atoms/tableScope"; + +import { getColumnType, getFieldProp } from "@src/components/fields"; +import { FieldType } from "@src/constants/fields"; +import { formatSubTableName } from "@src/utils/table"; +import { ColumnConfig } from "@src/types/table"; + +export type DataGridColumn = ColumnConfig & Column & { isNew?: true }; +export const DEFAULT_ROW_HEIGHT = 41; + +const rowKeyGetter = (row: any) => row.id; +const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : ""); +//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 }; + +export default function Table() { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [userSettings] = useAtom(userSettingsAtom, globalScope); + + const [tableId] = useAtom(tableIdAtom, tableScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const [tableLoadingMore] = useAtom(tableLoadingMoreAtom, tableScope); + const setTablePageAtom = useSetAtom(tablePageAtom, tableScope); + + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const userDocHiddenFields = + userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields; + + // Get column configs from table schema and map them to DataGridColumns + // Also filter out hidden columns and add end column + const columns = useMemo(() => { + const _columns: DataGridColumn[] = tableColumnsOrdered + .filter((column) => { + if (column.hidden) return false; + if ( + Array.isArray(userDocHiddenFields) && + userDocHiddenFields.includes(column.key) + ) + return false; + return true; + }) + .map((column: any) => ({ + draggable: true, + resizable: true, + frozen: column.fixed, + headerRenderer: ColumnHeader, + formatter: + getFieldProp("TableCell", getColumnType(column)) ?? + function InDev() { + return null; + }, + editor: + getFieldProp("TableEditor", getColumnType(column)) ?? + function InDev() { + return null; + }, + ...column, + editable: + tableSettings.readOnly && !userRoles.includes("ADMIN") + ? false + : column.editable ?? true, + width: (column.width as number) + ? (column.width as number) > 380 + ? 380 + : (column.width as number) + : 150, + })); + + if (userRoles.includes("ADMIN") || !tableSettings.readOnly) { + _columns.push({ + isNew: true, + key: "new", + fieldName: "_rowy_new", + name: "Add column", + type: FieldType.last, + index: _columns.length ?? 0, + width: 154, + headerRenderer: FinalColumnHeader, + headerCellClass: "final-column-header", + cellClass: "final-column-cell", + formatter: FinalColumn, + editable: false, + }); + } + + return _columns; + }, [ + tableColumnsOrdered, + userDocHiddenFields, + tableSettings.readOnly, + userRoles, + ]); + + // Handle columns with field names that use dot notation (nested fields) + const rows = + useMemo(() => { + const columnsWithNestedFieldNames = columns + .map((col) => col.fieldName) + .filter((fieldName) => fieldName.includes(".")); + + if (columnsWithNestedFieldNames.length === 0) return tableRows; + + return tableRows.map((row) => + columnsWithNestedFieldNames.reduce( + (acc, fieldName) => ({ + ...acc, + [fieldName]: get(row, fieldName), + }), + { ...row } + ) + ); + }, [columns, tableRows]) ?? []; + + const rowsContainerRef = useRef(null); + const [selectedRowsSet, setSelectedRowsSet] = useState>(); + const [selectedRows, setSelectedRows] = useState([]); + + // Gets more rows when scrolled down. + // https://github.com/adazzle/react-data-grid/blob/ead05032da79d7e2b86e37cdb9af27f2a4d80b90/stories/demos/AllFeatures.tsx#L60 + const handleScroll = (event: React.UIEvent) => { + const target = event.target as HTMLDivElement; + const offset = 800; + const isAtBottom = + target.clientHeight + target.scrollTop >= target.scrollHeight - offset; + if (!isAtBottom) return; + // Prevent calling more rows when they’ve already been called + if (tableLoadingMore) return; + // Call for the next page + setTablePageAtom((p) => p + 1); + }; + + const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; + const handleResize = useDebouncedCallback( + (colIndex: number, width: number) => { + const column = columns[colIndex]; + if (!column.key) return; + updateColumn({ key: column.key, config: { width } }); + }, + 1000 + ); + + return ( + <> + {/* }> + + */} + + + + + { + if (row._rowy_outOfOrder) + return rowHeight + OUT_OF_ORDER_MARGIN + 1; + + return rowHeight; + }} + headerRowHeight={42} + className="rdg-light" // Handle dark mode in MUI theme + cellNavigationMode="LOOP_OVER_ROW" + rowRenderer={TableRow} + rowKeyGetter={rowKeyGetter} + rowClass={rowClass} + selectedRows={selectedRowsSet} + onSelectedRowsChange={(newSelectedSet) => { + const newSelectedArray = newSelectedSet + ? [...newSelectedSet] + : []; + const prevSelectedRowsArray = selectedRowsSet + ? [...selectedRowsSet] + : []; + const addedSelections = difference( + newSelectedArray, + prevSelectedRowsArray + ); + const removedSelections = difference( + prevSelectedRowsArray, + newSelectedArray + ); + addedSelections.forEach((id) => { + const newRow = find(rows, { id }); + setSelectedRows([...selectedRows, newRow]); + }); + removedSelections.forEach((rowId) => { + setSelectedRows(selectedRows.filter((row) => row.id !== rowId)); + }); + setSelectedRowsSet(newSelectedSet); + }} + // onRowsChange={() => { + //console.log('onRowsChange',rows) + // }} + // FIXME: onFill={(e) => { + // console.log("onFill", e); + // const { columnKey, sourceRow, targetRows } = e; + // if (updateCell) + // targetRows.forEach((row) => + // updateCell(row.ref, columnKey, sourceRow[columnKey]) + // ); + // return []; + // }} + onPaste={(e) => { + const value = e.sourceRow[e.sourceColumnKey]; + updateField({ + path: e.targetRow._rowy_ref.path, + fieldName: e.targetColumnKey, + value, + }); + }} + // FIXME: + // onRowClick={(row, column) => { + // if (sideDrawerRef?.current) { + // sideDrawerRef.current.setCell({ + // row: findIndex(tableState.rows, { id: row.id }), + // column: column.key, + // }); + // } + // }} + // FIXME: + // onSelectedCellChange={({ rowIdx, idx }) => + // setSelectedCell({ + // rowIndex: rowIdx, + // colIndex: idx, + // }) + // } + /> + + + + {/* */} + {/* + { + setSelectedRowsSet(new Set()); + setSelectedRows([]); + }} + /> */} + + ); +} diff --git a/src/components/Table/TableContainer.tsx b/src/components/Table/TableContainer.tsx new file mode 100644 index 00000000..170fd413 --- /dev/null +++ b/src/components/Table/TableContainer.tsx @@ -0,0 +1,236 @@ +import { styled, alpha, darken, lighten } from "@mui/material"; +import { APP_BAR_HEIGHT } from "@src/layouts/Navigation"; +// import { DRAWER_COLLAPSED_WIDTH } from "@src/components/SideDrawer"; + +import { colord, extend } from "colord"; +import mixPlugin from "colord/plugins/lch"; +extend([mixPlugin]); + +export const OUT_OF_ORDER_MARGIN = 8; + +export const TableContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "rowHeight", +})<{ rowHeight: number }>(({ theme, rowHeight }) => ({ + display: "flex", + flexDirection: "column", + height: `calc(100vh - ${APP_BAR_HEIGHT}px)`, + + "& > .rdg": { + // FIXME: + // width: `calc(100% - ${DRAWER_COLLAPSED_WIDTH}px)`, + flex: 1, + paddingBottom: `max(env(safe-area-inset-bottom), ${theme.spacing(2)})`, + }, + + [theme.breakpoints.down("sm")]: { width: "100%" }, + + "& .rdg": { + "--color": theme.palette.text.primary, + "--border-color": theme.palette.divider, + // "--summary-border-color": "#aaa", + "--background-color": + theme.palette.mode === "light" + ? theme.palette.background.paper + : colord(theme.palette.background.paper) + .mix("#fff", 0.04) + .alpha(1) + .toHslString(), + "--header-background-color": theme.palette.background.default, + "--row-hover-background-color": colord(theme.palette.background.paper) + .mix(theme.palette.action.hover, theme.palette.action.hoverOpacity) + .alpha(1) + .toHslString(), + "--row-selected-background-color": + theme.palette.mode === "light" + ? lighten(theme.palette.primary.main, 0.9) + : darken(theme.palette.primary.main, 0.8), + "--row-selected-hover-background-color": + theme.palette.mode === "light" + ? lighten(theme.palette.primary.main, 0.8) + : darken(theme.palette.primary.main, 0.7), + "--checkbox-color": theme.palette.primary.main, + "--checkbox-focus-color": theme.palette.primary.main, + "--checkbox-disabled-border-color": "#ccc", + "--checkbox-disabled-background-color": "#ddd", + "--selection-color": theme.palette.primary.main, + "--font-size": "0.75rem", + "--cell-padding": theme.spacing(0, 1.25), + + border: "none", + backgroundColor: "transparent", + + ...(theme.typography.caption as any), + // fontSize: "0.8125rem", + lineHeight: "inherit !important", + + "& .rdg-cell": { + display: "flex", + alignItems: "center", + padding: 0, + + overflow: "visible", + contain: "none", + position: "relative", + + lineHeight: "calc(var(--row-height) - 1px)", + }, + + "& .rdg-cell-frozen": { + position: "sticky", + }, + "& .rdg-cell-frozen-last": { + boxShadow: theme.shadows[2] + .replace(/, 0 (\d+px)/g, ", $1 0") + .split("),") + .slice(1) + .join("),"), + + "&[aria-selected=true]": { + boxShadow: + theme.shadows[2] + .replace(/, 0 (\d+px)/g, ", $1 0") + .split("),") + .slice(1) + .join("),") + ", inset 0 0 0 2px var(--selection-color)", + }, + }, + + "& .rdg-cell-copied": { + backgroundColor: + theme.palette.mode === "light" + ? lighten(theme.palette.primary.main, 0.7) + : darken(theme.palette.primary.main, 0.6), + }, + + "& .final-column-cell": { + backgroundColor: "var(--header-background-color)", + borderColor: "var(--header-background-color)", + color: theme.palette.text.disabled, + padding: "var(--cell-padding)", + }, + }, + + ".rdg-row, .rdg-header-row": { + marginLeft: `max(env(safe-area-inset-left), ${theme.spacing(2)})`, + marginRight: `max(env(safe-area-inset-right), ${theme.spacing(8)})`, + display: "inline-grid", // Fix Safari not showing margin-right + }, + + ".rdg-header-row .rdg-cell:first-of-type": { + borderTopLeftRadius: theme.shape.borderRadius, + }, + ".rdg-header-row .rdg-cell:last-of-type": { + borderTopRightRadius: theme.shape.borderRadius, + }, + + ".rdg-header-row .rdg-cell.final-column-header": { + border: "none", + padding: theme.spacing(0, 0.75), + borderBottomRightRadius: theme.shape.borderRadius, + + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + + position: "relative", + "&::before": { + content: "''", + display: "block", + width: 88, + height: "100%", + + position: "absolute", + top: 0, + left: 0, + + border: "1px solid var(--border-color)", + borderLeftWidth: 0, + borderTopRightRadius: theme.shape.borderRadius, + borderBottomRightRadius: theme.shape.borderRadius, + }, + }, + + ".rdg-row .rdg-cell:first-of-type, .rdg-header-row .rdg-cell:first-of-type": { + borderLeft: "1px solid var(--border-color)", + }, + + ".rdg-row:last-of-type": { + borderBottomLeftRadius: theme.shape.borderRadius, + borderBottomRightRadius: theme.shape.borderRadius, + + "& .rdg-cell:first-of-type": { + borderBottomLeftRadius: theme.shape.borderRadius, + }, + "& .rdg-cell:nth-last-of-type(2)": { + borderBottomRightRadius: theme.shape.borderRadius, + }, + }, + + ".rdg-header-row .rdg-cell": { + borderTop: "1px solid var(--border-color)", + }, + + ".rdg-row:hover": { color: theme.palette.text.primary }, + + ".row-hover-iconButton": { + color: theme.palette.text.disabled, + transitionDuration: "0s", + }, + ".rdg-row:hover .row-hover-iconButton": { + color: theme.palette.text.primary, + backgroundColor: alpha( + theme.palette.action.hover, + theme.palette.action.hoverOpacity * 1.5 + ), + }, + + ".cell-collapse-padding": { + margin: theme.spacing(0, -1.25), + width: `calc(100% + ${theme.spacing(1.25 * 2)})`, + }, + + ".rdg-row.out-of-order": { + "--row-height": rowHeight + 1 + "px !important", + marginTop: -1, + marginBottom: OUT_OF_ORDER_MARGIN, + borderBottomLeftRadius: theme.shape.borderRadius, + + "& .rdg-cell:not(:last-of-type)": { + borderTop: `1px solid var(--border-color)`, + }, + "& .rdg-cell:first-of-type": { + borderBottomLeftRadius: theme.shape.borderRadius, + }, + "& .rdg-cell:nth-last-of-type(2)": { + borderBottomRightRadius: theme.shape.borderRadius, + }, + "&:not(:nth-of-type(4))": { + borderTopLeftRadius: theme.shape.borderRadius, + + "& .rdg-cell:first-of-type": { + borderTopLeftRadius: theme.shape.borderRadius, + }, + "& .rdg-cell:nth-last-of-type(2)": { + borderTopRightRadius: theme.shape.borderRadius, + }, + }, + + "& + .rdg-row:not(.out-of-order)": { + "--row-height": rowHeight + 1 + "px !important", + marginTop: -1, + borderTopLeftRadius: theme.shape.borderRadius, + + "& .rdg-cell:not(:last-of-type)": { + borderTop: `1px solid var(--border-color)`, + }, + "& .rdg-cell:first-of-type": { + borderTopLeftRadius: theme.shape.borderRadius, + }, + "& .rdg-cell:nth-last-of-type(2)": { + borderTopRightRadius: theme.shape.borderRadius, + }, + }, + }, +})); + +export default TableContainer; diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx new file mode 100644 index 00000000..cfabc16b --- /dev/null +++ b/src/components/Table/TableRow.tsx @@ -0,0 +1,31 @@ +// FIXME: +// import { useSetAnchorEle } from "@src/atoms/ContextMenu"; +import { Fragment } from "react"; +import { Row, RowRendererProps } from "react-data-grid"; + +import OutOfOrderIndicator from "./OutOfOrderIndicator"; + +export default function TableRow(props: RowRendererProps) { + // const { setAnchorEle } = useSetAnchorEle(); + const handleContextMenu = ( + e: React.MouseEvent + ) => { + e.preventDefault(); + // setAnchorEle?.(e?.target as HTMLElement); + }; + if (props.row._rowy_outOfOrder) + return ( + + + + + ); + + return ( + + ); +} diff --git a/src/components/Table/editors/NullEditor.tsx b/src/components/Table/editors/NullEditor.tsx new file mode 100644 index 00000000..9a9c3ebc --- /dev/null +++ b/src/components/Table/editors/NullEditor.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { EditorProps } from "react-data-grid"; +// import _findIndex from "lodash/findIndex"; + +import { withStyles, WithStyles } from "@mui/styles"; +import styles from "./styles"; + +/** + * Allow the cell to be editable, but disable react-data-grid’s default + * text editor to show. + * + * Hides the editor container so the cell below remains editable inline. + * + * Use for cells that have inline editing and don’t need to be double-clicked. + * + * TODO: fix NullEditor overwriting the formatter component + */ +class NullEditor extends React.Component< + EditorProps & WithStyles +> { + getInputNode = () => null; + getValue = () => null; + render = () => null; +} + +export default withStyles(styles)(NullEditor); diff --git a/src/components/Table/editors/TextEditor.tsx b/src/components/Table/editors/TextEditor.tsx new file mode 100644 index 00000000..5d96704b --- /dev/null +++ b/src/components/Table/editors/TextEditor.tsx @@ -0,0 +1,119 @@ +import { useRef, useLayoutEffect } from "react"; +import { EditorProps } from "react-data-grid"; +import { useSetAtom } from "jotai"; +import { get } from "lodash-es"; + +import { TextField } from "@mui/material"; + +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { getColumnType } from "@src/components/fields"; + +export default function TextEditor({ row, column }: EditorProps) { + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const type = getColumnType(column as any); + + const cellValue = get(row, column.key); + const defaultValue = + type === FieldType.percentage && typeof cellValue === "number" + ? cellValue * 100 + : cellValue; + + const inputRef = useRef(null); + + useLayoutEffect(() => { + return () => { + const newValue = inputRef.current?.value; + let formattedValue: any = newValue; + if (newValue !== undefined) { + if (type === FieldType.number) { + formattedValue = Number(newValue); + } else if (type === FieldType.percentage) { + formattedValue = Number(newValue) / 100; + } + + updateField({ + path: row._rowy_ref.path, + fieldName: column.key, + value: formattedValue, + }); + } + }; + }, []); + + let inputType = "text"; + switch (type) { + case FieldType.email: + inputType = "email"; + break; + case FieldType.phone: + inputType = "tel"; + break; + case FieldType.url: + inputType = "url"; + break; + case FieldType.number: + case FieldType.percentage: + inputType = "number"; + break; + + default: + break; + } + + const { maxLength } = (column as any).config; + + return ( + theme.typography.body2.lineHeight, + maxHeight: "100%", + boxSizing: "border-box", + py: 3 / 8, + }, + }} + InputProps={{ + endAdornment: + (column as any).type === FieldType.percentage ? "%" : undefined, + }} + autoFocus + onKeyDown={(e) => { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.stopPropagation(); + } + + if (e.key === "Escape") { + (e.target as any).value = defaultValue; + } + }} + /> + ); +} diff --git a/src/components/Table/editors/styles.ts b/src/components/Table/editors/styles.ts new file mode 100644 index 00000000..0eb31315 --- /dev/null +++ b/src/components/Table/editors/styles.ts @@ -0,0 +1,9 @@ +import { createStyles } from "@mui/material"; + +export const styles = createStyles({ + "@global": { + ".rdg-editor-container": { display: "none" }, + }, +}); + +export default styles; diff --git a/src/components/Table/editors/withNullEditor.tsx b/src/components/Table/editors/withNullEditor.tsx new file mode 100644 index 00000000..4a553a43 --- /dev/null +++ b/src/components/Table/editors/withNullEditor.tsx @@ -0,0 +1,44 @@ +import { get } from "lodash-es"; +import { EditorProps } from "react-data-grid"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +/** + * Allow the cell to be editable, but disable react-data-grid’s default + * text editor to show. + * + * Hides the editor container so the cell below remains editable inline. + * + * Use for cells that have inline editing and don’t need to be double-clicked. + */ +export default function withNullEditor( + HeavyCell?: React.ComponentType +) { + return function NullEditor(props: EditorProps) { + const { row, column } = props; + + return HeavyCell ? ( +
+ {}} + disabled={props.column.editable === false} + /> +
+ ) : null; + }; +} diff --git a/src/components/Table/editors/withSideDrawerEditor.tsx b/src/components/Table/editors/withSideDrawerEditor.tsx new file mode 100644 index 00000000..641a0a73 --- /dev/null +++ b/src/components/Table/editors/withSideDrawerEditor.tsx @@ -0,0 +1,52 @@ +// import { useEffect } from "react"; +import { EditorProps } from "react-data-grid"; +import { get } from "lodash-es"; + +import { IHeavyCellProps } from "@src/components/fields/types"; + +/** + * Allow the cell to be editable, but disable react-data-grid’s default + * text editor to show. Opens the side drawer in the appropriate position. + * + * Displays the current HeavyCell or HeavyCell since it overwrites cell contents. + * + * Use for cells that do not support any type of in-cell editing. + */ +export default function withSideDrawerEditor( + HeavyCell?: React.ComponentType +) { + return function SideDrawerEditor(props: EditorProps) { + const { row, column } = props; + // FIXME: const { sideDrawerRef } = useProjectContext(); + + // useEffect(() => { + // if (!sideDrawerRef?.current?.open && sideDrawerRef?.current?.setOpen) + // sideDrawerRef?.current?.setOpen(true); + // }, [column]); + + return HeavyCell ? ( +
+ {}} + disabled={props.column.editable === false} + /> +
+ ) : null; + }; +} diff --git a/src/components/Table/formatters/ChipList.tsx b/src/components/Table/formatters/ChipList.tsx new file mode 100644 index 00000000..5a6945d2 --- /dev/null +++ b/src/components/Table/formatters/ChipList.tsx @@ -0,0 +1,41 @@ +import { useAtom } from "jotai"; + +import { Grid } from "@mui/material"; + +import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +export default function ChipList({ children }: React.PropsWithChildren<{}>) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; + const canWrap = rowHeight > 24 * 2 + 4; + + return ( + `calc(100% + ${theme.spacing(0.5)})`, + py: 0.5, + + "& .MuiChip-root": { + height: 24, + lineHeight: (theme) => theme.typography.caption.lineHeight, + font: "inherit", + letterSpacing: "inherit", + display: "flex", + cursor: "inherit", + }, + }} + > + {children} + + ); +} diff --git a/src/components/Table/formatters/FinalColumn.tsx b/src/components/Table/formatters/FinalColumn.tsx new file mode 100644 index 00000000..49ee24dd --- /dev/null +++ b/src/components/Table/formatters/FinalColumn.tsx @@ -0,0 +1,102 @@ +import { useAtom, useSetAtom } from "jotai"; +import type { FormatterProps } from "react-data-grid"; + +import { Stack, Tooltip, IconButton, alpha } from "@mui/material"; +import CopyCellsIcon from "@src/assets/icons/CopyCells"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; + +import { + globalScope, + userRolesAtom, + tableAddRowIdTypeAtom, + confirmDialogAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + addRowAtom, + deleteRowAtom, +} from "@src/atoms/tableScope"; +import useKeyPress from "@src/hooks/useKeyPress"; + +export default function FinalColumn({ row }: FormatterProps) { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, globalScope); + const confirm = useSetAtom(confirmDialogAtom, globalScope); + + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const addRow = useSetAtom(addRowAtom, tableScope); + const deleteRow = useSetAtom(deleteRowAtom, tableScope); + + const altPress = useKeyPress("Alt"); + const handleDelete = () => deleteRow(row._rowy_ref.path); + + if (!userRoles.includes("ADMIN") && tableSettings.readOnly === true) + return null; + + return ( + + + + addRow({ + row, + setId: addRowIdType === "custom" ? "decrement" : addRowIdType, + }) + } + aria-label="Duplicate row" + className="row-hover-iconButton" + > + + + + + + { + confirm({ + title: "Delete row?", + body: ( + <> + Row path: +
+ + {row.ref.path} + + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleDelete, + }); + } + } + aria-label={`Delete row${altPress ? "" : "…"}`} + className="row-hover-iconButton" + sx={{ + ".rdg-row:hover .row-hover-iconButton&&": { + color: "error.main", + backgroundColor: (theme) => + alpha( + theme.palette.error.main, + theme.palette.action.hoverOpacity * 2 + ), + }, + }} + > + +
+
+
+ ); +} diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts new file mode 100644 index 00000000..9382270d --- /dev/null +++ b/src/components/Table/index.ts @@ -0,0 +1,2 @@ +export * from "./Table"; +export { default } from "./Table"; diff --git a/src/components/TableHeader/AddRow.tsx b/src/components/TableHeader/AddRow.tsx new file mode 100644 index 00000000..3f7fddc3 --- /dev/null +++ b/src/components/TableHeader/AddRow.tsx @@ -0,0 +1,182 @@ +import { useState, useRef } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { FieldType, FormDialog } from "@rowy/form-builder"; + +import { + Button, + ButtonGroup, + Select, + MenuItem, + ListItemText, + Box, +} from "@mui/material"; +import AddRowIcon from "@src/assets/icons/AddRow"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; + +import { + globalScope, + userRolesAtom, + tableAddRowIdTypeAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + addRowAtom, +} from "@src/atoms/tableScope"; + +export default function AddRow() { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const addRow = useSetAtom(addRowAtom, tableScope); + const [idType, setIdType] = useAtom(tableAddRowIdTypeAtom, globalScope); + + const anchorEl = useRef(null); + const [open, setOpen] = useState(false); + const [openIdModal, setOpenIdModal] = useState(false); + + const handleClick = () => { + if (idType === "decrement") { + addRow({ + row: { + _rowy_ref: { + id: "decrement", + path: tableSettings.collection + "/decrement", + }, + }, + setId: "decrement", + }); + } else if (idType === "random") { + addRow({ + row: { + _rowy_ref: { + id: "random", + path: tableSettings.collection + "/random", + }, + }, + setId: "random", + }); + } else if (idType === "custom") { + setOpenIdModal(true); + } + }; + + if (tableSettings.readOnly && !userRoles.includes("ADMIN")) + return ; + + return ( + <> + + + + + + + + + {openIdModal && ( + + // value && + // ( + // await db + // .collection(tableState!.tablePath!) + // .doc(value) + // .get() + // ).exists === false, + // ], + // ], + }, + ]} + onSubmit={(v) => + addRow({ + row: { + _rowy_ref: { + id: v.id, + path: tableSettings.collection + "/" + v.id, + }, + }, + }) + } + onClose={() => setOpenIdModal(false)} + DialogProps={{ maxWidth: "xs" }} + SubmitButtonProps={{ children: "Add row" }} + /> + )} + + ); +} diff --git a/src/components/TableHeader/HiddenFields.tsx b/src/components/TableHeader/HiddenFields.tsx new file mode 100644 index 00000000..cdb2a2e2 --- /dev/null +++ b/src/components/TableHeader/HiddenFields.tsx @@ -0,0 +1,144 @@ +import { useRef, useState } from "react"; +import { useAtom } from "jotai"; +import { isEqual } from "lodash-es"; + +import { AutocompleteProps } from "@mui/material"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOffOutlined"; + +import MultiSelect from "@rowy/multiselect"; +import ButtonWithStatus from "@src/components/ButtonWithStatus"; +// FIXME: +// import Column from "@src/components/Wizards/Column"; + +import { + globalScope, + userSettingsAtom, + updateUserSettingsAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableIdAtom, + tableColumnsOrderedAtom, +} from "@src/atoms/tableScope"; +import { formatSubTableName } from "@src/utils/table"; + +export default function HiddenFields() { + const buttonRef = useRef(null); + + const [userSettings] = useAtom(userSettingsAtom, globalScope); + const [tableId] = useAtom(tableIdAtom, tableScope); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + + const [open, setOpen] = useState(false); + + // Store local selection here + // Initialise hiddenFields from user doc + const userDocHiddenFields = + userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? []; + const [hiddenFields, setHiddenFields] = + useState(userDocHiddenFields); + + const tableColumns = tableColumnsOrdered.map(({ key, name }) => ({ + value: key, + label: name, + })); + + // Save when MultiSelect closes + const [updateUserSettings] = useAtom(updateUserSettingsAtom, globalScope); + const handleSave = () => { + // Only update if there were any changes because it’s slow to update + if (!isEqual(hiddenFields, userDocHiddenFields) && updateUserSettings) { + updateUserSettings({ + tables: { [formatSubTableName(tableId)]: { hiddenFields } }, + }); + } + setOpen(false); + }; + const renderOption: AutocompleteProps< + any, + true, + false, + any + >["renderOption"] = (props, option, { selected }) => ( +
  • + {/* FIXME: } + active={selected} + /> */} +
  • + ); + + return ( + <> + } + onClick={() => setOpen((o) => !o)} + active={hiddenFields.length > 0} + ref={buttonRef} + > + {hiddenFields.length > 0 ? `${hiddenFields.length} hidden` : "Hide"} + + div": { + color: "text.primary", + borderColor: "currentColor", + boxShadow: (theme: any) => + `0 0 0 1px ${theme.palette.text.primary} inset`, + }, + "& .hiddenIcon": { opacity: 0.5 }, + }, + + '&[aria-selected="true"], &[aria-selected="true"].Mui-focused, &[aria-selected="true"].Mui-focusVisible': + { + backgroundColor: "transparent", + + position: "relative", + zIndex: 1, + + "& .hiddenIcon": { opacity: 1 }, + }, + }, + }, + }, + } as any)} + label="Hidden fields" + labelPlural="fields" + options={tableColumns} + value={hiddenFields ?? []} + onChange={setHiddenFields} + onClose={handleSave} + /> + + ); +} diff --git a/src/components/TableHeader/ImportCsv.tsx b/src/components/TableHeader/ImportCsv.tsx new file mode 100644 index 00000000..796a198d --- /dev/null +++ b/src/components/TableHeader/ImportCsv.tsx @@ -0,0 +1,369 @@ +import React, { useState, useCallback, useRef } from "react"; +import { useAtom } from "jotai"; +// FIXME: import { parse } from "csv-parse"; +import { useDropzone } from "react-dropzone"; +import { useDebouncedCallback } from "use-debounce"; +import { useSnackbar } from "notistack"; + +import { + Button, + Popover, + PopoverProps as MuiPopoverProps, + Grid, + Typography, + TextField, + FormHelperText, + Divider, +} from "@mui/material"; +import Tab from "@mui/material/Tab"; +import TabContext from "@mui/lab/TabContext"; +import TabList from "@mui/lab/TabList"; +import TabPanel from "@mui/lab/TabPanel"; + +import TableHeaderButton from "./TableHeaderButton"; +import ImportIcon from "@src/assets/icons/Import"; +import FileUploadIcon from "@src/assets/icons/Upload"; +import CheckIcon from "@mui/icons-material/CheckCircle"; + +import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; +import { analytics, logEvent } from "@src/analytics"; +// FIXME: +// import ImportCsvWizard, { +// IImportCsvWizardProps, +// } from "@src/components/Wizards/ImportCsvWizard"; + +export enum ImportType { + csv = "csv", + tsv = "tsv", +} + +export enum ImportMethod { + paste = "paste", + upload = "upload", + url = "url", +} + +export interface IImportCsvProps { + render?: ( + onClick: (event: React.MouseEvent) => void + ) => React.ReactNode; + PopoverProps?: Partial; +} + +export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const { enqueueSnackbar } = useSnackbar(); + + const importTypeRef = useRef(ImportType.csv); + const importMethodRef = useRef(ImportMethod.upload); + const [open, setOpen] = useState(null); + const [tab, setTab] = useState("upload"); + const [csvData, setCsvData] = + useState(null); + const [error, setError] = useState(""); + const validCsv = + csvData !== null && csvData?.columns.length > 0 && csvData?.rows.length > 0; + + const handleOpen = (event: React.MouseEvent) => + setOpen(event.currentTarget); + const handleClose = () => { + setOpen(null); + setCsvData(null); + setTab("upload"); + setError(""); + }; + 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 onDrop = useCallback( + async (acceptedFiles: File[]) => { + try { + const file = acceptedFiles[0]; + const reader = new FileReader(); + reader.onload = (event: any) => parseCsv(event.target.result); + reader.readAsText(file); + importTypeRef.current = + file.type === "text/tab-separated-values" + ? ImportType.tsv + : ImportType.csv; + } catch (error) { + enqueueSnackbar(`Please import a .tsv or .csv file`, { + variant: "error", + anchorOrigin: { + vertical: "top", + horizontal: "center", + }, + }); + } + }, + [enqueueSnackbar] + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + multiple: false, + accept: ["text/csv", "text/tab-separated-values"], + }); + + 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' + * - w/ the \t pattern test it against the formatted string + */ + const strInQuotes = /"(.*?)"/; + const tabsWithSpace = (str: string) => str.replace("\t", "s"); + const formatString = + getFirstLine?.replace(strInQuotes, tabsWithSpace) ?? ""; + const tabPattern = /\t/; + return tabPattern.test(formatString) + ? (importTypeRef.current = ImportType.tsv) + : (importTypeRef.current = ImportType.csv); + } + const handlePaste = useDebouncedCallback((value: string) => { + parseCsv(value); + setDataTypeRef(value); + }, 1000); + + const [loading, setLoading] = useState(false); + const handleUrl = useDebouncedCallback((value: string) => { + setLoading(true); + setError(""); + fetch(value, { mode: "no-cors" }) + .then((res) => res.text()) + .then((data) => { + parseCsv(data); + setDataTypeRef(data); + setLoading(false); + }) + .catch((e) => { + setError(e.message); + setLoading(false); + }); + }, 1000); + + const [openWizard, setOpenWizard] = useState(false); + + if (tableSettings.readOnly && !userRoles.includes("ADMIN")) return null; + + return ( + <> + {render ? ( + render(handleOpen) + ) : ( + } + /> + )} + + + + { + setTab(v); + setCsvData(null); + setError(""); + }} + aria-label="Import CSV method tabs" + action={(actions) => + setTimeout(() => actions?.updateIndicator(), 200) + } + variant="fullWidth" + > + (importMethodRef.current = ImportMethod.upload)} + /> + (importMethodRef.current = ImportMethod.paste)} + /> + (importMethodRef.current = ImportMethod.url)} + /> + + + + + theme.palette.action.activeOpacity, + }, + + "&:focus": { + borderColor: "primary.main", + color: "primary.main", + outline: "none", + }, + }, + error ? { borderColor: "error.main", color: "error.main" } : {}, + ]} + > + + {isDragActive ? ( + + Drop CSV or TSV file here… + + ) : ( + <> + + {validCsv ? : } + + + + {validCsv + ? "Valid CSV or TSV" + : "Click to upload or drop CSV or TSV file here"} + + + + )} + + + {error && ( + + {error} + + )} + + + + { + if (csvData !== null) setCsvData(null); + handlePaste(e.target.value); + }} + sx={{ + typography: "body2", + fontFamily: "fontFamilyMono", + + "& .MuiInputBase-input": { + whiteSpace: "nowrap", + overflow: "auto", + fontFamily: "mono", + }, + }} + helperText={error} + error={!!error} + /> + + + + { + if (csvData !== null) setCsvData(null); + handleUrl(e.target.value); + }} + helperText={loading ? "Fetching…" : error} + error={!!error} + /> + + + + + + + {/* {openWizard && csvData && ( + setOpenWizard(false)} + csvData={csvData} + /> + )} */} + + ); +} diff --git a/src/components/TableHeader/LoadedRowsStatus.tsx b/src/components/TableHeader/LoadedRowsStatus.tsx new file mode 100644 index 00000000..4f96a35d --- /dev/null +++ b/src/components/TableHeader/LoadedRowsStatus.tsx @@ -0,0 +1,40 @@ +import { useAtom } from "jotai"; + +import { Tooltip, Typography } from "@mui/material"; + +import { + tableScope, + tableRowsAtom, + tableLoadingMoreAtom, + tablePageAtom, +} from "@src/atoms/tableScope"; +import { COLLECTION_PAGE_SIZE } from "@src/config/db"; + +export default function LoadedRowsStatus() { + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const [tableLoadingMore] = useAtom(tableLoadingMoreAtom, tableScope); + const [tablePage] = useAtom(tablePageAtom, tableScope); + + const allLoaded = + !tableLoadingMore && tableRows.length < COLLECTION_PAGE_SIZE * tablePage; + + return ( + + + Loaded {allLoaded && "all "} + {tableRows.length} row{tableRows.length !== 1 && "s"} + + + ); +} diff --git a/src/components/TableHeader/ReExecute.tsx b/src/components/TableHeader/ReExecute.tsx new file mode 100644 index 00000000..5f51f6de --- /dev/null +++ b/src/components/TableHeader/ReExecute.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { + collection, + collectionGroup, + getDocs, + writeBatch, +} from "firebase/firestore"; + +import TableHeaderButton from "./TableHeaderButton"; +import LoopIcon from "@mui/icons-material/Loop"; +import Modal from "@src/components/Modal"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +import { + globalScope, + projectSettingsAtom, + rowyRunModalAtom, +} from "@src/atoms/globalScope"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +/** + * NOTE: This is Firestore-specific + */ +export default function ReExecute() { + const [open, setOpen] = useState(false); + const [updating, setUpdating] = useState(false); + const handleClose = () => setOpen(false); + + const [projectSettings] = useAtom(projectSettingsAtom, globalScope); + const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); + + if (!firebaseDb) return null; + + if (!projectSettings.rowyRunUrl) + return ( + openRowyRunModal({ feature: "Force refresh" })} + icon={} + /> + ); + + const query = + tableSettings.tableType === "collectionGroup" + ? collectionGroup(firebaseDb, tableSettings.collection!) + : collection(firebaseDb, tableSettings.collection); + + const handleConfirm = async () => { + setUpdating(true); + const _forcedUpdateAt = new Date(); + const querySnapshot = await getDocs(query); + const docs = [...querySnapshot.docs]; + while (docs.length) { + const batch = writeBatch(firebaseDb); + const temp = docs.splice(0, 499); + temp.forEach((doc) => { + batch.update(doc.ref, { _forcedUpdateAt }); + }); + await batch.commit(); + } + setTimeout(() => { + setUpdating(false); + setOpen(false); + }, 3000); // give time to for function to run + }; + + return ( + <> + setOpen(true)} + icon={} + /> + + {open && ( + , + disabled: updating, + }, + secondary: { + children: "Cancel", + onClick: handleClose, + }, + }} + /> + )} + + ); +} diff --git a/src/components/TableHeader/RowHeight.tsx b/src/components/TableHeader/RowHeight.tsx new file mode 100644 index 00000000..5d23d6dc --- /dev/null +++ b/src/components/TableHeader/RowHeight.tsx @@ -0,0 +1,72 @@ +import { useRef, useState } from "react"; +import { useAtom } from "jotai"; + +import { useTheme, TextField, ListSubheader, MenuItem } from "@mui/material"; +import RowHeightIcon from "@src/assets/icons/RowHeight"; +import TableHeaderButton from "./TableHeaderButton"; + +import { + tableScope, + tableSchemaAtom, + updateTableSchemaAtom, +} from "@src/atoms/tableScope"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +const ROW_HEIGHTS = [33, 41, 65, 97, 129, 161]; + +export default function RowHeight() { + const theme = useTheme(); + + const buttonRef = useRef(null); + + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope); + + const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; + + return ( + <> + } + onClick={handleOpen} + ref={buttonRef} + /> + + { + if (updateTableSchema) + updateTableSchema({ rowHeight: Number(event.target.value) }); + }} + style={{ display: "none" }} + SelectProps={{ + open: open, + onOpen: handleOpen, + onClose: handleClose, + MenuProps: { + anchorEl: buttonRef.current, + anchorOrigin: { vertical: "bottom", horizontal: "right" }, + transformOrigin: { vertical: "top", horizontal: "right" }, + style: { zIndex: theme.zIndex.tooltip }, + }, + }} + label="Row height" + id="row-height-select" + > + Row height + {ROW_HEIGHTS.map((height) => ( + + {height - 1}px + + ))} + + + ); +} diff --git a/src/components/TableHeader/TableHeader.tsx b/src/components/TableHeader/TableHeader.tsx new file mode 100644 index 00000000..11b4a612 --- /dev/null +++ b/src/components/TableHeader/TableHeader.tsx @@ -0,0 +1,96 @@ +import { useAtom } from "jotai"; + +import { Stack } from "@mui/material"; +import AddRow from "./AddRow"; +// import Filters from "./Filters"; +import ImportCSV from "./ImportCsv"; +// import Export from "./Export"; +import LoadedRowsStatus from "./LoadedRowsStatus"; +import TableSettings from "./TableSettings"; +// import CloudLogs from "./CloudLogs"; +import HiddenFields from "./HiddenFields"; +import RowHeight from "./RowHeight"; +// import Extensions from "./Extensions"; +// import Webhooks from "./Webhooks"; +import ReExecute from "./ReExecute"; +// import BuildLogsSnack from "./CloudLogs/BuildLogs/BuildLogsSnack"; + +import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + tableSchemaAtom, +} from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +// import { useSnackLogContext } from "@src/contexts/SnackLogContext"; + +export const TABLE_HEADER_HEIGHT = 44; + +export default function TableHeader() { + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + // const snackLogContext = useSnackLogContext(); + + const hasDerivatives = + Object.values(tableSchema.columns ?? {}).filter( + (column) => column.type === FieldType.derivative + ).length > 0; + + const hasExtensions = + tableSchema.compiledExtension && + tableSchema.compiledExtension.replace(/\W/g, "")?.length > 0; + + return ( + `max(env(safe-area-inset-left), ${theme.spacing(2)})`, + pb: 1.5, + height: TABLE_HEADER_HEIGHT, + overflowX: "auto", + overflowY: "hidden", + "& > *": { flexShrink: 0 }, + + "& > .end-spacer": { + width: (theme) => + `max(env(safe-area-inset-right), ${theme.spacing(2)})`, + height: "100%", + ml: 0, + }, + }} + > + + {/* Spacer */}
    + + {/* */} + {/* Spacer */}
    + +
    + + {/* Spacer */}
    + {tableSettings.tableType !== "collectionGroup" && } + {/* */} + {userRoles.includes("ADMIN") && ( + <> + {/* Spacer */}
    + {/* */} + {/* */} + {/* */} + {/* {snackLogContext.isSnackLogOpen && ( + + )} */} + {(hasDerivatives || hasExtensions) && } + {/* Spacer */}
    + + + )} +
    + + ); +} diff --git a/src/components/TableHeader/TableHeaderButton.tsx b/src/components/TableHeader/TableHeaderButton.tsx new file mode 100644 index 00000000..6733f740 --- /dev/null +++ b/src/components/TableHeader/TableHeaderButton.tsx @@ -0,0 +1,30 @@ +import { forwardRef } from "react"; +import { Tooltip, Button, ButtonProps } from "@mui/material"; + +export interface ITableHeaderButtonProps extends Partial { + title: string; + icon: React.ReactNode; +} + +export const TableHeaderButton = forwardRef(function TableHeaderButton_( + { title, icon, ...props }: ITableHeaderButtonProps, + ref: React.Ref +) { + return ( + + + + ); +}); + +export default TableHeaderButton; diff --git a/src/components/TableHeader/TableSettings.tsx b/src/components/TableHeader/TableSettings.tsx new file mode 100644 index 00000000..3d97c828 --- /dev/null +++ b/src/components/TableHeader/TableSettings.tsx @@ -0,0 +1,26 @@ +import { useAtom, useSetAtom } from "jotai"; + +import TableHeaderButton from "./TableHeaderButton"; +import SettingsIcon from "@mui/icons-material/SettingsOutlined"; + +import { globalScope, tableSettingsDialogAtom } from "@src/atoms/globalScope"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function TableSettings() { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const openTableSettingsDialog = useSetAtom( + tableSettingsDialogAtom, + globalScope + ); + + return ( + + openTableSettingsDialog({ mode: "update", data: tableSettings }) + } + icon={} + disabled={!openTableSettingsDialog} + /> + ); +} diff --git a/src/components/TableHeader/index.ts b/src/components/TableHeader/index.ts new file mode 100644 index 00000000..edd1e42e --- /dev/null +++ b/src/components/TableHeader/index.ts @@ -0,0 +1,2 @@ +export * from "./TableHeader"; +export { default } from "./TableHeader"; diff --git a/src/config/db.ts b/src/config/db.ts new file mode 100644 index 00000000..c66337fe --- /dev/null +++ b/src/config/db.ts @@ -0,0 +1 @@ +export const COLLECTION_PAGE_SIZE = 30; diff --git a/src/constants/routes.tsx b/src/constants/routes.tsx index ad108cf5..703f5261 100644 --- a/src/constants/routes.tsx +++ b/src/constants/routes.tsx @@ -1,4 +1,5 @@ import Logo from "@src/assets/Logo"; +import Breadcrumbs from "@src/components/Table/Breadcrumbs"; import { GrowProps } from "@mui/material"; export enum ROUTES { @@ -46,7 +47,13 @@ export const ROUTE_TITLES = { ), }, - [ROUTES.table]: "Table Test", + [ROUTES.table]: { + title: "Table", + titleComponent: (open, pinned) => ( + + ), + titleTransitionProps: { style: { transformOrigin: "0 50%" } }, + }, [ROUTES.settings]: "Settings", [ROUTES.userSettings]: "Settings", diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts new file mode 100644 index 00000000..f4d225d9 --- /dev/null +++ b/src/hooks/useCombinedRefs.ts @@ -0,0 +1,43 @@ +import { useCallback, Ref } from "react"; + +// From https://github.com/adazzle/react-data-grid/blob/main/src/hooks/useCombinedRefs.ts +// The MIT License (MIT) + +// Original work Copyright (c) 2014 Prometheus Research +// Modified work Copyright 2015 Adazzle + +// For the original source code please see https://github.com/prometheusresearch-archive/react-grid + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +export default function useCombinedRefs(...refs: readonly Ref[]) { + return useCallback( + (handle: T | null) => { + for (const ref of refs) { + if (typeof ref === "function") { + ref(handle); + } else if (ref !== null) { + // @ts-expect-error: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 + ref.current = handle; + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + refs + ); +} diff --git a/src/hooks/useFirestoreCollectionWithAtom.ts b/src/hooks/useFirestoreCollectionWithAtom.ts index 3bae1b98..4a3a0782 100644 --- a/src/hooks/useFirestoreCollectionWithAtom.ts +++ b/src/hooks/useFirestoreCollectionWithAtom.ts @@ -29,8 +29,7 @@ import { TableRow, } from "@src/types/table"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; - -export const DEFAULT_COLLECTION_QUERY_LIMIT = 50; +import { COLLECTION_PAGE_SIZE } from "@src/config/db"; /** Options for {@link useFirestoreCollectionWithAtom} */ interface IUseFirestoreCollectionWithAtomOptions { @@ -88,7 +87,7 @@ export function useFirestoreCollectionWithAtom( collectionGroup, filters, orders, - limit = DEFAULT_COLLECTION_QUERY_LIMIT, + limit = COLLECTION_PAGE_SIZE, onError, disableSuspense, updateDocAtom, diff --git a/src/hooks/useKeyPress.ts b/src/hooks/useKeyPress.ts new file mode 100644 index 00000000..1f5e63f7 --- /dev/null +++ b/src/hooks/useKeyPress.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; + +/** + * A hook that listens to when the target key is pressed. + * @param targetKey - The key to listen to + * @returns If the key is currently pressed + */ +export default function useKeyPress(targetKey: string) { + // State for keeping track of whether key is pressed + const [keyPressed, setKeyPressed] = useState(false); + + // Add event listeners + useEffect(() => { + // If pressed key is our target key then set to true + const downHandler = ({ key }: KeyboardEvent) => { + if (key === targetKey) setKeyPressed(true); + }; + + // If released key is our target key then set to false + const upHandler = ({ key }: KeyboardEvent) => { + if (key === targetKey) setKeyPressed(false); + }; + + window.addEventListener("keydown", downHandler); + window.addEventListener("keyup", upHandler); + // Remove event listeners on cleanup + return () => { + window.removeEventListener("keydown", downHandler); + window.removeEventListener("keyup", upHandler); + }; + }, [targetKey]); + + return keyPressed; +} diff --git a/src/pages/Table.tsx b/src/pages/Table.tsx new file mode 100644 index 00000000..0e00007f --- /dev/null +++ b/src/pages/Table.tsx @@ -0,0 +1,67 @@ +import { Suspense } from "react"; +import { useAtom, Provider } from "jotai"; +import { useParams } from "react-router-dom"; +import { isEmpty } from "lodash-es"; + +import { Fade } from "@mui/material"; + +import TableHeaderSkeleton from "@src/components/Table/Skeleton/TableHeaderSkeleton"; +import HeaderRowSkeleton from "@src/components/Table/Skeleton/HeaderRowSkeleton"; +import EmptyTable from "@src/components/Table/EmptyTable"; +import Table from "@src/components/Table"; + +import { currentUserAtom, globalScope } from "@src/atoms/globalScope"; +import TableSourceFirestore from "@src/sources/TableSourceFirestore"; +import { + tableScope, + tableIdAtom, + tableSettingsAtom, + tableSchemaAtom, +} from "@src/atoms/tableScope"; + +function TablePage() { + const [tableId] = useAtom(tableIdAtom, tableScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + console.log(tableSchema); + + if (isEmpty(tableSchema.columns)) + return ( + +
    + +
    +
    + ); + + return ; +} + +export default function ProvidedTablePage() { + const { id } = useParams(); + const [currentUser] = useAtom(currentUserAtom, globalScope); + + return ( + + + + + } + > + + + + + + ); +} diff --git a/src/sources/TableSourceFirestore.tsx b/src/sources/TableSourceFirestore.tsx index a418a179..36c7210d 100644 --- a/src/sources/TableSourceFirestore.tsx +++ b/src/sources/TableSourceFirestore.tsx @@ -24,10 +24,9 @@ import { } from "@src/atoms/tableScope"; import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom"; -import useFirestoreCollectionWithAtom, { - DEFAULT_COLLECTION_QUERY_LIMIT, -} from "@src/hooks/useFirestoreCollectionWithAtom"; +import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom"; import { TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "@src/config/dbPaths"; +import { COLLECTION_PAGE_SIZE } from "@src/config/db"; import type { FirestoreError } from "firebase/firestore"; import { useSnackbar } from "notistack"; @@ -46,15 +45,13 @@ const TableSourceFirestore = memo(function TableSourceFirestore() { const setTableSettings = useSetAtom(tableSettingsAtom, tableScope); // Store tableSettings as local const so we don’t re-render // when tableSettingsAtom is set - const tableSettings = useMemo( - () => find(tables, ["id", tableId]), - [tables, tableId] - ); + const tableSettings = useMemo(() => { + const match = find(tables, ["id", tableId]); + // Store in tableSettingsAtom + if (match) setTableSettings(match); + return match; + }, [tables, tableId, setTableSettings]); if (!tableSettings) throw new Error(ERROR_TABLE_NOT_FOUND); - // Store in tableSettingsAtom - useEffect(() => { - setTableSettings(tableSettings); - }, [tableSettings, setTableSettings]); const isCollectionGroup = tableSettings?.tableType === "collectionGroup"; @@ -86,7 +83,7 @@ const TableSourceFirestore = memo(function TableSourceFirestore() { { filters, orders, - limit: DEFAULT_COLLECTION_QUERY_LIMIT * (page + 1), + limit: COLLECTION_PAGE_SIZE * (page + 1), collectionGroup: isCollectionGroup, onError: (error) => handleFirestoreError(error, enqueueSnackbar, elevateError), diff --git a/src/types/table.d.ts b/src/types/table.d.ts index ef950862..7f406282 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -65,6 +65,7 @@ export type TableSchema = { functionBuilderRef?: any; extensionObjects?: any[]; + compiledExtension?: string; webhooks?: any[]; }; @@ -81,12 +82,17 @@ export type ColumnConfig = { /** Column index set by addColumn, updateColumn functions */ index: number; + /** Set column width for all users */ width?: number; + /** If false (not undefined), locks the column for all users */ editable?: boolean; + /** Hide the column for all users */ + hidden?: boolean; - /** Column-specific config */ config?: { + /** Set column to required */ required?: boolean; + /** Set column default value */ defaultValue?: { type: "undefined" | "null" | "static" | "dynamic"; value?: any; @@ -94,9 +100,9 @@ export type ColumnConfig = { dynamicValueFn?: string; }; + /** Column-specific config */ [key: string]: any; }; - // [key: string]: any; }; export type TableFilter = { diff --git a/src/utils/table.ts b/src/utils/table.ts index 5535dd48..e04e91e5 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -1,5 +1,6 @@ import { mergeWith, isArray } from "lodash-es"; import type { User } from "firebase/auth"; +import { TABLE_GROUP_SCHEMAS, TABLE_SCHEMAS } from "@src/config/dbPaths"; /** * Creates a standard user object to write to table rows @@ -93,3 +94,22 @@ export const decrementId = (id: string = "zzzzzzzzzzzzzzzzzzzz") => { return newId.join(""); }; + +// Gets sub-table ID in $1 +const formatPathRegex = /\/[^\/]+\/([^\/]+)/g; + +/** Format table path */ +export const formatPath = ( + tablePath: string, + isCollectionGroup: boolean = false +) => { + return `${ + isCollectionGroup ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS + }/${tablePath.replace(formatPathRegex, "/subTables/$1")}`; +}; + +/** Format sub-table name to store settings in user settings */ +export const formatSubTableName = (tablePath: string) => + tablePath + ? tablePath.replace(formatPathRegex, "/subTables/$1").replace(/\//g, "_") + : ""; diff --git a/yarn.lock b/yarn.lock index 582b733f..bcfddfd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3004,16 +3004,31 @@ resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab" integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg== +"@react-dnd/asap@^5.0.1": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488" + integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A== + "@react-dnd/invariant@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e" integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw== +"@react-dnd/invariant@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df" + integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw== + "@react-dnd/shallowequal@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a" integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg== +"@react-dnd/shallowequal@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4" + integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA== + "@rollup/plugin-babel@^5.2.0": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -4423,6 +4438,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +attr-accept@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + autoprefixer@^10.4.4: version "10.4.4" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.4.tgz#3e85a245b32da876a893d3ac2ea19f01e7ea5a1e" @@ -4607,6 +4627,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -4754,6 +4779,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + builtin-modules@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" @@ -5484,6 +5517,11 @@ 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== +csv-parse@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.0.4.tgz#97e5e654413bcf95f2714ce09bcb2be6de0eb8e3" + integrity sha512-5AIdl8l6n3iYQYxan5djB5eKDa+vBnhfWZtRpJTcrETWfVLYN0WSj3L9RwvgYt+psoO77juUr8TG8qpfGZifVQ== + damerau-levenshtein@^1.0.7: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -5678,6 +5716,15 @@ dnd-core@14.0.1: "@react-dnd/invariant" "^2.0.0" redux "^4.1.1" +dnd-core@^16.0.1: + version "16.0.1" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19" + integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng== + dependencies: + "@react-dnd/asap" "^5.0.1" + "@react-dnd/invariant" "^4.0.1" + redux "^4.2.0" + dns-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" @@ -6514,6 +6561,13 @@ file-loader@^6.2.0: loader-utils "^2.0.0" schema-utils "^3.0.0" +file-selector@^0.1.12: + version "0.1.19" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.19.tgz#8ecc9d069a6f544f2e4a096b64a8052e70ec8abf" + integrity sha512-kCWw3+Aai8Uox+5tHCNgMFaUdgidxvMnLWO6fM5sZ0hA2wlHP5/DHGF0ECe84BiB95qdJbKNEJhWKVDvMN+JDQ== + dependencies: + tslib "^2.0.1" + filelist@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b" @@ -7118,6 +7172,11 @@ identity-obj-proxy@^3.0.0: dependencies: harmony-reflect "^1.4.6" +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^5.1.8, ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" @@ -7172,7 +7231,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -10486,6 +10545,13 @@ react-dnd-html5-backend@^14.0.0: dependencies: dnd-core "14.0.1" +react-dnd-html5-backend@^16.0.1: + version "16.0.1" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6" + integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw== + dependencies: + dnd-core "^16.0.1" + react-dnd@^14.0.2: version "14.0.5" resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.5.tgz#ecf264e220ae62e35634d9b941502f3fca0185ed" @@ -10497,6 +10563,17 @@ react-dnd@^14.0.2: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" +react-dnd@^16.0.1: + version "16.0.1" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37" + integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q== + dependencies: + "@react-dnd/invariant" "^4.0.1" + "@react-dnd/shallowequal" "^4.0.1" + dnd-core "^16.0.1" + fast-deep-equal "^3.1.3" + hoist-non-react-statics "^3.3.2" + react-dom@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.0.0.tgz#26b88534f8f1dbb80853e1eabe752f24100d8023" @@ -10505,6 +10582,15 @@ react-dom@^18.0.0: loose-envify "^1.1.0" scheduler "^0.21.0" +react-dropzone@^10: + version "10.2.2" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-10.2.2.tgz#67b4db7459589a42c3b891a82eaf9ade7650b815" + integrity sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA== + dependencies: + attr-accept "^2.0.0" + file-selector "^0.1.12" + prop-types "^15.7.2" + react-element-scroll-hook@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/react-element-scroll-hook/-/react-element-scroll-hook-1.1.0.tgz#4a472933f381667007ae249fb5790c39134bcae3" @@ -10704,7 +10790,7 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.2.2, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6: +readable-stream@^3.0.6, readable-stream@^3.5.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -10735,7 +10821,7 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux@^4.1.1: +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== @@ -11524,6 +11610,14 @@ static-module@^2.2.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +stream-browserify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" @@ -12078,6 +12172,11 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"