mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-28 16:06:41 +01:00
Merge pull request #978 from rowyio/feature/rowy-706-table-upgrade
Feature/rowy 706 table upgrade
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"@mui/x-date-pickers": "^5.0.0-alpha.4",
|
"@mui/x-date-pickers": "^5.0.0-alpha.4",
|
||||||
"@rowy/form-builder": "^0.7.0",
|
"@rowy/form-builder": "^0.7.0",
|
||||||
"@rowy/multiselect": "^0.4.1",
|
"@rowy/multiselect": "^0.4.1",
|
||||||
|
"@tanstack/react-table": "^8.5.15",
|
||||||
"@tinymce/tinymce-react": "^3",
|
"@tinymce/tinymce-react": "^3",
|
||||||
"@uiw/react-md-editor": "^3.14.1",
|
"@uiw/react-md-editor": "^3.14.1",
|
||||||
"algoliasearch": "^4.13.1",
|
"algoliasearch": "^4.13.1",
|
||||||
@@ -46,7 +47,6 @@
|
|||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-beautiful-dnd": "^13.1.0",
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
"react-color-palette": "^6.2.0",
|
"react-color-palette": "^6.2.0",
|
||||||
"react-data-grid": "7.0.0-beta.5",
|
|
||||||
"react-detect-offline": "^2.4.5",
|
"react-detect-offline": "^2.4.5",
|
||||||
"react-div-100vh": "^0.7.0",
|
"react-div-100vh": "^0.7.0",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"react-router-hash-link": "^2.4.3",
|
"react-router-hash-link": "^2.4.3",
|
||||||
"react-scripts": "^5.0.0",
|
"react-scripts": "^5.0.0",
|
||||||
"react-usestateref": "^1.0.8",
|
"react-usestateref": "^1.0.8",
|
||||||
|
"react-virtual": "^2.10.4",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"seedrandom": "^3.0.5",
|
"seedrandom": "^3.0.5",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
"typedoc": "typedoc src/atoms/projectScope/index.ts src/atoms/tableScope/index.ts --out typedoc"
|
"typedoc": "typedoc src/atoms/projectScope/index.ts src/atoms/tableScope/index.ts --out typedoc"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": "^16"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|||||||
17
src/App.tsx
17
src/App.tsx
@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
import { Backdrop } from "@mui/material";
|
||||||
import Loading from "@src/components/Loading";
|
import Loading from "@src/components/Loading";
|
||||||
import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase";
|
import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase";
|
||||||
import MembersSourceFirebase from "@src/sources/MembersSourceFirebase";
|
import MembersSourceFirebase from "@src/sources/MembersSourceFirebase";
|
||||||
@@ -116,7 +117,21 @@ export default function App() {
|
|||||||
<Route index element={<NotFound />} />
|
<Route index element={<NotFound />} />
|
||||||
<Route
|
<Route
|
||||||
path=":docPath/:subTableKey"
|
path=":docPath/:subTableKey"
|
||||||
element={<ProvidedSubTablePage />}
|
element={
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Backdrop
|
||||||
|
key="sub-table-modal-backdrop"
|
||||||
|
open
|
||||||
|
sx={{ zIndex: "modal" }}
|
||||||
|
>
|
||||||
|
<Loading />
|
||||||
|
</Backdrop>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProvidedSubTablePage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ export { FileTableBoxOutline as Project };
|
|||||||
import { TableColumn } from "mdi-material-ui";
|
import { TableColumn } from "mdi-material-ui";
|
||||||
export { TableColumn };
|
export { TableColumn };
|
||||||
|
|
||||||
|
import { DragVertical } from "mdi-material-ui";
|
||||||
|
export { DragVertical };
|
||||||
|
|
||||||
export * from "./AddRow";
|
export * from "./AddRow";
|
||||||
export * from "./AddRowTop";
|
export * from "./AddRowTop";
|
||||||
export * from "./ChevronDown";
|
export * from "./ChevronDown";
|
||||||
|
|||||||
@@ -97,8 +97,6 @@ export const updateColumnAtom = atom(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(tableColumnsOrdered);
|
|
||||||
|
|
||||||
// Reduce array into single object with updated indexes
|
// Reduce array into single object with updated indexes
|
||||||
const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {});
|
const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {});
|
||||||
await updateTableSchema({ columns: updatedColumns });
|
await updateTableSchema({ columns: updatedColumns });
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ export const sideDrawerAtom = atomWithHash<"table-information" | null>(
|
|||||||
/** Store side drawer open state */
|
/** Store side drawer open state */
|
||||||
export const sideDrawerOpenAtom = atom(false);
|
export const sideDrawerOpenAtom = atom(false);
|
||||||
|
|
||||||
export type SelectedCell = { path: string; columnKey: string };
|
export type SelectedCell = {
|
||||||
|
path: string | "_rowy_header";
|
||||||
|
columnKey: string | "_rowy_row_actions";
|
||||||
|
focusInside: boolean;
|
||||||
|
};
|
||||||
/** Store selected cell in table. Used in side drawer and context menu */
|
/** Store selected cell in table. Used in side drawer and context menu */
|
||||||
export const selectedCellAtom = atom<SelectedCell | null>(null);
|
export const selectedCellAtom = atom<SelectedCell | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,10 @@ import SettingsIcon from "@mui/icons-material/SettingsOutlined";
|
|||||||
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
|
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
|
||||||
|
|
||||||
import MenuContents, { IMenuContentsProps } from "./MenuContents";
|
import MenuContents, { IMenuContentsProps } from "./MenuContents";
|
||||||
import ColumnHeader from "@src/components/Table/Column";
|
import ColumnHeader from "@src/components/Table/Mock/Column";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectScope,
|
projectScope,
|
||||||
userRolesAtom,
|
|
||||||
userSettingsAtom,
|
userSettingsAtom,
|
||||||
updateUserSettingsAtom,
|
updateUserSettingsAtom,
|
||||||
confirmDialogAtom,
|
confirmDialogAtom,
|
||||||
@@ -80,8 +79,17 @@ export interface IMenuModalProps {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ColumnMenu() {
|
export interface IColumnMenuProps {
|
||||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
canAddColumns: boolean;
|
||||||
|
canEditColumns: boolean;
|
||||||
|
canDeleteColumns: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColumnMenu({
|
||||||
|
canAddColumns,
|
||||||
|
canEditColumns,
|
||||||
|
canDeleteColumns,
|
||||||
|
}: IColumnMenuProps) {
|
||||||
const [userSettings] = useAtom(userSettingsAtom, projectScope);
|
const [userSettings] = useAtom(userSettingsAtom, projectScope);
|
||||||
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
|
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
|
||||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||||
@@ -248,7 +256,7 @@ export default function ColumnMenu() {
|
|||||||
});
|
});
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
active: !column.editable,
|
active: column.editable === false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Disable resize",
|
label: "Disable resize",
|
||||||
@@ -387,6 +395,7 @@ export default function ColumnMenu() {
|
|||||||
openColumnModal({ type: "new", index: column.index - 1 });
|
openColumnModal({ type: "new", index: column.index - 1 });
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
|
disabled: !canAddColumns,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Insert to the right…",
|
label: "Insert to the right…",
|
||||||
@@ -396,6 +405,7 @@ export default function ColumnMenu() {
|
|||||||
openColumnModal({ type: "new", index: column.index + 1 });
|
openColumnModal({ type: "new", index: column.index + 1 });
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
|
disabled: !canAddColumns,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Delete column${altPress ? "" : "…"}`,
|
label: `Delete column${altPress ? "" : "…"}`,
|
||||||
@@ -450,16 +460,19 @@ export default function ColumnMenu() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
color: "error" as "error",
|
color: "error" as "error",
|
||||||
|
disabled: !canDeleteColumns,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let menuItems = [...localViewActions];
|
let menuItems = [...localViewActions];
|
||||||
|
|
||||||
if (userRoles.includes("ADMIN") || userRoles.includes("OPS")) {
|
if (canEditColumns) {
|
||||||
menuItems.push.apply(menuItems, configActions);
|
menuItems.push.apply(menuItems, configActions);
|
||||||
if (column.type === FieldType.derivative) {
|
if (column.type === FieldType.derivative) {
|
||||||
menuItems.push.apply(menuItems, derivativeActions);
|
menuItems.push.apply(menuItems, derivativeActions);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (canAddColumns || canDeleteColumns) {
|
||||||
menuItems.push.apply(menuItems, columnActions);
|
menuItems.push.apply(menuItems, columnActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const MemoizedField = memo(
|
|||||||
|
|
||||||
// Should not reach this state
|
// Should not reach this state
|
||||||
if (isEmpty(fieldComponent)) {
|
if (isEmpty(fieldComponent)) {
|
||||||
// console.error('Could not find SideDrawerField component', field);
|
console.error("Could not find SideDrawerField component", field);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +78,6 @@ export const MemoizedField = memo(
|
|||||||
},
|
},
|
||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
disabled,
|
disabled,
|
||||||
// TODO: Remove
|
|
||||||
control: {} as any,
|
|
||||||
useFormMethods: {} as any,
|
|
||||||
docRef: _rowy_ref,
|
|
||||||
})}
|
})}
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import useMemoValue from "use-memo-value";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { find, findIndex, isEqual } from "lodash-es";
|
import { find, findIndex } from "lodash-es";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { DataGridHandle } from "react-data-grid";
|
|
||||||
import { TransitionGroup } from "react-transition-group";
|
import { TransitionGroup } from "react-transition-group";
|
||||||
|
|
||||||
import { Fab, Fade } from "@mui/material";
|
import { Fab, Fade } from "@mui/material";
|
||||||
@@ -16,34 +14,20 @@ import ErrorFallback from "@src/components/ErrorFallback";
|
|||||||
import StyledDrawer from "./StyledDrawer";
|
import StyledDrawer from "./StyledDrawer";
|
||||||
import SideDrawerFields from "./SideDrawerFields";
|
import SideDrawerFields from "./SideDrawerFields";
|
||||||
|
|
||||||
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
|
|
||||||
import {
|
import {
|
||||||
tableScope,
|
tableScope,
|
||||||
tableIdAtom,
|
|
||||||
tableColumnsOrderedAtom,
|
|
||||||
tableRowsAtom,
|
tableRowsAtom,
|
||||||
sideDrawerOpenAtom,
|
sideDrawerOpenAtom,
|
||||||
selectedCellAtom,
|
selectedCellAtom,
|
||||||
} from "@src/atoms/tableScope";
|
} from "@src/atoms/tableScope";
|
||||||
import { analytics, logEvent } from "@src/analytics";
|
import { analytics, logEvent } from "@src/analytics";
|
||||||
import { formatSubTableName } from "@src/utils/table";
|
|
||||||
|
|
||||||
export const DRAWER_WIDTH = 512;
|
export const DRAWER_WIDTH = 512;
|
||||||
export const DRAWER_COLLAPSED_WIDTH = 36;
|
export const DRAWER_COLLAPSED_WIDTH = 36;
|
||||||
|
|
||||||
export default function SideDrawer({
|
export default function SideDrawer() {
|
||||||
dataGridRef,
|
|
||||||
}: {
|
|
||||||
dataGridRef?: React.MutableRefObject<DataGridHandle | null>;
|
|
||||||
}) {
|
|
||||||
const [userSettings] = useAtom(userSettingsAtom, projectScope);
|
|
||||||
const [tableId] = useAtom(tableIdAtom, tableScope);
|
|
||||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
|
||||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||||
|
|
||||||
const userDocHiddenFields =
|
|
||||||
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [];
|
|
||||||
|
|
||||||
const [cell, setCell] = useAtom(selectedCellAtom, tableScope);
|
const [cell, setCell] = useAtom(selectedCellAtom, tableScope);
|
||||||
const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope);
|
const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope);
|
||||||
const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]);
|
const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]);
|
||||||
@@ -51,26 +35,6 @@ export default function SideDrawer({
|
|||||||
"_rowy_ref.path",
|
"_rowy_ref.path",
|
||||||
cell?.path,
|
cell?.path,
|
||||||
]);
|
]);
|
||||||
// Memo a list of visible column keys for useEffect dependencies
|
|
||||||
const visibleColumnKeys = useMemoValue(
|
|
||||||
tableColumnsOrdered
|
|
||||||
.filter((col) => !userDocHiddenFields.includes(col.key))
|
|
||||||
.map((col) => col.key),
|
|
||||||
isEqual
|
|
||||||
);
|
|
||||||
|
|
||||||
// When side drawer is opened, select the cell in the table
|
|
||||||
// in case we’ve scrolled and selected cell was reset
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const columnIndex = visibleColumnKeys.indexOf(cell?.columnKey || "");
|
|
||||||
if (columnIndex === -1 || selectedCellRowIndex === -1) return;
|
|
||||||
dataGridRef?.current?.selectCell(
|
|
||||||
{ rowIdx: selectedCellRowIndex, idx: columnIndex },
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [open, visibleColumnKeys, selectedCellRowIndex, cell, dataGridRef]);
|
|
||||||
|
|
||||||
const handleNavigate = (direction: "up" | "down") => () => {
|
const handleNavigate = (direction: "up" | "down") => () => {
|
||||||
if (!tableRows || !cell) return;
|
if (!tableRows || !cell) return;
|
||||||
@@ -79,13 +43,11 @@ export default function SideDrawer({
|
|||||||
if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1;
|
if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1;
|
||||||
const newPath = tableRows[rowIndex]._rowy_ref.path;
|
const newPath = tableRows[rowIndex]._rowy_ref.path;
|
||||||
|
|
||||||
setCell((cell) => ({ columnKey: cell!.columnKey, path: newPath }));
|
setCell((cell) => ({
|
||||||
|
columnKey: cell!.columnKey,
|
||||||
const columnIndex = visibleColumnKeys.indexOf(cell!.columnKey || "");
|
path: newPath,
|
||||||
dataGridRef?.current?.selectCell(
|
focusInside: false,
|
||||||
{ rowIdx: rowIndex, idx: columnIndex },
|
}));
|
||||||
false
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// const [urlDocState, dispatchUrlDoc] = useDoc({});
|
// const [urlDocState, dispatchUrlDoc] = useDoc({});
|
||||||
@@ -109,7 +71,7 @@ export default function SideDrawer({
|
|||||||
// }
|
// }
|
||||||
// }, [cell]);
|
// }, [cell]);
|
||||||
|
|
||||||
const disabled = !open && !cell; // && !urlDocState.doc;
|
const disabled = (!open && !cell) || selectedCellRowIndex <= -1; // && !urlDocState.doc;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled && setOpen) setOpen(false);
|
if (disabled && setOpen) setOpen(false);
|
||||||
}, [disabled, setOpen]);
|
}, [disabled, setOpen]);
|
||||||
@@ -143,6 +105,7 @@ export default function SideDrawer({
|
|||||||
{!!cell && (
|
{!!cell && (
|
||||||
<div className="sidedrawer-nav-fab-container">
|
<div className="sidedrawer-nav-fab-container">
|
||||||
<Fab
|
<Fab
|
||||||
|
aria-label="Previous row"
|
||||||
style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
|
style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disabled || !cell || selectedCellRowIndex <= 0}
|
disabled={disabled || !cell || selectedCellRowIndex <= 0}
|
||||||
@@ -152,6 +115,7 @@ export default function SideDrawer({
|
|||||||
</Fab>
|
</Fab>
|
||||||
|
|
||||||
<Fab
|
<Fab
|
||||||
|
aria-label="Next row"
|
||||||
style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }}
|
style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={
|
disabled={
|
||||||
@@ -166,6 +130,7 @@ export default function SideDrawer({
|
|||||||
|
|
||||||
<div className="sidedrawer-open-fab-container">
|
<div className="sidedrawer-open-fab-container">
|
||||||
<Fab
|
<Fab
|
||||||
|
aria-label={open ? "Close side drawer" : "Open side drawer"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (setOpen)
|
if (setOpen)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useLocation, useParams, Link as RouterLink } from "react-router-dom";
|
import { useParams, Link as RouterLink } from "react-router-dom";
|
||||||
import { find, camelCase, uniq } from "lodash-es";
|
import { find, camelCase, uniq } from "lodash-es";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,82 +1,86 @@
|
|||||||
import { useRef } from "react";
|
import { memo, useRef } from "react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useDrag, useDrop } from "react-dnd";
|
import type { Header } from "@tanstack/react-table";
|
||||||
|
import type {
|
||||||
|
DraggableProvided,
|
||||||
|
DraggableStateSnapshot,
|
||||||
|
} from "react-beautiful-dnd";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
styled,
|
|
||||||
alpha,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipProps,
|
|
||||||
tooltipClasses,
|
|
||||||
Fade,
|
Fade,
|
||||||
Grid,
|
StackProps,
|
||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import DropdownIcon from "@mui/icons-material/MoreHoriz";
|
import DropdownIcon from "@mui/icons-material/MoreHoriz";
|
||||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||||
|
|
||||||
import ColumnHeaderSort from "./ColumnHeaderSort";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectScope,
|
StyledColumnHeader,
|
||||||
userRolesAtom,
|
StyledColumnHeaderNameTooltip,
|
||||||
altPressAtom,
|
} from "@src/components/Table/Styled/StyledColumnHeader";
|
||||||
} from "@src/atoms/projectScope";
|
import ColumnHeaderSort, { SORT_STATES } from "./ColumnHeaderSort";
|
||||||
|
import ColumnHeaderDragHandle from "./ColumnHeaderDragHandle";
|
||||||
|
import ColumnHeaderResizer from "./ColumnHeaderResizer";
|
||||||
|
|
||||||
|
import { projectScope, altPressAtom } from "@src/atoms/projectScope";
|
||||||
import {
|
import {
|
||||||
tableScope,
|
tableScope,
|
||||||
updateColumnAtom,
|
selectedCellAtom,
|
||||||
columnMenuAtom,
|
columnMenuAtom,
|
||||||
|
tableSortsAtom,
|
||||||
} from "@src/atoms/tableScope";
|
} from "@src/atoms/tableScope";
|
||||||
import { getFieldProp } from "@src/components/fields";
|
import { getFieldProp } from "@src/components/fields";
|
||||||
import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column";
|
import { FieldType } from "@src/constants/fields";
|
||||||
import { ColumnConfig } from "@src/types/table";
|
import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Mock/Column";
|
||||||
|
import type { ColumnConfig } from "@src/types/table";
|
||||||
|
import type { TableRow } from "@src/types/table";
|
||||||
|
|
||||||
export { COLUMN_HEADER_HEIGHT };
|
export { COLUMN_HEADER_HEIGHT };
|
||||||
|
|
||||||
const LightTooltip = styled(({ className, ...props }: TooltipProps) => (
|
export interface IColumnHeaderProps
|
||||||
<Tooltip {...props} classes={{ popper: className }} />
|
extends Partial<Omit<StackProps, "style" | "sx">> {
|
||||||
))(({ theme }) => ({
|
header: Header<TableRow, any>;
|
||||||
[`& .${tooltipClasses.tooltip}`]: {
|
|
||||||
backgroundColor: theme.palette.background.default,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
|
|
||||||
margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`,
|
|
||||||
padding: 0,
|
|
||||||
paddingRight: theme.spacing(1.5),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export interface IDraggableHeaderRendererProps {
|
|
||||||
column: ColumnConfig;
|
column: ColumnConfig;
|
||||||
|
|
||||||
|
provided: DraggableProvided;
|
||||||
|
snapshot: DraggableStateSnapshot;
|
||||||
|
|
||||||
|
width: number;
|
||||||
|
isSelectedCell: boolean;
|
||||||
|
focusInsideCell: boolean;
|
||||||
|
canEditColumns: boolean;
|
||||||
|
isLastFrozen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DraggableHeaderRenderer({
|
/**
|
||||||
|
* Renders UI components for each column header, including accessibility
|
||||||
|
* attributes. Memoized to prevent re-render when resizing or reordering other
|
||||||
|
* columns.
|
||||||
|
*
|
||||||
|
* Renders:
|
||||||
|
* - Drag handle (accessible)
|
||||||
|
* - Field type icon + click to copy field key
|
||||||
|
* - Field name + hover to view full name if cut off
|
||||||
|
* - Sort button
|
||||||
|
* - Resize handle (not accessible)
|
||||||
|
*/
|
||||||
|
export const ColumnHeader = memo(function ColumnHeader({
|
||||||
|
header,
|
||||||
column,
|
column,
|
||||||
}: IDraggableHeaderRendererProps) {
|
provided,
|
||||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
snapshot,
|
||||||
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
width,
|
||||||
|
isSelectedCell,
|
||||||
|
focusInsideCell,
|
||||||
|
canEditColumns,
|
||||||
|
isLastFrozen,
|
||||||
|
}: IColumnHeaderProps) {
|
||||||
const openColumnMenu = useSetAtom(columnMenuAtom, tableScope);
|
const openColumnMenu = useSetAtom(columnMenuAtom, tableScope);
|
||||||
|
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
|
||||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||||
|
const [tableSorts] = useAtom(tableSortsAtom, tableScope);
|
||||||
const [{ isDragging }, dragRef] = useDrag({
|
|
||||||
type: "COLUMN_DRAG",
|
|
||||||
item: { key: column.key },
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [{ isOver }, dropRef] = useDrop({
|
|
||||||
accept: "COLUMN_DRAG",
|
|
||||||
drop: ({ key }: { key: string }) => {
|
|
||||||
updateColumn({ key, config: {}, index: column.index });
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -85,53 +89,69 @@ export default function DraggableHeaderRenderer({
|
|||||||
openColumnMenu({ column, anchorEl: buttonRef.current });
|
openColumnMenu({ column, anchorEl: buttonRef.current });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _sortKey = getFieldProp("sortKey", (column as any).type);
|
||||||
|
const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key;
|
||||||
|
const currentSort: typeof SORT_STATES[number] =
|
||||||
|
tableSorts[0]?.key !== sortKey
|
||||||
|
? "none"
|
||||||
|
: tableSorts[0]?.direction || "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid
|
<StyledColumnHeader
|
||||||
key={column.key}
|
role="columnheader"
|
||||||
id={`column-header-${column.key}`}
|
id={`column-header-${column.key}`}
|
||||||
ref={(ref) => {
|
ref={provided.innerRef}
|
||||||
dragRef(ref);
|
{...provided.draggableProps}
|
||||||
dropRef(ref);
|
data-row-id={"_rowy_header"}
|
||||||
|
data-col-id={header.id}
|
||||||
|
data-frozen={header.column.getIsPinned() || undefined}
|
||||||
|
data-frozen-last={isLastFrozen || undefined}
|
||||||
|
tabIndex={isSelectedCell ? 0 : -1}
|
||||||
|
aria-colindex={header.index + 1}
|
||||||
|
aria-readonly={!canEditColumns}
|
||||||
|
aria-selected={isSelectedCell}
|
||||||
|
aria-sort={
|
||||||
|
currentSort === "none"
|
||||||
|
? "none"
|
||||||
|
: currentSort === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
left: header.column.getIsPinned()
|
||||||
|
? header.column.getStart()
|
||||||
|
: undefined,
|
||||||
|
zIndex: header.column.getIsPinned() ? 11 : 10,
|
||||||
|
...provided.draggableProps.style,
|
||||||
|
width,
|
||||||
|
borderLeftStyle: snapshot.isDragging ? "solid" : undefined,
|
||||||
}}
|
}}
|
||||||
container
|
|
||||||
alignItems="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
onContextMenu={handleOpenMenu}
|
onContextMenu={handleOpenMenu}
|
||||||
sx={[
|
onClick={(e) => {
|
||||||
{
|
setSelectedCell({
|
||||||
height: "100%",
|
path: "_rowy_header",
|
||||||
"& svg, & button": { display: "block" },
|
columnKey: header.id,
|
||||||
|
focusInside: false,
|
||||||
color: "text.secondary",
|
});
|
||||||
transition: (theme) =>
|
(e.target as HTMLDivElement).focus();
|
||||||
theme.transitions.create("color", {
|
}}
|
||||||
duration: theme.transitions.duration.short,
|
onDoubleClick={(e) => {
|
||||||
}),
|
setSelectedCell({
|
||||||
"&:hover": { color: "text.primary" },
|
path: "_rowy_header",
|
||||||
|
columnKey: header.id,
|
||||||
cursor: "move",
|
focusInside: true,
|
||||||
|
});
|
||||||
py: 0,
|
(e.target as HTMLDivElement).focus();
|
||||||
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",
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
]}
|
|
||||||
className="column-header"
|
|
||||||
>
|
>
|
||||||
{(column.width as number) > 140 && (
|
{provided.dragHandleProps && (
|
||||||
|
<ColumnHeaderDragHandle
|
||||||
|
dragHandleProps={provided.dragHandleProps}
|
||||||
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{width > 140 && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
@@ -144,94 +164,99 @@ export default function DraggableHeaderRenderer({
|
|||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Grid
|
<div
|
||||||
item
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(column.key);
|
navigator.clipboard.writeText(column.key);
|
||||||
}}
|
}}
|
||||||
|
style={{ position: "relative", zIndex: 2 }}
|
||||||
>
|
>
|
||||||
{column.editable === false ? (
|
{column.editable === false ? (
|
||||||
<LockIcon />
|
<LockIcon />
|
||||||
) : (
|
) : (
|
||||||
getFieldProp("icon", (column as any).type)
|
getFieldProp("icon", (column as any).type)
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Grid
|
<StyledColumnHeaderNameTooltip
|
||||||
item
|
title={
|
||||||
xs
|
|
||||||
sx={{ flexShrink: 1, overflow: "hidden", my: 0, ml: 0.5, mr: -30 / 8 }}
|
|
||||||
>
|
|
||||||
<LightTooltip
|
|
||||||
title={
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
typography: "caption",
|
|
||||||
fontWeight: "fontWeightMedium",
|
|
||||||
lineHeight: `${COLUMN_HEADER_HEIGHT - 2 - 4}px`,
|
|
||||||
textOverflow: "clip",
|
|
||||||
}}
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
{column.name as string}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
enterDelay={1000}
|
|
||||||
placement="bottom-start"
|
|
||||||
disableInteractive
|
|
||||||
TransitionComponent={Fade}
|
|
||||||
>
|
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
|
||||||
sx={{
|
sx={{
|
||||||
typography: "caption",
|
typography: "caption",
|
||||||
fontWeight: "fontWeightMedium",
|
fontWeight: "fontWeightMedium",
|
||||||
lineHeight: `${COLUMN_HEADER_HEIGHT}px`,
|
lineHeight: `${COLUMN_HEADER_HEIGHT - 2 - 4}px`,
|
||||||
textOverflow: "clip",
|
textOverflow: "clip",
|
||||||
}}
|
}}
|
||||||
component="div"
|
|
||||||
color="inherit"
|
color="inherit"
|
||||||
>
|
>
|
||||||
{altPress ? (
|
{column.name as string}
|
||||||
<>
|
|
||||||
{column.index} <code>{column.fieldName}</code>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
column.name
|
|
||||||
)}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</LightTooltip>
|
}
|
||||||
</Grid>
|
enterDelay={1000}
|
||||||
|
placement="bottom-start"
|
||||||
|
disableInteractive
|
||||||
|
TransitionComponent={Fade}
|
||||||
|
sx={{ "& .MuiTooltip-tooltip": { marginTop: "-28px !important" } }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
typography: "caption",
|
||||||
|
fontWeight: "fontWeightMedium",
|
||||||
|
textOverflow: "clip",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
|
||||||
<Grid item>
|
flexGrow: 1,
|
||||||
<ColumnHeaderSort column={column as any} />
|
flexShrink: 1,
|
||||||
</Grid>
|
overflow: "hidden",
|
||||||
|
my: 0,
|
||||||
|
ml: 0.5,
|
||||||
|
mr: -30 / 8,
|
||||||
|
}}
|
||||||
|
component="div"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
{altPress ? (
|
||||||
|
<>
|
||||||
|
{column.index} <code>{column.fieldName}</code>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
column.name
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</StyledColumnHeaderNameTooltip>
|
||||||
|
|
||||||
<Grid item>
|
{column.type !== FieldType.id && (
|
||||||
<Tooltip title="Column settings">
|
<ColumnHeaderSort
|
||||||
<IconButton
|
sortKey={sortKey}
|
||||||
size="small"
|
currentSort={currentSort}
|
||||||
aria-label={`Column settings for ${column.name as string}`}
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
id={`column-settings-${column.key}`}
|
/>
|
||||||
color="inherit"
|
)}
|
||||||
onClick={handleOpenMenu}
|
|
||||||
ref={buttonRef}
|
|
||||||
sx={{
|
|
||||||
transition: (theme) =>
|
|
||||||
theme.transitions.create("color", {
|
|
||||||
duration: theme.transitions.duration.short,
|
|
||||||
}),
|
|
||||||
|
|
||||||
color: "text.disabled",
|
<Tooltip title="Column settings">
|
||||||
".column-header:hover &": { color: "text.primary" },
|
<IconButton
|
||||||
}}
|
size="small"
|
||||||
>
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
<DropdownIcon />
|
id={`column-settings-${column.key}`}
|
||||||
</IconButton>
|
onClick={handleOpenMenu}
|
||||||
</Tooltip>
|
ref={buttonRef}
|
||||||
</Grid>
|
>
|
||||||
</Grid>
|
<DropdownIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<ColumnHeaderResizer
|
||||||
|
isResizing={header.column.getIsResizing()}
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledColumnHeader>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ColumnHeader;
|
||||||
|
|||||||
58
src/components/Table/ColumnHeader/ColumnHeaderDragHandle.tsx
Normal file
58
src/components/Table/ColumnHeader/ColumnHeaderDragHandle.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
|
||||||
|
import { DragVertical } from "@src/assets/icons";
|
||||||
|
|
||||||
|
export interface IColumnHeaderDragHandleProps {
|
||||||
|
dragHandleProps: DraggableProvidedDragHandleProps;
|
||||||
|
tabIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColumnHeaderDragHandle({
|
||||||
|
dragHandleProps,
|
||||||
|
tabIndex,
|
||||||
|
}: IColumnHeaderDragHandleProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...dragHandleProps}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
aria-describedby={
|
||||||
|
tabIndex > -1 ? dragHandleProps["aria-describedby"] : undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
className="column-drag-handle"
|
||||||
|
>
|
||||||
|
<DragVertical
|
||||||
|
sx={{
|
||||||
|
opacity: 0,
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: (theme) => theme.transitions.create(["opacity"]),
|
||||||
|
"[role='columnheader']:hover &, [role='columnheader']:focus-within &":
|
||||||
|
{
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
".column-drag-handle:hover &": {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
".column-drag-handle:active &": {
|
||||||
|
opacity: 1,
|
||||||
|
color: "primary.main",
|
||||||
|
},
|
||||||
|
".column-drag-handle:focus &": {
|
||||||
|
opacity: 1,
|
||||||
|
color: "primary.main",
|
||||||
|
outline: "2px solid",
|
||||||
|
outlineColor: "primary.main",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ width: 8 }}
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/Table/ColumnHeader/ColumnHeaderResizer.tsx
Normal file
56
src/components/Table/ColumnHeader/ColumnHeaderResizer.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
|
||||||
|
export interface IColumnHeaderResizerProps {
|
||||||
|
isResizing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHeaderResizer = styled("div", {
|
||||||
|
name: "ColumnHeaderResizer",
|
||||||
|
shouldForwardProp: (prop) => prop !== "isResizing",
|
||||||
|
})<IColumnHeaderResizerProps>(({ theme, isResizing }) => ({
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 5,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "100%",
|
||||||
|
width: 10,
|
||||||
|
|
||||||
|
cursor: "col-resize",
|
||||||
|
userSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
|
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
|
||||||
|
transition: theme.transitions.create("opacity", {
|
||||||
|
duration: theme.transitions.duration.shortest,
|
||||||
|
}),
|
||||||
|
opacity: isResizing ? 1 : 0,
|
||||||
|
"[role='columnheader']:hover &": { opacity: 0.33 },
|
||||||
|
"[role='columnheader'] &:hover, [role='columnheader'] &:active": {
|
||||||
|
opacity: 1,
|
||||||
|
"&::before": { transform: "scaleY(1.25)" },
|
||||||
|
},
|
||||||
|
|
||||||
|
"&::before": {
|
||||||
|
content: "''",
|
||||||
|
display: "block",
|
||||||
|
|
||||||
|
height: "50%",
|
||||||
|
width: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginRight: 2,
|
||||||
|
|
||||||
|
background: isResizing
|
||||||
|
? theme.palette.primary.main
|
||||||
|
: theme.palette.action.active,
|
||||||
|
transition: theme.transitions.create("transform", {
|
||||||
|
duration: theme.transitions.duration.shortest,
|
||||||
|
}),
|
||||||
|
transform: isResizing ? "scaleY(1.5) !important" : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
ColumnHeaderResizer.displayName = "ColumnHeaderResizer";
|
||||||
|
|
||||||
|
export default ColumnHeaderResizer;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useAtom } from "jotai";
|
import { memo } from "react";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import { colord } from "colord";
|
import { colord } from "colord";
|
||||||
|
|
||||||
import { Tooltip, IconButton } from "@mui/material";
|
import { Tooltip, IconButton } from "@mui/material";
|
||||||
@@ -8,27 +9,26 @@ import IconSlash, {
|
|||||||
} from "@src/components/IconSlash";
|
} from "@src/components/IconSlash";
|
||||||
|
|
||||||
import { tableScope, tableSortsAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableSortsAtom } from "@src/atoms/tableScope";
|
||||||
import { FieldType } from "@src/constants/fields";
|
|
||||||
import { getFieldProp } from "@src/components/fields";
|
|
||||||
|
|
||||||
import { ColumnConfig } from "@src/types/table";
|
export const SORT_STATES = ["none", "desc", "asc"] as const;
|
||||||
|
|
||||||
const SORT_STATES = ["none", "desc", "asc"] as const;
|
|
||||||
|
|
||||||
export interface IColumnHeaderSortProps {
|
export interface IColumnHeaderSortProps {
|
||||||
column: ColumnConfig;
|
sortKey: string;
|
||||||
|
currentSort: typeof SORT_STATES[number];
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
/**
|
||||||
const [tableSorts, setTableSorts] = useAtom(tableSortsAtom, tableScope);
|
* Renders button with current sort state.
|
||||||
|
* On click, updates `tableSortsAtom` in `tableScope`.
|
||||||
|
*/
|
||||||
|
export const ColumnHeaderSort = memo(function ColumnHeaderSort({
|
||||||
|
sortKey,
|
||||||
|
currentSort,
|
||||||
|
tabIndex,
|
||||||
|
}: IColumnHeaderSortProps) {
|
||||||
|
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
|
||||||
|
|
||||||
const _sortKey = getFieldProp("sortKey", (column as any).type);
|
|
||||||
const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key;
|
|
||||||
|
|
||||||
const currentSort: typeof SORT_STATES[number] =
|
|
||||||
tableSorts[0]?.key !== sortKey
|
|
||||||
? "none"
|
|
||||||
: tableSorts[0]?.direction || "none";
|
|
||||||
const nextSort =
|
const nextSort =
|
||||||
SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0];
|
SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0];
|
||||||
|
|
||||||
@@ -37,20 +37,19 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
|||||||
else setTableSorts([{ key: sortKey, direction: nextSort }]);
|
else setTableSorts([{ key: sortKey, direction: nextSort }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (column.type === FieldType.id) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={nextSort === "none" ? "Unsort" : `Sort by ${nextSort}ending`}
|
title={nextSort === "none" ? "Remove sort" : `Sort by ${nextSort}ending`}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
disableFocusRipple={true}
|
disableFocusRipple={true}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleSortClick}
|
onClick={handleSortClick}
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
tabIndex={tabIndex}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: "background.default",
|
bgcolor: "background.default",
|
||||||
"&:hover": {
|
"&:hover, &:focus": {
|
||||||
backgroundColor: (theme) =>
|
backgroundColor: (theme) =>
|
||||||
colord(theme.palette.background.default)
|
colord(theme.palette.background.default)
|
||||||
.mix(
|
.mix(
|
||||||
@@ -74,12 +73,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
|||||||
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
opacity: currentSort !== "none" ? 1 : 0,
|
opacity: currentSort !== "none" ? 1 : 0,
|
||||||
".column-header:hover &": { opacity: 1 },
|
|
||||||
|
|
||||||
transition: (theme) =>
|
|
||||||
theme.transitions.create(["background-color", "opacity"], {
|
|
||||||
duration: theme.transitions.duration.short,
|
|
||||||
}),
|
|
||||||
|
|
||||||
"& .arrow": {
|
"& .arrow": {
|
||||||
transition: (theme) =>
|
transition: (theme) =>
|
||||||
@@ -89,7 +82,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
|||||||
|
|
||||||
transform: currentSort === "asc" ? "rotate(180deg)" : "none",
|
transform: currentSort === "asc" ? "rotate(180deg)" : "none",
|
||||||
},
|
},
|
||||||
"&:hover .arrow": {
|
"&:hover .arrow, &:focus .arrow": {
|
||||||
transform:
|
transform:
|
||||||
currentSort === "asc" || nextSort === "asc"
|
currentSort === "asc" || nextSort === "asc"
|
||||||
? "rotate(180deg)"
|
? "rotate(180deg)"
|
||||||
@@ -100,7 +93,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
|||||||
strokeDashoffset:
|
strokeDashoffset:
|
||||||
currentSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
|
currentSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
|
||||||
},
|
},
|
||||||
"&:hover .icon-slash": {
|
"&:hover .icon-slash, &:focus .icon-slash": {
|
||||||
strokeDashoffset:
|
strokeDashoffset:
|
||||||
nextSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
|
nextSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
|
||||||
},
|
},
|
||||||
@@ -113,4 +106,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ColumnHeaderSort;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { NonFullScreenErrorFallback } from "@src/components/ErrorFallback";
|
import { NonFullScreenErrorFallback } from "@src/components/ErrorFallback";
|
||||||
@@ -8,10 +9,21 @@ import MenuContents from "./MenuContents";
|
|||||||
import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope";
|
import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope";
|
||||||
|
|
||||||
export default function ContextMenu() {
|
export default function ContextMenu() {
|
||||||
|
const menuRef = useRef<HTMLUListElement>(null);
|
||||||
const [contextMenuTarget, setContextMenuTarget] = useAtom(
|
const [contextMenuTarget, setContextMenuTarget] = useAtom(
|
||||||
contextMenuTargetAtom,
|
contextMenuTargetAtom,
|
||||||
tableScope
|
tableScope
|
||||||
);
|
);
|
||||||
|
const open = Boolean(contextMenuTarget);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (open && menuRef.current) {
|
||||||
|
const firstMenuitem = menuRef.current.querySelector("[role=menuitem]");
|
||||||
|
(firstMenuitem as HTMLElement)?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const handleClose = () => setContextMenuTarget(null);
|
const handleClose = () => setContextMenuTarget(null);
|
||||||
|
|
||||||
@@ -20,11 +32,12 @@ export default function ContextMenu() {
|
|||||||
id="cell-context-menu"
|
id="cell-context-menu"
|
||||||
aria-label="Cell context menu"
|
aria-label="Cell context menu"
|
||||||
anchorEl={contextMenuTarget as any}
|
anchorEl={contextMenuTarget as any}
|
||||||
open={Boolean(contextMenuTarget)}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||||
sx={{ "& .MuiMenu-paper": { minWidth: 160 } }}
|
sx={{ "& .MuiMenu-paper": { minWidth: 160 } }}
|
||||||
|
MenuListProps={{ ref: menuRef }}
|
||||||
>
|
>
|
||||||
<ErrorBoundary FallbackComponent={NonFullScreenErrorFallback}>
|
<ErrorBoundary FallbackComponent={NonFullScreenErrorFallback}>
|
||||||
<MenuContents onClose={handleClose} />
|
<MenuContents onClose={handleClose} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface IContextMenuItem extends Partial<MenuItemProps> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
hotkeyLabel?: string;
|
hotkeyLabel?: string;
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
|
subItems?: IContextMenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IContextMenuItemProps extends IContextMenuItem {
|
export interface IContextMenuItemProps extends IContextMenuItem {
|
||||||
@@ -82,16 +83,20 @@ export default function ContextMenuItem({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
if (props.divider) {
|
||||||
<MenuItem {...props} onClick={onClick}>
|
return <Divider variant="middle" />;
|
||||||
<ListItemIcon>{icon}</ListItemIcon>
|
} else {
|
||||||
<ListItemText>{label}</ListItemText>
|
return (
|
||||||
{hotkeyLabel && (
|
<MenuItem {...props} onClick={onClick}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<ListItemIcon>{icon}</ListItemIcon>
|
||||||
{hotkeyLabel}
|
<ListItemText>{label}</ListItemText>
|
||||||
</Typography>
|
{hotkeyLabel && (
|
||||||
)}
|
<Typography variant="body2" color="text.secondary">
|
||||||
</MenuItem>
|
{hotkeyLabel}
|
||||||
);
|
</Typography>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,182 +62,188 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
|||||||
if (!tableSchema.columns || !selectedCell) return null;
|
if (!tableSchema.columns || !selectedCell) return null;
|
||||||
|
|
||||||
const selectedColumn = tableSchema.columns[selectedCell.columnKey];
|
const selectedColumn = tableSchema.columns[selectedCell.columnKey];
|
||||||
const menuActions = getFieldProp("contextMenuActions", selectedColumn.type);
|
const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
const actionGroups: IContextMenuItem[][] = [];
|
const actionGroups: IContextMenuItem[][] = [];
|
||||||
const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
|
|
||||||
// Field type actions
|
|
||||||
const fieldTypeActions = menuActions
|
|
||||||
? menuActions(selectedCell, onClose)
|
|
||||||
: [];
|
|
||||||
if (fieldTypeActions.length > 0) actionGroups.push(fieldTypeActions);
|
|
||||||
|
|
||||||
if (selectedColumn.type === FieldType.derivative) {
|
const handleDuplicate = () => {
|
||||||
const renderedFieldMenuActions = getFieldProp(
|
addRow({
|
||||||
"contextMenuActions",
|
row,
|
||||||
selectedColumn.config?.renderFieldType
|
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||||
);
|
|
||||||
if (renderedFieldMenuActions) {
|
|
||||||
actionGroups.push(renderedFieldMenuActions(selectedCell, onClose));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cell actions
|
|
||||||
// TODO: Add copy and paste here
|
|
||||||
const cellValue = row?.[selectedCell.columnKey];
|
|
||||||
const handleClearValue = () =>
|
|
||||||
updateField({
|
|
||||||
path: selectedCell.path,
|
|
||||||
fieldName: selectedColumn.fieldName,
|
|
||||||
value: null,
|
|
||||||
deleteField: true,
|
|
||||||
});
|
});
|
||||||
const columnFilters = getFieldProp("filter", selectedColumn.type);
|
|
||||||
const handleFilterValue = () => {
|
|
||||||
openTableFiltersPopover({
|
|
||||||
defaultQuery: {
|
|
||||||
key: selectedColumn.fieldName,
|
|
||||||
operator: columnFilters!.operators[0]?.value || "==",
|
|
||||||
value: cellValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
const cellActions = [
|
const handleDelete = () => deleteRow(row._rowy_ref.path);
|
||||||
|
const rowActions: IContextMenuItem[] = [
|
||||||
{
|
{
|
||||||
label: altPress ? "Clear value" : "Clear value…",
|
label: "Copy ID",
|
||||||
color: "error",
|
icon: <CopyIcon />,
|
||||||
icon: <ClearIcon />,
|
onClick: () => {
|
||||||
|
navigator.clipboard.writeText(row._rowy_ref.id);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Copy path",
|
||||||
|
icon: <CopyIcon />,
|
||||||
|
onClick: () => {
|
||||||
|
navigator.clipboard.writeText(row._rowy_ref.path);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open in Firebase Console",
|
||||||
|
icon: <OpenIcon />,
|
||||||
|
onClick: () => {
|
||||||
|
window.open(
|
||||||
|
`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${row._rowy_ref.path.replace(
|
||||||
|
/\//g,
|
||||||
|
"~2F"
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ label: "Divider", divider: true },
|
||||||
|
{
|
||||||
|
label: "Duplicate",
|
||||||
|
icon: <DuplicateIcon />,
|
||||||
disabled:
|
disabled:
|
||||||
selectedColumn.editable === false ||
|
tableSettings.tableType === "collectionGroup" ||
|
||||||
!row ||
|
(!userRoles.includes("ADMIN") && tableSettings.readOnly),
|
||||||
cellValue === undefined ||
|
|
||||||
getFieldProp("group", selectedColumn.type) === "Auditing",
|
|
||||||
onClick: altPress
|
onClick: altPress
|
||||||
? handleClearValue
|
? handleDuplicate
|
||||||
: () => {
|
: () => {
|
||||||
confirm({
|
confirm({
|
||||||
title: "Clear cell value?",
|
title: "Duplicate row?",
|
||||||
body: "The cell’s value cannot be recovered after",
|
body: (
|
||||||
confirm: "Delete",
|
<>
|
||||||
confirmColor: "error",
|
Row path:
|
||||||
handleConfirm: handleClearValue,
|
<br />
|
||||||
|
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||||
|
{row._rowy_ref.path}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirm: "Duplicate",
|
||||||
|
handleConfirm: handleDuplicate,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Filter value",
|
label: altPress ? "Delete" : "Delete…",
|
||||||
icon: <FilterIcon />,
|
color: "error",
|
||||||
disabled: !columnFilters || cellValue === undefined,
|
icon: <DeleteIcon />,
|
||||||
onClick: handleFilterValue,
|
disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
|
||||||
|
onClick: altPress
|
||||||
|
? handleDelete
|
||||||
|
: () => {
|
||||||
|
confirm({
|
||||||
|
title: "Delete row?",
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
Row path:
|
||||||
|
<br />
|
||||||
|
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||||
|
{row._rowy_ref.path}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirm: "Delete",
|
||||||
|
confirmColor: "error",
|
||||||
|
handleConfirm: handleDelete,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
actionGroups.push(cellActions);
|
|
||||||
|
|
||||||
// Row actions
|
if (selectedColumn) {
|
||||||
if (row) {
|
const menuActions = getFieldProp(
|
||||||
const handleDuplicate = () => {
|
"contextMenuActions",
|
||||||
addRow({
|
selectedColumn?.type
|
||||||
row,
|
);
|
||||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
|
||||||
|
// Field type actions
|
||||||
|
const fieldTypeActions = menuActions
|
||||||
|
? menuActions(selectedCell, onClose)
|
||||||
|
: [];
|
||||||
|
if (fieldTypeActions.length > 0) actionGroups.push(fieldTypeActions);
|
||||||
|
|
||||||
|
if (selectedColumn?.type === FieldType.derivative) {
|
||||||
|
const renderedFieldMenuActions = getFieldProp(
|
||||||
|
"contextMenuActions",
|
||||||
|
selectedColumn.config?.renderFieldType
|
||||||
|
);
|
||||||
|
if (renderedFieldMenuActions) {
|
||||||
|
actionGroups.push(renderedFieldMenuActions(selectedCell, onClose));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell actions
|
||||||
|
// TODO: Add copy and paste here
|
||||||
|
const cellValue = row?.[selectedCell.columnKey];
|
||||||
|
const handleClearValue = () =>
|
||||||
|
updateField({
|
||||||
|
path: selectedCell.path,
|
||||||
|
fieldName: selectedColumn.fieldName,
|
||||||
|
value: null,
|
||||||
|
deleteField: true,
|
||||||
});
|
});
|
||||||
|
const columnFilters = getFieldProp("filter", selectedColumn?.type);
|
||||||
|
const handleFilterValue = () => {
|
||||||
|
openTableFiltersPopover({
|
||||||
|
defaultQuery: {
|
||||||
|
key: selectedColumn.fieldName,
|
||||||
|
operator: columnFilters!.operators[0]?.value || "==",
|
||||||
|
value: cellValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
const handleDelete = () => deleteRow(row._rowy_ref.path);
|
const cellActions = [
|
||||||
const rowActions = [
|
{
|
||||||
|
label: altPress ? "Clear value" : "Clear value…",
|
||||||
|
color: "error",
|
||||||
|
icon: <ClearIcon />,
|
||||||
|
disabled:
|
||||||
|
selectedColumn?.editable === false ||
|
||||||
|
!row ||
|
||||||
|
cellValue === undefined ||
|
||||||
|
getFieldProp("group", selectedColumn?.type) === "Auditing",
|
||||||
|
onClick: altPress
|
||||||
|
? handleClearValue
|
||||||
|
: () => {
|
||||||
|
confirm({
|
||||||
|
title: "Clear cell value?",
|
||||||
|
body: "The cell’s value cannot be recovered after",
|
||||||
|
confirm: "Delete",
|
||||||
|
confirmColor: "error",
|
||||||
|
handleConfirm: handleClearValue,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Filter value",
|
||||||
|
icon: <FilterIcon />,
|
||||||
|
disabled: !columnFilters || cellValue === undefined,
|
||||||
|
onClick: handleFilterValue,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
actionGroups.push(cellActions);
|
||||||
|
|
||||||
|
// Row actions as sub-menu
|
||||||
|
actionGroups.push([
|
||||||
{
|
{
|
||||||
label: "Row",
|
label: "Row",
|
||||||
icon: <RowIcon />,
|
icon: <RowIcon />,
|
||||||
subItems: [
|
subItems: rowActions,
|
||||||
{
|
|
||||||
label: "Copy ID",
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
onClick: () => {
|
|
||||||
navigator.clipboard.writeText(row._rowy_ref.id);
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Copy path",
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
onClick: () => {
|
|
||||||
navigator.clipboard.writeText(row._rowy_ref.path);
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Open in Firebase Console",
|
|
||||||
icon: <OpenIcon />,
|
|
||||||
onClick: () => {
|
|
||||||
window.open(
|
|
||||||
`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${row._rowy_ref.path.replace(
|
|
||||||
/\//g,
|
|
||||||
"~2F"
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ label: "Divider", divider: true },
|
|
||||||
{
|
|
||||||
label: "Duplicate",
|
|
||||||
icon: <DuplicateIcon />,
|
|
||||||
disabled:
|
|
||||||
tableSettings.tableType === "collectionGroup" ||
|
|
||||||
(!userRoles.includes("ADMIN") && tableSettings.readOnly),
|
|
||||||
onClick: altPress
|
|
||||||
? handleDuplicate
|
|
||||||
: () => {
|
|
||||||
confirm({
|
|
||||||
title: "Duplicate row?",
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
Row path:
|
|
||||||
<br />
|
|
||||||
<code
|
|
||||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
|
||||||
>
|
|
||||||
{row._rowy_ref.path}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
confirm: "Duplicate",
|
|
||||||
handleConfirm: handleDuplicate,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: altPress ? "Delete" : "Delete…",
|
|
||||||
color: "error",
|
|
||||||
icon: <DeleteIcon />,
|
|
||||||
disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
|
|
||||||
onClick: altPress
|
|
||||||
? handleDelete
|
|
||||||
: () => {
|
|
||||||
confirm({
|
|
||||||
title: "Delete row?",
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
Row path:
|
|
||||||
<br />
|
|
||||||
<code
|
|
||||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
|
||||||
>
|
|
||||||
{row._rowy_ref.path}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
confirm: "Delete",
|
|
||||||
confirmColor: "error",
|
|
||||||
handleConfirm: handleDelete,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
} else {
|
||||||
actionGroups.push(rowActions);
|
actionGroups.push(rowActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import type { FormatterProps } from "react-data-grid";
|
import type { IRenderedTableCellProps } from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import { Stack, Tooltip, IconButton, alpha } from "@mui/material";
|
import { Stack, Tooltip, IconButton, alpha } from "@mui/material";
|
||||||
import { CopyCells as CopyCellsIcon } from "@src/assets/icons";
|
import { CopyCells as CopyCellsIcon } from "@src/assets/icons";
|
||||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
|
import MenuIcon from "@mui/icons-material/MoreHoriz";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectScope,
|
projectScope,
|
||||||
@@ -17,10 +19,13 @@ import {
|
|||||||
tableSettingsAtom,
|
tableSettingsAtom,
|
||||||
addRowAtom,
|
addRowAtom,
|
||||||
deleteRowAtom,
|
deleteRowAtom,
|
||||||
|
contextMenuTargetAtom,
|
||||||
} from "@src/atoms/tableScope";
|
} from "@src/atoms/tableScope";
|
||||||
import { TableRow } from "@src/types/table";
|
|
||||||
|
|
||||||
export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
export const FinalColumn = memo(function FinalColumn({
|
||||||
|
row,
|
||||||
|
focusInsideCell,
|
||||||
|
}: IRenderedTableCellProps) {
|
||||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||||
const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
|
const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
|
||||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||||
@@ -28,12 +33,13 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
|||||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||||
const addRow = useSetAtom(addRowAtom, tableScope);
|
const addRow = useSetAtom(addRowAtom, tableScope);
|
||||||
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
|
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
|
||||||
|
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
|
||||||
|
|
||||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||||
const handleDelete = () => deleteRow(row._rowy_ref.path);
|
const handleDelete = () => deleteRow(row.original._rowy_ref.path);
|
||||||
const handleDuplicate = () => {
|
const handleDuplicate = () => {
|
||||||
addRow({
|
addRow({
|
||||||
row,
|
row: row.original,
|
||||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -42,7 +48,26 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={0.5}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
className="cell-contents"
|
||||||
|
gap={0.5}
|
||||||
|
>
|
||||||
|
<Tooltip title="Row menu">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
onClick={(e) => {
|
||||||
|
setContextMenuTarget(e.target as HTMLElement);
|
||||||
|
}}
|
||||||
|
className="row-hover-iconButton"
|
||||||
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip title="Duplicate row">
|
<Tooltip title="Duplicate row">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -61,7 +86,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
|||||||
<code
|
<code
|
||||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
style={{ userSelect: "all", wordBreak: "break-all" }}
|
||||||
>
|
>
|
||||||
{row._rowy_ref.path}
|
{row.original._rowy_ref.path}
|
||||||
</code>
|
</code>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -70,8 +95,8 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
aria-label="Duplicate row"
|
|
||||||
className="row-hover-iconButton"
|
className="row-hover-iconButton"
|
||||||
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
>
|
>
|
||||||
<CopyCellsIcon />
|
<CopyCellsIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -94,7 +119,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
|||||||
<code
|
<code
|
||||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
style={{ userSelect: "all", wordBreak: "break-all" }}
|
||||||
>
|
>
|
||||||
{row._rowy_ref.path}
|
{row.original._rowy_ref.path}
|
||||||
</code>
|
</code>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -104,23 +129,25 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
aria-label={`Delete row${altPress ? "" : "…"}`}
|
|
||||||
className="row-hover-iconButton"
|
className="row-hover-iconButton"
|
||||||
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
sx={{
|
sx={{
|
||||||
".rdg-row:hover .row-hover-iconButton&&": {
|
"[role='row']:hover .row-hover-iconButton&&, .row-hover-iconButton&&:focus":
|
||||||
color: "error.main",
|
{
|
||||||
backgroundColor: (theme) =>
|
color: "error.main",
|
||||||
alpha(
|
backgroundColor: (theme) =>
|
||||||
theme.palette.error.main,
|
alpha(
|
||||||
theme.palette.action.hoverOpacity * 2
|
theme.palette.error.main,
|
||||||
),
|
theme.palette.action.hoverOpacity * 2
|
||||||
},
|
),
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
disabled={!row._rowy_ref.path}
|
disabled={!row.original._rowy_ref.path}
|
||||||
>
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
export default FinalColumn;
|
||||||
86
src/components/Table/FinalColumn/FinalColumnHeader.tsx
Normal file
86
src/components/Table/FinalColumn/FinalColumnHeader.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
|
|
||||||
|
import { Box, BoxProps, Button } from "@mui/material";
|
||||||
|
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
||||||
|
|
||||||
|
import { projectScope, userRolesAtom } from "@src/atoms/projectScope";
|
||||||
|
import { tableScope, columnModalAtom } from "@src/atoms/tableScope";
|
||||||
|
import { spreadSx } from "@src/utils/ui";
|
||||||
|
|
||||||
|
export interface IFinalColumnHeaderProps extends Partial<BoxProps> {
|
||||||
|
focusInsideCell: boolean;
|
||||||
|
canAddColumns: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinalColumnHeader({
|
||||||
|
focusInsideCell,
|
||||||
|
canAddColumns,
|
||||||
|
...props
|
||||||
|
}: IFinalColumnHeaderProps) {
|
||||||
|
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||||
|
const openColumnModal = useSetAtom(columnModalAtom, tableScope);
|
||||||
|
|
||||||
|
if (!userRoles.includes("ADMIN"))
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="columnheader"
|
||||||
|
{...props}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
backgroundColor: "background.default",
|
||||||
|
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
borderLeft: "none",
|
||||||
|
borderTopRightRadius: (theme) => theme.shape.borderRadius,
|
||||||
|
borderBottomRightRadius: (theme) => theme.shape.borderRadius,
|
||||||
|
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
|
||||||
|
width: 32 * 3 + 4 * 2 + 10 * 2,
|
||||||
|
px: 1.5,
|
||||||
|
color: "text.secondary",
|
||||||
|
},
|
||||||
|
...spreadSx(props.sx),
|
||||||
|
]}
|
||||||
|
className="column-header"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="columnheader"
|
||||||
|
{...props}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
backgroundColor: "background.default",
|
||||||
|
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
borderLeft: "none",
|
||||||
|
borderTopRightRadius: (theme) => theme.shape.borderRadius,
|
||||||
|
borderBottomRightRadius: (theme) => theme.shape.borderRadius,
|
||||||
|
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
|
||||||
|
width: 32 * 3 + 4 * 2 + 10 * 2,
|
||||||
|
overflow: "visible",
|
||||||
|
px: 0.75,
|
||||||
|
},
|
||||||
|
...spreadSx(props.sx),
|
||||||
|
]}
|
||||||
|
className="column-header"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => openColumnModal({ type: "new" })}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<AddColumnIcon />}
|
||||||
|
style={{ zIndex: 1, flexShrink: 0 }}
|
||||||
|
tabIndex={focusInsideCell ? 0 : -1}
|
||||||
|
>
|
||||||
|
Add column
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useAtom, useSetAtom } from "jotai";
|
|
||||||
import { Column } from "react-data-grid";
|
|
||||||
|
|
||||||
import { Button } from "@mui/material";
|
|
||||||
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
|
||||||
|
|
||||||
import { projectScope, userRolesAtom } from "@src/atoms/projectScope";
|
|
||||||
import { tableScope, columnModalAtom } from "@src/atoms/tableScope";
|
|
||||||
|
|
||||||
const FinalColumnHeader: Column<any>["headerRenderer"] = ({ column }) => {
|
|
||||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
|
||||||
const openColumnModal = useSetAtom(columnModalAtom, tableScope);
|
|
||||||
|
|
||||||
if (!userRoles.includes("ADMIN")) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={(e) => openColumnModal({ type: "new" })}
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
startIcon={<AddColumnIcon />}
|
|
||||||
style={{ zIndex: 1 }}
|
|
||||||
>
|
|
||||||
Add column
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FinalColumnHeader;
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
import { Grid, GridProps, Typography } from "@mui/material";
|
import { Grid, GridProps, Typography } from "@mui/material";
|
||||||
import { alpha } from "@mui/material/styles";
|
import { alpha } from "@mui/material/styles";
|
||||||
|
|
||||||
import { FieldType } from "@src/constants/fields";
|
import { FieldType } from "@src/constants/fields";
|
||||||
import { getFieldProp } from "@src/components/fields";
|
import { getFieldProp } from "@src/components/fields";
|
||||||
|
import { spreadSx } from "@src/utils/ui";
|
||||||
|
|
||||||
export const COLUMN_HEADER_HEIGHT = 42;
|
export const COLUMN_HEADER_HEIGHT = 42;
|
||||||
|
|
||||||
@@ -10,23 +12,30 @@ export interface IColumnProps extends Partial<GridProps> {
|
|||||||
label: string;
|
label: string;
|
||||||
type?: FieldType;
|
type?: FieldType;
|
||||||
secondaryItem?: React.ReactNode;
|
secondaryItem?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Column({
|
export const Column = forwardRef(function Column(
|
||||||
label,
|
{
|
||||||
type,
|
label,
|
||||||
secondaryItem,
|
type,
|
||||||
|
secondaryItem,
|
||||||
|
children,
|
||||||
|
|
||||||
active,
|
active,
|
||||||
...props
|
...props
|
||||||
}: IColumnProps) {
|
}: IColumnProps,
|
||||||
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Grid
|
<Grid
|
||||||
|
ref={ref}
|
||||||
container
|
container
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
|
aria-label={label}
|
||||||
{...props}
|
{...props}
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
@@ -34,6 +43,7 @@ export default function Column({
|
|||||||
height: COLUMN_HEADER_HEIGHT,
|
height: COLUMN_HEADER_HEIGHT,
|
||||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
backgroundColor: "background.default",
|
backgroundColor: "background.default",
|
||||||
|
position: "relative",
|
||||||
|
|
||||||
py: 0,
|
py: 0,
|
||||||
px: 1,
|
px: 1,
|
||||||
@@ -68,6 +78,7 @@ export default function Column({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
|
...spreadSx(props.sx),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{type && <Grid item>{getFieldProp("icon", type)}</Grid>}
|
{type && <Grid item>{getFieldProp("icon", type)}</Grid>}
|
||||||
@@ -104,6 +115,10 @@ export default function Column({
|
|||||||
{secondaryItem}
|
{secondaryItem}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default Column;
|
||||||
@@ -3,7 +3,6 @@ import { useAtom } from "jotai";
|
|||||||
import { styled } from "@mui/material/styles";
|
import { styled } from "@mui/material/styles";
|
||||||
import RichTooltip from "@src/components/RichTooltip";
|
import RichTooltip from "@src/components/RichTooltip";
|
||||||
import WarningIcon from "@mui/icons-material/WarningAmber";
|
import WarningIcon from "@mui/icons-material/WarningAmber";
|
||||||
import { OUT_OF_ORDER_MARGIN } from "./TableContainer";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectScope,
|
projectScope,
|
||||||
@@ -24,15 +23,7 @@ const Dot = styled("div")(({ theme }) => ({
|
|||||||
backgroundColor: theme.palette.warning.main,
|
backgroundColor: theme.palette.warning.main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export interface IOutOfOrderIndicatorProps {
|
export default function OutOfOrderIndicator() {
|
||||||
top: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OutOfOrderIndicator({
|
|
||||||
top,
|
|
||||||
height,
|
|
||||||
}: IOutOfOrderIndicatorProps) {
|
|
||||||
const [dismissed, setDismissed] = useAtom(
|
const [dismissed, setDismissed] = useAtom(
|
||||||
tableOutOfOrderDismissedAtom,
|
tableOutOfOrderDismissedAtom,
|
||||||
projectScope
|
projectScope
|
||||||
@@ -42,11 +33,12 @@ export default function OutOfOrderIndicator({
|
|||||||
<div
|
<div
|
||||||
className="out-of-order-dot"
|
className="out-of-order-dot"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "sticky",
|
||||||
top: top,
|
zIndex: 9,
|
||||||
height: height - OUT_OF_ORDER_MARGIN - 2,
|
top: 0,
|
||||||
marginLeft: `max(env(safe-area-inset-left), 16px)`,
|
left: 8,
|
||||||
width: 12,
|
width: 12,
|
||||||
|
marginRight: -12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RichTooltip
|
<RichTooltip
|
||||||
|
|||||||
43
src/components/Table/Styled/StyledCell.tsx
Normal file
43
src/components/Table/Styled/StyledCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
|
||||||
|
export const StyledCell = styled("div")(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
lineHeight: "calc(var(--row-height) - 1px)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
"--cell-padding": theme.spacing(10 / 8),
|
||||||
|
|
||||||
|
"& > .cell-contents": {
|
||||||
|
padding: "0 var(--cell-padding)",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
contain: "strict",
|
||||||
|
overflow: "hidden",
|
||||||
|
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
|
||||||
|
backgroundColor: "var(--cell-background-color)",
|
||||||
|
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderTop: "none",
|
||||||
|
"& + &": { borderLeft: "none" },
|
||||||
|
|
||||||
|
"[role='row']:hover &": {
|
||||||
|
backgroundColor: "var(--row-hover-background-color)",
|
||||||
|
},
|
||||||
|
|
||||||
|
"[data-out-of-order='true'] + [role='row'] &": {
|
||||||
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
"&[aria-invalid='true'] .cell-contents": {
|
||||||
|
outline: `2px dotted ${theme.palette.error.main}`,
|
||||||
|
outlineOffset: -2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
StyledCell.displayName = "StyledCell";
|
||||||
|
|
||||||
|
export default StyledCell;
|
||||||
58
src/components/Table/Styled/StyledColumnHeader.tsx
Normal file
58
src/components/Table/Styled/StyledColumnHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
styled,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProps,
|
||||||
|
tooltipClasses,
|
||||||
|
Stack,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Mock/Column";
|
||||||
|
|
||||||
|
export const StyledColumnHeader = styled(Stack)(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
height: "100%",
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
"& + &": { borderLeftStyle: "none" },
|
||||||
|
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: theme.spacing(0, 0.5, 0, 1),
|
||||||
|
"& svg, & button": { display: "block", zIndex: 1 },
|
||||||
|
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
transition: theme.transitions.create("color", {
|
||||||
|
duration: theme.transitions.duration.short,
|
||||||
|
}),
|
||||||
|
"&:hover": { color: theme.palette.text.primary },
|
||||||
|
|
||||||
|
"& .MuiIconButton-root": {
|
||||||
|
color: theme.palette.text.disabled,
|
||||||
|
transition: theme.transitions.create(
|
||||||
|
["background-color", "opacity", "color"],
|
||||||
|
{ duration: theme.transitions.duration.short }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
[`&:hover .MuiIconButton-root,
|
||||||
|
&:focus .MuiIconButton-root,
|
||||||
|
&:focus-within .MuiIconButton-root,
|
||||||
|
.MuiIconButton-root:focus`]: {
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
export default StyledColumnHeader;
|
||||||
|
|
||||||
|
export const StyledColumnHeaderNameTooltip = styled(
|
||||||
|
({ className, ...props }: TooltipProps) => (
|
||||||
|
<Tooltip {...props} classes={{ popper: className }} />
|
||||||
|
)
|
||||||
|
)(({ theme }) => ({
|
||||||
|
[`& .${tooltipClasses.tooltip}`]: {
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
|
||||||
|
margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`,
|
||||||
|
padding: 0,
|
||||||
|
paddingRight: theme.spacing(1.5),
|
||||||
|
},
|
||||||
|
}));
|
||||||
22
src/components/Table/Styled/StyledDot.tsx
Normal file
22
src/components/Table/Styled/StyledDot.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
|
||||||
|
export const StyledDot = 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(--cell-background-color)`,
|
||||||
|
"[role='row']:hover &": {
|
||||||
|
boxShadow: `0 0 0 4px var(--row-hover-background-color)`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default StyledDot;
|
||||||
53
src/components/Table/Styled/StyledRow.tsx
Normal file
53
src/components/Table/Styled/StyledRow.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { styled, alpha } from "@mui/material";
|
||||||
|
|
||||||
|
export const StyledRow = styled("div")(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
position: "relative",
|
||||||
|
|
||||||
|
"& > *": {
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
"& [role='columnheader']": {
|
||||||
|
"&:first-of-type": {
|
||||||
|
borderTopLeftRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
"&:last-of-type": {
|
||||||
|
borderTopRightRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"&:last-of-type": {
|
||||||
|
"& [role='gridcell']:first-of-type": {
|
||||||
|
borderBottomLeftRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
"& [role='gridcell']:last-of-type": {
|
||||||
|
borderBottomRightRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& .row-hover-iconButton, .row-hover-iconButton:focus": {
|
||||||
|
color: theme.palette.text.disabled,
|
||||||
|
transitionDuration: "0s",
|
||||||
|
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
padding: (32 - 20) / 2,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
|
||||||
|
"&.end": { marginRight: theme.spacing(0.5) },
|
||||||
|
},
|
||||||
|
"&:hover .row-hover-iconButton, .row-hover-iconButton:focus": {
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
backgroundColor: alpha(
|
||||||
|
theme.palette.action.hover,
|
||||||
|
theme.palette.action.hoverOpacity * 1.5
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
StyledRow.displayName = "StyledRow";
|
||||||
|
|
||||||
|
export default StyledRow;
|
||||||
48
src/components/Table/Styled/StyledTable.tsx
Normal file
48
src/components/Table/Styled/StyledTable.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
import { colord } from "colord";
|
||||||
|
|
||||||
|
export const StyledTable = styled("div")(({ theme }) => ({
|
||||||
|
"--cell-background-color":
|
||||||
|
theme.palette.mode === "light"
|
||||||
|
? theme.palette.background.paper
|
||||||
|
: colord(theme.palette.background.paper)
|
||||||
|
.mix("#fff", 0.04)
|
||||||
|
.alpha(1)
|
||||||
|
.toHslString(),
|
||||||
|
"--row-hover-background-color": colord(theme.palette.background.paper)
|
||||||
|
.mix(theme.palette.action.hover, theme.palette.action.hoverOpacity)
|
||||||
|
.alpha(1)
|
||||||
|
.toHslString(),
|
||||||
|
|
||||||
|
...(theme.typography.caption as any),
|
||||||
|
lineHeight: "inherit !important",
|
||||||
|
|
||||||
|
"& [role='columnheader'], & [role='gridcell']": {
|
||||||
|
"&[aria-selected='true']": {
|
||||||
|
outline: `2px solid ${theme.palette.primary.main}`,
|
||||||
|
outlineOffset: "-2px",
|
||||||
|
},
|
||||||
|
"&:focus": {
|
||||||
|
outlineWidth: "3px",
|
||||||
|
outlineOffset: "-3px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& [data-frozen='left']": {
|
||||||
|
position: "sticky",
|
||||||
|
left: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
|
||||||
|
"&[data-frozen-last='true']": {
|
||||||
|
boxShadow: theme.shadows[2]
|
||||||
|
.replace(/, 0 (\d+px)/g, ", $1 0")
|
||||||
|
.split("),")
|
||||||
|
.slice(1)
|
||||||
|
.join("),"),
|
||||||
|
clipPath: "inset(0 -4px 0 0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
StyledTable.displayName = "StyledTable";
|
||||||
|
|
||||||
|
export default StyledTable;
|
||||||
@@ -1,343 +1,286 @@
|
|||||||
import React, { useMemo, useState, Suspense } from "react";
|
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
import useStateRef from "react-usestateref";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
|
import { useThrottledCallback } from "use-debounce";
|
||||||
import { DndProvider } from "react-dnd";
|
import {
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
createColumnHelper,
|
||||||
import { findIndex } from "lodash-es";
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { DropResult } from "react-beautiful-dnd";
|
||||||
|
import { get } from "lodash-es";
|
||||||
|
|
||||||
// import "react-data-grid/dist/react-data-grid.css";
|
import StyledTable from "./Styled/StyledTable";
|
||||||
import DataGrid, {
|
import TableHeader from "./TableHeader";
|
||||||
Column,
|
import TableBody from "./TableBody";
|
||||||
DataGridHandle,
|
import FinalColumn from "./FinalColumn/FinalColumn";
|
||||||
// SelectColumn as _SelectColumn,
|
|
||||||
} from "react-data-grid";
|
|
||||||
import { LinearProgress } from "@mui/material";
|
|
||||||
|
|
||||||
import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer";
|
|
||||||
import ColumnHeader, { COLUMN_HEADER_HEIGHT } from "./ColumnHeader";
|
|
||||||
import FinalColumnHeader from "./FinalColumnHeader";
|
|
||||||
import FinalColumn from "./formatters/FinalColumn";
|
|
||||||
import TableRow from "./TableRow";
|
|
||||||
import EmptyState from "@src/components/EmptyState";
|
|
||||||
// import BulkActions from "./BulkActions";
|
|
||||||
import AddRow from "@src/components/TableToolbar/AddRow";
|
|
||||||
import { AddRow as AddRowIcon } from "@src/assets/icons";
|
|
||||||
import Loading from "@src/components/Loading";
|
|
||||||
import ContextMenu from "./ContextMenu";
|
import ContextMenu from "./ContextMenu";
|
||||||
|
|
||||||
import {
|
import EmptyState from "@src/components/EmptyState";
|
||||||
projectScope,
|
// import BulkActions from "./BulkActions";
|
||||||
userRolesAtom,
|
|
||||||
userSettingsAtom,
|
|
||||||
} from "@src/atoms/projectScope";
|
|
||||||
import {
|
import {
|
||||||
tableScope,
|
tableScope,
|
||||||
tableIdAtom,
|
|
||||||
tableSettingsAtom,
|
|
||||||
tableSchemaAtom,
|
tableSchemaAtom,
|
||||||
tableColumnsOrderedAtom,
|
tableColumnsOrderedAtom,
|
||||||
tableRowsAtom,
|
tableRowsAtom,
|
||||||
tableNextPageAtom,
|
tableNextPageAtom,
|
||||||
tablePageAtom,
|
tablePageAtom,
|
||||||
updateColumnAtom,
|
updateColumnAtom,
|
||||||
updateFieldAtom,
|
|
||||||
selectedCellAtom,
|
|
||||||
} from "@src/atoms/tableScope";
|
} from "@src/atoms/tableScope";
|
||||||
|
|
||||||
import { getFieldType, getFieldProp } from "@src/components/fields";
|
import { getFieldType, getFieldProp } from "@src/components/fields";
|
||||||
import { FieldType } from "@src/constants/fields";
|
import { TableRow, ColumnConfig } from "@src/types/table";
|
||||||
import { formatSubTableName } from "@src/utils/table";
|
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||||
import { ColumnConfig } from "@src/types/table";
|
import { useSaveColumnSizing } from "./useSaveColumnSizing";
|
||||||
|
|
||||||
export type DataGridColumn = ColumnConfig & Column<any> & { isNew?: true };
|
|
||||||
export const DEFAULT_ROW_HEIGHT = 41;
|
export const DEFAULT_ROW_HEIGHT = 41;
|
||||||
export const DEFAULT_COL_WIDTH = 150;
|
export const DEFAULT_COL_WIDTH = 150;
|
||||||
export const MAX_COL_WIDTH = 380;
|
export const MIN_COL_WIDTH = 80;
|
||||||
|
export const TABLE_PADDING = 16;
|
||||||
|
export const OUT_OF_ORDER_MARGIN = 8;
|
||||||
|
export const DEBOUNCE_DELAY = 500;
|
||||||
|
|
||||||
const rowKeyGetter = (row: any) => row.id;
|
declare module "@tanstack/table-core" {
|
||||||
const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : "");
|
/** The `column.meta` property contains the column config from tableSchema */
|
||||||
//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 };
|
interface ColumnMeta<TData, TValue> extends ColumnConfig {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TableRow>();
|
||||||
|
const getRowId = (row: TableRow) => row._rowy_ref.path || row._rowy_ref.id;
|
||||||
|
|
||||||
|
export interface ITableProps {
|
||||||
|
/** Determines if “Add column” button is displayed */
|
||||||
|
canAddColumns: boolean;
|
||||||
|
/** Determines if columns can be rearranged */
|
||||||
|
canEditColumns: boolean;
|
||||||
|
/**
|
||||||
|
* Determines if any cell can be edited.
|
||||||
|
* If false, `Table` only ever renders `EditorCell`.
|
||||||
|
*/
|
||||||
|
canEditCells: boolean;
|
||||||
|
/** The hidden columns saved to user settings */
|
||||||
|
hiddenColumns?: string[];
|
||||||
|
/**
|
||||||
|
* Displayed when `tableRows` is empty.
|
||||||
|
* Loading state handled by Suspense in parent component.
|
||||||
|
*/
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes table schema and row data from `tableScope` and makes it compatible
|
||||||
|
* with TanStack Table. Renders table children and cell context menu.
|
||||||
|
*
|
||||||
|
* - Calls `useKeyboardNavigation` hook
|
||||||
|
* - Handles rearranging columns
|
||||||
|
* - Handles infinite scrolling
|
||||||
|
* - Stores local state for resizing columns, and asks admins if they want to
|
||||||
|
* save to table schema for all users
|
||||||
|
*/
|
||||||
export default function Table({
|
export default function Table({
|
||||||
dataGridRef,
|
canAddColumns,
|
||||||
}: {
|
canEditColumns,
|
||||||
dataGridRef?: React.MutableRefObject<DataGridHandle | null>;
|
canEditCells,
|
||||||
}) {
|
hiddenColumns,
|
||||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
emptyState,
|
||||||
const [userSettings] = useAtom(userSettingsAtom, projectScope);
|
}: ITableProps) {
|
||||||
|
|
||||||
const [tableId] = useAtom(tableIdAtom, tableScope);
|
|
||||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
|
||||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||||
const setTablePage = useSetAtom(tablePageAtom, tableScope);
|
const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
|
||||||
const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope);
|
|
||||||
|
|
||||||
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
||||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
|
||||||
|
|
||||||
const userDocHiddenFields =
|
// Store a **state** and reference to the container element
|
||||||
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields;
|
// so the state can re-render `TableBody`, preventing virtualization
|
||||||
|
// not detecting scroll if the container element was initially `null`
|
||||||
|
const [containerEl, setContainerEl, containerRef] =
|
||||||
|
useStateRef<HTMLDivElement | null>(null);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get column configs from table schema and map them to DataGridColumns
|
// Get column defs from table schema
|
||||||
// Also filter out hidden columns and add end column
|
// Also add end column for admins (canAddColumns || canEditCells)
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const _columns: DataGridColumn[] = tableColumnsOrdered
|
const _columns = tableColumnsOrdered
|
||||||
.filter((column) => {
|
// Hide column for all users using table schema
|
||||||
if (column.hidden) return false;
|
.filter((column) => !column.hidden)
|
||||||
if (
|
.map((columnConfig) =>
|
||||||
Array.isArray(userDocHiddenFields) &&
|
columnHelper.accessor((row) => get(row, columnConfig.fieldName), {
|
||||||
userDocHiddenFields.includes(column.key)
|
id: columnConfig.fieldName,
|
||||||
)
|
meta: columnConfig,
|
||||||
return false;
|
size: columnConfig.width,
|
||||||
return true;
|
enableResizing: columnConfig.resizable !== false,
|
||||||
})
|
minSize: MIN_COL_WIDTH,
|
||||||
.map((column: any) => ({
|
cell: getFieldProp("TableCell", getFieldType(columnConfig)),
|
||||||
draggable: true,
|
})
|
||||||
resizable: true,
|
);
|
||||||
frozen: column.fixed,
|
|
||||||
headerRenderer: ColumnHeader,
|
|
||||||
formatter:
|
|
||||||
getFieldProp("TableCell", getFieldType(column)) ??
|
|
||||||
function InDev() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
editor:
|
|
||||||
getFieldProp("TableEditor", getFieldType(column)) ??
|
|
||||||
function InDev() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
...column,
|
|
||||||
editable:
|
|
||||||
tableSettings.readOnly && !userRoles.includes("ADMIN")
|
|
||||||
? false
|
|
||||||
: column.editable ?? true,
|
|
||||||
width: column.width ?? DEFAULT_COL_WIDTH,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (userRoles.includes("ADMIN") || !tableSettings.readOnly) {
|
if (canAddColumns || canEditCells) {
|
||||||
_columns.push({
|
_columns.push(
|
||||||
isNew: true,
|
columnHelper.display({
|
||||||
key: "new",
|
id: "_rowy_column_actions",
|
||||||
fieldName: "_rowy_new",
|
cell: FinalColumn as any,
|
||||||
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;
|
return _columns;
|
||||||
}, [
|
}, [tableColumnsOrdered, canAddColumns, canEditCells]);
|
||||||
tableColumnsOrdered,
|
|
||||||
userDocHiddenFields,
|
|
||||||
tableSettings.readOnly,
|
|
||||||
userRoles,
|
|
||||||
]);
|
|
||||||
const selectedColumnIndex = useMemo(() => {
|
|
||||||
if (!selectedCell?.columnKey) return -1;
|
|
||||||
return findIndex(columns, ["key", selectedCell.columnKey]);
|
|
||||||
}, [selectedCell?.columnKey, columns]);
|
|
||||||
|
|
||||||
// Handle columns with field names that use dot notation (nested fields)
|
// Get user’s hidden columns from props and memoize into a `VisibilityState`
|
||||||
const rows =
|
const columnVisibility = useMemo(() => {
|
||||||
useMemo(() => {
|
if (!Array.isArray(hiddenColumns)) return {};
|
||||||
// const columnsWithNestedFieldNames = columns
|
return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
|
||||||
// .map((col) => col.fieldName)
|
}, [hiddenColumns]);
|
||||||
// .filter((fieldName) => fieldName.includes("."));
|
|
||||||
|
|
||||||
// if (columnsWithNestedFieldNames.length === 0)
|
// Get frozen columns and memoize into a `ColumnPinningState`
|
||||||
return tableRows;
|
const columnPinning = useMemo(
|
||||||
|
() => ({
|
||||||
|
left: columns.filter((c) => c.meta?.fixed && c.id).map((c) => c.id!),
|
||||||
|
}),
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
const lastFrozen: string | undefined =
|
||||||
|
columnPinning.left[columnPinning.left.length - 1];
|
||||||
|
|
||||||
// return tableRows.map((row) =>
|
// Call TanStack Table
|
||||||
// columnsWithNestedFieldNames.reduce(
|
const table = useReactTable({
|
||||||
// (acc, fieldName) => ({
|
data: tableRows,
|
||||||
// ...acc,
|
columns,
|
||||||
// [fieldName]: get(row, fieldName),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
// }),
|
getRowId,
|
||||||
// { ...row }
|
columnResizeMode: "onChange",
|
||||||
// )
|
});
|
||||||
// );
|
|
||||||
}, [tableRows]) ?? [];
|
|
||||||
|
|
||||||
// const [selectedRowsSet, setSelectedRowsSet] = useState<Set<React.Key>>();
|
// Store local `columnSizing` state so we can save it to table schema
|
||||||
// const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
// in `useSaveColumnSizing`. This could be generalized by storing the
|
||||||
|
// entire table state.
|
||||||
|
const [columnSizing, setColumnSizing] = useState(
|
||||||
|
table.initialState.columnSizing
|
||||||
|
);
|
||||||
|
table.setOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
state: { ...prev.state, columnVisibility, columnPinning, columnSizing },
|
||||||
|
onColumnSizingChange: setColumnSizing,
|
||||||
|
}));
|
||||||
|
// Get rows and columns for virtualization
|
||||||
|
const { rows } = table.getRowModel();
|
||||||
|
const leafColumns = table.getVisibleLeafColumns();
|
||||||
|
|
||||||
// Gets more rows when scrolled down.
|
// Handle keyboard navigation
|
||||||
// https://github.com/adazzle/react-data-grid/blob/ead05032da79d7e2b86e37cdb9af27f2a4d80b90/stories/demos/AllFeatures.tsx#L60
|
const { handleKeyDown } = useKeyboardNavigation({
|
||||||
const handleScroll = useThrottledCallback(
|
gridRef,
|
||||||
(event: React.UIEvent<HTMLDivElement>) => {
|
tableRows,
|
||||||
// Select corresponding header cell when scrolled to prevent jumping
|
leafColumns,
|
||||||
dataGridRef?.current?.selectCell({
|
});
|
||||||
idx:
|
|
||||||
selectedColumnIndex > -1 ? selectedColumnIndex : columns.length - 1,
|
// Handle prompt to save local column sizes if user `canEditColumns`
|
||||||
rowIdx: -1,
|
useSaveColumnSizing(columnSizing, canEditColumns);
|
||||||
|
|
||||||
|
const handleDropColumn = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
if (result.destination?.index === undefined || !result.draggableId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
console.log(result.draggableId, result.destination.index);
|
||||||
|
|
||||||
|
updateColumn({
|
||||||
|
key: result.draggableId,
|
||||||
|
index: result.destination.index,
|
||||||
|
config: {},
|
||||||
});
|
});
|
||||||
// console.log(
|
|
||||||
// "scroll",
|
|
||||||
// dataGridRef?.current,
|
|
||||||
// selectedColumnIndex,
|
|
||||||
// columns.length
|
|
||||||
// );
|
|
||||||
|
|
||||||
const target = event.target as HTMLDivElement;
|
|
||||||
|
|
||||||
// TODO:
|
|
||||||
// if (navPinned && !columns[0].fixed)
|
|
||||||
// setShowLeftScrollDivider(target.scrollLeft > 16);
|
|
||||||
|
|
||||||
const offset = 800;
|
|
||||||
const isAtBottom =
|
|
||||||
target.clientHeight + target.scrollTop >= target.scrollHeight - offset;
|
|
||||||
if (!isAtBottom) return;
|
|
||||||
// Call for the next page
|
|
||||||
setTablePage((p) => p + 1);
|
|
||||||
},
|
},
|
||||||
250
|
[updateColumn]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showLeftScrollDivider, setShowLeftScrollDivider] = useState(false);
|
const fetchMoreOnBottomReached = useThrottledCallback(
|
||||||
|
(containerElement?: HTMLDivElement | null) => {
|
||||||
|
if (!containerElement) return;
|
||||||
|
|
||||||
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
const { scrollHeight, scrollTop, clientHeight } = containerElement;
|
||||||
const handleResize = useDebouncedCallback(
|
if (scrollHeight - scrollTop - clientHeight < 300) {
|
||||||
(colIndex: number, width: number) => {
|
setTablePage((p) => p + 1);
|
||||||
const column = columns[colIndex];
|
}
|
||||||
if (!column.key) return;
|
|
||||||
updateColumn({ key: column.key, config: { width } });
|
|
||||||
},
|
},
|
||||||
1000
|
DEBOUNCE_DELAY
|
||||||
);
|
);
|
||||||
|
// Check on mount and after fetch to see if the table is at the bottom
|
||||||
|
// for large screen heights
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMoreOnBottomReached(containerRef.current);
|
||||||
|
}, [
|
||||||
|
fetchMoreOnBottomReached,
|
||||||
|
tablePage,
|
||||||
|
tableNextPage.loading,
|
||||||
|
containerRef,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loading message="Loading fields" />}>
|
<div
|
||||||
{/* <Hotkeys selectedCell={selectedCell} /> */}
|
ref={(el) => setContainerEl(el)}
|
||||||
<TableContainer rowHeight={rowHeight} className="table-container">
|
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||||
<DndProvider backend={HTML5Backend}>
|
style={{ overflow: "auto", width: "100%", height: "100%" }}
|
||||||
{showLeftScrollDivider && <div className="left-scroll-divider" />}
|
>
|
||||||
|
<StyledTable
|
||||||
<DataGrid
|
ref={gridRef}
|
||||||
onColumnResize={handleResize}
|
role="grid"
|
||||||
onScroll={handleScroll}
|
aria-readonly={!canEditCells}
|
||||||
ref={(handle) => {
|
aria-colcount={columns.length}
|
||||||
if (dataGridRef) dataGridRef.current = handle;
|
aria-rowcount={tableRows.length + 1}
|
||||||
}}
|
style={
|
||||||
rows={rows}
|
{
|
||||||
columns={columns}
|
width: table.getTotalSize(),
|
||||||
// Increase row height of out of order rows to add margins
|
userSelect: "none",
|
||||||
rowHeight={({ row }) => {
|
"--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`,
|
||||||
if (row._rowy_outOfOrder)
|
} as any
|
||||||
return rowHeight + OUT_OF_ORDER_MARGIN + 1;
|
}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
return rowHeight;
|
>
|
||||||
}}
|
<div
|
||||||
headerRowHeight={DEFAULT_ROW_HEIGHT + 1}
|
className="thead"
|
||||||
className="rdg-light" // Handle dark mode in MUI theme
|
role="rowgroup"
|
||||||
cellNavigationMode="LOOP_OVER_ROW"
|
style={{
|
||||||
rowRenderer={TableRow}
|
position: "sticky",
|
||||||
rowKeyGetter={rowKeyGetter}
|
top: 0,
|
||||||
rowClass={rowClass}
|
zIndex: 10,
|
||||||
// selectedRows={selectedRowsSet}
|
padding: `0 ${TABLE_PADDING}px`,
|
||||||
// onSelectedRowsChange={(newSelectedSet) => {
|
}}
|
||||||
// const newSelectedArray = newSelectedSet
|
>
|
||||||
// ? [...newSelectedSet]
|
<TableHeader
|
||||||
// : [];
|
headerGroups={table.getHeaderGroups()}
|
||||||
// const prevSelectedRowsArray = selectedRowsSet
|
handleDropColumn={handleDropColumn}
|
||||||
// ? [...selectedRowsSet]
|
canAddColumns={canAddColumns}
|
||||||
// : [];
|
canEditColumns={canEditColumns}
|
||||||
// const addedSelections = difference(
|
lastFrozen={lastFrozen}
|
||||||
// newSelectedArray,
|
columnSizing={columnSizing}
|
||||||
// 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)
|
|
||||||
// }}
|
|
||||||
// TODO: onFill={(e) => {
|
|
||||||
// console.log("onFill", e);
|
|
||||||
// const { columnKey, sourceRow, targetRows } = e;
|
|
||||||
// if (updateCell)
|
|
||||||
// targetRows.forEach((row) =>
|
|
||||||
// updateCell(row._rowy_ref, columnKey, sourceRow[columnKey])
|
|
||||||
// );
|
|
||||||
// return [];
|
|
||||||
// }}
|
|
||||||
onPaste={(e, ...args) => {
|
|
||||||
console.log("onPaste", e, ...args);
|
|
||||||
const value = e.sourceRow[e.sourceColumnKey];
|
|
||||||
updateField({
|
|
||||||
path: e.targetRow._rowy_ref.path,
|
|
||||||
fieldName: e.targetColumnKey,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onSelectedCellChange={({ rowIdx, idx }) => {
|
|
||||||
if (!rows[rowIdx]?._rowy_ref) return; // May be the header row
|
|
||||||
|
|
||||||
const path = rows[rowIdx]._rowy_ref.path;
|
|
||||||
if (!path) return;
|
|
||||||
|
|
||||||
const columnKey = tableColumnsOrdered.filter((col) =>
|
|
||||||
userDocHiddenFields
|
|
||||||
? !userDocHiddenFields.includes(col.key)
|
|
||||||
: true
|
|
||||||
)[idx]?.key;
|
|
||||||
if (!columnKey) return; // May be the final column
|
|
||||||
|
|
||||||
setSelectedCell({ path, columnKey });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</DndProvider>
|
</div>
|
||||||
|
|
||||||
{tableRows.length === 0 && (
|
{tableRows.length === 0 ? (
|
||||||
<EmptyState
|
emptyState ?? <EmptyState sx={{ py: 8 }} />
|
||||||
Icon={AddRowIcon}
|
) : (
|
||||||
message="Add a row to get started"
|
<TableBody
|
||||||
description={
|
containerEl={containerEl}
|
||||||
<div>
|
containerRef={containerRef}
|
||||||
<br />
|
leafColumns={leafColumns}
|
||||||
<AddRow />
|
rows={rows}
|
||||||
</div>
|
canEditCells={canEditCells}
|
||||||
}
|
lastFrozen={lastFrozen}
|
||||||
style={{
|
columnSizing={columnSizing}
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
top: COLUMN_HEADER_HEIGHT,
|
|
||||||
height: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tableNextPage.loading && <LinearProgress />}
|
</StyledTable>
|
||||||
</TableContainer>
|
|
||||||
|
<div
|
||||||
|
id="rowy-table-editable-cell-description"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
>
|
||||||
|
Press Enter to edit.
|
||||||
|
</div>
|
||||||
|
|
||||||
<ContextMenu />
|
<ContextMenu />
|
||||||
{/*
|
</div>
|
||||||
<BulkActions
|
|
||||||
selectedRows={selectedRows}
|
|
||||||
columns={columns}
|
|
||||||
clearSelection={() => {
|
|
||||||
setSelectedRowsSet(new Set());
|
|
||||||
setSelectedRows([]);
|
|
||||||
}}
|
|
||||||
/> */}
|
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/components/Table/TableBody.tsx
Normal file
144
src/components/Table/TableBody.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type { Column, Row, ColumnSizingState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import StyledRow from "./Styled/StyledRow";
|
||||||
|
import OutOfOrderIndicator from "./OutOfOrderIndicator";
|
||||||
|
import TableCell from "./TableCell";
|
||||||
|
import { RowsSkeleton } from "./TableSkeleton";
|
||||||
|
|
||||||
|
import {
|
||||||
|
tableScope,
|
||||||
|
tableSchemaAtom,
|
||||||
|
selectedCellAtom,
|
||||||
|
tableNextPageAtom,
|
||||||
|
} from "@src/atoms/tableScope";
|
||||||
|
|
||||||
|
import { getFieldProp } from "@src/components/fields";
|
||||||
|
import type { TableRow } from "@src/types/table";
|
||||||
|
import useVirtualization from "./useVirtualization";
|
||||||
|
import { DEFAULT_ROW_HEIGHT, OUT_OF_ORDER_MARGIN } from "./Table";
|
||||||
|
|
||||||
|
export interface ITableBodyProps {
|
||||||
|
/**
|
||||||
|
* Re-render this component when the container element changes, to fix a bug
|
||||||
|
* where virtualization doesn’t detect scrolls if `containerRef.current` was
|
||||||
|
* initially null
|
||||||
|
*/
|
||||||
|
containerEl: HTMLDivElement | null;
|
||||||
|
/** Used in `useVirtualization` */
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
/** Used in `useVirtualization` */
|
||||||
|
leafColumns: Column<TableRow, unknown>[];
|
||||||
|
/** Current table rows with context from TanStack Table state */
|
||||||
|
rows: Row<TableRow>[];
|
||||||
|
/** Determines if EditorCell can be displayed */
|
||||||
|
canEditCells: boolean;
|
||||||
|
/** If specified, renders a shadow in the last frozen column */
|
||||||
|
lastFrozen?: string;
|
||||||
|
/**
|
||||||
|
* Must pass this prop so that it re-renders when local column sizing changes */
|
||||||
|
columnSizing: ColumnSizingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders table body & data rows.
|
||||||
|
* Handles virtualization of rows & columns via `useVirtualization`.
|
||||||
|
*
|
||||||
|
* - Renders row out of order indicator
|
||||||
|
* - Renders next page loading UI (`RowsSkeleton`)
|
||||||
|
*/
|
||||||
|
export const TableBody = memo(function TableBody({
|
||||||
|
containerRef,
|
||||||
|
leafColumns,
|
||||||
|
rows,
|
||||||
|
canEditCells,
|
||||||
|
lastFrozen,
|
||||||
|
}: ITableBodyProps) {
|
||||||
|
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||||
|
const [selectedCell] = useAtom(selectedCellAtom, tableScope);
|
||||||
|
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||||
|
|
||||||
|
const {
|
||||||
|
virtualRows,
|
||||||
|
virtualCols,
|
||||||
|
paddingTop,
|
||||||
|
paddingBottom,
|
||||||
|
paddingLeft,
|
||||||
|
paddingRight,
|
||||||
|
} = useVirtualization(containerRef, leafColumns);
|
||||||
|
|
||||||
|
const rowHeight = tableSchema.rowHeight || DEFAULT_ROW_HEIGHT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tbody" role="rowgroup">
|
||||||
|
{paddingTop > 0 && (
|
||||||
|
<div role="presentation" style={{ height: `${paddingTop}px` }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index];
|
||||||
|
const outOfOrder = row.original._rowy_outOfOrder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledRow
|
||||||
|
key={row.id}
|
||||||
|
role="row"
|
||||||
|
aria-rowindex={row.index + 2}
|
||||||
|
style={{
|
||||||
|
height: rowHeight,
|
||||||
|
marginBottom: outOfOrder ? OUT_OF_ORDER_MARGIN : 0,
|
||||||
|
paddingLeft,
|
||||||
|
paddingRight,
|
||||||
|
}}
|
||||||
|
data-out-of-order={outOfOrder || undefined}
|
||||||
|
>
|
||||||
|
{outOfOrder && <OutOfOrderIndicator />}
|
||||||
|
|
||||||
|
{virtualCols.map((virtualCell) => {
|
||||||
|
const cellIndex = virtualCell.index;
|
||||||
|
const cell = row.getVisibleCells()[cellIndex];
|
||||||
|
|
||||||
|
const isSelectedCell =
|
||||||
|
selectedCell?.path === row.original._rowy_ref.path &&
|
||||||
|
selectedCell?.columnKey === cell.column.id;
|
||||||
|
|
||||||
|
const fieldTypeGroup = getFieldProp(
|
||||||
|
"group",
|
||||||
|
cell.column.columnDef.meta?.type
|
||||||
|
);
|
||||||
|
const isReadOnlyCell =
|
||||||
|
fieldTypeGroup === "Auditing" || fieldTypeGroup === "Metadata";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
row={row}
|
||||||
|
cell={cell}
|
||||||
|
index={cellIndex}
|
||||||
|
isSelectedCell={isSelectedCell}
|
||||||
|
focusInsideCell={isSelectedCell && selectedCell?.focusInside}
|
||||||
|
isReadOnlyCell={isReadOnlyCell}
|
||||||
|
canEditCells={canEditCells}
|
||||||
|
isLastFrozen={lastFrozen === cell.column.id}
|
||||||
|
width={cell.column.getSize()}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
left={virtualCell.start}
|
||||||
|
isPinned={cell.column.getIsPinned() === "left"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</StyledRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{tableNextPage.loading && <RowsSkeleton />}
|
||||||
|
|
||||||
|
{paddingBottom > 0 && (
|
||||||
|
<div role="presentation" style={{ height: `${paddingBottom}px` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TableBody;
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Grid } from "@mui/material";
|
import { Grid } from "@mui/material";
|
||||||
|
|
||||||
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
export default function ChipList({
|
||||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
children,
|
||||||
|
rowHeight,
|
||||||
export default function ChipList({ children }: React.PropsWithChildren<{}>) {
|
}: React.PropsWithChildren<{ rowHeight: number }>) {
|
||||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
|
||||||
|
|
||||||
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
|
||||||
const canWrap = rowHeight > 24 * 2 + 4;
|
const canWrap = rowHeight > 24 * 2 + 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
90
src/components/Table/TableCell/EditorCellController.tsx
Normal file
90
src/components/Table/TableCell/EditorCellController.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useLayoutEffect } from "react";
|
||||||
|
import useStateRef from "react-usestateref";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { isEqual } from "lodash-es";
|
||||||
|
|
||||||
|
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||||
|
import type {
|
||||||
|
IDisplayCellProps,
|
||||||
|
IEditorCellProps,
|
||||||
|
} from "@src/components/fields/types";
|
||||||
|
|
||||||
|
interface IEditorCellControllerProps extends IDisplayCellProps {
|
||||||
|
EditorCellComponent: React.ComponentType<IEditorCellProps>;
|
||||||
|
parentRef: IEditorCellProps["parentRef"];
|
||||||
|
saveOnUnmount: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a local state for the cell’s value, so that `EditorCell` doesn’t
|
||||||
|
* immediately update the database when the user quickly makes changes to the
|
||||||
|
* cell’s value (e.g. text input).
|
||||||
|
*
|
||||||
|
* Extracted from `withRenderTableCell()` so when the `DisplayCell` is
|
||||||
|
* rendered, an unnecessary extra state is not created.
|
||||||
|
*
|
||||||
|
* - Defines function to update the field in db
|
||||||
|
* - Tracks when the user has made the input “dirty”
|
||||||
|
* - By default, saves to db when the component is unmounted and the input
|
||||||
|
* is dirty
|
||||||
|
* - Has an effect to change the local value state when it receives an update
|
||||||
|
* from db and the field is not dirty. This is required to make inline
|
||||||
|
* `EditorCell` work when they haven’t been interacted with, but prevent the
|
||||||
|
* value changing while someone is editing a field, like Long Text.
|
||||||
|
*/
|
||||||
|
export default function EditorCellController({
|
||||||
|
EditorCellComponent,
|
||||||
|
saveOnUnmount,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: IEditorCellControllerProps) {
|
||||||
|
// Store local value so we don’t immediately write to db when the user
|
||||||
|
// types in a textbox, for example
|
||||||
|
const [localValue, setLocalValue, localValueRef] = useStateRef(value);
|
||||||
|
// Mark if the user has interacted with this cell and hasn’t saved yet
|
||||||
|
const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false);
|
||||||
|
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||||
|
|
||||||
|
// When this cell’s data has updated, update the local value if
|
||||||
|
// it’s not dirty and the value is different
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty && !isEqual(value, localValueRef.current))
|
||||||
|
setLocalValue(value);
|
||||||
|
}, [isDirty, localValueRef, setLocalValue, value]);
|
||||||
|
|
||||||
|
// This is where we update the documents
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// props.disabled should always be false as withRenderTableCell would
|
||||||
|
// render DisplayCell instead of EditorCell
|
||||||
|
if (props.disabled || !isDirtyRef.current) return;
|
||||||
|
|
||||||
|
updateField({
|
||||||
|
path: props._rowy_ref.path,
|
||||||
|
fieldName: props.column.fieldName,
|
||||||
|
value: localValueRef.current,
|
||||||
|
deleteField: localValueRef.current === undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveOnUnmount) handleSubmit();
|
||||||
|
};
|
||||||
|
// Warns that `saveOnUnmount` and `handleSubmit` should be included, but
|
||||||
|
// those don’t change across re-renders. We only want to run this on unmount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorCellComponent
|
||||||
|
{...props}
|
||||||
|
value={localValue}
|
||||||
|
onDirty={(dirty?: boolean) => setIsDirty(dirty ?? true)}
|
||||||
|
onChange={(v) => {
|
||||||
|
setIsDirty(true);
|
||||||
|
setLocalValue(v);
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/Table/TableCell/EditorCellTextField.tsx
Normal file
81
src/components/Table/TableCell/EditorCellTextField.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { IEditorCellProps } from "@src/components/fields/types";
|
||||||
|
import { InputBase, InputBaseProps } from "@mui/material";
|
||||||
|
import { spreadSx } from "@src/utils/ui";
|
||||||
|
|
||||||
|
export interface IEditorCellTextFieldProps extends IEditorCellProps<string> {
|
||||||
|
InputProps?: Partial<InputBaseProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorCellTextField({
|
||||||
|
column,
|
||||||
|
value,
|
||||||
|
onDirty,
|
||||||
|
onChange,
|
||||||
|
setFocusInsideCell,
|
||||||
|
InputProps = {},
|
||||||
|
}: IEditorCellTextFieldProps) {
|
||||||
|
const maxLength = column.config?.maxLength;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputBase
|
||||||
|
value={value}
|
||||||
|
onBlur={() => onDirty()}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "ArrowLeft" ||
|
||||||
|
e.key === "ArrowRight" ||
|
||||||
|
e.key === "ArrowUp" ||
|
||||||
|
e.key === "ArrowDown"
|
||||||
|
) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
// Escape prevents saving the new value
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
// Setting isDirty to false prevents saving
|
||||||
|
onDirty(false);
|
||||||
|
// Stop propagation to prevent the table from closing the editor
|
||||||
|
e.stopPropagation();
|
||||||
|
// Close the editor after isDirty is set to false again
|
||||||
|
setTimeout(() => setFocusInsideCell(false));
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
// Removes focus from inside cell, triggering save on unmount
|
||||||
|
setFocusInsideCell(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
{...InputProps}
|
||||||
|
inputProps={{ maxLength, ...InputProps.inputProps }}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
width: "100%",
|
||||||
|
height: "calc(100% - 1px)",
|
||||||
|
marginTop: "1px",
|
||||||
|
padding: 0,
|
||||||
|
paddingBottom: "1px",
|
||||||
|
|
||||||
|
backgroundColor: "var(--cell-background-color)",
|
||||||
|
outline: "inherit",
|
||||||
|
outlineOffset: "inherit",
|
||||||
|
|
||||||
|
font: "inherit", // Prevent text jumping
|
||||||
|
letterSpacing: "inherit", // Prevent text jumping
|
||||||
|
|
||||||
|
"& .MuiInputBase-input": { p: "var(--cell-padding)" },
|
||||||
|
|
||||||
|
"& textarea.MuiInputBase-input": {
|
||||||
|
lineHeight: (theme) => theme.typography.body2.lineHeight,
|
||||||
|
maxHeight: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
py: 2 / 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...spreadSx(InputProps.sx),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/Table/TableCell/TableCell.tsx
Normal file
202
src/components/Table/TableCell/TableCell.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { flexRender } from "@tanstack/react-table";
|
||||||
|
import type { Row, Cell } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import ErrorIcon from "@mui/icons-material/ErrorOutline";
|
||||||
|
import WarningIcon from "@mui/icons-material/WarningAmber";
|
||||||
|
|
||||||
|
import StyledCell from "@src/components/Table/Styled/StyledCell";
|
||||||
|
import { InlineErrorFallback } from "@src/components/ErrorFallback";
|
||||||
|
import RichTooltip from "@src/components/RichTooltip";
|
||||||
|
import StyledDot from "@src/components/Table/Styled/StyledDot";
|
||||||
|
|
||||||
|
import {
|
||||||
|
tableScope,
|
||||||
|
selectedCellAtom,
|
||||||
|
contextMenuTargetAtom,
|
||||||
|
} from "@src/atoms/tableScope";
|
||||||
|
import { TABLE_PADDING } from "@src/components/Table";
|
||||||
|
import type { TableRow } from "@src/types/table";
|
||||||
|
import type { IRenderedTableCellProps } from "./withRenderTableCell";
|
||||||
|
|
||||||
|
export interface ITableCellProps {
|
||||||
|
/** Current row with context from TanStack Table state */
|
||||||
|
row: Row<TableRow>;
|
||||||
|
/** Current cell with context from TanStack Table state */
|
||||||
|
cell: Cell<TableRow, any>;
|
||||||
|
/** Virtual cell index (column index) */
|
||||||
|
index: number;
|
||||||
|
/** User has clicked or navigated to this cell */
|
||||||
|
isSelectedCell: boolean;
|
||||||
|
/** User has double-clicked or pressed Enter and this cell is selected */
|
||||||
|
focusInsideCell: boolean;
|
||||||
|
/**
|
||||||
|
* Used to disable `aria-description` that says “Press Enter to edit”
|
||||||
|
* for Auditing and Metadata cells. Need to find another way to do this.
|
||||||
|
*/
|
||||||
|
isReadOnlyCell: boolean;
|
||||||
|
/** Determines if EditorCell can be displayed */
|
||||||
|
canEditCells: boolean;
|
||||||
|
/**
|
||||||
|
* Pass current row height as a prop so we don’t access `tableSchema` here.
|
||||||
|
* If that atom is listened to here, all table cells will re-render whenever
|
||||||
|
* `tableSchema` changes, which is unnecessary.
|
||||||
|
*/
|
||||||
|
rowHeight: number;
|
||||||
|
/** If true, renders a shadow */
|
||||||
|
isLastFrozen: boolean;
|
||||||
|
/** Pass width as a prop to get local column sizing state */
|
||||||
|
width: number;
|
||||||
|
/**
|
||||||
|
* If provided, cell is pinned/frozen, and this value is used for
|
||||||
|
* `position: sticky`.
|
||||||
|
*/
|
||||||
|
left: number;
|
||||||
|
isPinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the container div for each cell with accessibility attributes for
|
||||||
|
* keyboard navigation.
|
||||||
|
*
|
||||||
|
* - Performs regex & missing value check and renders associated UI
|
||||||
|
* - Provides children with value from `cell.getValue()` so they can work with
|
||||||
|
* memoization
|
||||||
|
* - Provides helpers as props to aid with memoization, so children components
|
||||||
|
* don’t have to read atoms, which may cause unnecessary re-renders of many
|
||||||
|
* cell components
|
||||||
|
* - Renders `ErrorBoundary`
|
||||||
|
*/
|
||||||
|
export const TableCell = memo(function TableCell({
|
||||||
|
row,
|
||||||
|
cell,
|
||||||
|
index,
|
||||||
|
isSelectedCell,
|
||||||
|
focusInsideCell,
|
||||||
|
isReadOnlyCell,
|
||||||
|
canEditCells,
|
||||||
|
rowHeight,
|
||||||
|
isLastFrozen,
|
||||||
|
width,
|
||||||
|
left,
|
||||||
|
isPinned,
|
||||||
|
}: ITableCellProps) {
|
||||||
|
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
|
||||||
|
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
|
||||||
|
|
||||||
|
const value = cell.getValue();
|
||||||
|
const required = cell.column.columnDef.meta?.config?.required;
|
||||||
|
const validationRegex = cell.column.columnDef.meta?.config?.validationRegex;
|
||||||
|
|
||||||
|
const isInvalid = validationRegex && !new RegExp(validationRegex).test(value);
|
||||||
|
const isMissing = required && value === undefined;
|
||||||
|
|
||||||
|
let renderedValidationTooltip = null;
|
||||||
|
|
||||||
|
if (isInvalid) {
|
||||||
|
renderedValidationTooltip = (
|
||||||
|
<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 }) => <StyledDot onClick={openTooltip} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (isMissing) {
|
||||||
|
renderedValidationTooltip = (
|
||||||
|
<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 }) => <StyledDot onClick={openTooltip} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableCellComponentProps: IRenderedTableCellProps = {
|
||||||
|
...cell.getContext(),
|
||||||
|
value,
|
||||||
|
focusInsideCell,
|
||||||
|
setFocusInsideCell: (focusInside: boolean) =>
|
||||||
|
setSelectedCell({
|
||||||
|
path: row.original._rowy_ref.path,
|
||||||
|
columnKey: cell.column.id,
|
||||||
|
focusInside,
|
||||||
|
}),
|
||||||
|
disabled: !canEditCells || cell.column.columnDef.meta?.editable === false,
|
||||||
|
rowHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledCell
|
||||||
|
key={cell.id}
|
||||||
|
data-row-id={row.id}
|
||||||
|
data-col-id={cell.column.id}
|
||||||
|
data-frozen={cell.column.getIsPinned() || undefined}
|
||||||
|
data-frozen-last={isLastFrozen || undefined}
|
||||||
|
role="gridcell"
|
||||||
|
tabIndex={isSelectedCell && !focusInsideCell ? 0 : -1}
|
||||||
|
aria-colindex={index + 1}
|
||||||
|
aria-readonly={
|
||||||
|
!canEditCells || cell.column.columnDef.meta?.editable === false
|
||||||
|
}
|
||||||
|
aria-required={Boolean(cell.column.columnDef.meta?.config?.required)}
|
||||||
|
aria-selected={isSelectedCell}
|
||||||
|
aria-describedby={
|
||||||
|
canEditCells && !isReadOnlyCell
|
||||||
|
? "rowy-table-editable-cell-description"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-invalid={isInvalid || isMissing}
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height: rowHeight,
|
||||||
|
position: isPinned ? "sticky" : "absolute",
|
||||||
|
left: left - (isPinned ? TABLE_PADDING : 0),
|
||||||
|
backgroundColor:
|
||||||
|
cell.column.id === "_rowy_column_actions" ? "transparent" : undefined,
|
||||||
|
borderBottomWidth:
|
||||||
|
cell.column.id === "_rowy_column_actions" ? 0 : undefined,
|
||||||
|
borderRightWidth:
|
||||||
|
cell.column.id === "_rowy_column_actions" ? 0 : undefined,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
setSelectedCell({
|
||||||
|
path: row.original._rowy_ref.path,
|
||||||
|
columnKey: cell.column.id,
|
||||||
|
focusInside: false,
|
||||||
|
});
|
||||||
|
(e.target as HTMLDivElement).focus();
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
setSelectedCell({
|
||||||
|
path: row.original._rowy_ref.path,
|
||||||
|
columnKey: cell.column.id,
|
||||||
|
focusInside: true,
|
||||||
|
});
|
||||||
|
(e.target as HTMLDivElement).focus();
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedCell({
|
||||||
|
path: row.original._rowy_ref.path,
|
||||||
|
columnKey: cell.column.id,
|
||||||
|
focusInside: false,
|
||||||
|
});
|
||||||
|
(e.target as HTMLDivElement).focus();
|
||||||
|
setContextMenuTarget(e.target as HTMLElement);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderedValidationTooltip}
|
||||||
|
<ErrorBoundary fallbackRender={InlineErrorFallback}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, tableCellComponentProps)}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</StyledCell>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TableCell;
|
||||||
2
src/components/Table/TableCell/index.ts
Normal file
2
src/components/Table/TableCell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./TableCell";
|
||||||
|
export * from "./TableCell";
|
||||||
259
src/components/Table/TableCell/withRenderTableCell.tsx
Normal file
259
src/components/Table/TableCell/withRenderTableCell.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { memo, Suspense, useState, useEffect, useRef } from "react";
|
||||||
|
import { isEqual } from "lodash-es";
|
||||||
|
import type { CellContext } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Popover, PopoverProps } from "@mui/material";
|
||||||
|
|
||||||
|
import EditorCellController from "./EditorCellController";
|
||||||
|
|
||||||
|
import { spreadSx } from "@src/utils/ui";
|
||||||
|
import type { TableRow } from "@src/types/table";
|
||||||
|
import type {
|
||||||
|
IDisplayCellProps,
|
||||||
|
IEditorCellProps,
|
||||||
|
} from "@src/components/fields/types";
|
||||||
|
|
||||||
|
export interface ICellOptions {
|
||||||
|
/** If the rest of the row’s data is used, set this to true for memoization */
|
||||||
|
usesRowData?: boolean;
|
||||||
|
/** Handle padding inside the cell component */
|
||||||
|
disablePadding?: boolean;
|
||||||
|
/** Set popover background to be transparent */
|
||||||
|
transparentPopover?: boolean;
|
||||||
|
/** Props to pass to MUI Popover component */
|
||||||
|
popoverProps?: Partial<PopoverProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Received from `TableCell` */
|
||||||
|
export interface IRenderedTableCellProps<TValue = any>
|
||||||
|
extends CellContext<TableRow, TValue> {
|
||||||
|
value: TValue;
|
||||||
|
focusInsideCell: boolean;
|
||||||
|
setFocusInsideCell: (focusInside: boolean) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
rowHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-order component to render each field type’s cell components.
|
||||||
|
* Handles when to render read-only `DisplayCell` and `EditorCell`.
|
||||||
|
*
|
||||||
|
* Memoized to re-render when value, column, focus, or disabled states change.
|
||||||
|
* Optionally re-renders when entire row updates.
|
||||||
|
*
|
||||||
|
* - Renders inline `EditorCell` after a timeout to improve scroll performance
|
||||||
|
* - Handles popovers
|
||||||
|
* - Renders Suspense for lazy-loaded `EditorCell`
|
||||||
|
* - Provides a `tabIndex` prop, so that interactive cell children (like
|
||||||
|
* buttons) cannot be interacted with unless the user has focused in the
|
||||||
|
* cell. Required for accessibility.
|
||||||
|
*
|
||||||
|
* @param DisplayCellComponent
|
||||||
|
* - The lighter cell component to display values. Also displayed when the
|
||||||
|
* column is disabled/read-only.
|
||||||
|
*
|
||||||
|
* - Keep these components lightweight, i.e. use base HTML or simple MUI
|
||||||
|
* components. Avoid `Tooltip`, which is heavy.
|
||||||
|
* - Avoid displaying disabled states (e.g. do not reduce opacity/grey out
|
||||||
|
* toggles). This improves the experience of read-only tables for non-admins
|
||||||
|
* - ⚠️ Make sure the disabled state does not render the buttons to open a
|
||||||
|
* popover `EditorCell` (like Single/Multi Select).
|
||||||
|
* - ⚠️ Make sure to use the `tabIndex` prop for buttons and other interactive
|
||||||
|
* elements.
|
||||||
|
* - {@link IDisplayCellProps}
|
||||||
|
*
|
||||||
|
* @param EditorCellComponent
|
||||||
|
* - The heavier cell component to edit values
|
||||||
|
*
|
||||||
|
* - `EditorCell` should use the `value` and `onChange` props for the
|
||||||
|
* rendered inputs. Avoid creating another local state here.
|
||||||
|
* - You can pass `null` to `withRenderTableCell()` to always display the
|
||||||
|
* `DisplayCell`.
|
||||||
|
* - ⚠️ If it’s displayed inline, you must call `onSubmit` to save the value
|
||||||
|
* to the database, because it never unmounts.
|
||||||
|
* - ✨ You can reuse your `SideDrawerField` as they take the same props. It
|
||||||
|
* should probably be displayed in a popover.
|
||||||
|
* - ⚠️ Make sure to use the `tabIndex` prop for buttons, text fields, and
|
||||||
|
* other interactive elements.
|
||||||
|
* - {@link IEditorCellProps}
|
||||||
|
*
|
||||||
|
* @param editorMode
|
||||||
|
* - When to display the `EditorCell`
|
||||||
|
* 1. **focus** (default): the user has focused on the cell by pressing Enter or
|
||||||
|
* double-clicking,
|
||||||
|
* 2. **inline**: always displayed if the cell is editable, or
|
||||||
|
* 3. **popover**: inside a popover when a user has focused on the cell
|
||||||
|
* (as above) or clicked a button rendered by `DisplayCell`
|
||||||
|
*
|
||||||
|
* @param options
|
||||||
|
* - Note this is OK to pass as an object since it’s not defined in runtime
|
||||||
|
* - {@link ICellOptions}
|
||||||
|
*/
|
||||||
|
export default function withRenderTableCell(
|
||||||
|
DisplayCellComponent: React.ComponentType<IDisplayCellProps>,
|
||||||
|
EditorCellComponent: React.ComponentType<IEditorCellProps> | null,
|
||||||
|
editorMode: "focus" | "inline" | "popover" = "focus",
|
||||||
|
options: ICellOptions = {}
|
||||||
|
) {
|
||||||
|
return memo(
|
||||||
|
function RenderedTableCell({
|
||||||
|
row,
|
||||||
|
column,
|
||||||
|
value,
|
||||||
|
focusInsideCell,
|
||||||
|
setFocusInsideCell,
|
||||||
|
disabled,
|
||||||
|
rowHeight,
|
||||||
|
}: IRenderedTableCellProps) {
|
||||||
|
// Render inline editor cell after timeout on mount
|
||||||
|
// to improve scroll performance
|
||||||
|
const [inlineEditorReady, setInlineEditorReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorMode === "inline")
|
||||||
|
setTimeout(() => setInlineEditorReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Store ref to rendered DisplayCell to get positioning for PopoverCell
|
||||||
|
const displayCellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const parentRef = displayCellRef.current?.parentElement;
|
||||||
|
|
||||||
|
// Store Popover open state here so we can add delay for close transition
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusInsideCell) setPopoverOpen(true);
|
||||||
|
}, [focusInsideCell]);
|
||||||
|
const showPopoverCell = (popover: boolean) => {
|
||||||
|
if (popover) {
|
||||||
|
setPopoverOpen(true);
|
||||||
|
// Need to call this after a timeout, since the cell’s `onClick`
|
||||||
|
// event is fired, which sets focusInsideCell false
|
||||||
|
setTimeout(() => setFocusInsideCell(true));
|
||||||
|
} else {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
// Call after a timeout to allow the close transition to finish
|
||||||
|
setTimeout(() => {
|
||||||
|
setFocusInsideCell(false);
|
||||||
|
// Focus the cell. Otherwise, it focuses the body.
|
||||||
|
parentRef?.focus();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Declare basicCell here so props can be reused by HeavyCellComponent
|
||||||
|
const basicCellProps: IDisplayCellProps = {
|
||||||
|
value,
|
||||||
|
name: column.columnDef.meta!.name,
|
||||||
|
type: column.columnDef.meta!.type,
|
||||||
|
row: row.original,
|
||||||
|
column: column.columnDef.meta!,
|
||||||
|
_rowy_ref: row.original._rowy_ref,
|
||||||
|
disabled,
|
||||||
|
tabIndex: focusInsideCell ? 0 : -1,
|
||||||
|
showPopoverCell,
|
||||||
|
setFocusInsideCell,
|
||||||
|
rowHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show display cell, unless if editorMode is inline
|
||||||
|
const displayCell = (
|
||||||
|
<div
|
||||||
|
className="cell-contents"
|
||||||
|
style={options.disablePadding ? { padding: 0 } : undefined}
|
||||||
|
ref={displayCellRef}
|
||||||
|
>
|
||||||
|
<DisplayCellComponent {...basicCellProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (disabled || (editorMode !== "inline" && !focusInsideCell))
|
||||||
|
return displayCell;
|
||||||
|
|
||||||
|
// If the inline editor cell is not ready to be rendered, display nothing
|
||||||
|
if (editorMode === "inline" && !inlineEditorReady) return null;
|
||||||
|
|
||||||
|
// Show displayCell as a fallback if intentionally null
|
||||||
|
const editorCell = EditorCellComponent ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<EditorCellController
|
||||||
|
{...basicCellProps}
|
||||||
|
EditorCellComponent={EditorCellComponent}
|
||||||
|
parentRef={parentRef}
|
||||||
|
saveOnUnmount={editorMode !== "inline"}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
displayCell
|
||||||
|
);
|
||||||
|
|
||||||
|
if (editorMode === "focus" && focusInsideCell) {
|
||||||
|
return editorCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorMode === "inline") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="cell-contents"
|
||||||
|
style={options.disablePadding ? { padding: 0 } : undefined}
|
||||||
|
ref={displayCellRef}
|
||||||
|
>
|
||||||
|
{editorCell}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorMode === "popover")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displayCell}
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={popoverOpen}
|
||||||
|
anchorEl={parentRef}
|
||||||
|
onClose={() => showPopoverCell(false)}
|
||||||
|
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
|
||||||
|
transformOrigin={{ horizontal: "center", vertical: "top" }}
|
||||||
|
{...options.popoverProps}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
"& .MuiPopover-paper": {
|
||||||
|
backgroundColor: options.transparentPopover
|
||||||
|
? "transparent"
|
||||||
|
: undefined,
|
||||||
|
boxShadow: options.transparentPopover ? "none" : undefined,
|
||||||
|
minWidth: column.getSize(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...spreadSx(options.popoverProps?.sx),
|
||||||
|
]}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{editorCell}
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not reach this line
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
// Memo function
|
||||||
|
(prev, next) => {
|
||||||
|
const valueEqual = isEqual(prev.value, next.value);
|
||||||
|
const columnEqual = isEqual(
|
||||||
|
prev.column.columnDef.meta,
|
||||||
|
next.column.columnDef.meta
|
||||||
|
);
|
||||||
|
const rowEqual = isEqual(prev.row.original, next.row.original);
|
||||||
|
const focusInsideCellEqual =
|
||||||
|
prev.focusInsideCell === next.focusInsideCell;
|
||||||
|
const disabledEqual = prev.disabled === next.disabled;
|
||||||
|
|
||||||
|
const baseEqualities =
|
||||||
|
valueEqual && columnEqual && focusInsideCellEqual && disabledEqual;
|
||||||
|
|
||||||
|
if (options?.usesRowData) return baseEqualities && rowEqual;
|
||||||
|
else return baseEqualities;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import { colord } from "colord";
|
|
||||||
import { styled, alpha, darken, lighten } from "@mui/material";
|
|
||||||
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
|
|
||||||
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
|
|
||||||
import {
|
|
||||||
DRAWER_COLLAPSED_WIDTH,
|
|
||||||
DRAWER_WIDTH,
|
|
||||||
} from "@src/components/SideDrawer";
|
|
||||||
|
|
||||||
export const OUT_OF_ORDER_MARGIN = 8;
|
|
||||||
|
|
||||||
export const TableContainer = styled("div", {
|
|
||||||
shouldForwardProp: (prop) => prop !== "rowHeight",
|
|
||||||
})<{ rowHeight: number }>(({ theme, rowHeight }) => ({
|
|
||||||
display: "flex",
|
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: `calc(100vh - ${TOP_BAR_HEIGHT}px - ${TABLE_TOOLBAR_HEIGHT}px)`,
|
|
||||||
|
|
||||||
"& .left-scroll-divider": {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 1,
|
|
||||||
zIndex: 1,
|
|
||||||
|
|
||||||
backgroundColor: colord(theme.palette.background.paper)
|
|
||||||
.mix(theme.palette.divider, 0.12)
|
|
||||||
.alpha(1)
|
|
||||||
.toHslString(),
|
|
||||||
},
|
|
||||||
|
|
||||||
"& > .rdg": {
|
|
||||||
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), ${DRAWER_WIDTH}px)`,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
TableContainer.displayName = "TableContainer";
|
|
||||||
|
|
||||||
export default TableContainer;
|
|
||||||
130
src/components/Table/TableHeader.tsx
Normal file
130
src/components/Table/TableHeader.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { memo, Fragment } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
|
||||||
|
import type { DropResult } from "react-beautiful-dnd";
|
||||||
|
import type { ColumnSizingState, HeaderGroup } from "@tanstack/react-table";
|
||||||
|
import type { TableRow } from "@src/types/table";
|
||||||
|
|
||||||
|
import StyledRow from "./Styled/StyledRow";
|
||||||
|
import ColumnHeader from "./ColumnHeader";
|
||||||
|
import FinalColumnHeader from "./FinalColumn/FinalColumnHeader";
|
||||||
|
|
||||||
|
import { tableScope, selectedCellAtom } from "@src/atoms/tableScope";
|
||||||
|
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||||
|
|
||||||
|
export interface ITableHeaderProps {
|
||||||
|
/** Headers with context from TanStack Table state */
|
||||||
|
headerGroups: HeaderGroup<TableRow>[];
|
||||||
|
/** Called when a header is dropped in a new position */
|
||||||
|
handleDropColumn: (result: DropResult) => void;
|
||||||
|
/** Passed to `FinalColumnHeader` */
|
||||||
|
canAddColumns: boolean;
|
||||||
|
/** Determines if columns can be re-ordered */
|
||||||
|
canEditColumns: boolean;
|
||||||
|
/** If specified, renders a shadow in the last frozen column */
|
||||||
|
lastFrozen?: string;
|
||||||
|
/**
|
||||||
|
* Must pass this prop so that it re-renders when local column sizing changes */
|
||||||
|
columnSizing: ColumnSizingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders table header row. Memoized to only re-render when column definitions
|
||||||
|
* and sizes change.
|
||||||
|
*
|
||||||
|
* - Renders drag & drop components
|
||||||
|
*/
|
||||||
|
export const TableHeader = memo(function TableHeader({
|
||||||
|
headerGroups,
|
||||||
|
handleDropColumn,
|
||||||
|
canAddColumns,
|
||||||
|
canEditColumns,
|
||||||
|
lastFrozen,
|
||||||
|
}: ITableHeaderProps) {
|
||||||
|
const [selectedCell] = useAtom(selectedCellAtom, tableScope);
|
||||||
|
const focusInside = selectedCell?.focusInside ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={handleDropColumn}>
|
||||||
|
{headerGroups.map((headerGroup) => (
|
||||||
|
<Droppable droppableId="droppable-column" direction="horizontal">
|
||||||
|
{(provided) => (
|
||||||
|
<StyledRow
|
||||||
|
key={headerGroup.id}
|
||||||
|
role="row"
|
||||||
|
aria-rowindex={1}
|
||||||
|
style={{ height: DEFAULT_ROW_HEIGHT + 1 }}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header, i) => {
|
||||||
|
const isSelectedCell =
|
||||||
|
(!selectedCell && header.index === 0) ||
|
||||||
|
(selectedCell?.path === "_rowy_header" &&
|
||||||
|
selectedCell?.columnKey === header.id);
|
||||||
|
|
||||||
|
const isLastHeader = i === headerGroup.headers.length - 1;
|
||||||
|
|
||||||
|
// Render later, after the drag & drop placeholder
|
||||||
|
if (header.id === "_rowy_column_actions")
|
||||||
|
return (
|
||||||
|
<Fragment key={header.id}>
|
||||||
|
{provided.placeholder}
|
||||||
|
<FinalColumnHeader
|
||||||
|
key={header.id}
|
||||||
|
data-row-id={"_rowy_header"}
|
||||||
|
data-col-id={header.id}
|
||||||
|
tabIndex={isSelectedCell ? 0 : -1}
|
||||||
|
focusInsideCell={isSelectedCell && focusInside}
|
||||||
|
aria-colindex={header.index + 1}
|
||||||
|
aria-readonly={!canEditColumns}
|
||||||
|
aria-selected={isSelectedCell}
|
||||||
|
canAddColumns={canAddColumns}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!header.column.columnDef.meta) return null;
|
||||||
|
|
||||||
|
const draggableHeader = (
|
||||||
|
<Draggable
|
||||||
|
key={header.id}
|
||||||
|
draggableId={header.id}
|
||||||
|
index={header.index}
|
||||||
|
isDragDisabled={!canEditColumns}
|
||||||
|
disableInteractiveElementBlocking
|
||||||
|
>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<ColumnHeader
|
||||||
|
header={header}
|
||||||
|
column={header.column.columnDef.meta!}
|
||||||
|
provided={provided}
|
||||||
|
snapshot={snapshot}
|
||||||
|
width={header.getSize()}
|
||||||
|
isSelectedCell={isSelectedCell}
|
||||||
|
focusInsideCell={isSelectedCell && focusInside}
|
||||||
|
canEditColumns={canEditColumns}
|
||||||
|
isLastFrozen={lastFrozen === header.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLastHeader)
|
||||||
|
return (
|
||||||
|
<Fragment key={header.id}>
|
||||||
|
{draggableHeader}
|
||||||
|
{provided.placeholder}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
else return draggableHeader;
|
||||||
|
})}
|
||||||
|
</StyledRow>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
))}
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TableHeader;
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Fragment } from "react";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { Row, RowRendererProps } from "react-data-grid";
|
|
||||||
|
|
||||||
import OutOfOrderIndicator from "./OutOfOrderIndicator";
|
|
||||||
|
|
||||||
import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope";
|
|
||||||
|
|
||||||
export default function TableRow(props: RowRendererProps<any>) {
|
|
||||||
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setContextMenuTarget(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}
|
|
||||||
id={`row-${props.row._rowy_ref.path}`}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { colord } from "colord";
|
|||||||
|
|
||||||
import { Fade, Stack, Skeleton, Button } from "@mui/material";
|
import { Fade, Stack, Skeleton, Button } from "@mui/material";
|
||||||
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
||||||
import Column from "./Column";
|
import Column from "./Mock/Column";
|
||||||
|
|
||||||
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
|
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
tableSchemaAtom,
|
tableSchemaAtom,
|
||||||
tableColumnsOrderedAtom,
|
tableColumnsOrderedAtom,
|
||||||
} from "@src/atoms/tableScope";
|
} from "@src/atoms/tableScope";
|
||||||
import { DEFAULT_ROW_HEIGHT, DEFAULT_COL_WIDTH } from "./Table";
|
import { DEFAULT_ROW_HEIGHT, DEFAULT_COL_WIDTH, TABLE_PADDING } from "./Table";
|
||||||
import { COLLECTION_PAGE_SIZE } from "@src/config/db";
|
import { COLLECTION_PAGE_SIZE } from "@src/config/db";
|
||||||
import { formatSubTableName } from "@src/utils/table";
|
import { formatSubTableName } from "@src/utils/table";
|
||||||
|
|
||||||
@@ -129,17 +129,7 @@ export function RowsSkeleton() {
|
|||||||
<Stack
|
<Stack
|
||||||
key={i}
|
key={i}
|
||||||
direction="row"
|
direction="row"
|
||||||
sx={{
|
style={{ padding: `0 ${TABLE_PADDING}px`, marginTop: -1 }}
|
||||||
px: 2,
|
|
||||||
mt: -1 / 8,
|
|
||||||
|
|
||||||
"&:last-of-type > div:first-of-type": {
|
|
||||||
borderBottomLeftRadius: (theme) => theme.shape.borderRadius,
|
|
||||||
},
|
|
||||||
"&:last-of-type > div:last-of-type": {
|
|
||||||
borderBottomRightRadius: (theme) => theme.shape.borderRadius,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{columns.map((col, j) => (
|
{columns.map((col, j) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -156,6 +146,7 @@ export function RowsSkeleton() {
|
|||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
borderLeftWidth: j === 0 ? 1 : 0,
|
borderLeftWidth: j === 0 ? 1 : 0,
|
||||||
|
borderRadius: 0,
|
||||||
width: col.width || DEFAULT_COL_WIDTH,
|
width: col.width || DEFAULT_COL_WIDTH,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
height: rowHeight + 1,
|
height: rowHeight + 1,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { EditorProps } from "react-data-grid";
|
|
||||||
import { GlobalStyles } from "tss-react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
export default class NullEditor extends React.Component<EditorProps<any, any>> {
|
|
||||||
getInputNode = () => null;
|
|
||||||
getValue = () => null;
|
|
||||||
render = () => (
|
|
||||||
<GlobalStyles
|
|
||||||
styles={{
|
|
||||||
".rdg-editor-container": { display: "none" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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 { getFieldType } from "@src/components/fields";
|
|
||||||
|
|
||||||
/** WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE */
|
|
||||||
export default function TextEditor({ row, column }: EditorProps<any>) {
|
|
||||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
|
||||||
|
|
||||||
const type = getFieldType(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);
|
|
||||||
|
|
||||||
// WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const inputElement = inputRef.current;
|
|
||||||
return () => {
|
|
||||||
const newValue = inputElement?.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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [column.key, row._rowy_ref.path, type, updateField]);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createStyles } from "@mui/material";
|
|
||||||
|
|
||||||
export const styles = createStyles({
|
|
||||||
"@global": {
|
|
||||||
".rdg-editor-container": { display: "none" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default styles;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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._rowy_ref}
|
|
||||||
onSubmit={() => {}}
|
|
||||||
disabled={props.column.editable === false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { EditorProps } from "react-data-grid";
|
|
||||||
import { get } from "lodash-es";
|
|
||||||
|
|
||||||
import { tableScope, sideDrawerOpenAtom } from "@src/atoms/tableScope";
|
|
||||||
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;
|
|
||||||
|
|
||||||
const setSideDrawerOpen = useSetAtom(sideDrawerOpenAtom, tableScope);
|
|
||||||
useEffect(() => {
|
|
||||||
setSideDrawerOpen(true);
|
|
||||||
}, [setSideDrawerOpen]);
|
|
||||||
|
|
||||||
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._rowy_ref}
|
|
||||||
onSubmit={() => {}}
|
|
||||||
disabled={props.column.editable === false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
154
src/components/Table/useKeyboardNavigation.tsx
Normal file
154
src/components/Table/useKeyboardNavigation.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { Column } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { tableScope, selectedCellAtom } from "@src/atoms/tableScope";
|
||||||
|
import { TableRow } from "@src/types/table";
|
||||||
|
import { COLLECTION_PAGE_SIZE } from "@src/config/db";
|
||||||
|
|
||||||
|
export interface IUseKeyboardNavigationProps {
|
||||||
|
gridRef: React.RefObject<HTMLDivElement>;
|
||||||
|
tableRows: TableRow[];
|
||||||
|
leafColumns: Column<TableRow, any>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of accessibility standards for data grids
|
||||||
|
* - https://www.w3.org/WAI/ARIA/apg/patterns/grid/
|
||||||
|
* - https://www.w3.org/WAI/ARIA/apg/example-index/grid/dataGrids
|
||||||
|
*/
|
||||||
|
export function useKeyboardNavigation({
|
||||||
|
gridRef,
|
||||||
|
tableRows,
|
||||||
|
leafColumns,
|
||||||
|
}: IUseKeyboardNavigationProps) {
|
||||||
|
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
// Block default browser behavior for arrow keys (scroll) and other keys
|
||||||
|
const LISTENED_KEYS = [
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"Enter",
|
||||||
|
"Escape",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
];
|
||||||
|
if (LISTENED_KEYS.includes(e.key)) e.preventDefault();
|
||||||
|
|
||||||
|
// Esc: exit cell
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setSelectedCell((c) => ({ ...c!, focusInside: false }));
|
||||||
|
(
|
||||||
|
gridRef.current?.querySelector(
|
||||||
|
"[aria-selected=true]"
|
||||||
|
) as HTMLDivElement
|
||||||
|
)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If event target is not a cell, ignore
|
||||||
|
const target = e.target as HTMLDivElement;
|
||||||
|
if (
|
||||||
|
target.getAttribute("role") !== "columnheader" &&
|
||||||
|
target.getAttribute("role") !== "gridcell"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// If Tab, ignore so we can exit the table
|
||||||
|
if (e.key === "Tab") return;
|
||||||
|
|
||||||
|
// Enter: enter cell
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
setSelectedCell((c) => ({ ...c!, focusInside: true }));
|
||||||
|
(target.querySelector("[tabindex]") as HTMLElement)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colIndex = Number(target.getAttribute("aria-colindex")) - 1;
|
||||||
|
const rowIndex =
|
||||||
|
Number(target.parentElement!.getAttribute("aria-rowindex")) - 2;
|
||||||
|
|
||||||
|
let newColIndex = colIndex;
|
||||||
|
let newRowIndex = rowIndex;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
if (e.ctrlKey || e.metaKey) newRowIndex = -1;
|
||||||
|
else if (rowIndex > -1) newRowIndex = rowIndex - 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowDown":
|
||||||
|
if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1;
|
||||||
|
else if (rowIndex < tableRows.length - 1) newRowIndex = rowIndex + 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (e.ctrlKey || e.metaKey) newColIndex = 0;
|
||||||
|
else if (colIndex > 0) newColIndex = colIndex - 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowRight":
|
||||||
|
if (e.ctrlKey || e.metaKey) newColIndex = leafColumns.length - 1;
|
||||||
|
else if (colIndex < leafColumns.length - 1)
|
||||||
|
newColIndex = colIndex + 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PageUp":
|
||||||
|
newRowIndex = Math.max(0, rowIndex - COLLECTION_PAGE_SIZE);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PageDown":
|
||||||
|
newRowIndex = Math.min(
|
||||||
|
tableRows.length - 1,
|
||||||
|
rowIndex + COLLECTION_PAGE_SIZE
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Home":
|
||||||
|
newColIndex = 0;
|
||||||
|
if (e.ctrlKey || e.metaKey) newRowIndex = -1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "End":
|
||||||
|
newColIndex = leafColumns.length - 1;
|
||||||
|
if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get `path` and `columnKey` from `tableRows` and `leafColumns` respectively
|
||||||
|
const newSelectedCell = {
|
||||||
|
path:
|
||||||
|
newRowIndex > -1
|
||||||
|
? tableRows[newRowIndex]._rowy_ref.path
|
||||||
|
: "_rowy_header",
|
||||||
|
columnKey: leafColumns[newColIndex].id! || leafColumns[0].id!,
|
||||||
|
// When selected cell changes, exit current cell
|
||||||
|
focusInside: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in selectedCellAtom
|
||||||
|
setSelectedCell(newSelectedCell);
|
||||||
|
|
||||||
|
// Find matching DOM element for the cell
|
||||||
|
const newCellEl = gridRef.current?.querySelector(
|
||||||
|
`[aria-rowindex="${newRowIndex + 2}"] [aria-colindex="${
|
||||||
|
newColIndex + 1
|
||||||
|
}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Focus the cell
|
||||||
|
if (newCellEl) setTimeout(() => (newCellEl as HTMLDivElement).focus());
|
||||||
|
},
|
||||||
|
[gridRef, leafColumns, setSelectedCell, tableRows]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { handleKeyDown } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useKeyboardNavigation;
|
||||||
105
src/components/Table/useSaveColumnSizing.tsx
Normal file
105
src/components/Table/useSaveColumnSizing.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { useSnackbar } from "notistack";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { isEqual, isEmpty } from "lodash-es";
|
||||||
|
|
||||||
|
import LoadingButton from "@mui/lab/LoadingButton";
|
||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||||
|
|
||||||
|
import {
|
||||||
|
tableScope,
|
||||||
|
updateColumnAtom,
|
||||||
|
IUpdateColumnOptions,
|
||||||
|
} from "@src/atoms/tableScope";
|
||||||
|
import { DEBOUNCE_DELAY } from "./Table";
|
||||||
|
import { ColumnSizingState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounces `columnSizing` and asks user if they want to save for all users,
|
||||||
|
* if they have the `canEditColumns` permission
|
||||||
|
*/
|
||||||
|
export function useSaveColumnSizing(
|
||||||
|
columnSizing: ColumnSizingState,
|
||||||
|
canEditColumns: boolean
|
||||||
|
) {
|
||||||
|
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||||
|
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
||||||
|
|
||||||
|
// Debounce for saving to schema
|
||||||
|
const [debouncedColumnSizing] = useDebounce(columnSizing, DEBOUNCE_DELAY, {
|
||||||
|
equalityFn: isEqual,
|
||||||
|
});
|
||||||
|
// Offer to save when column sizing changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canEditColumns || isEmpty(debouncedColumnSizing)) return;
|
||||||
|
|
||||||
|
const snackbarId = enqueueSnackbar("Save column sizes for all users?", {
|
||||||
|
action: (
|
||||||
|
<SaveColumnSizingButton
|
||||||
|
debouncedColumnSizing={debouncedColumnSizing}
|
||||||
|
updateColumn={updateColumn}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
anchorOrigin: { horizontal: "center", vertical: "top" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => closeSnackbar(snackbarId);
|
||||||
|
}, [
|
||||||
|
debouncedColumnSizing,
|
||||||
|
canEditColumns,
|
||||||
|
enqueueSnackbar,
|
||||||
|
closeSnackbar,
|
||||||
|
updateColumn,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISaveColumnSizingButtonProps {
|
||||||
|
debouncedColumnSizing: ColumnSizingState;
|
||||||
|
updateColumn: (update: IUpdateColumnOptions) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the button a component so it can have its own state,
|
||||||
|
* so we can display the loading state without showing a new snackbar
|
||||||
|
*/
|
||||||
|
function SaveColumnSizingButton({
|
||||||
|
debouncedColumnSizing,
|
||||||
|
updateColumn,
|
||||||
|
}: ISaveColumnSizingButtonProps) {
|
||||||
|
const [state, setState] = useState<"" | "loading" | "success">("");
|
||||||
|
|
||||||
|
const handleSaveToSchema = async () => {
|
||||||
|
setState("loading");
|
||||||
|
// Do this one by one for now to prevent race conditions.
|
||||||
|
// Need to support updating multiple columns in updateColumnAtom
|
||||||
|
// in the future.
|
||||||
|
for (const [key, value] of Object.entries(debouncedColumnSizing)) {
|
||||||
|
await updateColumn({ key, config: { width: value } });
|
||||||
|
}
|
||||||
|
setState("success");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleSaveToSchema}
|
||||||
|
loading={Boolean(state)}
|
||||||
|
loadingIndicator={
|
||||||
|
state === "success" ? (
|
||||||
|
<CheckIcon color="primary" />
|
||||||
|
) : (
|
||||||
|
<CircularProgressOptical size={20} color="primary" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</LoadingButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSaveColumnSizing;
|
||||||
139
src/components/Table/useVirtualization.tsx
Normal file
139
src/components/Table/useVirtualization.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useVirtual, defaultRangeExtractor } from "react-virtual";
|
||||||
|
import type { Range } from "react-virtual";
|
||||||
|
|
||||||
|
import {
|
||||||
|
tableScope,
|
||||||
|
tableSchemaAtom,
|
||||||
|
tableRowsAtom,
|
||||||
|
selectedCellAtom,
|
||||||
|
} from "@src/atoms/tableScope";
|
||||||
|
import {
|
||||||
|
TABLE_PADDING,
|
||||||
|
DEFAULT_ROW_HEIGHT,
|
||||||
|
OUT_OF_ORDER_MARGIN,
|
||||||
|
DEFAULT_COL_WIDTH,
|
||||||
|
} from "./Table";
|
||||||
|
import { TableRow } from "@src/types/table";
|
||||||
|
import { Column } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { MIN_COL_WIDTH } from "./Table";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtualizes rows and columns,
|
||||||
|
* and scrolls to selected cell
|
||||||
|
*/
|
||||||
|
export function useVirtualization(
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
|
leafColumns: Column<TableRow, unknown>[]
|
||||||
|
) {
|
||||||
|
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||||
|
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||||
|
const [selectedCell] = useAtom(selectedCellAtom, tableScope);
|
||||||
|
|
||||||
|
// Virtualize rows
|
||||||
|
const {
|
||||||
|
virtualItems: virtualRows,
|
||||||
|
totalSize: totalHeight,
|
||||||
|
scrollToIndex: scrollToRowIndex,
|
||||||
|
} = useVirtual({
|
||||||
|
parentRef: containerRef,
|
||||||
|
size: tableRows.length,
|
||||||
|
overscan: 5,
|
||||||
|
paddingEnd: TABLE_PADDING,
|
||||||
|
estimateSize: useCallback(
|
||||||
|
(index: number) =>
|
||||||
|
(tableSchema.rowHeight || DEFAULT_ROW_HEIGHT) +
|
||||||
|
(tableRows[index]._rowy_outOfOrder ? OUT_OF_ORDER_MARGIN : 0),
|
||||||
|
[tableSchema.rowHeight, tableRows]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtualize columns
|
||||||
|
const {
|
||||||
|
virtualItems: virtualCols,
|
||||||
|
totalSize: totalWidth,
|
||||||
|
scrollToIndex: scrollToColIndex,
|
||||||
|
} = useVirtual({
|
||||||
|
parentRef: containerRef,
|
||||||
|
horizontal: true,
|
||||||
|
size: leafColumns.length,
|
||||||
|
overscan: 5,
|
||||||
|
paddingStart: TABLE_PADDING,
|
||||||
|
paddingEnd: TABLE_PADDING,
|
||||||
|
estimateSize: useCallback(
|
||||||
|
(index: number) =>
|
||||||
|
Math.max(
|
||||||
|
MIN_COL_WIDTH,
|
||||||
|
leafColumns[index].columnDef.size || DEFAULT_COL_WIDTH
|
||||||
|
),
|
||||||
|
[leafColumns]
|
||||||
|
),
|
||||||
|
rangeExtractor: useCallback(
|
||||||
|
(range: Range) => {
|
||||||
|
const defaultRange = defaultRangeExtractor(range);
|
||||||
|
const frozenColumns = leafColumns
|
||||||
|
.filter((c) => c.getIsPinned())
|
||||||
|
.map((c) => c.getPinnedIndex());
|
||||||
|
|
||||||
|
const combinedRange = Array.from(
|
||||||
|
new Set([...defaultRange, ...frozenColumns])
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return combinedRange;
|
||||||
|
},
|
||||||
|
[leafColumns]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to selected cell
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCell) return;
|
||||||
|
if (selectedCell.path) {
|
||||||
|
const rowIndex = tableRows.findIndex(
|
||||||
|
(row) => row._rowy_ref.path === selectedCell.path
|
||||||
|
);
|
||||||
|
if (rowIndex > -1) scrollToRowIndex(rowIndex);
|
||||||
|
}
|
||||||
|
if (selectedCell.columnKey) {
|
||||||
|
const colIndex = leafColumns.findIndex(
|
||||||
|
(col) => col.id === selectedCell.columnKey
|
||||||
|
);
|
||||||
|
if (colIndex > -1) scrollToColIndex(colIndex);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
selectedCell,
|
||||||
|
tableRows,
|
||||||
|
leafColumns,
|
||||||
|
scrollToRowIndex,
|
||||||
|
scrollToColIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
|
||||||
|
const paddingBottom =
|
||||||
|
virtualRows.length > 0
|
||||||
|
? totalHeight - (virtualRows?.[virtualRows.length - 1]?.end || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const paddingLeft = virtualCols.length > 0 ? virtualCols?.[0]?.start || 0 : 0;
|
||||||
|
const paddingRight =
|
||||||
|
virtualCols.length > 0
|
||||||
|
? totalWidth - (virtualCols?.[virtualCols.length - 1]?.end || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
virtualRows,
|
||||||
|
totalHeight,
|
||||||
|
scrollToRowIndex,
|
||||||
|
virtualCols,
|
||||||
|
totalWidth,
|
||||||
|
scrollToColIndex,
|
||||||
|
paddingTop,
|
||||||
|
paddingBottom,
|
||||||
|
paddingLeft,
|
||||||
|
paddingRight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useVirtualization;
|
||||||
@@ -17,7 +17,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
|||||||
import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope";
|
import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope";
|
||||||
|
|
||||||
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
|
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
|
||||||
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar/TableToolbar";
|
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
|
||||||
import ErrorFallback from "@src/components/ErrorFallback";
|
import ErrorFallback from "@src/components/ErrorFallback";
|
||||||
import Details from "./Details";
|
import Details from "./Details";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons";
|
|||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import { AirtableConfig } from "@src/components/TableModals/ImportAirtableWizard";
|
import { AirtableConfig } from "@src/components/TableModals/ImportAirtableWizard";
|
||||||
import FadeList from "@src/components/TableModals/ScrollableList";
|
import FadeList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column";
|
import Column, {
|
||||||
|
COLUMN_HEADER_HEIGHT,
|
||||||
|
} from "@src/components/Table/Mock/Column";
|
||||||
import ColumnSelect from "@src/components/Table/ColumnSelect";
|
import ColumnSelect from "@src/components/Table/ColumnSelect";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|||||||
|
|
||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import Cell from "@src/components/Table/Cell";
|
import Cell from "@src/components/Table/Mock/Cell";
|
||||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||||
import { FieldType } from "@src/constants/fields";
|
import { FieldType } from "@src/constants/fields";
|
||||||
import { SELECTABLE_TYPES } from "@src/components/TableModals/ImportExistingWizard/utils";
|
import { SELECTABLE_TYPES } from "@src/components/TableModals/ImportExistingWizard/utils";
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useAtom } from "jotai";
|
|||||||
import { find } from "lodash-es";
|
import { find } from "lodash-es";
|
||||||
|
|
||||||
import { styled, Grid } from "@mui/material";
|
import { styled, Grid } from "@mui/material";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import Cell from "@src/components/Table/Cell";
|
import Cell from "@src/components/Table/Mock/Cell";
|
||||||
|
|
||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons";
|
|||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import { CsvConfig } from "@src/components/TableModals/ImportCsvWizard";
|
import { CsvConfig } from "@src/components/TableModals/ImportCsvWizard";
|
||||||
import FadeList from "@src/components/TableModals/ScrollableList";
|
import FadeList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column";
|
import Column, {
|
||||||
|
COLUMN_HEADER_HEIGHT,
|
||||||
|
} from "@src/components/Table/Mock/Column";
|
||||||
import ColumnSelect from "@src/components/Table/ColumnSelect";
|
import ColumnSelect from "@src/components/Table/ColumnSelect";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|||||||
|
|
||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import Cell from "@src/components/Table/Cell";
|
import Cell from "@src/components/Table/Mock/Cell";
|
||||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||||
|
|
||||||
import { FieldType } from "@src/constants/fields";
|
import { FieldType } from "@src/constants/fields";
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { find } from "lodash-es";
|
|||||||
import { parseJSON } from "date-fns";
|
import { parseJSON } from "date-fns";
|
||||||
|
|
||||||
import { styled, Grid } from "@mui/material";
|
import { styled, Grid } from "@mui/material";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import Cell from "@src/components/Table/Cell";
|
import Cell from "@src/components/Table/Mock/Cell";
|
||||||
|
|
||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import DragHandleIcon from "@mui/icons-material/DragHandle";
|
|||||||
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
||||||
|
|
||||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import EmptyState from "@src/components/EmptyState";
|
import EmptyState from "@src/components/EmptyState";
|
||||||
|
|
||||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import DoneIcon from "@mui/icons-material/Done";
|
|||||||
|
|
||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column";
|
import Column, {
|
||||||
|
COLUMN_HEADER_HEIGHT,
|
||||||
|
} from "@src/components/Table/Mock/Column";
|
||||||
|
|
||||||
export default function Step2Rename({
|
export default function Step2Rename({
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|||||||
|
|
||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import Cell from "@src/components/Table/Cell";
|
import Cell from "@src/components/Table/Mock/Cell";
|
||||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||||
|
|
||||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useAtom } from "jotai";
|
|||||||
import { IStepProps } from ".";
|
import { IStepProps } from ".";
|
||||||
|
|
||||||
import { styled, Grid } from "@mui/material";
|
import { styled, Grid } from "@mui/material";
|
||||||
import Column from "@src/components/Table/Column";
|
import Column from "@src/components/Table/Mock/Column";
|
||||||
import Cell from "@src/components/Table/Cell";
|
import Cell from "@src/components/Table/Mock/Cell";
|
||||||
|
|
||||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@src/atoms/tableScope";
|
} from "@src/atoms/tableScope";
|
||||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||||
|
|
||||||
const ROW_HEIGHTS = [33, 41, 65, 97, 129, 161];
|
const ROW_HEIGHTS = [32, 40, 64, 96, 128, 160].map((x) => x + 1);
|
||||||
|
|
||||||
export default function RowHeight() {
|
export default function RowHeight() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const getStateIcon = (actionState: "undo" | "redo" | string, config: any) => {
|
|||||||
export interface IActionFabProps extends Partial<FabProps> {
|
export interface IActionFabProps extends Partial<FabProps> {
|
||||||
row: any;
|
row: any;
|
||||||
column: any;
|
column: any;
|
||||||
onSubmit: (value: any) => void;
|
|
||||||
value: any;
|
value: any;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -59,7 +58,6 @@ export interface IActionFabProps extends Partial<FabProps> {
|
|||||||
export default function ActionFab({
|
export default function ActionFab({
|
||||||
row,
|
row,
|
||||||
column,
|
column,
|
||||||
onSubmit,
|
|
||||||
value,
|
value,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { IBasicCellProps } from "@src/components/fields/types";
|
|
||||||
|
|
||||||
export default function Action({ name, value }: IBasicCellProps) {
|
|
||||||
return <>{value ? value.status : name}</>;
|
|
||||||
}
|
|
||||||
29
src/components/fields/Action/DisplayCell.tsx
Normal file
29
src/components/fields/Action/DisplayCell.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
import { get } from "lodash-es";
|
||||||
|
import { sanitiseCallableName, isUrl } from "./utils";
|
||||||
|
|
||||||
|
export const getActionName = (column: IDisplayCellProps["column"]) => {
|
||||||
|
const config = get(column, "config");
|
||||||
|
if (!get(config, "customName.enabled")) {
|
||||||
|
return get(column, "name");
|
||||||
|
}
|
||||||
|
return get(config, "customName.actionName") || get(column, "name");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Action({ value, column }: IDisplayCellProps) {
|
||||||
|
const hasRan = value && ![null, undefined].includes(value.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "0 var(--cell-padding)" }}>
|
||||||
|
{hasRan && isUrl(value.status) ? (
|
||||||
|
<a href={value.status} target="_blank" rel="noopener noreferrer">
|
||||||
|
{value.status}
|
||||||
|
</a>
|
||||||
|
) : hasRan ? (
|
||||||
|
value.status
|
||||||
|
) : (
|
||||||
|
sanitiseCallableName(getActionName(column))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,25 @@
|
|||||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
import { IEditorCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
|
|
||||||
import ActionFab from "./ActionFab";
|
import ActionFab from "./ActionFab";
|
||||||
import { sanitiseCallableName, isUrl } from "./utils";
|
import { sanitiseCallableName, isUrl } from "./utils";
|
||||||
import { get } from "lodash-es";
|
import { getActionName } from "./DisplayCell";
|
||||||
|
|
||||||
|
|
||||||
export const getActionName = (column: any) => {
|
|
||||||
const config = get(column, "config")
|
|
||||||
if (!get(config, "customName.enabled")) { return get(column, "name") }
|
|
||||||
return get(config, "customName.actionName") || get(column, "name");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Action({
|
export default function Action({
|
||||||
column,
|
column,
|
||||||
row,
|
row,
|
||||||
value,
|
value,
|
||||||
onSubmit,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: IHeavyCellProps) {
|
tabIndex,
|
||||||
|
}: IEditorCellProps) {
|
||||||
const hasRan = value && ![null, undefined].includes(value.status);
|
const hasRan = value && ![null, undefined].includes(value.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
className="cell-collapse-padding"
|
sx={{ padding: "var(--cell-padding)", pr: 0.5, width: "100%" }}
|
||||||
sx={{ padding: "var(--cell-padding)", pr: 0.5 }}
|
|
||||||
>
|
>
|
||||||
<div style={{ flexGrow: 1, overflow: "hidden" }}>
|
<div style={{ flexGrow: 1, overflow: "hidden" }}>
|
||||||
{hasRan && isUrl(value.status) ? (
|
{hasRan && isUrl(value.status) ? (
|
||||||
@@ -44,9 +36,9 @@ export default function Action({
|
|||||||
<ActionFab
|
<ActionFab
|
||||||
row={row}
|
row={row}
|
||||||
column={column}
|
column={column}
|
||||||
onSubmit={onSubmit}
|
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
tabIndex={tabIndex}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -303,7 +303,9 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
|||||||
aria-label="Action will run"
|
aria-label="Action will run"
|
||||||
name="isActionScript"
|
name="isActionScript"
|
||||||
value={
|
value={
|
||||||
config.isActionScript !== false ? "actionScript" : "cloudFunction"
|
config.isActionScript !== false
|
||||||
|
? "actionScript"
|
||||||
|
: "cloudFunction"
|
||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange("isActionScript")(
|
onChange("isActionScript")(
|
||||||
@@ -559,45 +561,45 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
|||||||
title: "Customization",
|
title: "Customization",
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<Stack>
|
<Stack>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={config.customName?.enabled}
|
checked={config.customName?.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("customName.enabled")(e.target.checked)
|
||||||
|
}
|
||||||
|
name="customName.enabled"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Customize label for action"
|
||||||
|
style={{ marginLeft: -11 }}
|
||||||
|
/>
|
||||||
|
{config.customName?.enabled && (
|
||||||
|
<TextField
|
||||||
|
id="customName.actionName"
|
||||||
|
value={get(config, "customName.actionName")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange("customName.enabled")(e.target.checked)
|
onChange("customName.actionName")(e.target.value)
|
||||||
}
|
}
|
||||||
name="customName.enabled"
|
label="Action name:"
|
||||||
/>
|
className="labelHorizontal"
|
||||||
}
|
inputProps={{ style: { width: "10ch" } }}
|
||||||
label="Customize label for action"
|
></TextField>
|
||||||
style={{ marginLeft: -11 }}
|
)}
|
||||||
/>
|
<FormControlLabel
|
||||||
{config.customName?.enabled && (
|
control={
|
||||||
<TextField
|
<Checkbox
|
||||||
id="customName.actionName"
|
checked={config.customIcons?.enabled}
|
||||||
value={get(config, "customName.actionName")}
|
onChange={(e) =>
|
||||||
onChange={(e) =>
|
onChange("customIcons.enabled")(e.target.checked)
|
||||||
onChange("customName.actionName")(e.target.value)
|
}
|
||||||
}
|
name="customIcons.enabled"
|
||||||
label="Action name:"
|
/>
|
||||||
className="labelHorizontal"
|
}
|
||||||
inputProps={{ style: { width: "10ch" } }}
|
label="Customize button icons with emoji"
|
||||||
></TextField>
|
style={{ marginLeft: -11 }}
|
||||||
)}
|
/>
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={config.customIcons?.enabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("customIcons.enabled")(e.target.checked)
|
|
||||||
}
|
|
||||||
name="customIcons.enabled"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Customize button icons with emoji"
|
|
||||||
style={{ marginLeft: -11 }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{config.customIcons?.enabled && (
|
{config.customIcons?.enabled && (
|
||||||
<Grid container spacing={2} sx={{ mt: { xs: 0, sm: -1 } }}>
|
<Grid container spacing={2} sx={{ mt: { xs: 0, sm: -1 } }}>
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import ActionFab from "./ActionFab";
|
|||||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||||
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||||
import { sanitiseCallableName, isUrl } from "./utils";
|
import { sanitiseCallableName, isUrl } from "./utils";
|
||||||
import { getActionName } from "./TableCell"
|
import { getActionName } from "./DisplayCell";
|
||||||
|
|
||||||
export default function Action({
|
export default function Action({
|
||||||
column,
|
column,
|
||||||
_rowy_ref,
|
_rowy_ref,
|
||||||
value,
|
value,
|
||||||
onChange,
|
|
||||||
onSubmit,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: ISideDrawerFieldProps) {
|
}: ISideDrawerFieldProps) {
|
||||||
const [row] = useAtom(
|
const [row] = useAtom(
|
||||||
@@ -68,10 +66,6 @@ export default function Action({
|
|||||||
<ActionFab
|
<ActionFab
|
||||||
row={row}
|
row={row}
|
||||||
column={column}
|
column={column}
|
||||||
onSubmit={(value) => {
|
|
||||||
onChange(value);
|
|
||||||
onSubmit();
|
|
||||||
}}
|
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
id={getFieldId(column.key)}
|
id={getFieldId(column.key)}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import ActionIcon from "@mui/icons-material/TouchAppOutlined";
|
import ActionIcon from "@mui/icons-material/TouchAppOutlined";
|
||||||
import BasicCell from "./BasicCell";
|
import DisplayCell from "./DisplayCell";
|
||||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
|
||||||
|
|
||||||
const TableCell = lazy(
|
const EditorCell = lazy(
|
||||||
() => import("./TableCell" /* webpackChunkName: "TableCell-Action" */)
|
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Action" */)
|
||||||
);
|
);
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -25,8 +24,9 @@ export const config: IFieldConfig = {
|
|||||||
icon: <ActionIcon />,
|
icon: <ActionIcon />,
|
||||||
description:
|
description:
|
||||||
"Button with pre-defined action script or triggers a Cloud Function. Optionally supports Undo and Redo.",
|
"Button with pre-defined action script or triggers a Cloud Function. Optionally supports Undo and Redo.",
|
||||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", {
|
||||||
TableEditor: NullEditor as any,
|
disablePadding: true,
|
||||||
|
}),
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
requireConfiguration: true,
|
requireConfiguration: true,
|
||||||
|
|||||||
41
src/components/fields/Checkbox/DisplayCell.tsx
Normal file
41
src/components/fields/Checkbox/DisplayCell.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
|
import { FormControlLabel, Switch } from "@mui/material";
|
||||||
|
|
||||||
|
export default function Checkbox({ column, value }: IDisplayCellProps) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={!!value}
|
||||||
|
color="success"
|
||||||
|
tabIndex={-1}
|
||||||
|
readOnly
|
||||||
|
sx={{
|
||||||
|
pointerEvents: "none",
|
||||||
|
"& .MuiSwitch-thumb:active": { transform: "none" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={column.name as string}
|
||||||
|
labelPlacement="start"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
|
||||||
|
cursor: "default",
|
||||||
|
|
||||||
|
"& .MuiFormControlLabel-label": {
|
||||||
|
font: "inherit",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
flexGrow: 1,
|
||||||
|
overflowX: "hidden",
|
||||||
|
mt: "0 !important",
|
||||||
|
},
|
||||||
|
|
||||||
|
"& .MuiSwitch-root": { mr: -0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
import { IEditorCellProps } from "@src/components/fields/types";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { get } from "lodash-es";
|
import { get } from "lodash-es";
|
||||||
|
|
||||||
@@ -15,9 +15,11 @@ export default function Checkbox({
|
|||||||
row,
|
row,
|
||||||
column,
|
column,
|
||||||
value,
|
value,
|
||||||
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
disabled,
|
disabled,
|
||||||
}: IHeavyCellProps) {
|
tabIndex,
|
||||||
|
}: IEditorCellProps) {
|
||||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
@@ -28,10 +30,14 @@ export default function Checkbox({
|
|||||||
/\{\{(.*?)\}\}/g,
|
/\{\{(.*?)\}\}/g,
|
||||||
replacer(row)
|
replacer(row)
|
||||||
),
|
),
|
||||||
handleConfirm: () => onSubmit(!value),
|
handleConfirm: () => {
|
||||||
|
onChange(!value);
|
||||||
|
onSubmit();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
onSubmit(!value);
|
onChange(!value);
|
||||||
|
onSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +49,7 @@ export default function Checkbox({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
color="success"
|
color="success"
|
||||||
|
tabIndex={tabIndex}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={column.name as string}
|
label={column.name as string}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined";
|
import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined";
|
||||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellName";
|
import DisplayCell from "./DisplayCell";
|
||||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
|
||||||
|
|
||||||
const TableCell = lazy(
|
const EditorCell = lazy(
|
||||||
() => import("./TableCell" /* webpackChunkName: "TableCell-Checkbox" */)
|
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Checkbox" */)
|
||||||
);
|
);
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -25,8 +24,9 @@ export const config: IFieldConfig = {
|
|||||||
initializable: true,
|
initializable: true,
|
||||||
icon: <CheckboxIcon />,
|
icon: <CheckboxIcon />,
|
||||||
description: "True/false value. Default: false.",
|
description: "True/false value. Default: false.",
|
||||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", {
|
||||||
TableEditor: NullEditor as any,
|
usesRowData: true,
|
||||||
|
}),
|
||||||
csvImportParser: (value: string) => {
|
csvImportParser: (value: string) => {
|
||||||
if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true;
|
if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true;
|
||||||
else if (["NO", "FALSE", "0"].includes(value.toUpperCase())) return false;
|
else if (["NO", "FALSE", "0"].includes(value.toUpperCase())) return false;
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { IBasicCellProps } from "@src/components/fields/types";
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
|
|
||||||
export default function Code({ value }: IBasicCellProps) {
|
export default function Code({ value }: IDisplayCellProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
padding: theme.spacing(3 / 8, 0),
|
padding: "3px 0",
|
||||||
|
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
lineHeight: theme.typography.body2.lineHeight,
|
lineHeight: theme.typography.body2.lineHeight,
|
||||||
fontFamily: theme.typography.fontFamilyMono,
|
fontFamily: theme.typography.fontFamilyMono,
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
|
tabSize: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value.substring(0, 1000)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import CodeIcon from "@mui/icons-material/Code";
|
import CodeIcon from "@mui/icons-material/Code";
|
||||||
import BasicCell from "./BasicCell";
|
import DisplayCell from "./DisplayCell";
|
||||||
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
|
|
||||||
|
|
||||||
const Settings = lazy(
|
const Settings = lazy(
|
||||||
() => import("./Settings" /* webpackChunkName: "Settings-Code" */)
|
() => import("./Settings" /* webpackChunkName: "Settings-Code" */)
|
||||||
@@ -24,8 +23,12 @@ export const config: IFieldConfig = {
|
|||||||
initializable: true,
|
initializable: true,
|
||||||
icon: <CodeIcon />,
|
icon: <CodeIcon />,
|
||||||
description: "Raw code edited with the Monaco Editor.",
|
description: "Raw code edited with the Monaco Editor.",
|
||||||
TableCell: withBasicCell(BasicCell),
|
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", {
|
||||||
TableEditor: withSideDrawerEditor(BasicCell),
|
popoverProps: {
|
||||||
|
anchorOrigin: { vertical: "top", horizontal: "center" },
|
||||||
|
PaperProps: { sx: { borderRadius: 1 } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
};
|
};
|
||||||
|
|||||||
60
src/components/fields/Color/DisplayCell.tsx
Normal file
60
src/components/fields/Color/DisplayCell.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
|
import { ButtonBase, Box } from "@mui/material";
|
||||||
|
import { ChevronDown } from "@src/assets/icons";
|
||||||
|
|
||||||
|
export default function Color({
|
||||||
|
value,
|
||||||
|
showPopoverCell,
|
||||||
|
disabled,
|
||||||
|
tabIndex,
|
||||||
|
}: IDisplayCellProps) {
|
||||||
|
const rendered = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingLeft: "var(--cell-padding)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value?.hex && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
mr: 1,
|
||||||
|
|
||||||
|
backgroundColor: value.hex,
|
||||||
|
boxShadow: (theme) => `0 0 0 1px ${theme.palette.divider} inset`,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value?.hex}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disabled) return rendered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={() => showPopoverCell(true)}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
}}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
>
|
||||||
|
{rendered}
|
||||||
|
<ChevronDown className="row-hover-iconButton end" />
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/fields/Color/EditorCell.tsx
Normal file
19
src/components/fields/Color/EditorCell.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IEditorCellProps } from "@src/components/fields/types";
|
||||||
|
import { ColorPicker, toColor } from "react-color-palette";
|
||||||
|
import "react-color-palette/lib/css/styles.css";
|
||||||
|
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
export default function Color({ value, onChange }: IEditorCellProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ "& .rcp": { border: 0 } }}>
|
||||||
|
<ColorPicker
|
||||||
|
width={240}
|
||||||
|
height={180}
|
||||||
|
color={value?.hex ? toColor("hex", value.hex) : toColor("hex", "#fff")}
|
||||||
|
onChange={onChange}
|
||||||
|
alpha
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import { IPopoverInlineCellProps } from "@src/components/fields/types";
|
|
||||||
|
|
||||||
import { ButtonBase, Box } from "@mui/material";
|
|
||||||
|
|
||||||
export const Color = forwardRef(function Color(
|
|
||||||
{ value, showPopoverCell, disabled }: IPopoverInlineCellProps,
|
|
||||||
ref: React.Ref<any>
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<ButtonBase
|
|
||||||
onClick={() => showPopoverCell(true)}
|
|
||||||
ref={ref}
|
|
||||||
disabled={disabled}
|
|
||||||
className="cell-collapse-padding"
|
|
||||||
sx={{
|
|
||||||
font: "inherit",
|
|
||||||
letterSpacing: "inherit",
|
|
||||||
p: "var(--cell-padding)",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
mr: 1,
|
|
||||||
|
|
||||||
backgroundColor: value?.hex,
|
|
||||||
boxShadow: (theme) => `0 0 0 1px ${theme.palette.divider} inset`,
|
|
||||||
borderRadius: 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value?.hex}
|
|
||||||
</ButtonBase>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Color;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { IPopoverCellProps } from "@src/components/fields/types";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import { ColorPicker, toColor } from "react-color-palette";
|
|
||||||
import "react-color-palette/lib/css/styles.css";
|
|
||||||
|
|
||||||
import { Box } from "@mui/material";
|
|
||||||
|
|
||||||
export default function Color({ value, onSubmit }: IPopoverCellProps) {
|
|
||||||
const [localValue, setLocalValue] = useState(value);
|
|
||||||
const handleChangeComplete = useDebouncedCallback((color) => {
|
|
||||||
onSubmit(color);
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleChangeComplete(localValue);
|
|
||||||
}, [localValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ "& .rcp": { border: 0 } }}>
|
|
||||||
<ColorPicker
|
|
||||||
width={240}
|
|
||||||
height={180}
|
|
||||||
color={localValue?.hex ? localValue : toColor("hex", "#fff")}
|
|
||||||
onChange={setLocalValue}
|
|
||||||
alpha
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,9 @@ export default function Color({
|
|||||||
<ColorPicker
|
<ColorPicker
|
||||||
width={440}
|
width={440}
|
||||||
height={180}
|
height={180}
|
||||||
color={value?.hex ? value : toColor("hex", "#fff")}
|
color={
|
||||||
|
value?.hex ? toColor("hex", value.hex) : toColor("hex", "#fff")
|
||||||
|
}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onChangeComplete={onSubmit}
|
onChangeComplete={onSubmit}
|
||||||
alpha
|
alpha
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ export const filterOperators: IFilterOperator[] = [
|
|||||||
{
|
{
|
||||||
label: "is",
|
label: "is",
|
||||||
secondaryLabel: "==",
|
secondaryLabel: "==",
|
||||||
value: "color-equal"
|
value: "color-equal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "is not",
|
label: "is not",
|
||||||
secondaryLabel: "!=",
|
secondaryLabel: "!=",
|
||||||
value: "color-not-equal"
|
value: "color-not-equal",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const valueFormatter = (value: any) => {
|
export const valueFormatter = (value: any) => {
|
||||||
if (value && value.hex) {
|
if (value && value.hex) {
|
||||||
return value.hex.toString()
|
return value.hex.toString();
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
import { toColor } from "react-color-palette";
|
import { toColor } from "react-color-palette";
|
||||||
|
|
||||||
import ColorIcon from "@mui/icons-material/Colorize";
|
import ColorIcon from "@mui/icons-material/Colorize";
|
||||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
import DisplayCell from "./DisplayCell";
|
||||||
import InlineCell from "./InlineCell";
|
|
||||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
|
||||||
import { filterOperators, valueFormatter } from "./filters";
|
import { filterOperators, valueFormatter } from "./filters";
|
||||||
|
|
||||||
const PopoverCell = lazy(
|
const EditorCell = lazy(
|
||||||
() => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */)
|
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */)
|
||||||
);
|
);
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -27,15 +25,10 @@ export const config: IFieldConfig = {
|
|||||||
icon: <ColorIcon />,
|
icon: <ColorIcon />,
|
||||||
description:
|
description:
|
||||||
"Color stored as Hex, RGB, and HSV. Edited with a visual picker.",
|
"Color stored as Hex, RGB, and HSV. Edited with a visual picker.",
|
||||||
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
|
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
disablePadding: true,
|
||||||
}),
|
}),
|
||||||
TableEditor: NullEditor as any,
|
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
filter: {
|
|
||||||
operators: filterOperators,
|
|
||||||
valueFormatter
|
|
||||||
},
|
|
||||||
csvImportParser: (value: string) => {
|
csvImportParser: (value: string) => {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(value);
|
const obj = JSON.parse(value);
|
||||||
|
|||||||
51
src/components/fields/ConnectService/DisplayCell.tsx
Normal file
51
src/components/fields/ConnectService/DisplayCell.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
|
import { ButtonBase, Grid, Chip } from "@mui/material";
|
||||||
|
import { ChevronDown } from "@src/assets/icons";
|
||||||
|
|
||||||
|
import ChipList from "@src/components/Table/TableCell/ChipList";
|
||||||
|
import { get } from "lodash-es";
|
||||||
|
|
||||||
|
export default function ConnectService({
|
||||||
|
value,
|
||||||
|
showPopoverCell,
|
||||||
|
disabled,
|
||||||
|
column,
|
||||||
|
tabIndex,
|
||||||
|
rowHeight,
|
||||||
|
}: IDisplayCellProps) {
|
||||||
|
const config = column.config ?? {};
|
||||||
|
const displayKey = config.titleKey ?? config.primaryKey;
|
||||||
|
|
||||||
|
const rendered = (
|
||||||
|
<ChipList rowHeight={rowHeight}>
|
||||||
|
{Array.isArray(value) &&
|
||||||
|
value.map((snapshot) => (
|
||||||
|
<Grid item key={get(snapshot, config.primaryKey)}>
|
||||||
|
<Chip label={get(snapshot, displayKey)} size="small" />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</ChipList>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disabled) return rendered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={() => showPopoverCell(true)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit !important",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
textAlign: "inherit",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
}}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
>
|
||||||
|
{rendered}
|
||||||
|
<ChevronDown className="row-hover-iconButton end" />
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
import { IPopoverCellProps } from "@src/components/fields/types";
|
import { IEditorCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
import ConnectServiceSelect from "./ConnectServiceSelect";
|
import ConnectServiceSelect from "./ConnectServiceSelect";
|
||||||
|
|
||||||
export default function ConnectService({
|
export default function ConnectService({
|
||||||
value,
|
value,
|
||||||
onSubmit,
|
onChange,
|
||||||
column,
|
column,
|
||||||
parentRef,
|
parentRef,
|
||||||
showPopoverCell,
|
showPopoverCell,
|
||||||
disabled,
|
disabled,
|
||||||
docRef,
|
_rowy_ref,
|
||||||
}: IPopoverCellProps) {
|
}: IEditorCellProps) {
|
||||||
const config = column.config ?? {};
|
const config = column.config ?? {};
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConnectServiceSelect
|
<ConnectServiceSelect
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onSubmit}
|
onChange={onChange}
|
||||||
config={(config as any) ?? {}}
|
config={(config as any) ?? {}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
docRef={docRef as any}
|
docRef={_rowy_ref as any}
|
||||||
TextFieldProps={{
|
TextFieldProps={{
|
||||||
style: { display: "none" },
|
style: { display: "none" },
|
||||||
SelectProps: {
|
SelectProps: {
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import { IPopoverInlineCellProps } from "@src/components/fields/types";
|
|
||||||
|
|
||||||
import { ButtonBase, Grid, Chip } from "@mui/material";
|
|
||||||
import { ChevronDown } from "@src/assets/icons";
|
|
||||||
|
|
||||||
import ChipList from "@src/components/Table/formatters/ChipList";
|
|
||||||
import { get } from "lodash-es";
|
|
||||||
|
|
||||||
export const ConnectService = forwardRef(function ConnectService(
|
|
||||||
{ value, showPopoverCell, disabled, column }: IPopoverInlineCellProps,
|
|
||||||
ref: React.Ref<any>
|
|
||||||
) {
|
|
||||||
const config = column.config ?? {};
|
|
||||||
const displayKey = config.titleKey ?? config.primaryKey;
|
|
||||||
return (
|
|
||||||
<ButtonBase
|
|
||||||
onClick={() => showPopoverCell(true)}
|
|
||||||
ref={ref}
|
|
||||||
disabled={disabled}
|
|
||||||
className="cell-collapse-padding"
|
|
||||||
sx={{
|
|
||||||
height: "100%",
|
|
||||||
font: "inherit",
|
|
||||||
color: "inherit !important",
|
|
||||||
letterSpacing: "inherit",
|
|
||||||
textAlign: "inherit",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChipList>
|
|
||||||
{Array.isArray(value) &&
|
|
||||||
value.map((snapshot) => (
|
|
||||||
<Grid item key={get(snapshot, config.primaryKey)}>
|
|
||||||
<Chip label={get(snapshot, displayKey)} size="small" />
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</ChipList>
|
|
||||||
|
|
||||||
{!disabled && (
|
|
||||||
<ChevronDown
|
|
||||||
className="row-hover-iconButton"
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
mr: 0.5,
|
|
||||||
borderRadius: 1,
|
|
||||||
p: (32 - 20) / 2 / 8,
|
|
||||||
boxSizing: "content-box !important",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ButtonBase>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ConnectService;
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import ConnectServiceIcon from "@mui/icons-material/Http";
|
import ConnectServiceIcon from "@mui/icons-material/Http";
|
||||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
import DisplayCell from "./DisplayCell";
|
||||||
import InlineCell from "./InlineCell";
|
|
||||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
|
||||||
|
|
||||||
const PopoverCell = lazy(
|
const EditorCell = lazy(
|
||||||
() =>
|
() =>
|
||||||
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */)
|
import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */)
|
||||||
);
|
);
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -30,11 +28,10 @@ export const config: IFieldConfig = {
|
|||||||
icon: <ConnectServiceIcon />,
|
icon: <ConnectServiceIcon />,
|
||||||
description:
|
description:
|
||||||
"Connects to an external web service to fetch a list of results.",
|
"Connects to an external web service to fetch a list of results.",
|
||||||
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
|
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
disablePadding: true,
|
||||||
transparent: true,
|
transparentPopover: true,
|
||||||
}),
|
}),
|
||||||
TableEditor: NullEditor as any,
|
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
requireConfiguration: true,
|
requireConfiguration: true,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|||||||
62
src/components/fields/ConnectTable/DisplayCell.tsx
Normal file
62
src/components/fields/ConnectTable/DisplayCell.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
|
import { ButtonBase, Grid, Chip } from "@mui/material";
|
||||||
|
import { ChevronDown } from "@src/assets/icons";
|
||||||
|
|
||||||
|
import ChipList from "@src/components/Table/TableCell/ChipList";
|
||||||
|
|
||||||
|
export default function ConnectTable({
|
||||||
|
value,
|
||||||
|
showPopoverCell,
|
||||||
|
disabled,
|
||||||
|
column,
|
||||||
|
tabIndex,
|
||||||
|
rowHeight,
|
||||||
|
}: IDisplayCellProps) {
|
||||||
|
const config = column.config ?? {};
|
||||||
|
|
||||||
|
const rendered = (
|
||||||
|
<ChipList rowHeight={rowHeight}>
|
||||||
|
{Array.isArray(value) ? (
|
||||||
|
value.map((item: any) => (
|
||||||
|
<Grid item key={item.docPath}>
|
||||||
|
<Chip
|
||||||
|
label={(config.primaryKeys ?? [])
|
||||||
|
.map((key: string) => item.snapshot[key])
|
||||||
|
.join(" ")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
) : value ? (
|
||||||
|
<Grid item>
|
||||||
|
<Chip
|
||||||
|
label={(config.primaryKeys ?? [])
|
||||||
|
.map((key: string) => value.snapshot[key])
|
||||||
|
.join(" ")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
) : null}
|
||||||
|
</ChipList>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disabled) return rendered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={() => showPopoverCell(true)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit !important",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
textAlign: "inherit",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
}}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
>
|
||||||
|
{rendered}
|
||||||
|
<ChevronDown className="row-hover-iconButton end" />
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { IPopoverCellProps } from "@src/components/fields/types";
|
import { IEditorCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
import ConnectTableSelect from "./ConnectTableSelect";
|
import ConnectTableSelect from "./ConnectTableSelect";
|
||||||
|
|
||||||
export default function ConnectTable({
|
export default function ConnectTable({
|
||||||
value,
|
value,
|
||||||
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
column,
|
column,
|
||||||
parentRef,
|
parentRef,
|
||||||
showPopoverCell,
|
showPopoverCell,
|
||||||
row,
|
row,
|
||||||
disabled,
|
disabled,
|
||||||
}: IPopoverCellProps) {
|
}: IEditorCellProps) {
|
||||||
const config = column.config ?? {};
|
const config = column.config ?? {};
|
||||||
if (!config || !config.primaryKeys) return null;
|
if (!config || !config.primaryKeys) return null;
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export default function ConnectTable({
|
|||||||
row={row}
|
row={row}
|
||||||
column={column}
|
column={column}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onSubmit}
|
onChange={onChange}
|
||||||
config={(config as any) ?? {}}
|
config={(config as any) ?? {}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
TextFieldProps={{
|
TextFieldProps={{
|
||||||
@@ -33,7 +34,10 @@ export default function ConnectTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClose={() => showPopoverCell(false)}
|
onClose={() => {
|
||||||
|
showPopoverCell(false);
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
loadBeforeOpen
|
loadBeforeOpen
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import { IPopoverInlineCellProps } from "@src/components/fields/types";
|
|
||||||
|
|
||||||
import { ButtonBase, Grid, Chip } from "@mui/material";
|
|
||||||
import { ChevronDown } from "@src/assets/icons";
|
|
||||||
|
|
||||||
import ChipList from "@src/components/Table/formatters/ChipList";
|
|
||||||
|
|
||||||
export const ConnectTable = forwardRef(function ConnectTable(
|
|
||||||
{ value, showPopoverCell, disabled, column }: IPopoverInlineCellProps,
|
|
||||||
ref: React.Ref<any>
|
|
||||||
) {
|
|
||||||
const config = column.config ?? {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonBase
|
|
||||||
onClick={() => showPopoverCell(true)}
|
|
||||||
ref={ref}
|
|
||||||
disabled={disabled}
|
|
||||||
className="cell-collapse-padding"
|
|
||||||
sx={{
|
|
||||||
height: "100%",
|
|
||||||
font: "inherit",
|
|
||||||
color: "inherit !important",
|
|
||||||
letterSpacing: "inherit",
|
|
||||||
textAlign: "inherit",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChipList>
|
|
||||||
{Array.isArray(value) ? (
|
|
||||||
value.map((item: any) => (
|
|
||||||
<Grid item key={item.docPath}>
|
|
||||||
<Chip
|
|
||||||
label={config.primaryKeys
|
|
||||||
.map((key: string) => item.snapshot[key])
|
|
||||||
.join(" ")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
))
|
|
||||||
) : value ? (
|
|
||||||
<Grid item>
|
|
||||||
<Chip
|
|
||||||
label={config.primaryKeys
|
|
||||||
.map((key: string) => value.snapshot[key])
|
|
||||||
.join(" ")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
) : null}
|
|
||||||
</ChipList>
|
|
||||||
|
|
||||||
{!disabled && (
|
|
||||||
<ChevronDown
|
|
||||||
className="row-hover-iconButton"
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
mr: 0.5,
|
|
||||||
borderRadius: 1,
|
|
||||||
p: (32 - 20) / 2 / 8,
|
|
||||||
boxSizing: "content-box !important",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ButtonBase>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ConnectTable;
|
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import { ConnectTable as ConnectTableIcon } from "@src/assets/icons";
|
import { ConnectTable as ConnectTableIcon } from "@src/assets/icons";
|
||||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
import DisplayCell from "./DisplayCell";
|
||||||
import InlineCell from "./InlineCell";
|
|
||||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
|
||||||
|
|
||||||
const PopoverCell = lazy(
|
const EditorCell = lazy(
|
||||||
() =>
|
() => import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectTable" */)
|
||||||
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectTable" */)
|
|
||||||
);
|
);
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -31,11 +28,10 @@ export const config: IFieldConfig = {
|
|||||||
icon: <ConnectTableIcon />,
|
icon: <ConnectTableIcon />,
|
||||||
description:
|
description:
|
||||||
"Connects to an existing table to fetch a snapshot of values from a row. Requires Rowy Run and Algolia setup.",
|
"Connects to an existing table to fetch a snapshot of values from a row. Requires Rowy Run and Algolia setup.",
|
||||||
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
|
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
disablePadding: true,
|
||||||
transparent: true,
|
transparentPopover: true,
|
||||||
}),
|
}),
|
||||||
TableEditor: NullEditor as any,
|
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
requireConfiguration: true,
|
requireConfiguration: true,
|
||||||
|
|||||||
49
src/components/fields/Connector/DisplayCell.tsx
Normal file
49
src/components/fields/Connector/DisplayCell.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
|
import { ButtonBase, Grid, Chip } from "@mui/material";
|
||||||
|
import { ChevronDown } from "@src/assets/icons";
|
||||||
|
|
||||||
|
import ChipList from "@src/components/Table/TableCell/ChipList";
|
||||||
|
import { get } from "lodash-es";
|
||||||
|
import { getLabel } from "./utils";
|
||||||
|
|
||||||
|
export default function Connector({
|
||||||
|
value,
|
||||||
|
showPopoverCell,
|
||||||
|
disabled,
|
||||||
|
column,
|
||||||
|
tabIndex,
|
||||||
|
rowHeight,
|
||||||
|
}: IDisplayCellProps) {
|
||||||
|
const rendered = (
|
||||||
|
<ChipList rowHeight={rowHeight}>
|
||||||
|
{Array.isArray(value) &&
|
||||||
|
value.map((item) => (
|
||||||
|
<Grid item key={get(item, column.config?.id)}>
|
||||||
|
<Chip label={getLabel(column.config, item)} size="small" />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</ChipList>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disabled) return rendered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={() => showPopoverCell(true)}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit !important",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
textAlign: "inherit",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
}}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
>
|
||||||
|
{rendered}
|
||||||
|
<ChevronDown className="row-hover-iconButton end" />
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/fields/Connector/EditorCell.tsx
Normal file
25
src/components/fields/Connector/EditorCell.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { IEditorCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
|
import PopupContents from "./Select/PopupContents";
|
||||||
|
import Loading from "@src/components/Loading";
|
||||||
|
|
||||||
|
export default function Connector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
column,
|
||||||
|
disabled,
|
||||||
|
_rowy_ref,
|
||||||
|
}: IEditorCellProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PopupContents
|
||||||
|
value={Array.isArray(value) ? value : []}
|
||||||
|
onChange={onChange}
|
||||||
|
column={column}
|
||||||
|
disabled={disabled}
|
||||||
|
_rowy_ref={_rowy_ref}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import { IPopoverInlineCellProps } from "@src/components/fields/types";
|
|
||||||
|
|
||||||
import { ButtonBase, Grid, Chip } from "@mui/material";
|
|
||||||
import { ChevronDown } from "@src/assets/icons";
|
|
||||||
|
|
||||||
import ChipList from "@src/components/Table/formatters/ChipList";
|
|
||||||
import { get } from "lodash-es";
|
|
||||||
import { getLabel } from "./utils";
|
|
||||||
|
|
||||||
export const Connector = forwardRef(function Connector(
|
|
||||||
{ value, showPopoverCell, disabled, column }: IPopoverInlineCellProps,
|
|
||||||
ref: React.Ref<any>
|
|
||||||
) {
|
|
||||||
const config = column.config ?? {};
|
|
||||||
const displayKey = config.titleKey ?? config.primaryKey;
|
|
||||||
return (
|
|
||||||
<ButtonBase
|
|
||||||
onClick={() => showPopoverCell(true)}
|
|
||||||
ref={ref}
|
|
||||||
disabled={disabled}
|
|
||||||
className="cell-collapse-padding"
|
|
||||||
sx={{
|
|
||||||
height: "100%",
|
|
||||||
font: "inherit",
|
|
||||||
color: "inherit !important",
|
|
||||||
letterSpacing: "inherit",
|
|
||||||
textAlign: "inherit",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChipList>
|
|
||||||
{Array.isArray(value) &&
|
|
||||||
value.map((item) => (
|
|
||||||
<Grid item key={get(item, config.id)}>
|
|
||||||
<Chip label={getLabel(config, item)} size="small" />
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</ChipList>
|
|
||||||
|
|
||||||
{!disabled && (
|
|
||||||
<ChevronDown
|
|
||||||
className="row-hover-iconButton"
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
mr: 0.5,
|
|
||||||
borderRadius: 1,
|
|
||||||
p: (32 - 20) / 2 / 8,
|
|
||||||
boxSizing: "content-box !important",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ButtonBase>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Connector;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { IPopoverCellProps } from "@src/components/fields/types";
|
|
||||||
|
|
||||||
import Selector from "./Select";
|
|
||||||
|
|
||||||
export default function ConnectService({
|
|
||||||
value,
|
|
||||||
onSubmit,
|
|
||||||
column,
|
|
||||||
parentRef,
|
|
||||||
showPopoverCell,
|
|
||||||
disabled,
|
|
||||||
docRef,
|
|
||||||
}: IPopoverCellProps) {
|
|
||||||
return (
|
|
||||||
<Selector
|
|
||||||
value={value}
|
|
||||||
onChange={onSubmit}
|
|
||||||
column={column}
|
|
||||||
disabled={disabled}
|
|
||||||
docRef={docRef}
|
|
||||||
TextFieldProps={{
|
|
||||||
style: { display: "none" },
|
|
||||||
SelectProps: {
|
|
||||||
open: true,
|
|
||||||
MenuProps: {
|
|
||||||
anchorEl: parentRef,
|
|
||||||
anchorOrigin: { vertical: "bottom", horizontal: "left" },
|
|
||||||
transformOrigin: { vertical: "top", horizontal: "left" },
|
|
||||||
},
|
|
||||||
onClose: () => showPopoverCell(false),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { get } from "lodash-es";
|
import { get } from "lodash-es";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -7,7 +6,6 @@ import { useAtom } from "jotai";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Divider,
|
|
||||||
Grid,
|
Grid,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
List,
|
List,
|
||||||
@@ -21,7 +19,6 @@ import {
|
|||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
|
||||||
import { IConnectorSelectProps } from ".";
|
import { IConnectorSelectProps } from ".";
|
||||||
import useStyles from "./styles";
|
|
||||||
import Loading from "@src/components/Loading";
|
import Loading from "@src/components/Loading";
|
||||||
import { getLabel } from "@src/components/fields/Connector/utils";
|
import { getLabel } from "@src/components/fields/Connector/utils";
|
||||||
import { useSnackbar } from "notistack";
|
import { useSnackbar } from "notistack";
|
||||||
@@ -37,7 +34,7 @@ export default function PopupContents({
|
|||||||
value = [],
|
value = [],
|
||||||
onChange,
|
onChange,
|
||||||
column,
|
column,
|
||||||
docRef,
|
_rowy_ref,
|
||||||
}: IPopupContentsProps) {
|
}: IPopupContentsProps) {
|
||||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||||
@@ -48,8 +45,6 @@ export default function PopupContents({
|
|||||||
const elementId = config.elementId;
|
const elementId = config.elementId;
|
||||||
const multiple = Boolean(config.multiple);
|
const multiple = Boolean(config.multiple);
|
||||||
|
|
||||||
const { classes } = useStyles();
|
|
||||||
|
|
||||||
// Webservice search query
|
// Webservice search query
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// Webservice response
|
// Webservice response
|
||||||
@@ -75,7 +70,7 @@ export default function PopupContents({
|
|||||||
columnKey: column.key,
|
columnKey: column.key,
|
||||||
query: query,
|
query: query,
|
||||||
schemaDocPath: getTableSchemaPath(tableSettings),
|
schemaDocPath: getTableSchemaPath(tableSettings),
|
||||||
rowDocPath: docRef.path,
|
rowDocPath: _rowy_ref.path,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setResponse(resp);
|
setResponse(resp);
|
||||||
@@ -105,93 +100,88 @@ export default function PopupContents({
|
|||||||
const clearSelection = () => onChange([]);
|
const clearSelection = () => onChange([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container direction="column" className={classes.grid}>
|
<Grid container direction="column" sx={{ p: 1, height: "100%" }}>
|
||||||
<Grid item className={classes.searchRow}>
|
<Grid item>
|
||||||
<TextField
|
<TextField
|
||||||
value={query}
|
value={query}
|
||||||
|
type="search"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="filled"
|
variant="filled"
|
||||||
margin="dense"
|
|
||||||
label="Search items"
|
label="Search items"
|
||||||
className={classes.noMargins}
|
hiddenLabel
|
||||||
|
placeholder="Search items"
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="start">
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
InputLabelProps={{ className: "visually-hidden" }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs className={classes.listRow}>
|
<Grid item xs>
|
||||||
<List className={classes.list}>
|
<List sx={{ overflowY: "auto" }}>
|
||||||
{hits.map((hit) => {
|
{hits.map((hit) => {
|
||||||
const isSelected = selectedValues.some((v) => v === hit[elementId]);
|
const isSelected = selectedValues.some((v) => v === hit[elementId]);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={get(hit, elementId)}>
|
<MenuItem
|
||||||
<MenuItem
|
key={get(hit, elementId)}
|
||||||
dense
|
onClick={isSelected ? deselect(hit) : select(hit)}
|
||||||
onClick={isSelected ? deselect(hit) : select(hit)}
|
disabled={!isSelected && multiple && value.length >= config.max}
|
||||||
disabled={
|
disableGutters
|
||||||
!isSelected && multiple && value.length >= config.max
|
style={{ margin: 0, width: "100%" }}
|
||||||
}
|
>
|
||||||
>
|
<ListItemIcon>
|
||||||
<ListItemIcon className={classes.checkboxContainer}>
|
{multiple ? (
|
||||||
{multiple ? (
|
<Checkbox
|
||||||
<Checkbox
|
edge="start"
|
||||||
edge="start"
|
checked={isSelected}
|
||||||
checked={isSelected}
|
tabIndex={-1}
|
||||||
tabIndex={-1}
|
color="secondary"
|
||||||
color="secondary"
|
disableRipple
|
||||||
className={classes.checkbox}
|
inputProps={{
|
||||||
disableRipple
|
"aria-labelledby": `label-${get(hit, elementId)}`,
|
||||||
inputProps={{
|
}}
|
||||||
"aria-labelledby": `label-${get(hit, elementId)}`,
|
sx={{ py: 0 }}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<Radio
|
||||||
<Radio
|
edge="start"
|
||||||
edge="start"
|
checked={isSelected}
|
||||||
checked={isSelected}
|
tabIndex={-1}
|
||||||
tabIndex={-1}
|
color="secondary"
|
||||||
color="secondary"
|
disableRipple
|
||||||
className={classes.checkbox}
|
inputProps={{
|
||||||
disableRipple
|
"aria-labelledby": `label-${get(hit, elementId)}`,
|
||||||
inputProps={{
|
}}
|
||||||
"aria-labelledby": `label-${get(hit, elementId)}`,
|
sx={{ py: 0 }}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</ListItemIcon>
|
||||||
</ListItemIcon>
|
<ListItemText
|
||||||
<ListItemText
|
id={`label-${get(hit, elementId)}`}
|
||||||
id={`label-${get(hit, elementId)}`}
|
primary={getLabel(config, hit)}
|
||||||
primary={getLabel(config, hit)}
|
/>
|
||||||
/>
|
</MenuItem>
|
||||||
</MenuItem>
|
|
||||||
<Divider className={classes.divider} />
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{multiple && (
|
{multiple && (
|
||||||
<Grid item className={clsx(classes.footerRow, classes.selectedRow)}>
|
<Grid item>
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography variant="button" color="textSecondary" sx={{ ml: 1 }}>
|
||||||
variant="button"
|
|
||||||
color="textSecondary"
|
|
||||||
className={classes.selectedNum}
|
|
||||||
>
|
|
||||||
{value?.length} of {hits?.length}
|
{value?.length} of {hits?.length}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -199,9 +189,9 @@ export default function PopupContents({
|
|||||||
disabled={!value || value.length === 0}
|
disabled={!value || value.length === 0}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
color="primary"
|
color="primary"
|
||||||
className={classes.selectAllButton}
|
variant="text"
|
||||||
>
|
>
|
||||||
Clear selection
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface IConnectorSelectProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Override any props of the root MUI `TextField` component */
|
/** Override any props of the root MUI `TextField` component */
|
||||||
TextFieldProps?: Partial<TextFieldProps>;
|
TextFieldProps?: Partial<TextFieldProps>;
|
||||||
docRef: TableRowRef;
|
_rowy_ref: TableRowRef;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,11 @@ export default function ConnectorSelect({
|
|||||||
// prop for this component to a comma-separated string
|
// prop for this component to a comma-separated string
|
||||||
MenuProps: {
|
MenuProps: {
|
||||||
classes: { paper: classes.paper, list: classes.menuChild },
|
classes: { paper: classes.paper, list: classes.menuChild },
|
||||||
MenuListProps: { disablePadding: true },
|
MenuListProps: {
|
||||||
|
disablePadding: true,
|
||||||
|
style: { padding: 0 },
|
||||||
|
component: "div",
|
||||||
|
} as any,
|
||||||
anchorOrigin: { vertical: "bottom", horizontal: "center" },
|
anchorOrigin: { vertical: "bottom", horizontal: "center" },
|
||||||
transformOrigin: { vertical: "top", horizontal: "center" },
|
transformOrigin: { vertical: "top", horizontal: "center" },
|
||||||
...TextFieldProps.SelectProps?.MenuProps,
|
...TextFieldProps.SelectProps?.MenuProps,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default function Connector({
|
|||||||
column,
|
column,
|
||||||
_rowy_ref,
|
_rowy_ref,
|
||||||
value,
|
value,
|
||||||
onDirty,
|
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -32,7 +31,7 @@ export default function Connector({
|
|||||||
column={column}
|
column={column}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
docRef={_rowy_ref as any}
|
_rowy_ref={_rowy_ref}
|
||||||
TextFieldProps={{
|
TextFieldProps={{
|
||||||
label: "",
|
label: "",
|
||||||
hiddenLabel: true,
|
hiddenLabel: true,
|
||||||
@@ -50,7 +49,6 @@ export default function Connector({
|
|||||||
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
|
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
|
||||||
{value.map((item) => {
|
{value.map((item) => {
|
||||||
const key = get(item, config.elementId);
|
const key = get(item, config.elementId);
|
||||||
console.log(key, item);
|
|
||||||
return (
|
return (
|
||||||
<Grid item key={key}>
|
<Grid item key={key}>
|
||||||
<Chip
|
<Chip
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
import ConnectorIcon from "@mui/icons-material/Cable";
|
|
||||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
|
||||||
import InlineCell from "./InlineCell";
|
|
||||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
|
||||||
|
|
||||||
const PopoverCell = lazy(
|
import ConnectorIcon from "@mui/icons-material/Cable";
|
||||||
|
import DisplayCell from "./DisplayCell";
|
||||||
|
|
||||||
|
const EditorCell = lazy(
|
||||||
() =>
|
() =>
|
||||||
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */)
|
import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */)
|
||||||
);
|
);
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -30,11 +29,9 @@ export const config: IFieldConfig = {
|
|||||||
icon: <ConnectorIcon />,
|
icon: <ConnectorIcon />,
|
||||||
description:
|
description:
|
||||||
"Connects to any table or API to fetch a list of results based on a text query or row data.",
|
"Connects to any table or API to fetch a list of results based on a text query or row data.",
|
||||||
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
|
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
disablePadding: true,
|
||||||
transparent: true,
|
|
||||||
}),
|
}),
|
||||||
TableEditor: NullEditor as any,
|
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
requireConfiguration: true,
|
requireConfiguration: true,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||||
|
|
||||||
export default function UpdatedAt({ column, value }: IHeavyCellProps) {
|
export default function CreatedAt({ column, value }: IDisplayCellProps) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const dateLabel = format(
|
const dateLabel = format(
|
||||||
value.toDate ? value.toDate() : value,
|
value.toDate ? value.toDate() : value,
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||||
import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell";
|
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||||
|
|
||||||
import { CreatedAt as CreatedAtIcon } from "@src/assets/icons";
|
import { CreatedAt as CreatedAtIcon } from "@src/assets/icons";
|
||||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
import DisplayCell from "./DisplayCell";
|
||||||
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
|
|
||||||
|
|
||||||
const TableCell = lazy(
|
|
||||||
() => import("./TableCell" /* webpackChunkName: "TableCell-CreatedAt" */)
|
|
||||||
);
|
|
||||||
const SideDrawerField = lazy(
|
const SideDrawerField = lazy(
|
||||||
() =>
|
() =>
|
||||||
import(
|
import(
|
||||||
@@ -28,8 +24,7 @@ export const config: IFieldConfig = {
|
|||||||
initialValue: null,
|
initialValue: null,
|
||||||
icon: <CreatedAtIcon />,
|
icon: <CreatedAtIcon />,
|
||||||
description: "Displays the timestamp of when the row was created. Read-only.",
|
description: "Displays the timestamp of when the row was created. Read-only.",
|
||||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
TableCell: withRenderTableCell(DisplayCell, null),
|
||||||
TableEditor: withSideDrawerEditor(TableCell),
|
|
||||||
SideDrawerField,
|
SideDrawerField,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||||
|
|
||||||
import { Tooltip, Stack, Avatar } from "@mui/material";
|
import { Tooltip, Stack, Avatar } from "@mui/material";
|
||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||||
|
|
||||||
export default function CreatedBy({ column, value }: IHeavyCellProps) {
|
export default function CreatedBy({ column, value }: IDisplayCellProps) {
|
||||||
if (!value || !value.displayName || !value.timestamp) return null;
|
if (!value || !value.displayName || !value.timestamp) return null;
|
||||||
const dateLabel = format(
|
const dateLabel = format(
|
||||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user