mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
add main Table component
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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",
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
* ```
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
60
src/components/ButtonWithStatus.tsx
Normal file
60
src/components/ButtonWithStatus.tsx
Normal 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;
|
||||
@@ -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} />
|
||||
|
||||
107
src/components/InfoTooltip.tsx
Normal file
107
src/components/InfoTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/RichTooltip.tsx
Normal file
166
src/components/RichTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/components/Table/Breadcrumbs.tsx
Normal file
176
src/components/Table/Breadcrumbs.tsx
Normal 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 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
98
src/components/Table/CellValidation.tsx
Normal file
98
src/components/Table/CellValidation.tsx
Normal 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>;
|
||||
}
|
||||
305
src/components/Table/ColumnHeader.tsx
Normal file
305
src/components/Table/ColumnHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/components/Table/EmptyTable.tsx
Normal file
154
src/components/Table/EmptyTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/Table/FinalColumnHeader.tsx
Normal file
38
src/components/Table/FinalColumnHeader.tsx
Normal 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;
|
||||
63
src/components/Table/OutOfOrderIndicator.tsx
Normal file
63
src/components/Table/OutOfOrderIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
291
src/components/Table/Table.tsx
Normal file
291
src/components/Table/Table.tsx
Normal 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 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 (
|
||||
<>
|
||||
{/* <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([]);
|
||||
}}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
src/components/Table/TableContainer.tsx
Normal file
236
src/components/Table/TableContainer.tsx
Normal 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;
|
||||
31
src/components/Table/TableRow.tsx
Normal file
31
src/components/Table/TableRow.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
src/components/Table/editors/NullEditor.tsx
Normal file
26
src/components/Table/editors/NullEditor.tsx
Normal 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-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<any, any> & WithStyles<typeof styles>
|
||||
> {
|
||||
getInputNode = () => null;
|
||||
getValue = () => null;
|
||||
render = () => null;
|
||||
}
|
||||
|
||||
export default withStyles(styles)(NullEditor);
|
||||
119
src/components/Table/editors/TextEditor.tsx
Normal file
119
src/components/Table/editors/TextEditor.tsx
Normal 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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
src/components/Table/editors/styles.ts
Normal file
9
src/components/Table/editors/styles.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createStyles } from "@mui/material";
|
||||
|
||||
export const styles = createStyles({
|
||||
"@global": {
|
||||
".rdg-editor-container": { display: "none" },
|
||||
},
|
||||
});
|
||||
|
||||
export default styles;
|
||||
44
src/components/Table/editors/withNullEditor.tsx
Normal file
44
src/components/Table/editors/withNullEditor.tsx
Normal 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-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<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;
|
||||
};
|
||||
}
|
||||
52
src/components/Table/editors/withSideDrawerEditor.tsx
Normal file
52
src/components/Table/editors/withSideDrawerEditor.tsx
Normal 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-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<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;
|
||||
};
|
||||
}
|
||||
41
src/components/Table/formatters/ChipList.tsx
Normal file
41
src/components/Table/formatters/ChipList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/Table/formatters/FinalColumn.tsx
Normal file
102
src/components/Table/formatters/FinalColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/Table/index.ts
Normal file
2
src/components/Table/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./Table";
|
||||
export { default } from "./Table";
|
||||
182
src/components/TableHeader/AddRow.tsx
Normal file
182
src/components/TableHeader/AddRow.tsx
Normal 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" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
src/components/TableHeader/HiddenFields.tsx
Normal file
144
src/components/TableHeader/HiddenFields.tsx
Normal 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 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 }) => (
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
369
src/components/TableHeader/ImportCsv.tsx
Normal file
369
src/components/TableHeader/ImportCsv.tsx
Normal 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}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
src/components/TableHeader/LoadedRowsStatus.tsx
Normal file
40
src/components/TableHeader/LoadedRowsStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/components/TableHeader/ReExecute.tsx
Normal file
103
src/components/TableHeader/ReExecute.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
src/components/TableHeader/RowHeight.tsx
Normal file
72
src/components/TableHeader/RowHeight.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
src/components/TableHeader/TableHeader.tsx
Normal file
96
src/components/TableHeader/TableHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/TableHeader/TableHeaderButton.tsx
Normal file
30
src/components/TableHeader/TableHeaderButton.tsx
Normal 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;
|
||||
26
src/components/TableHeader/TableSettings.tsx
Normal file
26
src/components/TableHeader/TableSettings.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
src/components/TableHeader/index.ts
Normal file
2
src/components/TableHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./TableHeader";
|
||||
export { default } from "./TableHeader";
|
||||
1
src/config/db.ts
Normal file
1
src/config/db.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const COLLECTION_PAGE_SIZE = 30;
|
||||
@@ -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",
|
||||
|
||||
43
src/hooks/useCombinedRefs.ts
Normal file
43
src/hooks/useCombinedRefs.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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
34
src/hooks/useKeyPress.ts
Normal 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
67
src/pages/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
10
src/types/table.d.ts
vendored
10
src/types/table.d.ts
vendored
@@ -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 = {
|
||||
|
||||
@@ -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, "_")
|
||||
: "";
|
||||
|
||||
Reference in New Issue
Block a user