add main Table component

This commit is contained in:
Sidney Alcantara
2022-05-19 16:37:56 +10:00
parent 007a4f8387
commit 8fb9a47445
53 changed files with 3567 additions and 49 deletions

View File

@@ -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() {
/>
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
<Route path="/jotaiTest" element={<JotaiTestPage />} />
<Route path="/test/jotai" element={<JotaiTestPage />} />
</Route>
<Route path={ROUTES.themeTest} element={<ThemeTestPage />} />

View File

@@ -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<string[]>(
"__ROWY__TABLE_DESCRIPTION_DISMISSED",
[]
);

View File

@@ -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<ColumnConfig[]>((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<string, ColumnConfig>,
c: ColumnConfig,
index: number
) => {
a[c.key] = { ...c, index };
return a;
};
export interface IAddColumnOptions {
/** Column config to add. `config.index` is ignored */
config: Omit<ColumnConfig, "index">;

View File

@@ -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);

View File

@@ -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:
* ```

View File

@@ -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<string | undefined>(undefined);
export const tableIdAtom = atom("");
/** Store tableSettings from project settings document */
export const tableSettingsAtom = atom<TableSettings | undefined>(undefined);
export const tableSettingsAtom = atom<TableSettings>({
id: "",
collection: "",
name: "",
roles: [],
section: "",
tableType: "primaryCollection",
});
/** Store tableSchema from schema document */
export const tableSchemaAtom = atom<TableSchema>({});
/** Store function to update tableSchema */
export const updateTableSchemaAtom = atom<
UpdateDocFunction<TableSchema> | undefined
>(undefined);
/** Store the table columns as an ordered array */
export const tableColumnsOrderedAtom = atom<ColumnConfig[]>((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<string, ColumnConfig>,
c: ColumnConfig,
index: number
) => {
a[c.key] = { ...c, index };
return a;
};
/** Filters applied to the local view */
export const tableFiltersAtom = atom<TableFilter[]>([]);

View File

@@ -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<HTMLButtonElement>
) {
return (
<Button
{...props}
ref={ref}
variant="outlined"
color={active ? "primary" : "secondary"}
sx={[
{
position: "relative",
zIndex: 1,
},
active
? {
color: (theme) =>
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.dark,
backgroundColor: (theme) =>
alpha(
theme.palette.primary.main,
theme.palette.action.selectedOpacity
),
borderColor: "primary.main",
"&:hover": {
color: (theme) =>
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.dark,
backgroundColor: (theme) =>
alpha(
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.dark,
theme.palette.action.selectedOpacity +
theme.palette.action.hoverOpacity
),
borderColor: "currentColor",
},
}
: {},
]}
/>
);
});
export default ButtonWithStatus;

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { FallbackProps } from "react-error-boundary";
import { Link } from "react-router-dom";
import { useLocation, Link } from "react-router-dom";
import { Button } from "@mui/material";
import ReloadIcon from "@mui/icons-material/Refresh";
@@ -20,6 +21,13 @@ export default function ErrorFallback({
resetErrorBoundary,
...props
}: IErrorFallbackProps) {
// Reset error boundary when navigating away from the page
const location = useLocation();
const [errorPathname] = useState(location.pathname);
useEffect(() => {
if (errorPathname !== location.pathname) resetErrorBoundary();
}, [errorPathname, location.pathname, resetErrorBoundary]);
if ((error as any).code === "permission-denied")
return (
<AccessDenied error={error} resetErrorBoundary={resetErrorBoundary} />

View File

@@ -0,0 +1,107 @@
import { useState } from "react";
import { merge } from "lodash-es";
import { Tooltip, IconButton } from "@mui/material";
import { alpha } from "@mui/material/styles";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import CloseIcon from "@mui/icons-material/Close";
export interface IInfoTooltipProps {
description: React.ReactNode;
buttonLabel?: string;
defaultOpen?: boolean;
onClose?: () => void;
buttonProps?: Partial<React.ComponentProps<typeof IconButton>>;
tooltipProps?: Partial<React.ComponentProps<typeof Tooltip>>;
iconProps?: Partial<React.ComponentProps<typeof InfoIcon>>;
}
export default function InfoTooltip({
description,
buttonLabel = "Info",
defaultOpen,
onClose,
buttonProps,
tooltipProps,
iconProps,
}: IInfoTooltipProps) {
const [open, setOpen] = useState(defaultOpen || false);
const handleClose = () => {
setOpen(false);
if (onClose) onClose();
};
const toggleOpen = () => {
if (open) {
setOpen(false);
if (onClose) onClose();
} else {
setOpen(true);
}
};
return (
<Tooltip
title={
<>
{description}
<IconButton
aria-label={`Close ${buttonLabel}`}
size="small"
onClick={handleClose}
sx={{
m: -0.5,
opacity: 0.8,
"&:hover": {
backgroundColor: (theme) =>
alpha("#fff", theme.palette.action.hoverOpacity),
},
}}
color="inherit"
>
<CloseIcon fontSize="small" />
</IconButton>
</>
}
disableFocusListener
disableHoverListener
disableTouchListener
arrow
placement="right-start"
describeChild
{...tooltipProps}
open={open}
componentsProps={merge(
{
tooltip: {
style: {
marginLeft: "8px",
transformOrigin: "-8px 14px",
},
sx: {
typography: "body2",
display: "flex",
gap: 1.5,
alignItems: "flex-start",
pr: 0.5,
},
},
},
tooltipProps?.componentsProps
)}
>
<IconButton
aria-label={buttonLabel}
size="small"
{...buttonProps}
onClick={toggleOpen}
>
{buttonProps?.children || <InfoIcon fontSize="small" {...iconProps} />}
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from "react";
import {
Tooltip,
TooltipProps,
Box,
Typography,
Button,
ButtonProps,
} from "@mui/material";
import { colord, extend } from "colord";
import mixPlugin from "colord/plugins/lch";
extend([mixPlugin]);
export interface IRichTooltipProps
extends Partial<Omit<TooltipProps, "title">> {
render: (props: {
openTooltip: () => void;
closeTooltip: () => void;
toggleTooltip: () => void;
}) => TooltipProps["children"];
icon?: React.ReactNode;
title: React.ReactNode;
message?: React.ReactNode;
dismissButtonText?: React.ReactNode;
dismissButtonProps?: Partial<ButtonProps>;
defaultOpen?: boolean;
onOpen?: () => void;
onClose?: () => void;
onToggle?: (state: boolean) => void;
}
export default function RichTooltip({
render,
icon,
title,
message,
dismissButtonText,
dismissButtonProps,
defaultOpen,
onOpen,
onClose,
onToggle,
...props
}: IRichTooltipProps) {
const [open, setOpen] = useState(defaultOpen || false);
const openTooltip = () => {
setOpen(true);
if (onOpen) onOpen();
};
const closeTooltip = () => {
setOpen(false);
if (onClose) onClose();
};
const toggleTooltip = () =>
setOpen((state) => {
if (onToggle) onToggle(!state);
return !state;
});
return (
<Tooltip
disableFocusListener
disableHoverListener
disableTouchListener
arrow
open={open}
onClose={closeTooltip}
sx={{
"& .MuiTooltip-popper": { zIndex: (theme) => theme.zIndex.drawer - 1 },
"& .MuiTooltip-tooltip": {
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.background.default
: colord(theme.palette.background.paper)
.mix("#fff", 0.16)
.toHslString(),
boxShadow: 8,
typography: "body2",
color: "text.primary",
padding: 0,
},
"& .MuiTooltip-arrow::before": {
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.background.default
: colord(theme.palette.background.paper)
.mix("#fff", 0.16)
.toHslString(),
boxShadow: 8,
},
}}
title={
<Box
sx={{
p: 2,
cursor: "default",
display: "grid",
gridTemplateColumns: "48px auto",
gap: (theme) => theme.spacing(1, 1.5),
}}
onClick={closeTooltip}
>
<Box component="span" sx={{ mt: -0.5, fontSize: `${48 / 16}rem` }}>
{icon}
</Box>
<div style={{ alignSelf: "center" }}>
<Typography variant="subtitle2" gutterBottom>
{title}
</Typography>
<Typography>{message}</Typography>
</div>
{dismissButtonText ? (
<Button
{...dismissButtonProps}
onClick={closeTooltip}
style={{
gridColumn: 2,
justifySelf: "flex-start",
}}
>
{dismissButtonText}
</Button>
) : (
<Typography
variant="caption"
color="text.disabled"
style={{
gridColumn: 2,
justifySelf: "flex-start",
}}
>
Click to dismiss
</Typography>
)}
</Box>
}
PopperProps={{
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
altBoundary: true,
tether: false,
rootBoundary: "document",
padding: 8,
},
},
],
}}
{...props}
>
{render({ openTooltip, closeTooltip, toggleTooltip })}
</Tooltip>
);
}

View File

@@ -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 (
<MuiBreadcrumbs
separator={<ArrowRightIcon />}
aria-label="Sub-table breadcrumbs"
{...props}
sx={[
{
"& .MuiBreadcrumbs-ol": {
userSelect: "none",
flexWrap: "nowrap",
whiteSpace: "nowrap",
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Section name */}
{section && (
<Link
component={RouterLink}
to={`${ROUTES.home}#${camelCase(section)}`}
variant="h6"
color="textSecondary"
underline="hover"
>
{section}
</Link>
)}
{breadcrumbs.map((crumb: string, index) => {
// If its 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 its the last crumb, just show the label without linking
if (index === breadcrumbs.length - 1)
return (
<div
key={crumb || index}
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Typography {...crumbProps}>
{getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")}
</Typography>
{crumb === tableSettings.id && tableSettings.readOnly && (
<Tooltip
title={
userRoles.includes("ADMIN")
? "Table is read-only for non-ADMIN users"
: "Table is read-only"
}
>
<ReadOnlyIcon fontSize="small" sx={{ ml: 0.5 }} />
</Tooltip>
)}
{crumb === tableSettings.id && tableSettings.description && (
<InfoTooltip
description={
<div>
<RenderedMarkdown
children={tableSettings.description}
restrictionPreset="singleLine"
/>
</div>
}
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]))
}
/>
)}
</div>
);
// 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 (
<Link
{...crumbProps}
component={RouterLink}
to={`${ROUTES.table}/${encodeURIComponent(
breadcrumbs.slice(0, index).join("/")
)}?rowRef=${breadcrumbs.slice(0, index + 1).join("%2F")}`}
underline="hover"
>
{getLabel(
parentLabel.split(",")[Math.ceil(index / 2) - 1] || crumb
)}
</Link>
);
// Otherwise, even: breadcrumb points to a Firestore collection
return (
<Link
{...crumbProps}
component={RouterLink}
to={`${ROUTES.table}/${encodeURIComponent(
breadcrumbs.slice(0, index + 1).join("/")
)}`}
underline="hover"
>
{getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")}
</Link>
);
})}
</MuiBreadcrumbs>
);
}

View File

@@ -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>,
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 (
<>
<RichTooltip
icon={<ErrorIcon fontSize="inherit" color="error" />}
title="Invalid data"
message="This row will not be saved until all the required fields contain valid data"
placement="right"
render={({ openTooltip }) => <Dot onClick={openTooltip} />}
/>
<Root {...({ error: true } as any)}>{children}</Root>
</>
);
if (isMissing)
return (
<>
<RichTooltip
icon={<WarningIcon fontSize="inherit" color="warning" />}
title="Required field"
message="This row will not be saved until all the required fields contain valid data"
placement="right"
render={({ openTooltip }) => <Dot onClick={openTooltip} />}
/>
<Root {...({ error: true } as any)}>{children}</Root>
</>
);
return <Root>{children}</Root>;
}

View File

@@ -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<R>({
column,
}: HeaderRendererProps<R> & {
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<HTMLButtonElement>(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 (
<Grid
ref={headerRef}
container
alignItems="center"
wrap="nowrap"
onContextMenu={handleOpenMenu}
sx={[
{
height: "100%",
"& svg, & button": { display: "block" },
color: "text.secondary",
transition: (theme) =>
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 && (
<Tooltip
title={
<>
Click to copy field key:
<br />
<code style={{ padding: 0 }}>{column.key}</code>
</>
}
enterDelay={1000}
placement="bottom-start"
>
<Grid
item
onClick={() => {
navigator.clipboard.writeText(column.key);
}}
>
{column.editable === false ? (
<LockIcon />
) : (
getFieldProp("icon", (column as any).type)
)}
</Grid>
</Tooltip>
)}
<Grid
item
xs
sx={{ flexShrink: 1, overflow: "hidden", my: 0, ml: 0.5, mr: -30 / 8 }}
>
<Tooltip
title={
<Typography
sx={{
typography: "caption",
fontWeight: "fontWeightMedium",
lineHeight: `${DEFAULT_ROW_HEIGHT + 1}px`,
textOverflow: "clip",
}}
color="inherit"
>
{column.name as string}
</Typography>
}
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" },
},
}}
>
<Typography
noWrap
sx={{
typography: "caption",
fontWeight: "fontWeightMedium",
lineHeight: `${DEFAULT_ROW_HEIGHT + 1}px`,
textOverflow: "clip",
}}
component="div"
color="inherit"
>
{column.name as string}
</Typography>
</Tooltip>
</Grid>
{(column as any).type !== FieldType.id && (
<Grid
item
sx={{
backgroundColor: "background.default",
opacity: isSorted ? 1 : 0,
transition: (theme) =>
theme.transitions.create("opacity", {
duration: theme.transitions.duration.shortest,
}),
"$root:hover &": { opacity: 1 },
}}
>
<Tooltip
title={
isAsc
? "Unsort"
: `Sort by ${isDesc ? "ascending" : "descending"}`
}
>
<IconButton
disableFocusRipple={true}
size="small"
onClick={handleSortClick}
color="inherit"
aria-label={
isAsc
? "Unsort"
: `Sort by ${isDesc ? "ascending" : "descending"}`
}
sx={{
transition: (theme) =>
theme.transitions.create(["background-color", "transform"], {
duration: theme.transitions.duration.short,
}),
transform: isAsc ? "rotate(180deg)" : "none",
}}
>
<SortDescIcon />
</IconButton>
</Tooltip>
</Grid>
)}
{(userRoles.includes("ADMIN") ||
(userRoles.includes("OPS") &&
[FieldType.multiSelect, FieldType.singleSelect].includes(
(column as any).type
))) && (
<Grid item>
<Tooltip title="Column settings">
<IconButton
size="small"
aria-label={`Column settings for ${column.name as string}`}
color="inherit"
onClick={handleOpenMenu}
ref={buttonRef}
sx={{
transition: (theme) =>
theme.transitions.create("color", {
duration: theme.transitions.duration.short,
}),
color: "text.disabled",
"$root:hover &": { color: "text.primary" },
}}
>
<DropdownIcon />
</IconButton>
</Tooltip>
</Grid>
)}
</Grid>
);
}

View File

@@ -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 = (
<>
<div>
<Typography variant="h6" component="h2" gutterBottom>
Get started
</Typography>
<Typography>
There is existing data in the Firestore collection:
<br />
<code>{tableSettings.collection}</code>
</Typography>
</div>
<div>
<Typography paragraph>
You can import that existing data to this table.
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<ImportIcon />}
// onClick={() => importWizardRef?.current?.setOpen(true)}
>
Import
</Button>
{/* <ImportWizard /> */}
</div>
</>
);
} else {
contents = (
<>
<div>
<Typography variant="h6" component="h2" gutterBottom>
Get started
</Typography>
<Typography>
There is no data in the Firestore collection:
<br />
<code>{tableSettings.collection}</code>
</Typography>
</div>
<Grid container spacing={1}>
<Grid item xs>
<Typography paragraph>
You can import data from an external CSV file:
</Typography>
{/* <ImportCSV
render={(onClick) => ( */}
<Button
variant="contained"
color="primary"
startIcon={<ImportIcon />}
// onClick={onClick}
disabled
>
Import CSV
</Button>
{/* )}
PopoverProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "center",
},
transformOrigin: {
vertical: "top",
horizontal: "center",
},
}}
/> */}
</Grid>
<Grid item>
<Divider orientation="vertical">
<Typography variant="overline">or</Typography>
</Divider>
</Grid>
<Grid item xs>
<Typography paragraph>
You can manually add new columns and rows:
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddColumnIcon />}
// onClick={(event) =>
// columnMenuRef?.current?.setSelectedColumnHeader({
// column: { isNew: true, key: "new", type: "LAST" } as any,
// anchorEl: event.currentTarget,
// })
// }
// disabled={!columnMenuRef?.current}
disabled
>
Add column
</Button>
{/* <ColumnMenu /> */}
</Grid>
</Grid>
</>
);
}
return (
<Stack
spacing={3}
justifyContent="center"
alignItems="center"
sx={{
height: `calc(100vh - ${APP_BAR_HEIGHT}px)`,
width: "100%",
p: 2,
maxWidth: 480,
margin: "0 auto",
textAlign: "center",
}}
>
{contents}
</Stack>
);
}

View File

@@ -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<any>["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<HTMLButtonElement, MouseEvent>
) => {
// columnMenuRef?.current?.setSelectedColumnHeader({
// column,
// anchorEl: event.currentTarget,
// });
};
return (
<Button
onClick={handleClick}
variant="contained"
color="primary"
startIcon={<AddColumnIcon />}
style={{ zIndex: 1 }}
>
Add column
</Button>
);
};
export default FinalColumnHeader;

View File

@@ -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 (
<div
className="out-of-order-dot"
style={{
position: "absolute",
top: top,
height: height - OUT_OF_ORDER_MARGIN - 2,
marginLeft: `max(env(safe-area-inset-left), 16px)`,
width: 12,
}}
>
<RichTooltip
icon={<WarningIcon fontSize="inherit" color="warning" />}
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 }) => <Dot onClick={openTooltip} />}
defaultOpen={!dismissed}
onClose={() => setDismissed(true)}
/>
</div>
);
}

View File

@@ -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;

View File

@@ -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<any> & { 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<HTMLDivElement>(null);
const [selectedRowsSet, setSelectedRowsSet] = useState<Set<React.Key>>();
const [selectedRows, setSelectedRows] = useState<any[]>([]);
// 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<HTMLDivElement>) => {
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 theyve 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 (
<>
{/* <Suspense fallback={<Loading message="Loading header" />}>
<Hotkeys selectedCell={selectedCell} />
</Suspense> */}
<TableContainer ref={rowsContainerRef} rowHeight={rowHeight}>
<TableHeader />
<DndProvider backend={HTML5Backend}>
<DataGrid
onColumnResize={handleResize}
onScroll={handleScroll}
// ref={dataGridRef}
rows={rows}
columns={columns}
// Increase row height of out of order rows to add margins
rowHeight={({ row }) => {
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,
// })
// }
/>
</DndProvider>
</TableContainer>
{/* <ColumnMenu /> */}
{/* <ContextMenu />
<BulkActions
selectedRows={selectedRows}
columns={columns}
clearSelection={() => {
setSelectedRowsSet(new Set());
setSelectedRows([]);
}}
/> */}
</>
);
}

View File

@@ -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;

View File

@@ -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<any>) {
// const { setAnchorEle } = useSetAnchorEle();
const handleContextMenu = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
e.preventDefault();
// setAnchorEle?.(e?.target as HTMLElement);
};
if (props.row._rowy_outOfOrder)
return (
<Fragment key={props.row._rowy_ref.path}>
<OutOfOrderIndicator top={props.top} height={props.height} />
<Row onContextMenu={handleContextMenu} {...props} />
</Fragment>
);
return (
<Row
key={props.row._rowy_ref.path}
onContextMenu={handleContextMenu}
{...props}
/>
);
}

View File

@@ -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-grids default
* text editor to show.
*
* Hides the editor container so the cell below remains editable inline.
*
* Use for cells that have inline editing and dont need to be double-clicked.
*
* TODO: fix NullEditor overwriting the formatter component
*/
class NullEditor extends React.Component<
EditorProps<any, any> & WithStyles<typeof styles>
> {
getInputNode = () => null;
getValue = () => null;
render = () => null;
}
export default withStyles(styles)(NullEditor);

View File

@@ -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<any>) {
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<HTMLInputElement>(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 (
<TextField
defaultValue={defaultValue}
type={inputType}
fullWidth
multiline={type === FieldType.longText}
variant="standard"
inputProps={{
ref: inputRef,
maxLength: maxLength,
}}
sx={{
width: "100%",
height: "100%",
backgroundColor: "var(--background-color)",
"& .MuiInputBase-root": {
height: "100%",
font: "inherit", // Prevent text jumping
letterSpacing: "inherit", // Prevent text jumping
p: 0,
},
"& .MuiInputBase-input": {
height: "100%",
font: "inherit", // Prevent text jumping
letterSpacing: "inherit", // Prevent text jumping
p: "var(--cell-padding)",
pb: 1 / 8,
},
"& textarea.MuiInputBase-input": {
lineHeight: (theme) => 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;
}
}}
/>
);
}

View File

@@ -0,0 +1,9 @@
import { createStyles } from "@mui/material";
export const styles = createStyles({
"@global": {
".rdg-editor-container": { display: "none" },
},
});
export default styles;

View File

@@ -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-grids default
* text editor to show.
*
* Hides the editor container so the cell below remains editable inline.
*
* Use for cells that have inline editing and dont need to be double-clicked.
*/
export default function withNullEditor(
HeavyCell?: React.ComponentType<IHeavyCellProps>
) {
return function NullEditor(props: EditorProps<any, any>) {
const { row, column } = props;
return HeavyCell ? (
<div
style={{
width: "100%",
height: "100%",
padding: "var(--cell-padding)",
position: "relative",
overflow: "hidden",
contain: "strict",
display: "flex",
alignItems: "center",
}}
>
<HeavyCell
{...(props as any)}
value={get(row, column.key)}
name={column.name as string}
type={(column as any).type}
docRef={props.row.ref}
onSubmit={() => {}}
disabled={props.column.editable === false}
/>
</div>
) : null;
};
}

View File

@@ -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-grids 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<IHeavyCellProps>
) {
return function SideDrawerEditor(props: EditorProps<any, any>) {
const { row, column } = props;
// FIXME: const { sideDrawerRef } = useProjectContext();
// useEffect(() => {
// if (!sideDrawerRef?.current?.open && sideDrawerRef?.current?.setOpen)
// sideDrawerRef?.current?.setOpen(true);
// }, [column]);
return HeavyCell ? (
<div
style={{
width: "100%",
height: "100%",
padding: "var(--cell-padding)",
position: "relative",
overflow: "hidden",
contain: "strict",
display: "flex",
alignItems: "center",
}}
>
<HeavyCell
{...(props as any)}
value={get(row, column.key)}
name={column.name as string}
type={(column as any).type}
docRef={props.row.ref}
onSubmit={() => {}}
disabled={props.column.editable === false}
/>
</div>
) : null;
};
}

View File

@@ -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 (
<Grid
container
wrap={canWrap ? "wrap" : "nowrap"}
alignItems="center"
alignContent="flex-start"
spacing={0.5}
sx={{
pl: 1,
flexGrow: 1,
overflow: "hidden",
maxHeight: (theme) => `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}
</Grid>
);
}

View File

@@ -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<any, any>) {
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 (
<Stack direction="row" spacing={0.5}>
<Tooltip title="Duplicate row">
<IconButton
size="small"
color="inherit"
disabled={tableSettings.tableType === "collectionGroup"}
onClick={() =>
addRow({
row,
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
})
}
aria-label="Duplicate row"
className="row-hover-iconButton"
>
<CopyCellsIcon />
</IconButton>
</Tooltip>
<Tooltip title={`Delete row${altPress ? "" : "…"}`}>
<IconButton
size="small"
color="inherit"
onClick={
altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row.ref.path}
</code>
</>
),
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
),
},
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./Table";
export { default } from "./Table";

View File

@@ -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<HTMLDivElement>(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 <Box sx={{ mr: -2 }} />;
return (
<>
<ButtonGroup
variant="contained"
color="primary"
aria-label="Split button"
ref={anchorEl}
disabled={tableSettings.tableType === "collectionGroup" || !addRow}
>
<Button
variant="contained"
color="primary"
onClick={handleClick}
startIcon={<AddRowIcon />}
>
Add row{idType === "custom" ? "…" : ""}
</Button>
<Button
variant="contained"
color="primary"
aria-label="Select row add position"
aria-haspopup="menu"
style={{ padding: 0 }}
onClick={() => setOpen(true)}
id="add-row-menu-button"
aria-controls={open ? "add-row-menu" : undefined}
aria-expanded={open ? "true" : "false"}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Select
id="add-row-menu"
open={open}
onClose={() => setOpen(false)}
label="Row add position"
style={{ display: "none" }}
value={idType}
onChange={(e) => setIdType(e.target.value as typeof idType)}
MenuProps={{
anchorEl: anchorEl.current,
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
anchorOrigin: { horizontal: "right", vertical: "bottom" },
transformOrigin: { horizontal: "right", vertical: "top" },
}}
>
<MenuItem value="decrement">
<ListItemText
primary="Auto-generated ID"
secondary="Generates a smaller ID so the new row will appear on the top"
secondaryTypographyProps={{ variant: "caption" }}
/>
</MenuItem>
<MenuItem value="custom">
<ListItemText
primary="Custom ID"
secondary={
"Temporarily displays the new row on the top for editing,\nbut will appear in a different position afterwards"
}
secondaryTypographyProps={{
variant: "caption",
whiteSpace: "pre-line",
}}
/>
</MenuItem>
</Select>
{openIdModal && (
<FormDialog
title="Add row with custom ID"
fields={[
{
type: FieldType.shortText,
name: "id",
label: "Custom ID",
required: true,
autoFocus: true,
// Disable validation to make it compatible with non-Firestore
// databases. If a user adds a row with an existing ID, it will
// update that document.
// validation: [
// [
// "test",
// "existing-id",
// "A row with this ID already exists",
// async (value) =>
// 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" }}
/>
)}
</>
);
}

View File

@@ -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<HTMLButtonElement>(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<string[]>(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 its 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 }) => (
<li {...props}>
{/* FIXME: <Column
label={option.label}
type={tableState.columns[option.value]?.type}
secondaryItem={<VisibilityOffIcon className="hiddenIcon" />}
active={selected}
/> */}
</li>
);
return (
<>
<ButtonWithStatus
startIcon={<VisibilityOffIcon />}
onClick={() => setOpen((o) => !o)}
active={hiddenFields.length > 0}
ref={buttonRef}
>
{hiddenFields.length > 0 ? `${hiddenFields.length} hidden` : "Hide"}
</ButtonWithStatus>
<MultiSelect
TextFieldProps={{
style: { display: "none" },
SelectProps: {
open,
MenuProps: {
anchorEl: buttonRef.current,
anchorOrigin: { vertical: "bottom", horizontal: "left" },
transformOrigin: { vertical: "top", horizontal: "left" },
},
},
}}
{...({
AutocompleteProps: {
renderOption,
sx: {
"& .MuiAutocomplete-option": {
padding: 0,
paddingLeft: "0 !important",
borderRadius: 0,
marginBottom: "-1px",
"&::after": { content: "none" },
"&:hover, &.Mui-focused, &.Mui-focusVisible": {
backgroundColor: "transparent",
position: "relative",
zIndex: 2,
"& > 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}
/>
</>
);
}

View File

@@ -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<HTMLButtonElement>) => void
) => React.ReactNode;
PopoverProps?: Partial<MuiPopoverProps>;
}
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<HTMLButtonElement | null>(null);
const [tab, setTab] = useState("upload");
const [csvData, setCsvData] =
useState</* IImportCsvWizardProps["csvData"] */ any>(null);
const [error, setError] = useState("");
const validCsv =
csvData !== null && csvData?.columns.length > 0 && csvData?.rows.length > 0;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
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)
) : (
<TableHeaderButton
title="Import CSV or TSV"
onClick={handleOpen}
icon={<ImportIcon />}
/>
)}
<Popover
id={popoverId}
open={!!open}
anchorEl={open}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
{...PopoverProps}
sx={{
"& .MuiTabPanel-root": { py: 2, px: 3, width: 400, height: 200 },
}}
>
<TabContext value={tab}>
<TabList
onChange={(_, v) => {
setTab(v);
setCsvData(null);
setError("");
}}
aria-label="Import CSV method tabs"
action={(actions) =>
setTimeout(() => actions?.updateIndicator(), 200)
}
variant="fullWidth"
>
<Tab
label="Upload"
value="upload"
onClick={() => (importMethodRef.current = ImportMethod.upload)}
/>
<Tab
label="Paste"
value="paste"
onClick={() => (importMethodRef.current = ImportMethod.paste)}
/>
<Tab
label="URL"
value="url"
onClick={() => (importMethodRef.current = ImportMethod.url)}
/>
</TabList>
<Divider style={{ marginTop: -1 }} />
<TabPanel value="upload">
<Grid
container
justifyContent="center"
alignContent="center"
alignItems="center"
direction="column"
{...getRootProps()}
sx={[
{
height: 137,
borderRadius: 1,
border: `dashed 2px currentColor`,
borderColor: "divider",
backgroundColor: "action.input",
cursor: "pointer",
"& svg": {
opacity: (theme) => theme.palette.action.activeOpacity,
},
"&:focus": {
borderColor: "primary.main",
color: "primary.main",
outline: "none",
},
},
error ? { borderColor: "error.main", color: "error.main" } : {},
]}
>
<input {...getInputProps()} />
{isDragActive ? (
<Typography variant="button" color="primary">
Drop CSV or TSV file here
</Typography>
) : (
<>
<Grid item>
{validCsv ? <CheckIcon /> : <FileUploadIcon />}
</Grid>
<Grid item>
<Typography variant="button" color="inherit">
{validCsv
? "Valid CSV or TSV"
: "Click to upload or drop CSV or TSV file here"}
</Typography>
</Grid>
</>
)}
</Grid>
{error && (
<FormHelperText error sx={{ my: 0.5, mx: 1.5 }}>
{error}
</FormHelperText>
)}
</TabPanel>
<TabPanel value="paste">
<TextField
variant="filled"
multiline
inputProps={{ minRows: 3 }}
autoFocus
fullWidth
label="Paste CSV or TSV text"
placeholder="column, column, …"
onChange={(e) => {
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}
/>
</TabPanel>
<TabPanel value="url">
<TextField
variant="filled"
autoFocus
fullWidth
label="Paste URL to CSV or TSV file"
placeholder="https://"
onChange={(e) => {
if (csvData !== null) setCsvData(null);
handleUrl(e.target.value);
}}
helperText={loading ? "Fetching…" : error}
error={!!error}
/>
</TabPanel>
</TabContext>
<Button
variant="contained"
color="primary"
disabled={!validCsv}
sx={{
mt: -4,
mx: "auto",
mb: 2,
display: "flex",
minWidth: 100,
}}
onClick={() => {
setOpenWizard(true);
logEvent(analytics, `import_${importMethodRef.current}`, {
type: importTypeRef.current,
});
}}
>
Continue
</Button>
</Popover>
{/* {openWizard && csvData && (
<ImportCsvWizard
importType={importTypeRef.current}
handleClose={() => setOpenWizard(false)}
csvData={csvData}
/>
)} */}
</>
);
}

View File

@@ -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 (
<Tooltip
title={
allLoaded
? "All rows have been loaded in this table"
: `Scroll to the bottom to load more rows`
}
>
<Typography
variant="body2"
color="text.disabled"
display="block"
style={{ userSelect: "none" }}
>
Loaded {allLoaded && "all "}
{tableRows.length} row{tableRows.length !== 1 && "s"}
</Typography>
</Tooltip>
);
}

View File

@@ -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 (
<TableHeaderButton
title="Force refresh"
onClick={() => openRowyRunModal({ feature: "Force refresh" })}
icon={<LoopIcon />}
/>
);
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 (
<>
<TableHeaderButton
title="Force refresh"
onClick={() => setOpen(true)}
icon={<LoopIcon />}
/>
{open && (
<Modal
onClose={handleClose}
maxWidth="xs"
fullWidth
hideCloseButton
title="Force refresh?"
children="All Extensions and Derivatives in this table will re-execute."
actions={{
primary: {
children: "Confirm",
onClick: handleConfirm,
startIcon: updating && <CircularProgressOptical size={16} />,
disabled: updating,
},
secondary: {
children: "Cancel",
onClick: handleClose,
},
}}
/>
)}
</>
);
}

View File

@@ -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<HTMLButtonElement>(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 (
<>
<TableHeaderButton
disabled={!updateTableSchema}
title="Row height"
icon={<RowHeightIcon />}
onClick={handleOpen}
ref={buttonRef}
/>
<TextField
select
value={rowHeight ?? DEFAULT_ROW_HEIGHT}
onChange={(event) => {
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"
>
<ListSubheader>Row height</ListSubheader>
{ROW_HEIGHTS.map((height) => (
<MenuItem key={height} value={height}>
{height - 1}px
</MenuItem>
))}
</TextField>
</>
);
}

View File

@@ -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 (
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
pl: (theme) => `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,
},
}}
>
<AddRow />
{/* Spacer */} <div />
<HiddenFields />
{/* <Filters /> */}
{/* Spacer */} <div />
<LoadedRowsStatus />
<div style={{ flexGrow: 1, minWidth: 64 }} />
<RowHeight />
{/* Spacer */} <div />
{tableSettings.tableType !== "collectionGroup" && <ImportCSV />}
{/* <Export /> */}
{userRoles.includes("ADMIN") && (
<>
{/* Spacer */} <div />
{/* <Webhooks /> */}
{/* <Extensions /> */}
{/* <CloudLogs /> */}
{/* {snackLogContext.isSnackLogOpen && (
<BuildLogsSnack
onClose={snackLogContext.closeSnackLog}
onOpenPanel={alert}
/>
)} */}
{(hasDerivatives || hasExtensions) && <ReExecute />}
{/* Spacer */} <div />
<TableSettings />
</>
)}
<div className="end-spacer" />
</Stack>
);
}

View File

@@ -0,0 +1,30 @@
import { forwardRef } from "react";
import { Tooltip, Button, ButtonProps } from "@mui/material";
export interface ITableHeaderButtonProps extends Partial<ButtonProps> {
title: string;
icon: React.ReactNode;
}
export const TableHeaderButton = forwardRef(function TableHeaderButton_(
{ title, icon, ...props }: ITableHeaderButtonProps,
ref: React.Ref<HTMLButtonElement>
) {
return (
<Tooltip title={title}>
<Button
variant="outlined"
color="secondary"
size="small"
style={{ minWidth: 40, height: 32, padding: 0 }}
aria-label={title}
{...props}
ref={ref}
>
{icon}
</Button>
</Tooltip>
);
});
export default TableHeaderButton;

View File

@@ -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 (
<TableHeaderButton
title="Table settings"
onClick={() =>
openTableSettingsDialog({ mode: "update", data: tableSettings })
}
icon={<SettingsIcon />}
disabled={!openTableSettingsDialog}
/>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./TableHeader";
export { default } from "./TableHeader";

1
src/config/db.ts Normal file
View File

@@ -0,0 +1 @@
export const COLLECTION_PAGE_SIZE = 30;

View File

@@ -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) => (
<Breadcrumbs sx={{ ml: open && pinned ? -48 / 8 : 2 }} />
),
titleTransitionProps: { style: { transformOrigin: "0 50%" } },
},
[ROUTES.settings]: "Settings",
[ROUTES.userSettings]: "Settings",

View File

@@ -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<T>(...refs: readonly Ref<T>[]) {
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
);
}

View File

@@ -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<T> {
@@ -88,7 +87,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
collectionGroup,
filters,
orders,
limit = DEFAULT_COLLECTION_QUERY_LIMIT,
limit = COLLECTION_PAGE_SIZE,
onError,
disableSuspense,
updateDocAtom,

34
src/hooks/useKeyPress.ts Normal file
View File

@@ -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;
}

67
src/pages/Table.tsx Normal file
View File

@@ -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 (
<Fade style={{ transitionDelay: "500ms" }}>
<div>
<EmptyTable />
</div>
</Fade>
);
return <Table />;
}
export default function ProvidedTablePage() {
const { id } = useParams();
const [currentUser] = useAtom(currentUserAtom, globalScope);
return (
<Suspense
fallback={
<>
<TableHeaderSkeleton />
<HeaderRowSkeleton />
</>
}
>
<Provider
key={id}
scope={tableScope}
initialValues={[
[tableIdAtom, id],
[currentUserAtom, currentUser],
]}
>
<TableSourceFirestore />
<TablePage />
</Provider>
</Suspense>
);
}

View File

@@ -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 dont 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),

10
src/types/table.d.ts vendored
View File

@@ -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 = {

View File

@@ -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, "_")
: "";