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",
|
||||
"@rowy/form-builder": "^0.7.0",
|
||||
"@rowy/multiselect": "^0.4.1",
|
||||
"@tanstack/react-table": "^8.5.15",
|
||||
"@tinymce/tinymce-react": "^3",
|
||||
"@uiw/react-md-editor": "^3.14.1",
|
||||
"algoliasearch": "^4.13.1",
|
||||
@@ -46,7 +47,6 @@
|
||||
"react": "^18.0.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-color-palette": "^6.2.0",
|
||||
"react-data-grid": "7.0.0-beta.5",
|
||||
"react-detect-offline": "^2.4.5",
|
||||
"react-div-100vh": "^0.7.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -64,6 +64,7 @@
|
||||
"react-router-hash-link": "^2.4.3",
|
||||
"react-scripts": "^5.0.0",
|
||||
"react-usestateref": "^1.0.8",
|
||||
"react-virtual": "^2.10.4",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"stream-browserify": "^3.0.0",
|
||||
@@ -91,7 +92,7 @@
|
||||
"typedoc": "typedoc src/atoms/projectScope/index.ts src/atoms/tableScope/index.ts --out typedoc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": "^16"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"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 { useAtom } from "jotai";
|
||||
|
||||
import { Backdrop } from "@mui/material";
|
||||
import Loading from "@src/components/Loading";
|
||||
import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase";
|
||||
import MembersSourceFirebase from "@src/sources/MembersSourceFirebase";
|
||||
@@ -116,7 +117,21 @@ export default function App() {
|
||||
<Route index element={<NotFound />} />
|
||||
<Route
|
||||
path=":docPath/:subTableKey"
|
||||
element={<ProvidedSubTablePage />}
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Backdrop
|
||||
key="sub-table-modal-backdrop"
|
||||
open
|
||||
sx={{ zIndex: "modal" }}
|
||||
>
|
||||
<Loading />
|
||||
</Backdrop>
|
||||
}
|
||||
>
|
||||
<ProvidedSubTablePage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -103,6 +103,9 @@ export { FileTableBoxOutline as Project };
|
||||
import { TableColumn } from "mdi-material-ui";
|
||||
export { TableColumn };
|
||||
|
||||
import { DragVertical } from "mdi-material-ui";
|
||||
export { DragVertical };
|
||||
|
||||
export * from "./AddRow";
|
||||
export * from "./AddRowTop";
|
||||
export * from "./ChevronDown";
|
||||
|
||||
@@ -97,8 +97,6 @@ export const updateColumnAtom = atom(
|
||||
});
|
||||
}
|
||||
|
||||
console.log(tableColumnsOrdered);
|
||||
|
||||
// Reduce array into single object with updated indexes
|
||||
const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {});
|
||||
await updateTableSchema({ columns: updatedColumns });
|
||||
|
||||
@@ -130,7 +130,11 @@ export const sideDrawerAtom = atomWithHash<"table-information" | null>(
|
||||
/** Store side drawer open state */
|
||||
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 */
|
||||
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 MenuContents, { IMenuContentsProps } from "./MenuContents";
|
||||
import ColumnHeader from "@src/components/Table/Column";
|
||||
import ColumnHeader from "@src/components/Table/Mock/Column";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
userRolesAtom,
|
||||
userSettingsAtom,
|
||||
updateUserSettingsAtom,
|
||||
confirmDialogAtom,
|
||||
@@ -80,8 +79,17 @@ export interface IMenuModalProps {
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function ColumnMenu() {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
export interface IColumnMenuProps {
|
||||
canAddColumns: boolean;
|
||||
canEditColumns: boolean;
|
||||
canDeleteColumns: boolean;
|
||||
}
|
||||
|
||||
export default function ColumnMenu({
|
||||
canAddColumns,
|
||||
canEditColumns,
|
||||
canDeleteColumns,
|
||||
}: IColumnMenuProps) {
|
||||
const [userSettings] = useAtom(userSettingsAtom, projectScope);
|
||||
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
@@ -248,7 +256,7 @@ export default function ColumnMenu() {
|
||||
});
|
||||
handleClose();
|
||||
},
|
||||
active: !column.editable,
|
||||
active: column.editable === false,
|
||||
},
|
||||
{
|
||||
label: "Disable resize",
|
||||
@@ -387,6 +395,7 @@ export default function ColumnMenu() {
|
||||
openColumnModal({ type: "new", index: column.index - 1 });
|
||||
handleClose();
|
||||
},
|
||||
disabled: !canAddColumns,
|
||||
},
|
||||
{
|
||||
label: "Insert to the right…",
|
||||
@@ -396,6 +405,7 @@ export default function ColumnMenu() {
|
||||
openColumnModal({ type: "new", index: column.index + 1 });
|
||||
handleClose();
|
||||
},
|
||||
disabled: !canAddColumns,
|
||||
},
|
||||
{
|
||||
label: `Delete column${altPress ? "" : "…"}`,
|
||||
@@ -450,16 +460,19 @@ export default function ColumnMenu() {
|
||||
});
|
||||
},
|
||||
color: "error" as "error",
|
||||
disabled: !canDeleteColumns,
|
||||
},
|
||||
];
|
||||
|
||||
let menuItems = [...localViewActions];
|
||||
|
||||
if (userRoles.includes("ADMIN") || userRoles.includes("OPS")) {
|
||||
if (canEditColumns) {
|
||||
menuItems.push.apply(menuItems, configActions);
|
||||
if (column.type === FieldType.derivative) {
|
||||
menuItems.push.apply(menuItems, derivativeActions);
|
||||
}
|
||||
}
|
||||
if (canAddColumns || canDeleteColumns) {
|
||||
menuItems.push.apply(menuItems, columnActions);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const MemoizedField = memo(
|
||||
|
||||
// Should not reach this state
|
||||
if (isEmpty(fieldComponent)) {
|
||||
// console.error('Could not find SideDrawerField component', field);
|
||||
console.error("Could not find SideDrawerField component", field);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,10 +78,6 @@ export const MemoizedField = memo(
|
||||
},
|
||||
onSubmit: handleSubmit,
|
||||
disabled,
|
||||
// TODO: Remove
|
||||
control: {} as any,
|
||||
useFormMethods: {} as any,
|
||||
docRef: _rowy_ref,
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import useMemoValue from "use-memo-value";
|
||||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { find, findIndex, isEqual } from "lodash-es";
|
||||
import { find, findIndex } from "lodash-es";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { DataGridHandle } from "react-data-grid";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
|
||||
import { Fab, Fade } from "@mui/material";
|
||||
@@ -16,34 +14,20 @@ import ErrorFallback from "@src/components/ErrorFallback";
|
||||
import StyledDrawer from "./StyledDrawer";
|
||||
import SideDrawerFields from "./SideDrawerFields";
|
||||
|
||||
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableScope,
|
||||
tableIdAtom,
|
||||
tableColumnsOrderedAtom,
|
||||
tableRowsAtom,
|
||||
sideDrawerOpenAtom,
|
||||
selectedCellAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { analytics, logEvent } from "@src/analytics";
|
||||
import { formatSubTableName } from "@src/utils/table";
|
||||
|
||||
export const DRAWER_WIDTH = 512;
|
||||
export const DRAWER_COLLAPSED_WIDTH = 36;
|
||||
|
||||
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);
|
||||
export default function SideDrawer() {
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
|
||||
const userDocHiddenFields =
|
||||
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [];
|
||||
|
||||
const [cell, setCell] = useAtom(selectedCellAtom, tableScope);
|
||||
const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope);
|
||||
const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]);
|
||||
@@ -51,26 +35,6 @@ export default function SideDrawer({
|
||||
"_rowy_ref.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") => () => {
|
||||
if (!tableRows || !cell) return;
|
||||
@@ -79,13 +43,11 @@ export default function SideDrawer({
|
||||
if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1;
|
||||
const newPath = tableRows[rowIndex]._rowy_ref.path;
|
||||
|
||||
setCell((cell) => ({ columnKey: cell!.columnKey, path: newPath }));
|
||||
|
||||
const columnIndex = visibleColumnKeys.indexOf(cell!.columnKey || "");
|
||||
dataGridRef?.current?.selectCell(
|
||||
{ rowIdx: rowIndex, idx: columnIndex },
|
||||
false
|
||||
);
|
||||
setCell((cell) => ({
|
||||
columnKey: cell!.columnKey,
|
||||
path: newPath,
|
||||
focusInside: false,
|
||||
}));
|
||||
};
|
||||
|
||||
// const [urlDocState, dispatchUrlDoc] = useDoc({});
|
||||
@@ -109,7 +71,7 @@ export default function SideDrawer({
|
||||
// }
|
||||
// }, [cell]);
|
||||
|
||||
const disabled = !open && !cell; // && !urlDocState.doc;
|
||||
const disabled = (!open && !cell) || selectedCellRowIndex <= -1; // && !urlDocState.doc;
|
||||
useEffect(() => {
|
||||
if (disabled && setOpen) setOpen(false);
|
||||
}, [disabled, setOpen]);
|
||||
@@ -143,6 +105,7 @@ export default function SideDrawer({
|
||||
{!!cell && (
|
||||
<div className="sidedrawer-nav-fab-container">
|
||||
<Fab
|
||||
aria-label="Previous row"
|
||||
style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
|
||||
size="small"
|
||||
disabled={disabled || !cell || selectedCellRowIndex <= 0}
|
||||
@@ -152,6 +115,7 @@ export default function SideDrawer({
|
||||
</Fab>
|
||||
|
||||
<Fab
|
||||
aria-label="Next row"
|
||||
style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }}
|
||||
size="small"
|
||||
disabled={
|
||||
@@ -166,6 +130,7 @@ export default function SideDrawer({
|
||||
|
||||
<div className="sidedrawer-open-fab-container">
|
||||
<Fab
|
||||
aria-label={open ? "Close side drawer" : "Open side drawer"}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (setOpen)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
@@ -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 { useDrag, useDrop } from "react-dnd";
|
||||
import type { Header } from "@tanstack/react-table";
|
||||
import type {
|
||||
DraggableProvided,
|
||||
DraggableStateSnapshot,
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
import {
|
||||
styled,
|
||||
alpha,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
tooltipClasses,
|
||||
Fade,
|
||||
Grid,
|
||||
StackProps,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DropdownIcon from "@mui/icons-material/MoreHoriz";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
|
||||
import ColumnHeaderSort from "./ColumnHeaderSort";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
userRolesAtom,
|
||||
altPressAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
StyledColumnHeader,
|
||||
StyledColumnHeaderNameTooltip,
|
||||
} from "@src/components/Table/Styled/StyledColumnHeader";
|
||||
import ColumnHeaderSort, { SORT_STATES } from "./ColumnHeaderSort";
|
||||
import ColumnHeaderDragHandle from "./ColumnHeaderDragHandle";
|
||||
import ColumnHeaderResizer from "./ColumnHeaderResizer";
|
||||
|
||||
import { projectScope, altPressAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableScope,
|
||||
updateColumnAtom,
|
||||
selectedCellAtom,
|
||||
columnMenuAtom,
|
||||
tableSortsAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column";
|
||||
import { ColumnConfig } from "@src/types/table";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
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 };
|
||||
|
||||
const LightTooltip = 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),
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IDraggableHeaderRendererProps {
|
||||
export interface IColumnHeaderProps
|
||||
extends Partial<Omit<StackProps, "style" | "sx">> {
|
||||
header: Header<TableRow, any>;
|
||||
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,
|
||||
}: IDraggableHeaderRendererProps) {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
||||
provided,
|
||||
snapshot,
|
||||
width,
|
||||
isSelectedCell,
|
||||
focusInsideCell,
|
||||
canEditColumns,
|
||||
isLastFrozen,
|
||||
}: IColumnHeaderProps) {
|
||||
const openColumnMenu = useSetAtom(columnMenuAtom, tableScope);
|
||||
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
|
||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||
|
||||
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 [tableSorts] = useAtom(tableSortsAtom, tableScope);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -85,53 +89,69 @@ export default function DraggableHeaderRenderer({
|
||||
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 (
|
||||
<Grid
|
||||
key={column.key}
|
||||
<StyledColumnHeader
|
||||
role="columnheader"
|
||||
id={`column-header-${column.key}`}
|
||||
ref={(ref) => {
|
||||
dragRef(ref);
|
||||
dropRef(ref);
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
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}
|
||||
sx={[
|
||||
{
|
||||
height: "100%",
|
||||
"& svg, & button": { display: "block" },
|
||||
|
||||
color: "text.secondary",
|
||||
transition: (theme) =>
|
||||
theme.transitions.create("color", {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
"&:hover": { color: "text.primary" },
|
||||
|
||||
cursor: "move",
|
||||
|
||||
py: 0,
|
||||
pr: 0.5,
|
||||
pl: 1,
|
||||
width: "100%",
|
||||
},
|
||||
isDragging
|
||||
? { opacity: 0.5 }
|
||||
: isOver
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
alpha(
|
||||
theme.palette.primary.main,
|
||||
theme.palette.action.focusOpacity
|
||||
),
|
||||
color: "primary.main",
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
className="column-header"
|
||||
onClick={(e) => {
|
||||
setSelectedCell({
|
||||
path: "_rowy_header",
|
||||
columnKey: header.id,
|
||||
focusInside: false,
|
||||
});
|
||||
(e.target as HTMLDivElement).focus();
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
setSelectedCell({
|
||||
path: "_rowy_header",
|
||||
columnKey: header.id,
|
||||
focusInside: true,
|
||||
});
|
||||
(e.target as HTMLDivElement).focus();
|
||||
}}
|
||||
>
|
||||
{(column.width as number) > 140 && (
|
||||
{provided.dragHandleProps && (
|
||||
<ColumnHeaderDragHandle
|
||||
dragHandleProps={provided.dragHandleProps}
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{width > 140 && (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
@@ -144,94 +164,99 @@ export default function DraggableHeaderRenderer({
|
||||
placement="bottom-start"
|
||||
arrow
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
<div
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(column.key);
|
||||
}}
|
||||
style={{ position: "relative", zIndex: 2 }}
|
||||
>
|
||||
{column.editable === false ? (
|
||||
<LockIcon />
|
||||
) : (
|
||||
getFieldProp("icon", (column as any).type)
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Grid
|
||||
item
|
||||
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}
|
||||
>
|
||||
<StyledColumnHeaderNameTooltip
|
||||
title={
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
typography: "caption",
|
||||
fontWeight: "fontWeightMedium",
|
||||
lineHeight: `${COLUMN_HEADER_HEIGHT}px`,
|
||||
lineHeight: `${COLUMN_HEADER_HEIGHT - 2 - 4}px`,
|
||||
textOverflow: "clip",
|
||||
}}
|
||||
component="div"
|
||||
color="inherit"
|
||||
>
|
||||
{altPress ? (
|
||||
<>
|
||||
{column.index} <code>{column.fieldName}</code>
|
||||
</>
|
||||
) : (
|
||||
column.name
|
||||
)}
|
||||
{column.name as string}
|
||||
</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>
|
||||
<ColumnHeaderSort column={column as any} />
|
||||
</Grid>
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
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>
|
||||
<Tooltip title="Column settings">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Column settings for ${column.name as string}`}
|
||||
id={`column-settings-${column.key}`}
|
||||
color="inherit"
|
||||
onClick={handleOpenMenu}
|
||||
ref={buttonRef}
|
||||
sx={{
|
||||
transition: (theme) =>
|
||||
theme.transitions.create("color", {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
{column.type !== FieldType.id && (
|
||||
<ColumnHeaderSort
|
||||
sortKey={sortKey}
|
||||
currentSort={currentSort}
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
|
||||
color: "text.disabled",
|
||||
".column-header:hover &": { color: "text.primary" },
|
||||
}}
|
||||
>
|
||||
<DropdownIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Tooltip title="Column settings">
|
||||
<IconButton
|
||||
size="small"
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
id={`column-settings-${column.key}`}
|
||||
onClick={handleOpenMenu}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<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 { Tooltip, IconButton } from "@mui/material";
|
||||
@@ -8,27 +9,26 @@ import IconSlash, {
|
||||
} from "@src/components/IconSlash";
|
||||
|
||||
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";
|
||||
|
||||
const SORT_STATES = ["none", "desc", "asc"] as const;
|
||||
export const SORT_STATES = ["none", "desc", "asc"] as const;
|
||||
|
||||
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 =
|
||||
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 }]);
|
||||
};
|
||||
|
||||
if (column.type === FieldType.id) return null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={nextSort === "none" ? "Unsort" : `Sort by ${nextSort}ending`}
|
||||
title={nextSort === "none" ? "Remove sort" : `Sort by ${nextSort}ending`}
|
||||
>
|
||||
<IconButton
|
||||
disableFocusRipple={true}
|
||||
size="small"
|
||||
onClick={handleSortClick}
|
||||
color="inherit"
|
||||
tabIndex={tabIndex}
|
||||
sx={{
|
||||
bgcolor: "background.default",
|
||||
"&:hover": {
|
||||
"&:hover, &:focus": {
|
||||
backgroundColor: (theme) =>
|
||||
colord(theme.palette.background.default)
|
||||
.mix(
|
||||
@@ -74,12 +73,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
||||
|
||||
position: "relative",
|
||||
opacity: currentSort !== "none" ? 1 : 0,
|
||||
".column-header:hover &": { opacity: 1 },
|
||||
|
||||
transition: (theme) =>
|
||||
theme.transitions.create(["background-color", "opacity"], {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
|
||||
"& .arrow": {
|
||||
transition: (theme) =>
|
||||
@@ -89,7 +82,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
||||
|
||||
transform: currentSort === "asc" ? "rotate(180deg)" : "none",
|
||||
},
|
||||
"&:hover .arrow": {
|
||||
"&:hover .arrow, &:focus .arrow": {
|
||||
transform:
|
||||
currentSort === "asc" || nextSort === "asc"
|
||||
? "rotate(180deg)"
|
||||
@@ -100,7 +93,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
||||
strokeDashoffset:
|
||||
currentSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
|
||||
},
|
||||
"&:hover .icon-slash": {
|
||||
"&:hover .icon-slash, &:focus .icon-slash": {
|
||||
strokeDashoffset:
|
||||
nextSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
|
||||
},
|
||||
@@ -113,4 +106,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ColumnHeaderSort;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { NonFullScreenErrorFallback } from "@src/components/ErrorFallback";
|
||||
@@ -8,10 +9,21 @@ import MenuContents from "./MenuContents";
|
||||
import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope";
|
||||
|
||||
export default function ContextMenu() {
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
const [contextMenuTarget, setContextMenuTarget] = useAtom(
|
||||
contextMenuTargetAtom,
|
||||
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);
|
||||
|
||||
@@ -20,11 +32,12 @@ export default function ContextMenu() {
|
||||
id="cell-context-menu"
|
||||
aria-label="Cell context menu"
|
||||
anchorEl={contextMenuTarget as any}
|
||||
open={Boolean(contextMenuTarget)}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
sx={{ "& .MuiMenu-paper": { minWidth: 160 } }}
|
||||
MenuListProps={{ ref: menuRef }}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={NonFullScreenErrorFallback}>
|
||||
<MenuContents onClose={handleClose} />
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface IContextMenuItem extends Partial<MenuItemProps> {
|
||||
disabled?: boolean;
|
||||
hotkeyLabel?: string;
|
||||
divider?: boolean;
|
||||
subItems?: IContextMenuItem[];
|
||||
}
|
||||
|
||||
export interface IContextMenuItemProps extends IContextMenuItem {
|
||||
@@ -82,16 +83,20 @@ export default function ContextMenuItem({
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MenuItem {...props} onClick={onClick}>
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
<ListItemText>{label}</ListItemText>
|
||||
{hotkeyLabel && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{hotkeyLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
if (props.divider) {
|
||||
return <Divider variant="middle" />;
|
||||
} else {
|
||||
return (
|
||||
<MenuItem {...props} onClick={onClick}>
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
<ListItemText>{label}</ListItemText>
|
||||
{hotkeyLabel && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{hotkeyLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,182 +62,188 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
if (!tableSchema.columns || !selectedCell) return null;
|
||||
|
||||
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 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 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 handleDuplicate = () => {
|
||||
addRow({
|
||||
row,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
});
|
||||
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…",
|
||||
color: "error",
|
||||
icon: <ClearIcon />,
|
||||
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:
|
||||
selectedColumn.editable === false ||
|
||||
!row ||
|
||||
cellValue === undefined ||
|
||||
getFieldProp("group", selectedColumn.type) === "Auditing",
|
||||
tableSettings.tableType === "collectionGroup" ||
|
||||
(!userRoles.includes("ADMIN") && tableSettings.readOnly),
|
||||
onClick: altPress
|
||||
? handleClearValue
|
||||
? handleDuplicate
|
||||
: () => {
|
||||
confirm({
|
||||
title: "Clear cell value?",
|
||||
body: "The cell’s value cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleClearValue,
|
||||
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: "Filter value",
|
||||
icon: <FilterIcon />,
|
||||
disabled: !columnFilters || cellValue === undefined,
|
||||
onClick: handleFilterValue,
|
||||
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();
|
||||
},
|
||||
},
|
||||
];
|
||||
actionGroups.push(cellActions);
|
||||
|
||||
// Row actions
|
||||
if (row) {
|
||||
const handleDuplicate = () => {
|
||||
addRow({
|
||||
row,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
if (selectedColumn) {
|
||||
const menuActions = getFieldProp(
|
||||
"contextMenuActions",
|
||||
selectedColumn?.type
|
||||
);
|
||||
|
||||
// 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 rowActions = [
|
||||
const cellActions = [
|
||||
{
|
||||
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",
|
||||
icon: <RowIcon />,
|
||||
subItems: [
|
||||
{
|
||||
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();
|
||||
},
|
||||
},
|
||||
],
|
||||
subItems: rowActions,
|
||||
},
|
||||
];
|
||||
]);
|
||||
} else {
|
||||
actionGroups.push(rowActions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo } from "react";
|
||||
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 { CopyCells as CopyCellsIcon } from "@src/assets/icons";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import MenuIcon from "@mui/icons-material/MoreHoriz";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
@@ -17,10 +19,13 @@ import {
|
||||
tableSettingsAtom,
|
||||
addRowAtom,
|
||||
deleteRowAtom,
|
||||
contextMenuTargetAtom,
|
||||
} 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 [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
@@ -28,12 +33,13 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const addRow = useSetAtom(addRowAtom, tableScope);
|
||||
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
|
||||
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
|
||||
|
||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||
const handleDelete = () => deleteRow(row._rowy_ref.path);
|
||||
const handleDelete = () => deleteRow(row.original._rowy_ref.path);
|
||||
const handleDuplicate = () => {
|
||||
addRow({
|
||||
row,
|
||||
row: row.original,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
});
|
||||
};
|
||||
@@ -42,7 +48,26 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
||||
return null;
|
||||
|
||||
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">
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -61,7 +86,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
||||
<code
|
||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
||||
>
|
||||
{row._rowy_ref.path}
|
||||
{row.original._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
@@ -70,8 +95,8 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
||||
});
|
||||
}
|
||||
}
|
||||
aria-label="Duplicate row"
|
||||
className="row-hover-iconButton"
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
>
|
||||
<CopyCellsIcon />
|
||||
</IconButton>
|
||||
@@ -94,7 +119,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
||||
<code
|
||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
||||
>
|
||||
{row._rowy_ref.path}
|
||||
{row.original._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
@@ -104,23 +129,25 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
|
||||
});
|
||||
}
|
||||
}
|
||||
aria-label={`Delete row${altPress ? "" : "…"}`}
|
||||
className="row-hover-iconButton"
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
sx={{
|
||||
".rdg-row:hover .row-hover-iconButton&&": {
|
||||
color: "error.main",
|
||||
backgroundColor: (theme) =>
|
||||
alpha(
|
||||
theme.palette.error.main,
|
||||
theme.palette.action.hoverOpacity * 2
|
||||
),
|
||||
},
|
||||
"[role='row']:hover .row-hover-iconButton&&, .row-hover-iconButton&&:focus":
|
||||
{
|
||||
color: "error.main",
|
||||
backgroundColor: (theme) =>
|
||||
alpha(
|
||||
theme.palette.error.main,
|
||||
theme.palette.action.hoverOpacity * 2
|
||||
),
|
||||
},
|
||||
}}
|
||||
disabled={!row._rowy_ref.path}
|
||||
disabled={!row.original._rowy_ref.path}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</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 { alpha } from "@mui/material/styles";
|
||||
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
|
||||
export const COLUMN_HEADER_HEIGHT = 42;
|
||||
|
||||
@@ -10,23 +12,30 @@ export interface IColumnProps extends Partial<GridProps> {
|
||||
label: string;
|
||||
type?: FieldType;
|
||||
secondaryItem?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export default function Column({
|
||||
label,
|
||||
type,
|
||||
secondaryItem,
|
||||
export const Column = forwardRef(function Column(
|
||||
{
|
||||
label,
|
||||
type,
|
||||
secondaryItem,
|
||||
children,
|
||||
|
||||
active,
|
||||
...props
|
||||
}: IColumnProps) {
|
||||
active,
|
||||
...props
|
||||
}: IColumnProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Grid
|
||||
ref={ref}
|
||||
container
|
||||
alignItems="center"
|
||||
wrap="nowrap"
|
||||
aria-label={label}
|
||||
{...props}
|
||||
sx={[
|
||||
{
|
||||
@@ -34,6 +43,7 @@ export default function Column({
|
||||
height: COLUMN_HEADER_HEIGHT,
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: "background.default",
|
||||
position: "relative",
|
||||
|
||||
py: 0,
|
||||
px: 1,
|
||||
@@ -68,6 +78,7 @@ export default function Column({
|
||||
},
|
||||
}
|
||||
: {},
|
||||
...spreadSx(props.sx),
|
||||
]}
|
||||
>
|
||||
{type && <Grid item>{getFieldProp("icon", type)}</Grid>}
|
||||
@@ -104,6 +115,10 @@ export default function Column({
|
||||
{secondaryItem}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Column;
|
||||
@@ -3,7 +3,6 @@ import { useAtom } from "jotai";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import RichTooltip from "@src/components/RichTooltip";
|
||||
import WarningIcon from "@mui/icons-material/WarningAmber";
|
||||
import { OUT_OF_ORDER_MARGIN } from "./TableContainer";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
@@ -24,15 +23,7 @@ const Dot = styled("div")(({ theme }) => ({
|
||||
backgroundColor: theme.palette.warning.main,
|
||||
}));
|
||||
|
||||
export interface IOutOfOrderIndicatorProps {
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function OutOfOrderIndicator({
|
||||
top,
|
||||
height,
|
||||
}: IOutOfOrderIndicatorProps) {
|
||||
export default function OutOfOrderIndicator() {
|
||||
const [dismissed, setDismissed] = useAtom(
|
||||
tableOutOfOrderDismissedAtom,
|
||||
projectScope
|
||||
@@ -42,11 +33,12 @@ export default function OutOfOrderIndicator({
|
||||
<div
|
||||
className="out-of-order-dot"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: top,
|
||||
height: height - OUT_OF_ORDER_MARGIN - 2,
|
||||
marginLeft: `max(env(safe-area-inset-left), 16px)`,
|
||||
position: "sticky",
|
||||
zIndex: 9,
|
||||
top: 0,
|
||||
left: 8,
|
||||
width: 12,
|
||||
marginRight: -12,
|
||||
}}
|
||||
>
|
||||
<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 { useDebouncedCallback, useThrottledCallback } from "use-debounce";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { findIndex } from "lodash-es";
|
||||
import { useThrottledCallback } from "use-debounce";
|
||||
import {
|
||||
createColumnHelper,
|
||||
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 DataGrid, {
|
||||
Column,
|
||||
DataGridHandle,
|
||||
// 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 StyledTable from "./Styled/StyledTable";
|
||||
import TableHeader from "./TableHeader";
|
||||
import TableBody from "./TableBody";
|
||||
import FinalColumn from "./FinalColumn/FinalColumn";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
userRolesAtom,
|
||||
userSettingsAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import EmptyState from "@src/components/EmptyState";
|
||||
// import BulkActions from "./BulkActions";
|
||||
|
||||
import {
|
||||
tableScope,
|
||||
tableIdAtom,
|
||||
tableSettingsAtom,
|
||||
tableSchemaAtom,
|
||||
tableColumnsOrderedAtom,
|
||||
tableRowsAtom,
|
||||
tableNextPageAtom,
|
||||
tablePageAtom,
|
||||
updateColumnAtom,
|
||||
updateFieldAtom,
|
||||
selectedCellAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
|
||||
import { getFieldType, getFieldProp } from "@src/components/fields";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { formatSubTableName } from "@src/utils/table";
|
||||
import { ColumnConfig } from "@src/types/table";
|
||||
import { TableRow, ColumnConfig } from "@src/types/table";
|
||||
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||
import { useSaveColumnSizing } from "./useSaveColumnSizing";
|
||||
|
||||
export type DataGridColumn = ColumnConfig & Column<any> & { isNew?: true };
|
||||
export const DEFAULT_ROW_HEIGHT = 41;
|
||||
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;
|
||||
const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : "");
|
||||
//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 };
|
||||
declare module "@tanstack/table-core" {
|
||||
/** The `column.meta` property contains the column config from tableSchema */
|
||||
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({
|
||||
dataGridRef,
|
||||
}: {
|
||||
dataGridRef?: React.MutableRefObject<DataGridHandle | null>;
|
||||
}) {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const [userSettings] = useAtom(userSettingsAtom, projectScope);
|
||||
|
||||
const [tableId] = useAtom(tableIdAtom, tableScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
canAddColumns,
|
||||
canEditColumns,
|
||||
canEditCells,
|
||||
hiddenColumns,
|
||||
emptyState,
|
||||
}: ITableProps) {
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||
const setTablePage = useSetAtom(tablePageAtom, tableScope);
|
||||
const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope);
|
||||
const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
|
||||
|
||||
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
|
||||
const userDocHiddenFields =
|
||||
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields;
|
||||
// Store a **state** and reference to the container element
|
||||
// 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
|
||||
// Also filter out hidden columns and add end column
|
||||
// Get column defs from table schema
|
||||
// Also add end column for admins (canAddColumns || canEditCells)
|
||||
const columns = useMemo(() => {
|
||||
const _columns: DataGridColumn[] = tableColumnsOrdered
|
||||
.filter((column) => {
|
||||
if (column.hidden) return false;
|
||||
if (
|
||||
Array.isArray(userDocHiddenFields) &&
|
||||
userDocHiddenFields.includes(column.key)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
.map((column: any) => ({
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
frozen: column.fixed,
|
||||
headerRenderer: ColumnHeader,
|
||||
formatter:
|
||||
getFieldProp("TableCell", 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,
|
||||
}));
|
||||
const _columns = tableColumnsOrdered
|
||||
// Hide column for all users using table schema
|
||||
.filter((column) => !column.hidden)
|
||||
.map((columnConfig) =>
|
||||
columnHelper.accessor((row) => get(row, columnConfig.fieldName), {
|
||||
id: columnConfig.fieldName,
|
||||
meta: columnConfig,
|
||||
size: columnConfig.width,
|
||||
enableResizing: columnConfig.resizable !== false,
|
||||
minSize: MIN_COL_WIDTH,
|
||||
cell: getFieldProp("TableCell", getFieldType(columnConfig)),
|
||||
})
|
||||
);
|
||||
|
||||
if (userRoles.includes("ADMIN") || !tableSettings.readOnly) {
|
||||
_columns.push({
|
||||
isNew: true,
|
||||
key: "new",
|
||||
fieldName: "_rowy_new",
|
||||
name: "Add column",
|
||||
type: FieldType.last,
|
||||
index: _columns.length ?? 0,
|
||||
width: 154,
|
||||
headerRenderer: FinalColumnHeader,
|
||||
headerCellClass: "final-column-header",
|
||||
cellClass: "final-column-cell",
|
||||
formatter: FinalColumn,
|
||||
editable: false,
|
||||
});
|
||||
if (canAddColumns || canEditCells) {
|
||||
_columns.push(
|
||||
columnHelper.display({
|
||||
id: "_rowy_column_actions",
|
||||
cell: FinalColumn as any,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return _columns;
|
||||
}, [
|
||||
tableColumnsOrdered,
|
||||
userDocHiddenFields,
|
||||
tableSettings.readOnly,
|
||||
userRoles,
|
||||
]);
|
||||
const selectedColumnIndex = useMemo(() => {
|
||||
if (!selectedCell?.columnKey) return -1;
|
||||
return findIndex(columns, ["key", selectedCell.columnKey]);
|
||||
}, [selectedCell?.columnKey, columns]);
|
||||
}, [tableColumnsOrdered, canAddColumns, canEditCells]);
|
||||
|
||||
// Handle columns with field names that use dot notation (nested fields)
|
||||
const rows =
|
||||
useMemo(() => {
|
||||
// const columnsWithNestedFieldNames = columns
|
||||
// .map((col) => col.fieldName)
|
||||
// .filter((fieldName) => fieldName.includes("."));
|
||||
// Get user’s hidden columns from props and memoize into a `VisibilityState`
|
||||
const columnVisibility = useMemo(() => {
|
||||
if (!Array.isArray(hiddenColumns)) return {};
|
||||
return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
|
||||
}, [hiddenColumns]);
|
||||
|
||||
// if (columnsWithNestedFieldNames.length === 0)
|
||||
return tableRows;
|
||||
// Get frozen columns and memoize into a `ColumnPinningState`
|
||||
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) =>
|
||||
// columnsWithNestedFieldNames.reduce(
|
||||
// (acc, fieldName) => ({
|
||||
// ...acc,
|
||||
// [fieldName]: get(row, fieldName),
|
||||
// }),
|
||||
// { ...row }
|
||||
// )
|
||||
// );
|
||||
}, [tableRows]) ?? [];
|
||||
// Call TanStack Table
|
||||
const table = useReactTable({
|
||||
data: tableRows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
|
||||
// const [selectedRowsSet, setSelectedRowsSet] = useState<Set<React.Key>>();
|
||||
// const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
// Store local `columnSizing` state so we can save it to table schema
|
||||
// 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.
|
||||
// https://github.com/adazzle/react-data-grid/blob/ead05032da79d7e2b86e37cdb9af27f2a4d80b90/stories/demos/AllFeatures.tsx#L60
|
||||
const handleScroll = useThrottledCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
// Select corresponding header cell when scrolled to prevent jumping
|
||||
dataGridRef?.current?.selectCell({
|
||||
idx:
|
||||
selectedColumnIndex > -1 ? selectedColumnIndex : columns.length - 1,
|
||||
rowIdx: -1,
|
||||
// Handle keyboard navigation
|
||||
const { handleKeyDown } = useKeyboardNavigation({
|
||||
gridRef,
|
||||
tableRows,
|
||||
leafColumns,
|
||||
});
|
||||
|
||||
// Handle prompt to save local column sizes if user `canEditColumns`
|
||||
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 handleResize = useDebouncedCallback(
|
||||
(colIndex: number, width: number) => {
|
||||
const column = columns[colIndex];
|
||||
if (!column.key) return;
|
||||
updateColumn({ key: column.key, config: { width } });
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerElement;
|
||||
if (scrollHeight - scrollTop - clientHeight < 300) {
|
||||
setTablePage((p) => p + 1);
|
||||
}
|
||||
},
|
||||
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 (
|
||||
<Suspense fallback={<Loading message="Loading fields" />}>
|
||||
{/* <Hotkeys selectedCell={selectedCell} /> */}
|
||||
<TableContainer rowHeight={rowHeight} className="table-container">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{showLeftScrollDivider && <div className="left-scroll-divider" />}
|
||||
|
||||
<DataGrid
|
||||
onColumnResize={handleResize}
|
||||
onScroll={handleScroll}
|
||||
ref={(handle) => {
|
||||
if (dataGridRef) dataGridRef.current = handle;
|
||||
}}
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
// Increase row height of out of order rows to add margins
|
||||
rowHeight={({ row }) => {
|
||||
if (row._rowy_outOfOrder)
|
||||
return rowHeight + OUT_OF_ORDER_MARGIN + 1;
|
||||
|
||||
return rowHeight;
|
||||
}}
|
||||
headerRowHeight={DEFAULT_ROW_HEIGHT + 1}
|
||||
className="rdg-light" // Handle dark mode in MUI theme
|
||||
cellNavigationMode="LOOP_OVER_ROW"
|
||||
rowRenderer={TableRow}
|
||||
rowKeyGetter={rowKeyGetter}
|
||||
rowClass={rowClass}
|
||||
// selectedRows={selectedRowsSet}
|
||||
// onSelectedRowsChange={(newSelectedSet) => {
|
||||
// const newSelectedArray = newSelectedSet
|
||||
// ? [...newSelectedSet]
|
||||
// : [];
|
||||
// const prevSelectedRowsArray = selectedRowsSet
|
||||
// ? [...selectedRowsSet]
|
||||
// : [];
|
||||
// const addedSelections = difference(
|
||||
// newSelectedArray,
|
||||
// prevSelectedRowsArray
|
||||
// );
|
||||
// const removedSelections = difference(
|
||||
// prevSelectedRowsArray,
|
||||
// newSelectedArray
|
||||
// );
|
||||
// addedSelections.forEach((id) => {
|
||||
// const newRow = find(rows, { id });
|
||||
// setSelectedRows([...selectedRows, newRow]);
|
||||
// });
|
||||
// removedSelections.forEach((rowId) => {
|
||||
// setSelectedRows(selectedRows.filter((row) => row.id !== rowId));
|
||||
// });
|
||||
// setSelectedRowsSet(newSelectedSet);
|
||||
// }}
|
||||
// onRowsChange={() => {
|
||||
//console.log('onRowsChange',rows)
|
||||
// }}
|
||||
// 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 });
|
||||
}}
|
||||
<div
|
||||
ref={(el) => setContainerEl(el)}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||
style={{ overflow: "auto", width: "100%", height: "100%" }}
|
||||
>
|
||||
<StyledTable
|
||||
ref={gridRef}
|
||||
role="grid"
|
||||
aria-readonly={!canEditCells}
|
||||
aria-colcount={columns.length}
|
||||
aria-rowcount={tableRows.length + 1}
|
||||
style={
|
||||
{
|
||||
width: table.getTotalSize(),
|
||||
userSelect: "none",
|
||||
"--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`,
|
||||
} as any
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className="thead"
|
||||
role="rowgroup"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
padding: `0 ${TABLE_PADDING}px`,
|
||||
}}
|
||||
>
|
||||
<TableHeader
|
||||
headerGroups={table.getHeaderGroups()}
|
||||
handleDropColumn={handleDropColumn}
|
||||
canAddColumns={canAddColumns}
|
||||
canEditColumns={canEditColumns}
|
||||
lastFrozen={lastFrozen}
|
||||
columnSizing={columnSizing}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
|
||||
{tableRows.length === 0 && (
|
||||
<EmptyState
|
||||
Icon={AddRowIcon}
|
||||
message="Add a row to get started"
|
||||
description={
|
||||
<div>
|
||||
<br />
|
||||
<AddRow />
|
||||
</div>
|
||||
}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
top: COLUMN_HEADER_HEIGHT,
|
||||
height: "auto",
|
||||
}}
|
||||
{tableRows.length === 0 ? (
|
||||
emptyState ?? <EmptyState sx={{ py: 8 }} />
|
||||
) : (
|
||||
<TableBody
|
||||
containerEl={containerEl}
|
||||
containerRef={containerRef}
|
||||
leafColumns={leafColumns}
|
||||
rows={rows}
|
||||
canEditCells={canEditCells}
|
||||
lastFrozen={lastFrozen}
|
||||
columnSizing={columnSizing}
|
||||
/>
|
||||
)}
|
||||
{tableNextPage.loading && <LinearProgress />}
|
||||
</TableContainer>
|
||||
</StyledTable>
|
||||
|
||||
<div
|
||||
id="rowy-table-editable-cell-description"
|
||||
style={{ display: "none" }}
|
||||
>
|
||||
Press Enter to edit.
|
||||
</div>
|
||||
|
||||
<ContextMenu />
|
||||
{/*
|
||||
<BulkActions
|
||||
selectedRows={selectedRows}
|
||||
columns={columns}
|
||||
clearSelection={() => {
|
||||
setSelectedRowsSet(new Set());
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
/> */}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
|
||||
export default function ChipList({ children }: React.PropsWithChildren<{}>) {
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
|
||||
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
||||
export default function ChipList({
|
||||
children,
|
||||
rowHeight,
|
||||
}: React.PropsWithChildren<{ rowHeight: number }>) {
|
||||
const canWrap = rowHeight > 24 * 2 + 4;
|
||||
|
||||
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 { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
||||
import Column from "./Column";
|
||||
import Column from "./Mock/Column";
|
||||
|
||||
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
tableSchemaAtom,
|
||||
tableColumnsOrderedAtom,
|
||||
} 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 { formatSubTableName } from "@src/utils/table";
|
||||
|
||||
@@ -129,17 +129,7 @@ export function RowsSkeleton() {
|
||||
<Stack
|
||||
key={i}
|
||||
direction="row"
|
||||
sx={{
|
||||
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,
|
||||
},
|
||||
}}
|
||||
style={{ padding: `0 ${TABLE_PADDING}px`, marginTop: -1 }}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<Skeleton
|
||||
@@ -156,6 +146,7 @@ export function RowsSkeleton() {
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderLeftWidth: j === 0 ? 1 : 0,
|
||||
borderRadius: 0,
|
||||
width: col.width || DEFAULT_COL_WIDTH,
|
||||
flexShrink: 0,
|
||||
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 { 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 Details from "./Details";
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons";
|
||||
import { IStepProps } from ".";
|
||||
import { AirtableConfig } from "@src/components/TableModals/ImportAirtableWizard";
|
||||
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 {
|
||||
|
||||
@@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
|
||||
import { IStepProps } from ".";
|
||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||
import Column from "@src/components/Table/Column";
|
||||
import Cell from "@src/components/Table/Cell";
|
||||
import Column from "@src/components/Table/Mock/Column";
|
||||
import Cell from "@src/components/Table/Mock/Cell";
|
||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { SELECTABLE_TYPES } from "@src/components/TableModals/ImportExistingWizard/utils";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useAtom } from "jotai";
|
||||
import { find } from "lodash-es";
|
||||
|
||||
import { styled, Grid } from "@mui/material";
|
||||
import Column from "@src/components/Table/Column";
|
||||
import Cell from "@src/components/Table/Cell";
|
||||
import Column from "@src/components/Table/Mock/Column";
|
||||
import Cell from "@src/components/Table/Mock/Cell";
|
||||
|
||||
import { IStepProps } from ".";
|
||||
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
|
||||
|
||||
@@ -26,7 +26,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons";
|
||||
import { IStepProps } from ".";
|
||||
import { CsvConfig } from "@src/components/TableModals/ImportCsvWizard";
|
||||
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 {
|
||||
|
||||
@@ -7,8 +7,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
|
||||
import { IStepProps } from ".";
|
||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||
import Column from "@src/components/Table/Column";
|
||||
import Cell from "@src/components/Table/Cell";
|
||||
import Column from "@src/components/Table/Mock/Column";
|
||||
import Cell from "@src/components/Table/Mock/Cell";
|
||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
|
||||
@@ -3,8 +3,8 @@ import { find } from "lodash-es";
|
||||
import { parseJSON } from "date-fns";
|
||||
|
||||
import { styled, Grid } from "@mui/material";
|
||||
import Column from "@src/components/Table/Column";
|
||||
import Cell from "@src/components/Table/Cell";
|
||||
import Column from "@src/components/Table/Mock/Column";
|
||||
import Cell from "@src/components/Table/Mock/Cell";
|
||||
|
||||
import { IStepProps } from ".";
|
||||
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 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 { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||
|
||||
@@ -14,7 +14,9 @@ import DoneIcon from "@mui/icons-material/Done";
|
||||
|
||||
import { IStepProps } from ".";
|
||||
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({
|
||||
config,
|
||||
|
||||
@@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
|
||||
import { IStepProps } from ".";
|
||||
import ScrollableList from "@src/components/TableModals/ScrollableList";
|
||||
import Column from "@src/components/Table/Column";
|
||||
import Cell from "@src/components/Table/Cell";
|
||||
import Column from "@src/components/Table/Mock/Column";
|
||||
import Cell from "@src/components/Table/Mock/Cell";
|
||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||
|
||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useAtom } from "jotai";
|
||||
import { IStepProps } from ".";
|
||||
|
||||
import { styled, Grid } from "@mui/material";
|
||||
import Column from "@src/components/Table/Column";
|
||||
import Cell from "@src/components/Table/Cell";
|
||||
import Column from "@src/components/Table/Mock/Column";
|
||||
import Cell from "@src/components/Table/Mock/Cell";
|
||||
|
||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@src/atoms/tableScope";
|
||||
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() {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -51,7 +51,6 @@ const getStateIcon = (actionState: "undo" | "redo" | string, config: any) => {
|
||||
export interface IActionFabProps extends Partial<FabProps> {
|
||||
row: any;
|
||||
column: any;
|
||||
onSubmit: (value: any) => void;
|
||||
value: any;
|
||||
disabled: boolean;
|
||||
}
|
||||
@@ -59,7 +58,6 @@ export interface IActionFabProps extends Partial<FabProps> {
|
||||
export default function ActionFab({
|
||||
row,
|
||||
column,
|
||||
onSubmit,
|
||||
value,
|
||||
disabled,
|
||||
...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 ActionFab from "./ActionFab";
|
||||
import { sanitiseCallableName, isUrl } from "./utils";
|
||||
import { get } from "lodash-es";
|
||||
|
||||
|
||||
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");
|
||||
};
|
||||
import { getActionName } from "./DisplayCell";
|
||||
|
||||
export default function Action({
|
||||
column,
|
||||
row,
|
||||
value,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: IHeavyCellProps) {
|
||||
tabIndex,
|
||||
}: IEditorCellProps) {
|
||||
const hasRan = value && ![null, undefined].includes(value.status);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
className="cell-collapse-padding"
|
||||
sx={{ padding: "var(--cell-padding)", pr: 0.5 }}
|
||||
sx={{ padding: "var(--cell-padding)", pr: 0.5, width: "100%" }}
|
||||
>
|
||||
<div style={{ flexGrow: 1, overflow: "hidden" }}>
|
||||
{hasRan && isUrl(value.status) ? (
|
||||
@@ -44,9 +36,9 @@ export default function Action({
|
||||
<ActionFab
|
||||
row={row}
|
||||
column={column}
|
||||
onSubmit={onSubmit}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
@@ -303,7 +303,9 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
aria-label="Action will run"
|
||||
name="isActionScript"
|
||||
value={
|
||||
config.isActionScript !== false ? "actionScript" : "cloudFunction"
|
||||
config.isActionScript !== false
|
||||
? "actionScript"
|
||||
: "cloudFunction"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange("isActionScript")(
|
||||
@@ -559,45 +561,45 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
title: "Customization",
|
||||
content: (
|
||||
<>
|
||||
<Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={config.customName?.enabled}
|
||||
<Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
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("customName.enabled")(e.target.checked)
|
||||
onChange("customName.actionName")(e.target.value)
|
||||
}
|
||||
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("customName.actionName")(e.target.value)
|
||||
}
|
||||
label="Action name:"
|
||||
className="labelHorizontal"
|
||||
inputProps={{ style: { width: "10ch" } }}
|
||||
></TextField>
|
||||
)}
|
||||
<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 }}
|
||||
/>
|
||||
label="Action name:"
|
||||
className="labelHorizontal"
|
||||
inputProps={{ style: { width: "10ch" } }}
|
||||
></TextField>
|
||||
)}
|
||||
<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>
|
||||
{config.customIcons?.enabled && (
|
||||
<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 { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import { sanitiseCallableName, isUrl } from "./utils";
|
||||
import { getActionName } from "./TableCell"
|
||||
import { getActionName } from "./DisplayCell";
|
||||
|
||||
export default function Action({
|
||||
column,
|
||||
_rowy_ref,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: ISideDrawerFieldProps) {
|
||||
const [row] = useAtom(
|
||||
@@ -68,10 +66,6 @@ export default function Action({
|
||||
<ActionFab
|
||||
row={row}
|
||||
column={column}
|
||||
onSubmit={(value) => {
|
||||
onChange(value);
|
||||
onSubmit();
|
||||
}}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
id={getFieldId(column.key)}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { lazy } from "react";
|
||||
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 BasicCell from "./BasicCell";
|
||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const TableCell = lazy(
|
||||
() => import("./TableCell" /* webpackChunkName: "TableCell-Action" */)
|
||||
const EditorCell = lazy(
|
||||
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Action" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
@@ -25,8 +24,9 @@ export const config: IFieldConfig = {
|
||||
icon: <ActionIcon />,
|
||||
description:
|
||||
"Button with pre-defined action script or triggers a Cloud Function. Optionally supports Undo and Redo.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: NullEditor as any,
|
||||
TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", {
|
||||
disablePadding: true,
|
||||
}),
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
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 { get } from "lodash-es";
|
||||
|
||||
@@ -15,9 +15,11 @@ export default function Checkbox({
|
||||
row,
|
||||
column,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: IHeavyCellProps) {
|
||||
tabIndex,
|
||||
}: IEditorCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
|
||||
const handleChange = () => {
|
||||
@@ -28,10 +30,14 @@ export default function Checkbox({
|
||||
/\{\{(.*?)\}\}/g,
|
||||
replacer(row)
|
||||
),
|
||||
handleConfirm: () => onSubmit(!value),
|
||||
handleConfirm: () => {
|
||||
onChange(!value);
|
||||
onSubmit();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
onSubmit(!value);
|
||||
onChange(!value);
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,6 +49,7 @@ export default function Checkbox({
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
color="success"
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
}
|
||||
label={column.name as string}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { lazy } from "react";
|
||||
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 BasicCell from "@src/components/fields/_BasicCell/BasicCellName";
|
||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const TableCell = lazy(
|
||||
() => import("./TableCell" /* webpackChunkName: "TableCell-Checkbox" */)
|
||||
const EditorCell = lazy(
|
||||
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Checkbox" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
@@ -25,8 +24,9 @@ export const config: IFieldConfig = {
|
||||
initializable: true,
|
||||
icon: <CheckboxIcon />,
|
||||
description: "True/false value. Default: false.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: NullEditor as any,
|
||||
TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", {
|
||||
usesRowData: true,
|
||||
}),
|
||||
csvImportParser: (value: string) => {
|
||||
if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true;
|
||||
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";
|
||||
|
||||
export default function Code({ value }: IBasicCellProps) {
|
||||
export default function Code({ value }: IDisplayCellProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
padding: theme.spacing(3 / 8, 0),
|
||||
padding: "3px 0",
|
||||
|
||||
whiteSpace: "pre-wrap",
|
||||
lineHeight: theme.typography.body2.lineHeight,
|
||||
fontFamily: theme.typography.fontFamilyMono,
|
||||
wordBreak: "break-word",
|
||||
tabSize: 2,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{value.substring(0, 1000)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { lazy } from "react";
|
||||
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 BasicCell from "./BasicCell";
|
||||
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const Settings = lazy(
|
||||
() => import("./Settings" /* webpackChunkName: "Settings-Code" */)
|
||||
@@ -24,8 +23,12 @@ export const config: IFieldConfig = {
|
||||
initializable: true,
|
||||
icon: <CodeIcon />,
|
||||
description: "Raw code edited with the Monaco Editor.",
|
||||
TableCell: withBasicCell(BasicCell),
|
||||
TableEditor: withSideDrawerEditor(BasicCell),
|
||||
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", {
|
||||
popoverProps: {
|
||||
anchorOrigin: { vertical: "top", horizontal: "center" },
|
||||
PaperProps: { sx: { borderRadius: 1 } },
|
||||
},
|
||||
}),
|
||||
SideDrawerField,
|
||||
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
|
||||
width={440}
|
||||
height={180}
|
||||
color={value?.hex ? value : toColor("hex", "#fff")}
|
||||
color={
|
||||
value?.hex ? toColor("hex", value.hex) : toColor("hex", "#fff")
|
||||
}
|
||||
onChange={onChange}
|
||||
onChangeComplete={onSubmit}
|
||||
alpha
|
||||
|
||||
@@ -4,18 +4,18 @@ export const filterOperators: IFilterOperator[] = [
|
||||
{
|
||||
label: "is",
|
||||
secondaryLabel: "==",
|
||||
value: "color-equal"
|
||||
value: "color-equal",
|
||||
},
|
||||
{
|
||||
label: "is not",
|
||||
secondaryLabel: "!=",
|
||||
value: "color-not-equal"
|
||||
}
|
||||
value: "color-not-equal",
|
||||
},
|
||||
];
|
||||
|
||||
export const valueFormatter = (value: any) => {
|
||||
if (value && value.hex) {
|
||||
return value.hex.toString()
|
||||
return value.hex.toString();
|
||||
}
|
||||
return "";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { lazy } from "react";
|
||||
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 ColorIcon from "@mui/icons-material/Colorize";
|
||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
||||
import InlineCell from "./InlineCell";
|
||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
import { filterOperators, valueFormatter } from "./filters";
|
||||
|
||||
const PopoverCell = lazy(
|
||||
() => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */)
|
||||
const EditorCell = lazy(
|
||||
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
@@ -27,15 +25,10 @@ export const config: IFieldConfig = {
|
||||
icon: <ColorIcon />,
|
||||
description:
|
||||
"Color stored as Hex, RGB, and HSV. Edited with a visual picker.",
|
||||
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||
disablePadding: true,
|
||||
}),
|
||||
TableEditor: NullEditor as any,
|
||||
SideDrawerField,
|
||||
filter: {
|
||||
operators: filterOperators,
|
||||
valueFormatter
|
||||
},
|
||||
csvImportParser: (value: string) => {
|
||||
try {
|
||||
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";
|
||||
|
||||
export default function ConnectService({
|
||||
value,
|
||||
onSubmit,
|
||||
onChange,
|
||||
column,
|
||||
parentRef,
|
||||
showPopoverCell,
|
||||
disabled,
|
||||
docRef,
|
||||
}: IPopoverCellProps) {
|
||||
_rowy_ref,
|
||||
}: IEditorCellProps) {
|
||||
const config = column.config ?? {};
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<ConnectServiceSelect
|
||||
value={value}
|
||||
onChange={onSubmit}
|
||||
onChange={onChange}
|
||||
config={(config as any) ?? {}}
|
||||
disabled={disabled}
|
||||
docRef={docRef as any}
|
||||
docRef={_rowy_ref as any}
|
||||
TextFieldProps={{
|
||||
style: { display: "none" },
|
||||
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 { 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 BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
||||
import InlineCell from "./InlineCell";
|
||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const PopoverCell = lazy(
|
||||
const EditorCell = lazy(
|
||||
() =>
|
||||
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */)
|
||||
import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
@@ -30,11 +28,10 @@ export const config: IFieldConfig = {
|
||||
icon: <ConnectServiceIcon />,
|
||||
description:
|
||||
"Connects to an external web service to fetch a list of results.",
|
||||
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
transparent: true,
|
||||
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||
disablePadding: true,
|
||||
transparentPopover: true,
|
||||
}),
|
||||
TableEditor: NullEditor as any,
|
||||
SideDrawerField,
|
||||
requireConfiguration: true,
|
||||
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";
|
||||
|
||||
export default function ConnectTable({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
column,
|
||||
parentRef,
|
||||
showPopoverCell,
|
||||
row,
|
||||
disabled,
|
||||
}: IPopoverCellProps) {
|
||||
}: IEditorCellProps) {
|
||||
const config = column.config ?? {};
|
||||
if (!config || !config.primaryKeys) return null;
|
||||
|
||||
@@ -19,7 +20,7 @@ export default function ConnectTable({
|
||||
row={row}
|
||||
column={column}
|
||||
value={value}
|
||||
onChange={onSubmit}
|
||||
onChange={onChange}
|
||||
config={(config as any) ?? {}}
|
||||
disabled={disabled}
|
||||
TextFieldProps={{
|
||||
@@ -33,7 +34,10 @@ export default function ConnectTable({
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClose={() => showPopoverCell(false)}
|
||||
onClose={() => {
|
||||
showPopoverCell(false);
|
||||
onSubmit();
|
||||
}}
|
||||
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 { 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 BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
||||
import InlineCell from "./InlineCell";
|
||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const PopoverCell = lazy(
|
||||
() =>
|
||||
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectTable" */)
|
||||
const EditorCell = lazy(
|
||||
() => import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectTable" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
@@ -31,11 +28,10 @@ export const config: IFieldConfig = {
|
||||
icon: <ConnectTableIcon />,
|
||||
description:
|
||||
"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, {
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
transparent: true,
|
||||
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||
disablePadding: true,
|
||||
transparentPopover: true,
|
||||
}),
|
||||
TableEditor: NullEditor as any,
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
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 clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { get } from "lodash-es";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -7,7 +6,6 @@ import { useAtom } from "jotai";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
List,
|
||||
@@ -21,7 +19,6 @@ import {
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
import { IConnectorSelectProps } from ".";
|
||||
import useStyles from "./styles";
|
||||
import Loading from "@src/components/Loading";
|
||||
import { getLabel } from "@src/components/fields/Connector/utils";
|
||||
import { useSnackbar } from "notistack";
|
||||
@@ -37,7 +34,7 @@ export default function PopupContents({
|
||||
value = [],
|
||||
onChange,
|
||||
column,
|
||||
docRef,
|
||||
_rowy_ref,
|
||||
}: IPopupContentsProps) {
|
||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
@@ -48,8 +45,6 @@ export default function PopupContents({
|
||||
const elementId = config.elementId;
|
||||
const multiple = Boolean(config.multiple);
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
// Webservice search query
|
||||
const [query, setQuery] = useState("");
|
||||
// Webservice response
|
||||
@@ -75,7 +70,7 @@ export default function PopupContents({
|
||||
columnKey: column.key,
|
||||
query: query,
|
||||
schemaDocPath: getTableSchemaPath(tableSettings),
|
||||
rowDocPath: docRef.path,
|
||||
rowDocPath: _rowy_ref.path,
|
||||
},
|
||||
});
|
||||
setResponse(resp);
|
||||
@@ -105,93 +100,88 @@ export default function PopupContents({
|
||||
const clearSelection = () => onChange([]);
|
||||
|
||||
return (
|
||||
<Grid container direction="column" className={classes.grid}>
|
||||
<Grid item className={classes.searchRow}>
|
||||
<Grid container direction="column" sx={{ p: 1, height: "100%" }}>
|
||||
<Grid item>
|
||||
<TextField
|
||||
value={query}
|
||||
type="search"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
fullWidth
|
||||
variant="filled"
|
||||
margin="dense"
|
||||
label="Search items"
|
||||
className={classes.noMargins}
|
||||
hiddenLabel
|
||||
placeholder="Search items"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
InputLabelProps={{ className: "visually-hidden" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs className={classes.listRow}>
|
||||
<List className={classes.list}>
|
||||
<Grid item xs>
|
||||
<List sx={{ overflowY: "auto" }}>
|
||||
{hits.map((hit) => {
|
||||
const isSelected = selectedValues.some((v) => v === hit[elementId]);
|
||||
return (
|
||||
<React.Fragment key={get(hit, elementId)}>
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={isSelected ? deselect(hit) : select(hit)}
|
||||
disabled={
|
||||
!isSelected && multiple && value.length >= config.max
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.checkboxContainer}>
|
||||
{multiple ? (
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
className={classes.checkbox}
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${get(hit, elementId)}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
className={classes.checkbox}
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${get(hit, elementId)}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
id={`label-${get(hit, elementId)}`}
|
||||
primary={getLabel(config, hit)}
|
||||
/>
|
||||
</MenuItem>
|
||||
<Divider className={classes.divider} />
|
||||
</React.Fragment>
|
||||
<MenuItem
|
||||
key={get(hit, elementId)}
|
||||
onClick={isSelected ? deselect(hit) : select(hit)}
|
||||
disabled={!isSelected && multiple && value.length >= config.max}
|
||||
disableGutters
|
||||
style={{ margin: 0, width: "100%" }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{multiple ? (
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${get(hit, elementId)}`,
|
||||
}}
|
||||
sx={{ py: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${get(hit, elementId)}`,
|
||||
}}
|
||||
sx={{ py: 0 }}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
id={`label-${get(hit, elementId)}`}
|
||||
primary={getLabel(config, hit)}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
{multiple && (
|
||||
<Grid item className={clsx(classes.footerRow, classes.selectedRow)}>
|
||||
<Grid item>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
variant="button"
|
||||
color="textSecondary"
|
||||
className={classes.selectedNum}
|
||||
>
|
||||
<Typography variant="button" color="textSecondary" sx={{ ml: 1 }}>
|
||||
{value?.length} of {hits?.length}
|
||||
</Typography>
|
||||
|
||||
@@ -199,9 +189,9 @@ export default function PopupContents({
|
||||
disabled={!value || value.length === 0}
|
||||
onClick={clearSelection}
|
||||
color="primary"
|
||||
className={classes.selectAllButton}
|
||||
variant="text"
|
||||
>
|
||||
Clear selection
|
||||
Clear
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface IConnectorSelectProps {
|
||||
className?: string;
|
||||
/** Override any props of the root MUI `TextField` component */
|
||||
TextFieldProps?: Partial<TextFieldProps>;
|
||||
docRef: TableRowRef;
|
||||
_rowy_ref: TableRowRef;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,11 @@ export default function ConnectorSelect({
|
||||
// prop for this component to a comma-separated string
|
||||
MenuProps: {
|
||||
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" },
|
||||
transformOrigin: { vertical: "top", horizontal: "center" },
|
||||
...TextFieldProps.SelectProps?.MenuProps,
|
||||
|
||||
@@ -11,7 +11,6 @@ export default function Connector({
|
||||
column,
|
||||
_rowy_ref,
|
||||
value,
|
||||
onDirty,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
@@ -32,7 +31,7 @@ export default function Connector({
|
||||
column={column}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
docRef={_rowy_ref as any}
|
||||
_rowy_ref={_rowy_ref}
|
||||
TextFieldProps={{
|
||||
label: "",
|
||||
hiddenLabel: true,
|
||||
@@ -50,7 +49,6 @@ export default function Connector({
|
||||
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
|
||||
{value.map((item) => {
|
||||
const key = get(item, config.elementId);
|
||||
console.log(key, item);
|
||||
return (
|
||||
<Grid item key={key}>
|
||||
<Chip
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { lazy } from "react";
|
||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell";
|
||||
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";
|
||||
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||
|
||||
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(
|
||||
() =>
|
||||
@@ -30,11 +29,9 @@ export const config: IFieldConfig = {
|
||||
icon: <ConnectorIcon />,
|
||||
description:
|
||||
"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, {
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
transparent: true,
|
||||
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
|
||||
disablePadding: true,
|
||||
}),
|
||||
TableEditor: NullEditor as any,
|
||||
SideDrawerField,
|
||||
requireConfiguration: true,
|
||||
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 { 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;
|
||||
const dateLabel = format(
|
||||
value.toDate ? value.toDate() : value,
|
||||
@@ -1,14 +1,10 @@
|
||||
import { lazy } from "react";
|
||||
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 BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
||||
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const TableCell = lazy(
|
||||
() => import("./TableCell" /* webpackChunkName: "TableCell-CreatedAt" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
import(
|
||||
@@ -28,8 +24,7 @@ export const config: IFieldConfig = {
|
||||
initialValue: null,
|
||||
icon: <CreatedAtIcon />,
|
||||
description: "Displays the timestamp of when the row was created. Read-only.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: withSideDrawerEditor(TableCell),
|
||||
TableCell: withRenderTableCell(DisplayCell, null),
|
||||
SideDrawerField,
|
||||
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 { format } from "date-fns";
|
||||
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;
|
||||
const dateLabel = format(
|
||||
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