Merge pull request #978 from rowyio/feature/rowy-706-table-upgrade

Feature/rowy 706 table upgrade
This commit is contained in:
Shams
2022-11-24 00:56:19 +01:00
committed by GitHub
208 changed files with 4553 additions and 3600 deletions

View File

@@ -18,6 +18,7 @@
"@mui/x-date-pickers": "^5.0.0-alpha.4", "@mui/x-date-pickers": "^5.0.0-alpha.4",
"@rowy/form-builder": "^0.7.0", "@rowy/form-builder": "^0.7.0",
"@rowy/multiselect": "^0.4.1", "@rowy/multiselect": "^0.4.1",
"@tanstack/react-table": "^8.5.15",
"@tinymce/tinymce-react": "^3", "@tinymce/tinymce-react": "^3",
"@uiw/react-md-editor": "^3.14.1", "@uiw/react-md-editor": "^3.14.1",
"algoliasearch": "^4.13.1", "algoliasearch": "^4.13.1",
@@ -46,7 +47,6 @@
"react": "^18.0.0", "react": "^18.0.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-color-palette": "^6.2.0", "react-color-palette": "^6.2.0",
"react-data-grid": "7.0.0-beta.5",
"react-detect-offline": "^2.4.5", "react-detect-offline": "^2.4.5",
"react-div-100vh": "^0.7.0", "react-div-100vh": "^0.7.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
@@ -64,6 +64,7 @@
"react-router-hash-link": "^2.4.3", "react-router-hash-link": "^2.4.3",
"react-scripts": "^5.0.0", "react-scripts": "^5.0.0",
"react-usestateref": "^1.0.8", "react-usestateref": "^1.0.8",
"react-virtual": "^2.10.4",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
@@ -91,7 +92,7 @@
"typedoc": "typedoc src/atoms/projectScope/index.ts src/atoms/tableScope/index.ts --out typedoc" "typedoc": "typedoc src/atoms/projectScope/index.ts src/atoms/tableScope/index.ts --out typedoc"
}, },
"engines": { "engines": {
"node": ">=16" "node": "^16"
}, },
"eslintConfig": { "eslintConfig": {
"plugins": [ "plugins": [

View File

@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Backdrop } from "@mui/material";
import Loading from "@src/components/Loading"; import Loading from "@src/components/Loading";
import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase"; import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase";
import MembersSourceFirebase from "@src/sources/MembersSourceFirebase"; import MembersSourceFirebase from "@src/sources/MembersSourceFirebase";
@@ -116,7 +117,21 @@ export default function App() {
<Route index element={<NotFound />} /> <Route index element={<NotFound />} />
<Route <Route
path=":docPath/:subTableKey" path=":docPath/:subTableKey"
element={<ProvidedSubTablePage />} element={
<Suspense
fallback={
<Backdrop
key="sub-table-modal-backdrop"
open
sx={{ zIndex: "modal" }}
>
<Loading />
</Backdrop>
}
>
<ProvidedSubTablePage />
</Suspense>
}
/> />
</Route> </Route>
</Route> </Route>

View File

@@ -103,6 +103,9 @@ export { FileTableBoxOutline as Project };
import { TableColumn } from "mdi-material-ui"; import { TableColumn } from "mdi-material-ui";
export { TableColumn }; export { TableColumn };
import { DragVertical } from "mdi-material-ui";
export { DragVertical };
export * from "./AddRow"; export * from "./AddRow";
export * from "./AddRowTop"; export * from "./AddRowTop";
export * from "./ChevronDown"; export * from "./ChevronDown";

View File

@@ -97,8 +97,6 @@ export const updateColumnAtom = atom(
}); });
} }
console.log(tableColumnsOrdered);
// Reduce array into single object with updated indexes // Reduce array into single object with updated indexes
const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {}); const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {});
await updateTableSchema({ columns: updatedColumns }); await updateTableSchema({ columns: updatedColumns });

View File

@@ -130,7 +130,11 @@ export const sideDrawerAtom = atomWithHash<"table-information" | null>(
/** Store side drawer open state */ /** Store side drawer open state */
export const sideDrawerOpenAtom = atom(false); export const sideDrawerOpenAtom = atom(false);
export type SelectedCell = { path: string; columnKey: string }; export type SelectedCell = {
path: string | "_rowy_header";
columnKey: string | "_rowy_row_actions";
focusInside: boolean;
};
/** Store selected cell in table. Used in side drawer and context menu */ /** Store selected cell in table. Used in side drawer and context menu */
export const selectedCellAtom = atom<SelectedCell | null>(null); export const selectedCellAtom = atom<SelectedCell | null>(null);

View File

@@ -29,11 +29,10 @@ import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import EvalIcon from "@mui/icons-material/PlayCircleOutline"; import EvalIcon from "@mui/icons-material/PlayCircleOutline";
import MenuContents, { IMenuContentsProps } from "./MenuContents"; import MenuContents, { IMenuContentsProps } from "./MenuContents";
import ColumnHeader from "@src/components/Table/Column"; import ColumnHeader from "@src/components/Table/Mock/Column";
import { import {
projectScope, projectScope,
userRolesAtom,
userSettingsAtom, userSettingsAtom,
updateUserSettingsAtom, updateUserSettingsAtom,
confirmDialogAtom, confirmDialogAtom,
@@ -80,8 +79,17 @@ export interface IMenuModalProps {
) => void; ) => void;
} }
export default function ColumnMenu() { export interface IColumnMenuProps {
const [userRoles] = useAtom(userRolesAtom, projectScope); canAddColumns: boolean;
canEditColumns: boolean;
canDeleteColumns: boolean;
}
export default function ColumnMenu({
canAddColumns,
canEditColumns,
canDeleteColumns,
}: IColumnMenuProps) {
const [userSettings] = useAtom(userSettingsAtom, projectScope); const [userSettings] = useAtom(userSettingsAtom, projectScope);
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope); const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
const confirm = useSetAtom(confirmDialogAtom, projectScope); const confirm = useSetAtom(confirmDialogAtom, projectScope);
@@ -248,7 +256,7 @@ export default function ColumnMenu() {
}); });
handleClose(); handleClose();
}, },
active: !column.editable, active: column.editable === false,
}, },
{ {
label: "Disable resize", label: "Disable resize",
@@ -387,6 +395,7 @@ export default function ColumnMenu() {
openColumnModal({ type: "new", index: column.index - 1 }); openColumnModal({ type: "new", index: column.index - 1 });
handleClose(); handleClose();
}, },
disabled: !canAddColumns,
}, },
{ {
label: "Insert to the right…", label: "Insert to the right…",
@@ -396,6 +405,7 @@ export default function ColumnMenu() {
openColumnModal({ type: "new", index: column.index + 1 }); openColumnModal({ type: "new", index: column.index + 1 });
handleClose(); handleClose();
}, },
disabled: !canAddColumns,
}, },
{ {
label: `Delete column${altPress ? "" : "…"}`, label: `Delete column${altPress ? "" : "…"}`,
@@ -450,16 +460,19 @@ export default function ColumnMenu() {
}); });
}, },
color: "error" as "error", color: "error" as "error",
disabled: !canDeleteColumns,
}, },
]; ];
let menuItems = [...localViewActions]; let menuItems = [...localViewActions];
if (userRoles.includes("ADMIN") || userRoles.includes("OPS")) { if (canEditColumns) {
menuItems.push.apply(menuItems, configActions); menuItems.push.apply(menuItems, configActions);
if (column.type === FieldType.derivative) { if (column.type === FieldType.derivative) {
menuItems.push.apply(menuItems, derivativeActions); menuItems.push.apply(menuItems, derivativeActions);
} }
}
if (canAddColumns || canDeleteColumns) {
menuItems.push.apply(menuItems, columnActions); menuItems.push.apply(menuItems, columnActions);
} }

View File

@@ -53,7 +53,7 @@ export const MemoizedField = memo(
// Should not reach this state // Should not reach this state
if (isEmpty(fieldComponent)) { if (isEmpty(fieldComponent)) {
// console.error('Could not find SideDrawerField component', field); console.error("Could not find SideDrawerField component", field);
return null; return null;
} }
@@ -78,10 +78,6 @@ export const MemoizedField = memo(
}, },
onSubmit: handleSubmit, onSubmit: handleSubmit,
disabled, disabled,
// TODO: Remove
control: {} as any,
useFormMethods: {} as any,
docRef: _rowy_ref,
})} })}
</FieldWrapper> </FieldWrapper>
); );

View File

@@ -1,10 +1,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import useMemoValue from "use-memo-value";
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { find, findIndex, isEqual } from "lodash-es"; import { find, findIndex } from "lodash-es";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { DataGridHandle } from "react-data-grid";
import { TransitionGroup } from "react-transition-group"; import { TransitionGroup } from "react-transition-group";
import { Fab, Fade } from "@mui/material"; import { Fab, Fade } from "@mui/material";
@@ -16,34 +14,20 @@ import ErrorFallback from "@src/components/ErrorFallback";
import StyledDrawer from "./StyledDrawer"; import StyledDrawer from "./StyledDrawer";
import SideDrawerFields from "./SideDrawerFields"; import SideDrawerFields from "./SideDrawerFields";
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
import { import {
tableScope, tableScope,
tableIdAtom,
tableColumnsOrderedAtom,
tableRowsAtom, tableRowsAtom,
sideDrawerOpenAtom, sideDrawerOpenAtom,
selectedCellAtom, selectedCellAtom,
} from "@src/atoms/tableScope"; } from "@src/atoms/tableScope";
import { analytics, logEvent } from "@src/analytics"; import { analytics, logEvent } from "@src/analytics";
import { formatSubTableName } from "@src/utils/table";
export const DRAWER_WIDTH = 512; export const DRAWER_WIDTH = 512;
export const DRAWER_COLLAPSED_WIDTH = 36; export const DRAWER_COLLAPSED_WIDTH = 36;
export default function SideDrawer({ export default function SideDrawer() {
dataGridRef,
}: {
dataGridRef?: React.MutableRefObject<DataGridHandle | null>;
}) {
const [userSettings] = useAtom(userSettingsAtom, projectScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope); const [tableRows] = useAtom(tableRowsAtom, tableScope);
const userDocHiddenFields =
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [];
const [cell, setCell] = useAtom(selectedCellAtom, tableScope); const [cell, setCell] = useAtom(selectedCellAtom, tableScope);
const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope); const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope);
const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]); const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]);
@@ -51,26 +35,6 @@ export default function SideDrawer({
"_rowy_ref.path", "_rowy_ref.path",
cell?.path, cell?.path,
]); ]);
// Memo a list of visible column keys for useEffect dependencies
const visibleColumnKeys = useMemoValue(
tableColumnsOrdered
.filter((col) => !userDocHiddenFields.includes(col.key))
.map((col) => col.key),
isEqual
);
// When side drawer is opened, select the cell in the table
// in case weve scrolled and selected cell was reset
useEffect(() => {
if (open) {
const columnIndex = visibleColumnKeys.indexOf(cell?.columnKey || "");
if (columnIndex === -1 || selectedCellRowIndex === -1) return;
dataGridRef?.current?.selectCell(
{ rowIdx: selectedCellRowIndex, idx: columnIndex },
false
);
}
}, [open, visibleColumnKeys, selectedCellRowIndex, cell, dataGridRef]);
const handleNavigate = (direction: "up" | "down") => () => { const handleNavigate = (direction: "up" | "down") => () => {
if (!tableRows || !cell) return; if (!tableRows || !cell) return;
@@ -79,13 +43,11 @@ export default function SideDrawer({
if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1; if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1;
const newPath = tableRows[rowIndex]._rowy_ref.path; const newPath = tableRows[rowIndex]._rowy_ref.path;
setCell((cell) => ({ columnKey: cell!.columnKey, path: newPath })); setCell((cell) => ({
columnKey: cell!.columnKey,
const columnIndex = visibleColumnKeys.indexOf(cell!.columnKey || ""); path: newPath,
dataGridRef?.current?.selectCell( focusInside: false,
{ rowIdx: rowIndex, idx: columnIndex }, }));
false
);
}; };
// const [urlDocState, dispatchUrlDoc] = useDoc({}); // const [urlDocState, dispatchUrlDoc] = useDoc({});
@@ -109,7 +71,7 @@ export default function SideDrawer({
// } // }
// }, [cell]); // }, [cell]);
const disabled = !open && !cell; // && !urlDocState.doc; const disabled = (!open && !cell) || selectedCellRowIndex <= -1; // && !urlDocState.doc;
useEffect(() => { useEffect(() => {
if (disabled && setOpen) setOpen(false); if (disabled && setOpen) setOpen(false);
}, [disabled, setOpen]); }, [disabled, setOpen]);
@@ -143,6 +105,7 @@ export default function SideDrawer({
{!!cell && ( {!!cell && (
<div className="sidedrawer-nav-fab-container"> <div className="sidedrawer-nav-fab-container">
<Fab <Fab
aria-label="Previous row"
style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }} style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
size="small" size="small"
disabled={disabled || !cell || selectedCellRowIndex <= 0} disabled={disabled || !cell || selectedCellRowIndex <= 0}
@@ -152,6 +115,7 @@ export default function SideDrawer({
</Fab> </Fab>
<Fab <Fab
aria-label="Next row"
style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }} style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }}
size="small" size="small"
disabled={ disabled={
@@ -166,6 +130,7 @@ export default function SideDrawer({
<div className="sidedrawer-open-fab-container"> <div className="sidedrawer-open-fab-container">
<Fab <Fab
aria-label={open ? "Close side drawer" : "Open side drawer"}
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
if (setOpen) if (setOpen)

View File

@@ -1,5 +1,5 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useLocation, useParams, Link as RouterLink } from "react-router-dom"; import { useParams, Link as RouterLink } from "react-router-dom";
import { find, camelCase, uniq } from "lodash-es"; import { find, camelCase, uniq } from "lodash-es";
import { import {

View File

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

View File

@@ -1,82 +1,86 @@
import { useRef } from "react"; import { memo, useRef } from "react";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useDrag, useDrop } from "react-dnd"; import type { Header } from "@tanstack/react-table";
import type {
DraggableProvided,
DraggableStateSnapshot,
} from "react-beautiful-dnd";
import { import {
styled,
alpha,
Tooltip, Tooltip,
TooltipProps,
tooltipClasses,
Fade, Fade,
Grid, StackProps,
IconButton, IconButton,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import DropdownIcon from "@mui/icons-material/MoreHoriz"; import DropdownIcon from "@mui/icons-material/MoreHoriz";
import LockIcon from "@mui/icons-material/LockOutlined"; import LockIcon from "@mui/icons-material/LockOutlined";
import ColumnHeaderSort from "./ColumnHeaderSort";
import { import {
projectScope, StyledColumnHeader,
userRolesAtom, StyledColumnHeaderNameTooltip,
altPressAtom, } from "@src/components/Table/Styled/StyledColumnHeader";
} from "@src/atoms/projectScope"; import ColumnHeaderSort, { SORT_STATES } from "./ColumnHeaderSort";
import ColumnHeaderDragHandle from "./ColumnHeaderDragHandle";
import ColumnHeaderResizer from "./ColumnHeaderResizer";
import { projectScope, altPressAtom } from "@src/atoms/projectScope";
import { import {
tableScope, tableScope,
updateColumnAtom, selectedCellAtom,
columnMenuAtom, columnMenuAtom,
tableSortsAtom,
} from "@src/atoms/tableScope"; } from "@src/atoms/tableScope";
import { getFieldProp } from "@src/components/fields"; import { getFieldProp } from "@src/components/fields";
import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; import { FieldType } from "@src/constants/fields";
import { ColumnConfig } from "@src/types/table"; import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Mock/Column";
import type { ColumnConfig } from "@src/types/table";
import type { TableRow } from "@src/types/table";
export { COLUMN_HEADER_HEIGHT }; export { COLUMN_HEADER_HEIGHT };
const LightTooltip = styled(({ className, ...props }: TooltipProps) => ( export interface IColumnHeaderProps
<Tooltip {...props} classes={{ popper: className }} /> extends Partial<Omit<StackProps, "style" | "sx">> {
))(({ theme }) => ({ header: Header<TableRow, any>;
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`,
padding: 0,
paddingRight: theme.spacing(1.5),
},
}));
export interface IDraggableHeaderRendererProps {
column: ColumnConfig; column: ColumnConfig;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
width: number;
isSelectedCell: boolean;
focusInsideCell: boolean;
canEditColumns: boolean;
isLastFrozen: boolean;
} }
export default function DraggableHeaderRenderer({ /**
* Renders UI components for each column header, including accessibility
* attributes. Memoized to prevent re-render when resizing or reordering other
* columns.
*
* Renders:
* - Drag handle (accessible)
* - Field type icon + click to copy field key
* - Field name + hover to view full name if cut off
* - Sort button
* - Resize handle (not accessible)
*/
export const ColumnHeader = memo(function ColumnHeader({
header,
column, column,
}: IDraggableHeaderRendererProps) { provided,
const [userRoles] = useAtom(userRolesAtom, projectScope); snapshot,
const updateColumn = useSetAtom(updateColumnAtom, tableScope); width,
isSelectedCell,
focusInsideCell,
canEditColumns,
isLastFrozen,
}: IColumnHeaderProps) {
const openColumnMenu = useSetAtom(columnMenuAtom, tableScope); const openColumnMenu = useSetAtom(columnMenuAtom, tableScope);
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
const [altPress] = useAtom(altPressAtom, projectScope); const [altPress] = useAtom(altPressAtom, projectScope);
const [tableSorts] = useAtom(tableSortsAtom, tableScope);
const [{ isDragging }, dragRef] = useDrag({
type: "COLUMN_DRAG",
item: { key: column.key },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ isOver }, dropRef] = useDrop({
accept: "COLUMN_DRAG",
drop: ({ key }: { key: string }) => {
updateColumn({ key, config: {}, index: column.index });
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@@ -85,53 +89,69 @@ export default function DraggableHeaderRenderer({
openColumnMenu({ column, anchorEl: buttonRef.current }); openColumnMenu({ column, anchorEl: buttonRef.current });
}; };
const _sortKey = getFieldProp("sortKey", (column as any).type);
const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key;
const currentSort: typeof SORT_STATES[number] =
tableSorts[0]?.key !== sortKey
? "none"
: tableSorts[0]?.direction || "none";
return ( return (
<Grid <StyledColumnHeader
key={column.key} role="columnheader"
id={`column-header-${column.key}`} id={`column-header-${column.key}`}
ref={(ref) => { ref={provided.innerRef}
dragRef(ref); {...provided.draggableProps}
dropRef(ref); data-row-id={"_rowy_header"}
data-col-id={header.id}
data-frozen={header.column.getIsPinned() || undefined}
data-frozen-last={isLastFrozen || undefined}
tabIndex={isSelectedCell ? 0 : -1}
aria-colindex={header.index + 1}
aria-readonly={!canEditColumns}
aria-selected={isSelectedCell}
aria-sort={
currentSort === "none"
? "none"
: currentSort === "asc"
? "ascending"
: "descending"
}
style={{
left: header.column.getIsPinned()
? header.column.getStart()
: undefined,
zIndex: header.column.getIsPinned() ? 11 : 10,
...provided.draggableProps.style,
width,
borderLeftStyle: snapshot.isDragging ? "solid" : undefined,
}} }}
container
alignItems="center"
wrap="nowrap"
onContextMenu={handleOpenMenu} onContextMenu={handleOpenMenu}
sx={[ onClick={(e) => {
{ setSelectedCell({
height: "100%", path: "_rowy_header",
"& svg, & button": { display: "block" }, columnKey: header.id,
focusInside: false,
color: "text.secondary", });
transition: (theme) => (e.target as HTMLDivElement).focus();
theme.transitions.create("color", { }}
duration: theme.transitions.duration.short, onDoubleClick={(e) => {
}), setSelectedCell({
"&:hover": { color: "text.primary" }, path: "_rowy_header",
columnKey: header.id,
cursor: "move", focusInside: true,
});
py: 0, (e.target as HTMLDivElement).focus();
pr: 0.5, }}
pl: 1,
width: "100%",
},
isDragging
? { opacity: 0.5 }
: isOver
? {
backgroundColor: (theme) =>
alpha(
theme.palette.primary.main,
theme.palette.action.focusOpacity
),
color: "primary.main",
}
: {},
]}
className="column-header"
> >
{(column.width as number) > 140 && ( {provided.dragHandleProps && (
<ColumnHeaderDragHandle
dragHandleProps={provided.dragHandleProps}
tabIndex={focusInsideCell ? 0 : -1}
/>
)}
{width > 140 && (
<Tooltip <Tooltip
title={ title={
<> <>
@@ -144,94 +164,99 @@ export default function DraggableHeaderRenderer({
placement="bottom-start" placement="bottom-start"
arrow arrow
> >
<Grid <div
item
onClick={() => { onClick={() => {
navigator.clipboard.writeText(column.key); navigator.clipboard.writeText(column.key);
}} }}
style={{ position: "relative", zIndex: 2 }}
> >
{column.editable === false ? ( {column.editable === false ? (
<LockIcon /> <LockIcon />
) : ( ) : (
getFieldProp("icon", (column as any).type) getFieldProp("icon", (column as any).type)
)} )}
</Grid> </div>
</Tooltip> </Tooltip>
)} )}
<Grid <StyledColumnHeaderNameTooltip
item title={
xs
sx={{ flexShrink: 1, overflow: "hidden", my: 0, ml: 0.5, mr: -30 / 8 }}
>
<LightTooltip
title={
<Typography
sx={{
typography: "caption",
fontWeight: "fontWeightMedium",
lineHeight: `${COLUMN_HEADER_HEIGHT - 2 - 4}px`,
textOverflow: "clip",
}}
color="inherit"
>
{column.name as string}
</Typography>
}
enterDelay={1000}
placement="bottom-start"
disableInteractive
TransitionComponent={Fade}
>
<Typography <Typography
noWrap
sx={{ sx={{
typography: "caption", typography: "caption",
fontWeight: "fontWeightMedium", fontWeight: "fontWeightMedium",
lineHeight: `${COLUMN_HEADER_HEIGHT}px`, lineHeight: `${COLUMN_HEADER_HEIGHT - 2 - 4}px`,
textOverflow: "clip", textOverflow: "clip",
}} }}
component="div"
color="inherit" color="inherit"
> >
{altPress ? ( {column.name as string}
<>
{column.index} <code>{column.fieldName}</code>
</>
) : (
column.name
)}
</Typography> </Typography>
</LightTooltip> }
</Grid> enterDelay={1000}
placement="bottom-start"
disableInteractive
TransitionComponent={Fade}
sx={{ "& .MuiTooltip-tooltip": { marginTop: "-28px !important" } }}
>
<Typography
noWrap
sx={{
typography: "caption",
fontWeight: "fontWeightMedium",
textOverflow: "clip",
position: "relative",
zIndex: 1,
<Grid item> flexGrow: 1,
<ColumnHeaderSort column={column as any} /> flexShrink: 1,
</Grid> overflow: "hidden",
my: 0,
ml: 0.5,
mr: -30 / 8,
}}
component="div"
color="inherit"
>
{altPress ? (
<>
{column.index} <code>{column.fieldName}</code>
</>
) : (
column.name
)}
</Typography>
</StyledColumnHeaderNameTooltip>
<Grid item> {column.type !== FieldType.id && (
<Tooltip title="Column settings"> <ColumnHeaderSort
<IconButton sortKey={sortKey}
size="small" currentSort={currentSort}
aria-label={`Column settings for ${column.name as string}`} tabIndex={focusInsideCell ? 0 : -1}
id={`column-settings-${column.key}`} />
color="inherit" )}
onClick={handleOpenMenu}
ref={buttonRef}
sx={{
transition: (theme) =>
theme.transitions.create("color", {
duration: theme.transitions.duration.short,
}),
color: "text.disabled", <Tooltip title="Column settings">
".column-header:hover &": { color: "text.primary" }, <IconButton
}} size="small"
> tabIndex={focusInsideCell ? 0 : -1}
<DropdownIcon /> id={`column-settings-${column.key}`}
</IconButton> onClick={handleOpenMenu}
</Tooltip> ref={buttonRef}
</Grid> >
</Grid> <DropdownIcon />
</IconButton>
</Tooltip>
{header.column.getCanResize() && (
<ColumnHeaderResizer
isResizing={header.column.getIsResizing()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
)}
</StyledColumnHeader>
); );
} });
export default ColumnHeader;

View 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>
);
}

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

View File

@@ -1,4 +1,5 @@
import { useAtom } from "jotai"; import { memo } from "react";
import { useSetAtom } from "jotai";
import { colord } from "colord"; import { colord } from "colord";
import { Tooltip, IconButton } from "@mui/material"; import { Tooltip, IconButton } from "@mui/material";
@@ -8,27 +9,26 @@ import IconSlash, {
} from "@src/components/IconSlash"; } from "@src/components/IconSlash";
import { tableScope, tableSortsAtom } from "@src/atoms/tableScope"; import { tableScope, tableSortsAtom } from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
import { ColumnConfig } from "@src/types/table"; export const SORT_STATES = ["none", "desc", "asc"] as const;
const SORT_STATES = ["none", "desc", "asc"] as const;
export interface IColumnHeaderSortProps { export interface IColumnHeaderSortProps {
column: ColumnConfig; sortKey: string;
currentSort: typeof SORT_STATES[number];
tabIndex?: number;
} }
export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { /**
const [tableSorts, setTableSorts] = useAtom(tableSortsAtom, tableScope); * Renders button with current sort state.
* On click, updates `tableSortsAtom` in `tableScope`.
*/
export const ColumnHeaderSort = memo(function ColumnHeaderSort({
sortKey,
currentSort,
tabIndex,
}: IColumnHeaderSortProps) {
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
const _sortKey = getFieldProp("sortKey", (column as any).type);
const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key;
const currentSort: typeof SORT_STATES[number] =
tableSorts[0]?.key !== sortKey
? "none"
: tableSorts[0]?.direction || "none";
const nextSort = const nextSort =
SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0]; SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0];
@@ -37,20 +37,19 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
else setTableSorts([{ key: sortKey, direction: nextSort }]); else setTableSorts([{ key: sortKey, direction: nextSort }]);
}; };
if (column.type === FieldType.id) return null;
return ( return (
<Tooltip <Tooltip
title={nextSort === "none" ? "Unsort" : `Sort by ${nextSort}ending`} title={nextSort === "none" ? "Remove sort" : `Sort by ${nextSort}ending`}
> >
<IconButton <IconButton
disableFocusRipple={true} disableFocusRipple={true}
size="small" size="small"
onClick={handleSortClick} onClick={handleSortClick}
color="inherit" color="inherit"
tabIndex={tabIndex}
sx={{ sx={{
bgcolor: "background.default", bgcolor: "background.default",
"&:hover": { "&:hover, &:focus": {
backgroundColor: (theme) => backgroundColor: (theme) =>
colord(theme.palette.background.default) colord(theme.palette.background.default)
.mix( .mix(
@@ -74,12 +73,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
position: "relative", position: "relative",
opacity: currentSort !== "none" ? 1 : 0, opacity: currentSort !== "none" ? 1 : 0,
".column-header:hover &": { opacity: 1 },
transition: (theme) =>
theme.transitions.create(["background-color", "opacity"], {
duration: theme.transitions.duration.short,
}),
"& .arrow": { "& .arrow": {
transition: (theme) => transition: (theme) =>
@@ -89,7 +82,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
transform: currentSort === "asc" ? "rotate(180deg)" : "none", transform: currentSort === "asc" ? "rotate(180deg)" : "none",
}, },
"&:hover .arrow": { "&:hover .arrow, &:focus .arrow": {
transform: transform:
currentSort === "asc" || nextSort === "asc" currentSort === "asc" || nextSort === "asc"
? "rotate(180deg)" ? "rotate(180deg)"
@@ -100,7 +93,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
strokeDashoffset: strokeDashoffset:
currentSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET, currentSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
}, },
"&:hover .icon-slash": { "&:hover .icon-slash, &:focus .icon-slash": {
strokeDashoffset: strokeDashoffset:
nextSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET, nextSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET,
}, },
@@ -113,4 +106,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
); );
} });
export default ColumnHeaderSort;

View File

@@ -1,3 +1,4 @@
import { useRef, useEffect } from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { NonFullScreenErrorFallback } from "@src/components/ErrorFallback"; import { NonFullScreenErrorFallback } from "@src/components/ErrorFallback";
@@ -8,10 +9,21 @@ import MenuContents from "./MenuContents";
import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope"; import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope";
export default function ContextMenu() { export default function ContextMenu() {
const menuRef = useRef<HTMLUListElement>(null);
const [contextMenuTarget, setContextMenuTarget] = useAtom( const [contextMenuTarget, setContextMenuTarget] = useAtom(
contextMenuTargetAtom, contextMenuTargetAtom,
tableScope tableScope
); );
const open = Boolean(contextMenuTarget);
useEffect(() => {
setTimeout(() => {
if (open && menuRef.current) {
const firstMenuitem = menuRef.current.querySelector("[role=menuitem]");
(firstMenuitem as HTMLElement)?.focus();
}
});
}, [open]);
const handleClose = () => setContextMenuTarget(null); const handleClose = () => setContextMenuTarget(null);
@@ -20,11 +32,12 @@ export default function ContextMenu() {
id="cell-context-menu" id="cell-context-menu"
aria-label="Cell context menu" aria-label="Cell context menu"
anchorEl={contextMenuTarget as any} anchorEl={contextMenuTarget as any}
open={Boolean(contextMenuTarget)} open={open}
onClose={handleClose} onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }} anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }}
sx={{ "& .MuiMenu-paper": { minWidth: 160 } }} sx={{ "& .MuiMenu-paper": { minWidth: 160 } }}
MenuListProps={{ ref: menuRef }}
> >
<ErrorBoundary FallbackComponent={NonFullScreenErrorFallback}> <ErrorBoundary FallbackComponent={NonFullScreenErrorFallback}>
<MenuContents onClose={handleClose} /> <MenuContents onClose={handleClose} />

View File

@@ -19,6 +19,7 @@ export interface IContextMenuItem extends Partial<MenuItemProps> {
disabled?: boolean; disabled?: boolean;
hotkeyLabel?: string; hotkeyLabel?: string;
divider?: boolean; divider?: boolean;
subItems?: IContextMenuItem[];
} }
export interface IContextMenuItemProps extends IContextMenuItem { export interface IContextMenuItemProps extends IContextMenuItem {
@@ -82,16 +83,20 @@ export default function ContextMenuItem({
</> </>
); );
} else { } else {
return ( if (props.divider) {
<MenuItem {...props} onClick={onClick}> return <Divider variant="middle" />;
<ListItemIcon>{icon}</ListItemIcon> } else {
<ListItemText>{label}</ListItemText> return (
{hotkeyLabel && ( <MenuItem {...props} onClick={onClick}>
<Typography variant="body2" color="text.secondary"> <ListItemIcon>{icon}</ListItemIcon>
{hotkeyLabel} <ListItemText>{label}</ListItemText>
</Typography> {hotkeyLabel && (
)} <Typography variant="body2" color="text.secondary">
</MenuItem> {hotkeyLabel}
); </Typography>
)}
</MenuItem>
);
}
} }
} }

View File

@@ -62,182 +62,188 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
if (!tableSchema.columns || !selectedCell) return null; if (!tableSchema.columns || !selectedCell) return null;
const selectedColumn = tableSchema.columns[selectedCell.columnKey]; const selectedColumn = tableSchema.columns[selectedCell.columnKey];
const menuActions = getFieldProp("contextMenuActions", selectedColumn.type); const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
if (!row) return null;
const actionGroups: IContextMenuItem[][] = []; const actionGroups: IContextMenuItem[][] = [];
const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
// Field type actions
const fieldTypeActions = menuActions
? menuActions(selectedCell, onClose)
: [];
if (fieldTypeActions.length > 0) actionGroups.push(fieldTypeActions);
if (selectedColumn.type === FieldType.derivative) { const handleDuplicate = () => {
const renderedFieldMenuActions = getFieldProp( addRow({
"contextMenuActions", row,
selectedColumn.config?.renderFieldType setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
);
if (renderedFieldMenuActions) {
actionGroups.push(renderedFieldMenuActions(selectedCell, onClose));
}
}
// Cell actions
// TODO: Add copy and paste here
const cellValue = row?.[selectedCell.columnKey];
const handleClearValue = () =>
updateField({
path: selectedCell.path,
fieldName: selectedColumn.fieldName,
value: null,
deleteField: true,
}); });
const columnFilters = getFieldProp("filter", selectedColumn.type);
const handleFilterValue = () => {
openTableFiltersPopover({
defaultQuery: {
key: selectedColumn.fieldName,
operator: columnFilters!.operators[0]?.value || "==",
value: cellValue,
},
});
onClose();
}; };
const cellActions = [ const handleDelete = () => deleteRow(row._rowy_ref.path);
const rowActions: IContextMenuItem[] = [
{ {
label: altPress ? "Clear value" : "Clear value…", label: "Copy ID",
color: "error", icon: <CopyIcon />,
icon: <ClearIcon />, onClick: () => {
navigator.clipboard.writeText(row._rowy_ref.id);
onClose();
},
},
{
label: "Copy path",
icon: <CopyIcon />,
onClick: () => {
navigator.clipboard.writeText(row._rowy_ref.path);
onClose();
},
},
{
label: "Open in Firebase Console",
icon: <OpenIcon />,
onClick: () => {
window.open(
`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${row._rowy_ref.path.replace(
/\//g,
"~2F"
)}`
);
onClose();
},
},
{ label: "Divider", divider: true },
{
label: "Duplicate",
icon: <DuplicateIcon />,
disabled: disabled:
selectedColumn.editable === false || tableSettings.tableType === "collectionGroup" ||
!row || (!userRoles.includes("ADMIN") && tableSettings.readOnly),
cellValue === undefined ||
getFieldProp("group", selectedColumn.type) === "Auditing",
onClick: altPress onClick: altPress
? handleClearValue ? handleDuplicate
: () => { : () => {
confirm({ confirm({
title: "Clear cell value?", title: "Duplicate row?",
body: "The cells value cannot be recovered after", body: (
confirm: "Delete", <>
confirmColor: "error", Row path:
handleConfirm: handleClearValue, <br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: handleDuplicate,
}); });
onClose(); onClose();
}, },
}, },
{ {
label: "Filter value", label: altPress ? "Delete" : "Delete…",
icon: <FilterIcon />, color: "error",
disabled: !columnFilters || cellValue === undefined, icon: <DeleteIcon />,
onClick: handleFilterValue, disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
onClick: altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
});
onClose();
},
}, },
]; ];
actionGroups.push(cellActions);
// Row actions if (selectedColumn) {
if (row) { const menuActions = getFieldProp(
const handleDuplicate = () => { "contextMenuActions",
addRow({ selectedColumn?.type
row, );
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
// Field type actions
const fieldTypeActions = menuActions
? menuActions(selectedCell, onClose)
: [];
if (fieldTypeActions.length > 0) actionGroups.push(fieldTypeActions);
if (selectedColumn?.type === FieldType.derivative) {
const renderedFieldMenuActions = getFieldProp(
"contextMenuActions",
selectedColumn.config?.renderFieldType
);
if (renderedFieldMenuActions) {
actionGroups.push(renderedFieldMenuActions(selectedCell, onClose));
}
}
// Cell actions
// TODO: Add copy and paste here
const cellValue = row?.[selectedCell.columnKey];
const handleClearValue = () =>
updateField({
path: selectedCell.path,
fieldName: selectedColumn.fieldName,
value: null,
deleteField: true,
}); });
const columnFilters = getFieldProp("filter", selectedColumn?.type);
const handleFilterValue = () => {
openTableFiltersPopover({
defaultQuery: {
key: selectedColumn.fieldName,
operator: columnFilters!.operators[0]?.value || "==",
value: cellValue,
},
});
onClose();
}; };
const handleDelete = () => deleteRow(row._rowy_ref.path); const cellActions = [
const rowActions = [ {
label: altPress ? "Clear value" : "Clear value…",
color: "error",
icon: <ClearIcon />,
disabled:
selectedColumn?.editable === false ||
!row ||
cellValue === undefined ||
getFieldProp("group", selectedColumn?.type) === "Auditing",
onClick: altPress
? handleClearValue
: () => {
confirm({
title: "Clear cell value?",
body: "The cells value cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleClearValue,
});
onClose();
},
},
{
label: "Filter value",
icon: <FilterIcon />,
disabled: !columnFilters || cellValue === undefined,
onClick: handleFilterValue,
},
];
actionGroups.push(cellActions);
// Row actions as sub-menu
actionGroups.push([
{ {
label: "Row", label: "Row",
icon: <RowIcon />, icon: <RowIcon />,
subItems: [ subItems: rowActions,
{
label: "Copy ID",
icon: <CopyIcon />,
onClick: () => {
navigator.clipboard.writeText(row._rowy_ref.id);
onClose();
},
},
{
label: "Copy path",
icon: <CopyIcon />,
onClick: () => {
navigator.clipboard.writeText(row._rowy_ref.path);
onClose();
},
},
{
label: "Open in Firebase Console",
icon: <OpenIcon />,
onClick: () => {
window.open(
`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${row._rowy_ref.path.replace(
/\//g,
"~2F"
)}`
);
onClose();
},
},
{ label: "Divider", divider: true },
{
label: "Duplicate",
icon: <DuplicateIcon />,
disabled:
tableSettings.tableType === "collectionGroup" ||
(!userRoles.includes("ADMIN") && tableSettings.readOnly),
onClick: altPress
? handleDuplicate
: () => {
confirm({
title: "Duplicate row?",
body: (
<>
Row path:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: handleDuplicate,
});
onClose();
},
},
{
label: altPress ? "Delete" : "Delete…",
color: "error",
icon: <DeleteIcon />,
disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
onClick: altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
});
onClose();
},
},
],
}, },
]; ]);
} else {
actionGroups.push(rowActions); actionGroups.push(rowActions);
} }

View File

@@ -1,9 +1,11 @@
import { memo } from "react";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import type { FormatterProps } from "react-data-grid"; import type { IRenderedTableCellProps } from "@src/components/Table/TableCell/withRenderTableCell";
import { Stack, Tooltip, IconButton, alpha } from "@mui/material"; import { Stack, Tooltip, IconButton, alpha } from "@mui/material";
import { CopyCells as CopyCellsIcon } from "@src/assets/icons"; import { CopyCells as CopyCellsIcon } from "@src/assets/icons";
import DeleteIcon from "@mui/icons-material/DeleteOutlined"; import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import MenuIcon from "@mui/icons-material/MoreHoriz";
import { import {
projectScope, projectScope,
@@ -17,10 +19,13 @@ import {
tableSettingsAtom, tableSettingsAtom,
addRowAtom, addRowAtom,
deleteRowAtom, deleteRowAtom,
contextMenuTargetAtom,
} from "@src/atoms/tableScope"; } from "@src/atoms/tableScope";
import { TableRow } from "@src/types/table";
export default function FinalColumn({ row }: FormatterProps<TableRow, any>) { export const FinalColumn = memo(function FinalColumn({
row,
focusInsideCell,
}: IRenderedTableCellProps) {
const [userRoles] = useAtom(userRolesAtom, projectScope); const [userRoles] = useAtom(userRolesAtom, projectScope);
const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope); const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
const confirm = useSetAtom(confirmDialogAtom, projectScope); const confirm = useSetAtom(confirmDialogAtom, projectScope);
@@ -28,12 +33,13 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const addRow = useSetAtom(addRowAtom, tableScope); const addRow = useSetAtom(addRowAtom, tableScope);
const deleteRow = useSetAtom(deleteRowAtom, tableScope); const deleteRow = useSetAtom(deleteRowAtom, tableScope);
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
const [altPress] = useAtom(altPressAtom, projectScope); const [altPress] = useAtom(altPressAtom, projectScope);
const handleDelete = () => deleteRow(row._rowy_ref.path); const handleDelete = () => deleteRow(row.original._rowy_ref.path);
const handleDuplicate = () => { const handleDuplicate = () => {
addRow({ addRow({
row, row: row.original,
setId: addRowIdType === "custom" ? "decrement" : addRowIdType, setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
}); });
}; };
@@ -42,7 +48,26 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
return null; return null;
return ( return (
<Stack direction="row" spacing={0.5}> <Stack
direction="row"
alignItems="center"
className="cell-contents"
gap={0.5}
>
<Tooltip title="Row menu">
<IconButton
size="small"
color="inherit"
onClick={(e) => {
setContextMenuTarget(e.target as HTMLElement);
}}
className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
>
<MenuIcon />
</IconButton>
</Tooltip>
<Tooltip title="Duplicate row"> <Tooltip title="Duplicate row">
<IconButton <IconButton
size="small" size="small"
@@ -61,7 +86,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
<code <code
style={{ userSelect: "all", wordBreak: "break-all" }} style={{ userSelect: "all", wordBreak: "break-all" }}
> >
{row._rowy_ref.path} {row.original._rowy_ref.path}
</code> </code>
</> </>
), ),
@@ -70,8 +95,8 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
}); });
} }
} }
aria-label="Duplicate row"
className="row-hover-iconButton" className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
> >
<CopyCellsIcon /> <CopyCellsIcon />
</IconButton> </IconButton>
@@ -94,7 +119,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
<code <code
style={{ userSelect: "all", wordBreak: "break-all" }} style={{ userSelect: "all", wordBreak: "break-all" }}
> >
{row._rowy_ref.path} {row.original._rowy_ref.path}
</code> </code>
</> </>
), ),
@@ -104,23 +129,25 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
}); });
} }
} }
aria-label={`Delete row${altPress ? "" : "…"}`}
className="row-hover-iconButton" className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
sx={{ sx={{
".rdg-row:hover .row-hover-iconButton&&": { "[role='row']:hover .row-hover-iconButton&&, .row-hover-iconButton&&:focus":
color: "error.main", {
backgroundColor: (theme) => color: "error.main",
alpha( backgroundColor: (theme) =>
theme.palette.error.main, alpha(
theme.palette.action.hoverOpacity * 2 theme.palette.error.main,
), theme.palette.action.hoverOpacity * 2
}, ),
},
}} }}
disabled={!row._rowy_ref.path} disabled={!row.original._rowy_ref.path}
> >
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Stack> </Stack>
); );
} });
export default FinalColumn;

View 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>
);
}

View File

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

View File

@@ -1,8 +1,10 @@
import { forwardRef } from "react";
import { Grid, GridProps, Typography } from "@mui/material"; import { Grid, GridProps, Typography } from "@mui/material";
import { alpha } from "@mui/material/styles"; import { alpha } from "@mui/material/styles";
import { FieldType } from "@src/constants/fields"; import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields"; import { getFieldProp } from "@src/components/fields";
import { spreadSx } from "@src/utils/ui";
export const COLUMN_HEADER_HEIGHT = 42; export const COLUMN_HEADER_HEIGHT = 42;
@@ -10,23 +12,30 @@ export interface IColumnProps extends Partial<GridProps> {
label: string; label: string;
type?: FieldType; type?: FieldType;
secondaryItem?: React.ReactNode; secondaryItem?: React.ReactNode;
children?: React.ReactNode;
active?: boolean; active?: boolean;
} }
export default function Column({ export const Column = forwardRef(function Column(
label, {
type, label,
secondaryItem, type,
secondaryItem,
children,
active, active,
...props ...props
}: IColumnProps) { }: IColumnProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
return ( return (
<Grid <Grid
ref={ref}
container container
alignItems="center" alignItems="center"
wrap="nowrap" wrap="nowrap"
aria-label={label}
{...props} {...props}
sx={[ sx={[
{ {
@@ -34,6 +43,7 @@ export default function Column({
height: COLUMN_HEADER_HEIGHT, height: COLUMN_HEADER_HEIGHT,
border: (theme) => `1px solid ${theme.palette.divider}`, border: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: "background.default", backgroundColor: "background.default",
position: "relative",
py: 0, py: 0,
px: 1, px: 1,
@@ -68,6 +78,7 @@ export default function Column({
}, },
} }
: {}, : {},
...spreadSx(props.sx),
]} ]}
> >
{type && <Grid item>{getFieldProp("icon", type)}</Grid>} {type && <Grid item>{getFieldProp("icon", type)}</Grid>}
@@ -104,6 +115,10 @@ export default function Column({
{secondaryItem} {secondaryItem}
</Grid> </Grid>
)} )}
{children}
</Grid> </Grid>
); );
} });
export default Column;

View File

@@ -3,7 +3,6 @@ import { useAtom } from "jotai";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import RichTooltip from "@src/components/RichTooltip"; import RichTooltip from "@src/components/RichTooltip";
import WarningIcon from "@mui/icons-material/WarningAmber"; import WarningIcon from "@mui/icons-material/WarningAmber";
import { OUT_OF_ORDER_MARGIN } from "./TableContainer";
import { import {
projectScope, projectScope,
@@ -24,15 +23,7 @@ const Dot = styled("div")(({ theme }) => ({
backgroundColor: theme.palette.warning.main, backgroundColor: theme.palette.warning.main,
})); }));
export interface IOutOfOrderIndicatorProps { export default function OutOfOrderIndicator() {
top: number;
height: number;
}
export default function OutOfOrderIndicator({
top,
height,
}: IOutOfOrderIndicatorProps) {
const [dismissed, setDismissed] = useAtom( const [dismissed, setDismissed] = useAtom(
tableOutOfOrderDismissedAtom, tableOutOfOrderDismissedAtom,
projectScope projectScope
@@ -42,11 +33,12 @@ export default function OutOfOrderIndicator({
<div <div
className="out-of-order-dot" className="out-of-order-dot"
style={{ style={{
position: "absolute", position: "sticky",
top: top, zIndex: 9,
height: height - OUT_OF_ORDER_MARGIN - 2, top: 0,
marginLeft: `max(env(safe-area-inset-left), 16px)`, left: 8,
width: 12, width: 12,
marginRight: -12,
}} }}
> >
<RichTooltip <RichTooltip

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

View 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),
},
}));

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

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

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

View File

@@ -1,343 +1,286 @@
import React, { useMemo, useState, Suspense } from "react"; import { useMemo, useRef, useState, useEffect, useCallback } from "react";
import useStateRef from "react-usestateref";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useDebouncedCallback, useThrottledCallback } from "use-debounce"; import { useThrottledCallback } from "use-debounce";
import { DndProvider } from "react-dnd"; import {
import { HTML5Backend } from "react-dnd-html5-backend"; createColumnHelper,
import { findIndex } from "lodash-es"; getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DropResult } from "react-beautiful-dnd";
import { get } from "lodash-es";
// import "react-data-grid/dist/react-data-grid.css"; import StyledTable from "./Styled/StyledTable";
import DataGrid, { import TableHeader from "./TableHeader";
Column, import TableBody from "./TableBody";
DataGridHandle, import FinalColumn from "./FinalColumn/FinalColumn";
// SelectColumn as _SelectColumn,
} from "react-data-grid";
import { LinearProgress } from "@mui/material";
import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer";
import ColumnHeader, { COLUMN_HEADER_HEIGHT } from "./ColumnHeader";
import FinalColumnHeader from "./FinalColumnHeader";
import FinalColumn from "./formatters/FinalColumn";
import TableRow from "./TableRow";
import EmptyState from "@src/components/EmptyState";
// import BulkActions from "./BulkActions";
import AddRow from "@src/components/TableToolbar/AddRow";
import { AddRow as AddRowIcon } from "@src/assets/icons";
import Loading from "@src/components/Loading";
import ContextMenu from "./ContextMenu"; import ContextMenu from "./ContextMenu";
import { import EmptyState from "@src/components/EmptyState";
projectScope, // import BulkActions from "./BulkActions";
userRolesAtom,
userSettingsAtom,
} from "@src/atoms/projectScope";
import { import {
tableScope, tableScope,
tableIdAtom,
tableSettingsAtom,
tableSchemaAtom, tableSchemaAtom,
tableColumnsOrderedAtom, tableColumnsOrderedAtom,
tableRowsAtom, tableRowsAtom,
tableNextPageAtom, tableNextPageAtom,
tablePageAtom, tablePageAtom,
updateColumnAtom, updateColumnAtom,
updateFieldAtom,
selectedCellAtom,
} from "@src/atoms/tableScope"; } from "@src/atoms/tableScope";
import { getFieldType, getFieldProp } from "@src/components/fields"; import { getFieldType, getFieldProp } from "@src/components/fields";
import { FieldType } from "@src/constants/fields"; import { TableRow, ColumnConfig } from "@src/types/table";
import { formatSubTableName } from "@src/utils/table"; import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { ColumnConfig } from "@src/types/table"; import { useSaveColumnSizing } from "./useSaveColumnSizing";
export type DataGridColumn = ColumnConfig & Column<any> & { isNew?: true };
export const DEFAULT_ROW_HEIGHT = 41; export const DEFAULT_ROW_HEIGHT = 41;
export const DEFAULT_COL_WIDTH = 150; export const DEFAULT_COL_WIDTH = 150;
export const MAX_COL_WIDTH = 380; export const MIN_COL_WIDTH = 80;
export const TABLE_PADDING = 16;
export const OUT_OF_ORDER_MARGIN = 8;
export const DEBOUNCE_DELAY = 500;
const rowKeyGetter = (row: any) => row.id; declare module "@tanstack/table-core" {
const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : ""); /** The `column.meta` property contains the column config from tableSchema */
//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 }; interface ColumnMeta<TData, TValue> extends ColumnConfig {}
}
const columnHelper = createColumnHelper<TableRow>();
const getRowId = (row: TableRow) => row._rowy_ref.path || row._rowy_ref.id;
export interface ITableProps {
/** Determines if “Add column” button is displayed */
canAddColumns: boolean;
/** Determines if columns can be rearranged */
canEditColumns: boolean;
/**
* Determines if any cell can be edited.
* If false, `Table` only ever renders `EditorCell`.
*/
canEditCells: boolean;
/** The hidden columns saved to user settings */
hiddenColumns?: string[];
/**
* Displayed when `tableRows` is empty.
* Loading state handled by Suspense in parent component.
*/
emptyState?: React.ReactNode;
}
/**
* Takes table schema and row data from `tableScope` and makes it compatible
* with TanStack Table. Renders table children and cell context menu.
*
* - Calls `useKeyboardNavigation` hook
* - Handles rearranging columns
* - Handles infinite scrolling
* - Stores local state for resizing columns, and asks admins if they want to
* save to table schema for all users
*/
export default function Table({ export default function Table({
dataGridRef, canAddColumns,
}: { canEditColumns,
dataGridRef?: React.MutableRefObject<DataGridHandle | null>; canEditCells,
}) { hiddenColumns,
const [userRoles] = useAtom(userRolesAtom, projectScope); emptyState,
const [userSettings] = useAtom(userSettingsAtom, projectScope); }: ITableProps) {
const [tableId] = useAtom(tableIdAtom, tableScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope); const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope); const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const setTablePage = useSetAtom(tablePageAtom, tableScope); const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope);
const updateColumn = useSetAtom(updateColumnAtom, tableScope); const updateColumn = useSetAtom(updateColumnAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const userDocHiddenFields = // Store a **state** and reference to the container element
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields; // so the state can re-render `TableBody`, preventing virtualization
// not detecting scroll if the container element was initially `null`
const [containerEl, setContainerEl, containerRef] =
useStateRef<HTMLDivElement | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
// Get column configs from table schema and map them to DataGridColumns // Get column defs from table schema
// Also filter out hidden columns and add end column // Also add end column for admins (canAddColumns || canEditCells)
const columns = useMemo(() => { const columns = useMemo(() => {
const _columns: DataGridColumn[] = tableColumnsOrdered const _columns = tableColumnsOrdered
.filter((column) => { // Hide column for all users using table schema
if (column.hidden) return false; .filter((column) => !column.hidden)
if ( .map((columnConfig) =>
Array.isArray(userDocHiddenFields) && columnHelper.accessor((row) => get(row, columnConfig.fieldName), {
userDocHiddenFields.includes(column.key) id: columnConfig.fieldName,
) meta: columnConfig,
return false; size: columnConfig.width,
return true; enableResizing: columnConfig.resizable !== false,
}) minSize: MIN_COL_WIDTH,
.map((column: any) => ({ cell: getFieldProp("TableCell", getFieldType(columnConfig)),
draggable: true, })
resizable: true, );
frozen: column.fixed,
headerRenderer: ColumnHeader,
formatter:
getFieldProp("TableCell", getFieldType(column)) ??
function InDev() {
return null;
},
editor:
getFieldProp("TableEditor", getFieldType(column)) ??
function InDev() {
return null;
},
...column,
editable:
tableSettings.readOnly && !userRoles.includes("ADMIN")
? false
: column.editable ?? true,
width: column.width ?? DEFAULT_COL_WIDTH,
}));
if (userRoles.includes("ADMIN") || !tableSettings.readOnly) { if (canAddColumns || canEditCells) {
_columns.push({ _columns.push(
isNew: true, columnHelper.display({
key: "new", id: "_rowy_column_actions",
fieldName: "_rowy_new", cell: FinalColumn as any,
name: "Add column", })
type: FieldType.last, );
index: _columns.length ?? 0,
width: 154,
headerRenderer: FinalColumnHeader,
headerCellClass: "final-column-header",
cellClass: "final-column-cell",
formatter: FinalColumn,
editable: false,
});
} }
return _columns; return _columns;
}, [ }, [tableColumnsOrdered, canAddColumns, canEditCells]);
tableColumnsOrdered,
userDocHiddenFields,
tableSettings.readOnly,
userRoles,
]);
const selectedColumnIndex = useMemo(() => {
if (!selectedCell?.columnKey) return -1;
return findIndex(columns, ["key", selectedCell.columnKey]);
}, [selectedCell?.columnKey, columns]);
// Handle columns with field names that use dot notation (nested fields) // Get users hidden columns from props and memoize into a `VisibilityState`
const rows = const columnVisibility = useMemo(() => {
useMemo(() => { if (!Array.isArray(hiddenColumns)) return {};
// const columnsWithNestedFieldNames = columns return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
// .map((col) => col.fieldName) }, [hiddenColumns]);
// .filter((fieldName) => fieldName.includes("."));
// if (columnsWithNestedFieldNames.length === 0) // Get frozen columns and memoize into a `ColumnPinningState`
return tableRows; const columnPinning = useMemo(
() => ({
left: columns.filter((c) => c.meta?.fixed && c.id).map((c) => c.id!),
}),
[columns]
);
const lastFrozen: string | undefined =
columnPinning.left[columnPinning.left.length - 1];
// return tableRows.map((row) => // Call TanStack Table
// columnsWithNestedFieldNames.reduce( const table = useReactTable({
// (acc, fieldName) => ({ data: tableRows,
// ...acc, columns,
// [fieldName]: get(row, fieldName), getCoreRowModel: getCoreRowModel(),
// }), getRowId,
// { ...row } columnResizeMode: "onChange",
// ) });
// );
}, [tableRows]) ?? [];
// const [selectedRowsSet, setSelectedRowsSet] = useState<Set<React.Key>>(); // Store local `columnSizing` state so we can save it to table schema
// const [selectedRows, setSelectedRows] = useState<any[]>([]); // in `useSaveColumnSizing`. This could be generalized by storing the
// entire table state.
const [columnSizing, setColumnSizing] = useState(
table.initialState.columnSizing
);
table.setOptions((prev) => ({
...prev,
state: { ...prev.state, columnVisibility, columnPinning, columnSizing },
onColumnSizingChange: setColumnSizing,
}));
// Get rows and columns for virtualization
const { rows } = table.getRowModel();
const leafColumns = table.getVisibleLeafColumns();
// Gets more rows when scrolled down. // Handle keyboard navigation
// https://github.com/adazzle/react-data-grid/blob/ead05032da79d7e2b86e37cdb9af27f2a4d80b90/stories/demos/AllFeatures.tsx#L60 const { handleKeyDown } = useKeyboardNavigation({
const handleScroll = useThrottledCallback( gridRef,
(event: React.UIEvent<HTMLDivElement>) => { tableRows,
// Select corresponding header cell when scrolled to prevent jumping leafColumns,
dataGridRef?.current?.selectCell({ });
idx:
selectedColumnIndex > -1 ? selectedColumnIndex : columns.length - 1, // Handle prompt to save local column sizes if user `canEditColumns`
rowIdx: -1, useSaveColumnSizing(columnSizing, canEditColumns);
const handleDropColumn = useCallback(
(result: DropResult) => {
if (result.destination?.index === undefined || !result.draggableId)
return;
console.log(result.draggableId, result.destination.index);
updateColumn({
key: result.draggableId,
index: result.destination.index,
config: {},
}); });
// console.log(
// "scroll",
// dataGridRef?.current,
// selectedColumnIndex,
// columns.length
// );
const target = event.target as HTMLDivElement;
// TODO:
// if (navPinned && !columns[0].fixed)
// setShowLeftScrollDivider(target.scrollLeft > 16);
const offset = 800;
const isAtBottom =
target.clientHeight + target.scrollTop >= target.scrollHeight - offset;
if (!isAtBottom) return;
// Call for the next page
setTablePage((p) => p + 1);
}, },
250 [updateColumn]
); );
const [showLeftScrollDivider, setShowLeftScrollDivider] = useState(false); const fetchMoreOnBottomReached = useThrottledCallback(
(containerElement?: HTMLDivElement | null) => {
if (!containerElement) return;
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; const { scrollHeight, scrollTop, clientHeight } = containerElement;
const handleResize = useDebouncedCallback( if (scrollHeight - scrollTop - clientHeight < 300) {
(colIndex: number, width: number) => { setTablePage((p) => p + 1);
const column = columns[colIndex]; }
if (!column.key) return;
updateColumn({ key: column.key, config: { width } });
}, },
1000 DEBOUNCE_DELAY
); );
// Check on mount and after fetch to see if the table is at the bottom
// for large screen heights
useEffect(() => {
fetchMoreOnBottomReached(containerRef.current);
}, [
fetchMoreOnBottomReached,
tablePage,
tableNextPage.loading,
containerRef,
]);
return ( return (
<Suspense fallback={<Loading message="Loading fields" />}> <div
{/* <Hotkeys selectedCell={selectedCell} /> */} ref={(el) => setContainerEl(el)}
<TableContainer rowHeight={rowHeight} className="table-container"> onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
<DndProvider backend={HTML5Backend}> style={{ overflow: "auto", width: "100%", height: "100%" }}
{showLeftScrollDivider && <div className="left-scroll-divider" />} >
<StyledTable
<DataGrid ref={gridRef}
onColumnResize={handleResize} role="grid"
onScroll={handleScroll} aria-readonly={!canEditCells}
ref={(handle) => { aria-colcount={columns.length}
if (dataGridRef) dataGridRef.current = handle; aria-rowcount={tableRows.length + 1}
}} style={
rows={rows} {
columns={columns} width: table.getTotalSize(),
// Increase row height of out of order rows to add margins userSelect: "none",
rowHeight={({ row }) => { "--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`,
if (row._rowy_outOfOrder) } as any
return rowHeight + OUT_OF_ORDER_MARGIN + 1; }
onKeyDown={handleKeyDown}
return rowHeight; >
}} <div
headerRowHeight={DEFAULT_ROW_HEIGHT + 1} className="thead"
className="rdg-light" // Handle dark mode in MUI theme role="rowgroup"
cellNavigationMode="LOOP_OVER_ROW" style={{
rowRenderer={TableRow} position: "sticky",
rowKeyGetter={rowKeyGetter} top: 0,
rowClass={rowClass} zIndex: 10,
// selectedRows={selectedRowsSet} padding: `0 ${TABLE_PADDING}px`,
// onSelectedRowsChange={(newSelectedSet) => { }}
// const newSelectedArray = newSelectedSet >
// ? [...newSelectedSet] <TableHeader
// : []; headerGroups={table.getHeaderGroups()}
// const prevSelectedRowsArray = selectedRowsSet handleDropColumn={handleDropColumn}
// ? [...selectedRowsSet] canAddColumns={canAddColumns}
// : []; canEditColumns={canEditColumns}
// const addedSelections = difference( lastFrozen={lastFrozen}
// newSelectedArray, columnSizing={columnSizing}
// prevSelectedRowsArray
// );
// const removedSelections = difference(
// prevSelectedRowsArray,
// newSelectedArray
// );
// addedSelections.forEach((id) => {
// const newRow = find(rows, { id });
// setSelectedRows([...selectedRows, newRow]);
// });
// removedSelections.forEach((rowId) => {
// setSelectedRows(selectedRows.filter((row) => row.id !== rowId));
// });
// setSelectedRowsSet(newSelectedSet);
// }}
// onRowsChange={() => {
//console.log('onRowsChange',rows)
// }}
// TODO: onFill={(e) => {
// console.log("onFill", e);
// const { columnKey, sourceRow, targetRows } = e;
// if (updateCell)
// targetRows.forEach((row) =>
// updateCell(row._rowy_ref, columnKey, sourceRow[columnKey])
// );
// return [];
// }}
onPaste={(e, ...args) => {
console.log("onPaste", e, ...args);
const value = e.sourceRow[e.sourceColumnKey];
updateField({
path: e.targetRow._rowy_ref.path,
fieldName: e.targetColumnKey,
value,
});
}}
onSelectedCellChange={({ rowIdx, idx }) => {
if (!rows[rowIdx]?._rowy_ref) return; // May be the header row
const path = rows[rowIdx]._rowy_ref.path;
if (!path) return;
const columnKey = tableColumnsOrdered.filter((col) =>
userDocHiddenFields
? !userDocHiddenFields.includes(col.key)
: true
)[idx]?.key;
if (!columnKey) return; // May be the final column
setSelectedCell({ path, columnKey });
}}
/> />
</DndProvider> </div>
{tableRows.length === 0 && ( {tableRows.length === 0 ? (
<EmptyState emptyState ?? <EmptyState sx={{ py: 8 }} />
Icon={AddRowIcon} ) : (
message="Add a row to get started" <TableBody
description={ containerEl={containerEl}
<div> containerRef={containerRef}
<br /> leafColumns={leafColumns}
<AddRow /> rows={rows}
</div> canEditCells={canEditCells}
} lastFrozen={lastFrozen}
style={{ columnSizing={columnSizing}
position: "absolute",
inset: 0,
top: COLUMN_HEADER_HEIGHT,
height: "auto",
}}
/> />
)} )}
{tableNextPage.loading && <LinearProgress />} </StyledTable>
</TableContainer>
<div
id="rowy-table-editable-cell-description"
style={{ display: "none" }}
>
Press Enter to edit.
</div>
<ContextMenu /> <ContextMenu />
{/* </div>
<BulkActions
selectedRows={selectedRows}
columns={columns}
clearSelection={() => {
setSelectedRowsSet(new Set());
setSelectedRows([]);
}}
/> */}
</Suspense>
); );
} }

View 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 doesnt 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;

View File

@@ -1,14 +1,9 @@
import { useAtom } from "jotai";
import { Grid } from "@mui/material"; import { Grid } from "@mui/material";
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; export default function ChipList({
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; children,
rowHeight,
export default function ChipList({ children }: React.PropsWithChildren<{}>) { }: React.PropsWithChildren<{ rowHeight: number }>) {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
const canWrap = rowHeight > 24 * 2 + 4; const canWrap = rowHeight > 24 * 2 + 4;
return ( return (

View 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 cells value, so that `EditorCell` doesnt
* immediately update the database when the user quickly makes changes to the
* cells 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 havent 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 dont 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 hasnt saved yet
const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false);
const updateField = useSetAtom(updateFieldAtom, tableScope);
// When this cells data has updated, update the local value if
// its 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 dont 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}
/>
);
}

View 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),
]}
/>
);
}

View 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 dont 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
* dont 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;

View File

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

View 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 rows 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 types 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 its 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 its 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 cells `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;
}
);
}

View File

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

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

View File

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

View File

@@ -4,7 +4,7 @@ import { colord } from "colord";
import { Fade, Stack, Skeleton, Button } from "@mui/material"; import { Fade, Stack, Skeleton, Button } from "@mui/material";
import { AddColumn as AddColumnIcon } from "@src/assets/icons"; import { AddColumn as AddColumnIcon } from "@src/assets/icons";
import Column from "./Column"; import Column from "./Mock/Column";
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope"; import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
import { import {
@@ -13,7 +13,7 @@ import {
tableSchemaAtom, tableSchemaAtom,
tableColumnsOrderedAtom, tableColumnsOrderedAtom,
} from "@src/atoms/tableScope"; } from "@src/atoms/tableScope";
import { DEFAULT_ROW_HEIGHT, DEFAULT_COL_WIDTH } from "./Table"; import { DEFAULT_ROW_HEIGHT, DEFAULT_COL_WIDTH, TABLE_PADDING } from "./Table";
import { COLLECTION_PAGE_SIZE } from "@src/config/db"; import { COLLECTION_PAGE_SIZE } from "@src/config/db";
import { formatSubTableName } from "@src/utils/table"; import { formatSubTableName } from "@src/utils/table";
@@ -129,17 +129,7 @@ export function RowsSkeleton() {
<Stack <Stack
key={i} key={i}
direction="row" direction="row"
sx={{ style={{ padding: `0 ${TABLE_PADDING}px`, marginTop: -1 }}
px: 2,
mt: -1 / 8,
"&:last-of-type > div:first-of-type": {
borderBottomLeftRadius: (theme) => theme.shape.borderRadius,
},
"&:last-of-type > div:last-of-type": {
borderBottomRightRadius: (theme) => theme.shape.borderRadius,
},
}}
> >
{columns.map((col, j) => ( {columns.map((col, j) => (
<Skeleton <Skeleton
@@ -156,6 +146,7 @@ export function RowsSkeleton() {
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
borderLeftWidth: j === 0 ? 1 : 0, borderLeftWidth: j === 0 ? 1 : 0,
borderRadius: 0,
width: col.width || DEFAULT_COL_WIDTH, width: col.width || DEFAULT_COL_WIDTH,
flexShrink: 0, flexShrink: 0,
height: rowHeight + 1, height: rowHeight + 1,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-grids default
* text editor to show. Opens the side drawer in the appropriate position.
*
* Displays the current HeavyCell or HeavyCell since it overwrites cell contents.
*
* Use for cells that do not support any type of in-cell editing.
*/
export default function withSideDrawerEditor(
HeavyCell?: React.ComponentType<IHeavyCellProps>
) {
return function SideDrawerEditor(props: EditorProps<any, any>) {
const { row, column } = props;
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;
};
}

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

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

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

View File

@@ -17,7 +17,7 @@ import CloseIcon from "@mui/icons-material/Close";
import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope"; import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope";
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar/TableToolbar"; import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
import ErrorFallback from "@src/components/ErrorFallback"; import ErrorFallback from "@src/components/ErrorFallback";
import Details from "./Details"; import Details from "./Details";

View File

@@ -22,7 +22,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons";
import { IStepProps } from "."; import { IStepProps } from ".";
import { AirtableConfig } from "@src/components/TableModals/ImportAirtableWizard"; import { AirtableConfig } from "@src/components/TableModals/ImportAirtableWizard";
import FadeList from "@src/components/TableModals/ScrollableList"; import FadeList from "@src/components/TableModals/ScrollableList";
import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; import Column, {
COLUMN_HEADER_HEIGHT,
} from "@src/components/Table/Mock/Column";
import ColumnSelect from "@src/components/Table/ColumnSelect"; import ColumnSelect from "@src/components/Table/ColumnSelect";
import { import {

View File

@@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { IStepProps } from "."; import { IStepProps } from ".";
import ScrollableList from "@src/components/TableModals/ScrollableList"; import ScrollableList from "@src/components/TableModals/ScrollableList";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import Cell from "@src/components/Table/Cell"; import Cell from "@src/components/Table/Mock/Cell";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { FieldType } from "@src/constants/fields"; import { FieldType } from "@src/constants/fields";
import { SELECTABLE_TYPES } from "@src/components/TableModals/ImportExistingWizard/utils"; import { SELECTABLE_TYPES } from "@src/components/TableModals/ImportExistingWizard/utils";

View File

@@ -2,8 +2,8 @@ import { useAtom } from "jotai";
import { find } from "lodash-es"; import { find } from "lodash-es";
import { styled, Grid } from "@mui/material"; import { styled, Grid } from "@mui/material";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import Cell from "@src/components/Table/Cell"; import Cell from "@src/components/Table/Mock/Cell";
import { IStepProps } from "."; import { IStepProps } from ".";
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";

View File

@@ -26,7 +26,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons";
import { IStepProps } from "."; import { IStepProps } from ".";
import { CsvConfig } from "@src/components/TableModals/ImportCsvWizard"; import { CsvConfig } from "@src/components/TableModals/ImportCsvWizard";
import FadeList from "@src/components/TableModals/ScrollableList"; import FadeList from "@src/components/TableModals/ScrollableList";
import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; import Column, {
COLUMN_HEADER_HEIGHT,
} from "@src/components/Table/Mock/Column";
import ColumnSelect from "@src/components/Table/ColumnSelect"; import ColumnSelect from "@src/components/Table/ColumnSelect";
import { import {

View File

@@ -7,8 +7,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { IStepProps } from "."; import { IStepProps } from ".";
import ScrollableList from "@src/components/TableModals/ScrollableList"; import ScrollableList from "@src/components/TableModals/ScrollableList";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import Cell from "@src/components/Table/Cell"; import Cell from "@src/components/Table/Mock/Cell";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { FieldType } from "@src/constants/fields"; import { FieldType } from "@src/constants/fields";

View File

@@ -3,8 +3,8 @@ import { find } from "lodash-es";
import { parseJSON } from "date-fns"; import { parseJSON } from "date-fns";
import { styled, Grid } from "@mui/material"; import { styled, Grid } from "@mui/material";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import Cell from "@src/components/Table/Cell"; import Cell from "@src/components/Table/Mock/Cell";
import { IStepProps } from "."; import { IStepProps } from ".";
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";

View File

@@ -20,7 +20,7 @@ import DragHandleIcon from "@mui/icons-material/DragHandle";
import { AddColumn as AddColumnIcon } from "@src/assets/icons"; import { AddColumn as AddColumnIcon } from "@src/assets/icons";
import ScrollableList from "@src/components/TableModals/ScrollableList"; import ScrollableList from "@src/components/TableModals/ScrollableList";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import EmptyState from "@src/components/EmptyState"; import EmptyState from "@src/components/EmptyState";
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";

View File

@@ -14,7 +14,9 @@ import DoneIcon from "@mui/icons-material/Done";
import { IStepProps } from "."; import { IStepProps } from ".";
import ScrollableList from "@src/components/TableModals/ScrollableList"; import ScrollableList from "@src/components/TableModals/ScrollableList";
import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; import Column, {
COLUMN_HEADER_HEIGHT,
} from "@src/components/Table/Mock/Column";
export default function Step2Rename({ export default function Step2Rename({
config, config,

View File

@@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { IStepProps } from "."; import { IStepProps } from ".";
import ScrollableList from "@src/components/TableModals/ScrollableList"; import ScrollableList from "@src/components/TableModals/ScrollableList";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import Cell from "@src/components/Table/Cell"; import Cell from "@src/components/Table/Mock/Cell";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";

View File

@@ -2,8 +2,8 @@ import { useAtom } from "jotai";
import { IStepProps } from "."; import { IStepProps } from ".";
import { styled, Grid } from "@mui/material"; import { styled, Grid } from "@mui/material";
import Column from "@src/components/Table/Column"; import Column from "@src/components/Table/Mock/Column";
import Cell from "@src/components/Table/Cell"; import Cell from "@src/components/Table/Mock/Cell";
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";

View File

@@ -12,7 +12,7 @@ import {
} from "@src/atoms/tableScope"; } from "@src/atoms/tableScope";
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
const ROW_HEIGHTS = [33, 41, 65, 97, 129, 161]; const ROW_HEIGHTS = [32, 40, 64, 96, 128, 160].map((x) => x + 1);
export default function RowHeight() { export default function RowHeight() {
const theme = useTheme(); const theme = useTheme();

View File

@@ -51,7 +51,6 @@ const getStateIcon = (actionState: "undo" | "redo" | string, config: any) => {
export interface IActionFabProps extends Partial<FabProps> { export interface IActionFabProps extends Partial<FabProps> {
row: any; row: any;
column: any; column: any;
onSubmit: (value: any) => void;
value: any; value: any;
disabled: boolean; disabled: boolean;
} }
@@ -59,7 +58,6 @@ export interface IActionFabProps extends Partial<FabProps> {
export default function ActionFab({ export default function ActionFab({
row, row,
column, column,
onSubmit,
value, value,
disabled, disabled,
...props ...props

View File

@@ -1,5 +0,0 @@
import { IBasicCellProps } from "@src/components/fields/types";
export default function Action({ name, value }: IBasicCellProps) {
return <>{value ? value.status : name}</>;
}

View 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>
);
}

View File

@@ -1,33 +1,25 @@
import { IHeavyCellProps } from "@src/components/fields/types"; import { IEditorCellProps } from "@src/components/fields/types";
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import ActionFab from "./ActionFab"; import ActionFab from "./ActionFab";
import { sanitiseCallableName, isUrl } from "./utils"; import { sanitiseCallableName, isUrl } from "./utils";
import { get } from "lodash-es"; import { getActionName } from "./DisplayCell";
export const getActionName = (column: any) => {
const config = get(column, "config")
if (!get(config, "customName.enabled")) { return get(column, "name") }
return get(config, "customName.actionName") || get(column, "name");
};
export default function Action({ export default function Action({
column, column,
row, row,
value, value,
onSubmit,
disabled, disabled,
}: IHeavyCellProps) { tabIndex,
}: IEditorCellProps) {
const hasRan = value && ![null, undefined].includes(value.status); const hasRan = value && ![null, undefined].includes(value.status);
return ( return (
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
className="cell-collapse-padding" sx={{ padding: "var(--cell-padding)", pr: 0.5, width: "100%" }}
sx={{ padding: "var(--cell-padding)", pr: 0.5 }}
> >
<div style={{ flexGrow: 1, overflow: "hidden" }}> <div style={{ flexGrow: 1, overflow: "hidden" }}>
{hasRan && isUrl(value.status) ? ( {hasRan && isUrl(value.status) ? (
@@ -44,9 +36,9 @@ export default function Action({
<ActionFab <ActionFab
row={row} row={row}
column={column} column={column}
onSubmit={onSubmit}
value={value} value={value}
disabled={disabled} disabled={disabled}
tabIndex={tabIndex}
/> />
</Stack> </Stack>
); );

View File

@@ -303,7 +303,9 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
aria-label="Action will run" aria-label="Action will run"
name="isActionScript" name="isActionScript"
value={ value={
config.isActionScript !== false ? "actionScript" : "cloudFunction" config.isActionScript !== false
? "actionScript"
: "cloudFunction"
} }
onChange={(e) => onChange={(e) =>
onChange("isActionScript")( onChange("isActionScript")(
@@ -559,45 +561,45 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
title: "Customization", title: "Customization",
content: ( content: (
<> <>
<Stack> <Stack>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
checked={config.customName?.enabled} checked={config.customName?.enabled}
onChange={(e) =>
onChange("customName.enabled")(e.target.checked)
}
name="customName.enabled"
/>
}
label="Customize label for action"
style={{ marginLeft: -11 }}
/>
{config.customName?.enabled && (
<TextField
id="customName.actionName"
value={get(config, "customName.actionName")}
onChange={(e) => onChange={(e) =>
onChange("customName.enabled")(e.target.checked) onChange("customName.actionName")(e.target.value)
} }
name="customName.enabled" label="Action name:"
/> className="labelHorizontal"
} inputProps={{ style: { width: "10ch" } }}
label="Customize label for action" ></TextField>
style={{ marginLeft: -11 }} )}
/> <FormControlLabel
{config.customName?.enabled && ( control={
<TextField <Checkbox
id="customName.actionName" checked={config.customIcons?.enabled}
value={get(config, "customName.actionName")} onChange={(e) =>
onChange={(e) => onChange("customIcons.enabled")(e.target.checked)
onChange("customName.actionName")(e.target.value) }
} name="customIcons.enabled"
label="Action name:" />
className="labelHorizontal" }
inputProps={{ style: { width: "10ch" } }} label="Customize button icons with emoji"
></TextField> style={{ marginLeft: -11 }}
)} />
<FormControlLabel
control={
<Checkbox
checked={config.customIcons?.enabled}
onChange={(e) =>
onChange("customIcons.enabled")(e.target.checked)
}
name="customIcons.enabled"
/>
}
label="Customize button icons with emoji"
style={{ marginLeft: -11 }}
/>
</Stack> </Stack>
{config.customIcons?.enabled && ( {config.customIcons?.enabled && (
<Grid container spacing={2} sx={{ mt: { xs: 0, sm: -1 } }}> <Grid container spacing={2} sx={{ mt: { xs: 0, sm: -1 } }}>

View File

@@ -10,14 +10,12 @@ import ActionFab from "./ActionFab";
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import { sanitiseCallableName, isUrl } from "./utils"; import { sanitiseCallableName, isUrl } from "./utils";
import { getActionName } from "./TableCell" import { getActionName } from "./DisplayCell";
export default function Action({ export default function Action({
column, column,
_rowy_ref, _rowy_ref,
value, value,
onChange,
onSubmit,
disabled, disabled,
}: ISideDrawerFieldProps) { }: ISideDrawerFieldProps) {
const [row] = useAtom( const [row] = useAtom(
@@ -68,10 +66,6 @@ export default function Action({
<ActionFab <ActionFab
row={row} row={row}
column={column} column={column}
onSubmit={(value) => {
onChange(value);
onSubmit();
}}
value={value} value={value}
disabled={disabled} disabled={disabled}
id={getFieldId(column.key)} id={getFieldId(column.key)}

View File

@@ -1,13 +1,12 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import ActionIcon from "@mui/icons-material/TouchAppOutlined"; import ActionIcon from "@mui/icons-material/TouchAppOutlined";
import BasicCell from "./BasicCell"; import DisplayCell from "./DisplayCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
const TableCell = lazy( const EditorCell = lazy(
() => import("./TableCell" /* webpackChunkName: "TableCell-Action" */) () => import("./EditorCell" /* webpackChunkName: "EditorCell-Action" */)
); );
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
@@ -25,8 +24,9 @@ export const config: IFieldConfig = {
icon: <ActionIcon />, icon: <ActionIcon />,
description: description:
"Button with pre-defined action script or triggers a Cloud Function. Optionally supports Undo and Redo.", "Button with pre-defined action script or triggers a Cloud Function. Optionally supports Undo and Redo.",
TableCell: withHeavyCell(BasicCell, TableCell), TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", {
TableEditor: NullEditor as any, disablePadding: true,
}),
SideDrawerField, SideDrawerField,
settings: Settings, settings: Settings,
requireConfiguration: true, requireConfiguration: true,

View 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 },
}}
/>
);
}

View File

@@ -1,4 +1,4 @@
import { IHeavyCellProps } from "@src/components/fields/types"; import { IEditorCellProps } from "@src/components/fields/types";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { get } from "lodash-es"; import { get } from "lodash-es";
@@ -15,9 +15,11 @@ export default function Checkbox({
row, row,
column, column,
value, value,
onChange,
onSubmit, onSubmit,
disabled, disabled,
}: IHeavyCellProps) { tabIndex,
}: IEditorCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope); const confirm = useSetAtom(confirmDialogAtom, projectScope);
const handleChange = () => { const handleChange = () => {
@@ -28,10 +30,14 @@ export default function Checkbox({
/\{\{(.*?)\}\}/g, /\{\{(.*?)\}\}/g,
replacer(row) replacer(row)
), ),
handleConfirm: () => onSubmit(!value), handleConfirm: () => {
onChange(!value);
onSubmit();
},
}); });
} else { } else {
onSubmit(!value); onChange(!value);
onSubmit();
} }
}; };
@@ -43,6 +49,7 @@ export default function Checkbox({
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
color="success" color="success"
tabIndex={tabIndex}
/> />
} }
label={column.name as string} label={column.name as string}

View File

@@ -1,13 +1,12 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined"; import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellName"; import DisplayCell from "./DisplayCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
const TableCell = lazy( const EditorCell = lazy(
() => import("./TableCell" /* webpackChunkName: "TableCell-Checkbox" */) () => import("./EditorCell" /* webpackChunkName: "EditorCell-Checkbox" */)
); );
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
@@ -25,8 +24,9 @@ export const config: IFieldConfig = {
initializable: true, initializable: true,
icon: <CheckboxIcon />, icon: <CheckboxIcon />,
description: "True/false value. Default: false.", description: "True/false value. Default: false.",
TableCell: withHeavyCell(BasicCell, TableCell), TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", {
TableEditor: NullEditor as any, usesRowData: true,
}),
csvImportParser: (value: string) => { csvImportParser: (value: string) => {
if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true; if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true;
else if (["NO", "FALSE", "0"].includes(value.toUpperCase())) return false; else if (["NO", "FALSE", "0"].includes(value.toUpperCase())) return false;

View File

@@ -1,23 +1,27 @@
import { IBasicCellProps } from "@src/components/fields/types"; import { IDisplayCellProps } from "@src/components/fields/types";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
export default function Code({ value }: IBasicCellProps) { export default function Code({ value }: IDisplayCellProps) {
const theme = useTheme(); const theme = useTheme();
if (typeof value !== "string") return null;
return ( return (
<div <div
style={{ style={{
width: "100%", width: "100%",
maxHeight: "100%", maxHeight: "100%",
padding: theme.spacing(3 / 8, 0), padding: "3px 0",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
lineHeight: theme.typography.body2.lineHeight, lineHeight: theme.typography.body2.lineHeight,
fontFamily: theme.typography.fontFamilyMono, fontFamily: theme.typography.fontFamilyMono,
wordBreak: "break-word", wordBreak: "break-word",
tabSize: 2,
}} }}
> >
{value} {value.substring(0, 1000)}
</div> </div>
); );
} }

View File

@@ -1,10 +1,9 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import CodeIcon from "@mui/icons-material/Code"; import CodeIcon from "@mui/icons-material/Code";
import BasicCell from "./BasicCell"; import DisplayCell from "./DisplayCell";
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
const Settings = lazy( const Settings = lazy(
() => import("./Settings" /* webpackChunkName: "Settings-Code" */) () => import("./Settings" /* webpackChunkName: "Settings-Code" */)
@@ -24,8 +23,12 @@ export const config: IFieldConfig = {
initializable: true, initializable: true,
icon: <CodeIcon />, icon: <CodeIcon />,
description: "Raw code edited with the Monaco Editor.", description: "Raw code edited with the Monaco Editor.",
TableCell: withBasicCell(BasicCell), TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", {
TableEditor: withSideDrawerEditor(BasicCell), popoverProps: {
anchorOrigin: { vertical: "top", horizontal: "center" },
PaperProps: { sx: { borderRadius: 1 } },
},
}),
SideDrawerField, SideDrawerField,
settings: Settings, settings: Settings,
}; };

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

@@ -87,7 +87,9 @@ export default function Color({
<ColorPicker <ColorPicker
width={440} width={440}
height={180} height={180}
color={value?.hex ? value : toColor("hex", "#fff")} color={
value?.hex ? toColor("hex", value.hex) : toColor("hex", "#fff")
}
onChange={onChange} onChange={onChange}
onChangeComplete={onSubmit} onChangeComplete={onSubmit}
alpha alpha

View File

@@ -4,18 +4,18 @@ export const filterOperators: IFilterOperator[] = [
{ {
label: "is", label: "is",
secondaryLabel: "==", secondaryLabel: "==",
value: "color-equal" value: "color-equal",
}, },
{ {
label: "is not", label: "is not",
secondaryLabel: "!=", secondaryLabel: "!=",
value: "color-not-equal" value: "color-not-equal",
} },
]; ];
export const valueFormatter = (value: any) => { export const valueFormatter = (value: any) => {
if (value && value.hex) { if (value && value.hex) {
return value.hex.toString() return value.hex.toString();
} }
return ""; return "";
}; };

View File

@@ -1,16 +1,14 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import { toColor } from "react-color-palette"; import { toColor } from "react-color-palette";
import ColorIcon from "@mui/icons-material/Colorize"; import ColorIcon from "@mui/icons-material/Colorize";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; import DisplayCell from "./DisplayCell";
import InlineCell from "./InlineCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
import { filterOperators, valueFormatter } from "./filters"; import { filterOperators, valueFormatter } from "./filters";
const PopoverCell = lazy( const EditorCell = lazy(
() => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */) () => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */)
); );
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
@@ -27,15 +25,10 @@ export const config: IFieldConfig = {
icon: <ColorIcon />, icon: <ColorIcon />,
description: description:
"Color stored as Hex, RGB, and HSV. Edited with a visual picker.", "Color stored as Hex, RGB, and HSV. Edited with a visual picker.",
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
anchorOrigin: { horizontal: "left", vertical: "bottom" }, disablePadding: true,
}), }),
TableEditor: NullEditor as any,
SideDrawerField, SideDrawerField,
filter: {
operators: filterOperators,
valueFormatter
},
csvImportParser: (value: string) => { csvImportParser: (value: string) => {
try { try {
const obj = JSON.parse(value); const obj = JSON.parse(value);

View 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>
);
}

View File

@@ -1,26 +1,26 @@
import { IPopoverCellProps } from "@src/components/fields/types"; import { IEditorCellProps } from "@src/components/fields/types";
import ConnectServiceSelect from "./ConnectServiceSelect"; import ConnectServiceSelect from "./ConnectServiceSelect";
export default function ConnectService({ export default function ConnectService({
value, value,
onSubmit, onChange,
column, column,
parentRef, parentRef,
showPopoverCell, showPopoverCell,
disabled, disabled,
docRef, _rowy_ref,
}: IPopoverCellProps) { }: IEditorCellProps) {
const config = column.config ?? {}; const config = column.config ?? {};
if (!config) return null; if (!config) return null;
return ( return (
<ConnectServiceSelect <ConnectServiceSelect
value={value} value={value}
onChange={onSubmit} onChange={onChange}
config={(config as any) ?? {}} config={(config as any) ?? {}}
disabled={disabled} disabled={disabled}
docRef={docRef as any} docRef={_rowy_ref as any}
TextFieldProps={{ TextFieldProps={{
style: { display: "none" }, style: { display: "none" },
SelectProps: { SelectProps: {

View File

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

View File

@@ -1,15 +1,13 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import ConnectServiceIcon from "@mui/icons-material/Http"; import ConnectServiceIcon from "@mui/icons-material/Http";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; import DisplayCell from "./DisplayCell";
import InlineCell from "./InlineCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
const PopoverCell = lazy( const EditorCell = lazy(
() => () =>
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */) import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */)
); );
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
@@ -30,11 +28,10 @@ export const config: IFieldConfig = {
icon: <ConnectServiceIcon />, icon: <ConnectServiceIcon />,
description: description:
"Connects to an external web service to fetch a list of results.", "Connects to an external web service to fetch a list of results.",
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
anchorOrigin: { horizontal: "left", vertical: "bottom" }, disablePadding: true,
transparent: true, transparentPopover: true,
}), }),
TableEditor: NullEditor as any,
SideDrawerField, SideDrawerField,
requireConfiguration: true, requireConfiguration: true,
settings: Settings, settings: Settings,

View 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>
);
}

View File

@@ -1,16 +1,17 @@
import { IPopoverCellProps } from "@src/components/fields/types"; import { IEditorCellProps } from "@src/components/fields/types";
import ConnectTableSelect from "./ConnectTableSelect"; import ConnectTableSelect from "./ConnectTableSelect";
export default function ConnectTable({ export default function ConnectTable({
value, value,
onChange,
onSubmit, onSubmit,
column, column,
parentRef, parentRef,
showPopoverCell, showPopoverCell,
row, row,
disabled, disabled,
}: IPopoverCellProps) { }: IEditorCellProps) {
const config = column.config ?? {}; const config = column.config ?? {};
if (!config || !config.primaryKeys) return null; if (!config || !config.primaryKeys) return null;
@@ -19,7 +20,7 @@ export default function ConnectTable({
row={row} row={row}
column={column} column={column}
value={value} value={value}
onChange={onSubmit} onChange={onChange}
config={(config as any) ?? {}} config={(config as any) ?? {}}
disabled={disabled} disabled={disabled}
TextFieldProps={{ TextFieldProps={{
@@ -33,7 +34,10 @@ export default function ConnectTable({
}, },
}, },
}} }}
onClose={() => showPopoverCell(false)} onClose={() => {
showPopoverCell(false);
onSubmit();
}}
loadBeforeOpen loadBeforeOpen
/> />
); );

View File

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

View File

@@ -1,15 +1,12 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import { ConnectTable as ConnectTableIcon } from "@src/assets/icons"; import { ConnectTable as ConnectTableIcon } from "@src/assets/icons";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; import DisplayCell from "./DisplayCell";
import InlineCell from "./InlineCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
const PopoverCell = lazy( const EditorCell = lazy(
() => () => import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectTable" */)
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectTable" */)
); );
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
@@ -31,11 +28,10 @@ export const config: IFieldConfig = {
icon: <ConnectTableIcon />, icon: <ConnectTableIcon />,
description: description:
"Connects to an existing table to fetch a snapshot of values from a row. Requires Rowy Run and Algolia setup.", "Connects to an existing table to fetch a snapshot of values from a row. Requires Rowy Run and Algolia setup.",
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
anchorOrigin: { horizontal: "left", vertical: "bottom" }, disablePadding: true,
transparent: true, transparentPopover: true,
}), }),
TableEditor: NullEditor as any,
SideDrawerField, SideDrawerField,
settings: Settings, settings: Settings,
requireConfiguration: true, requireConfiguration: true,

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -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),
},
}}
/>
);
}

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import clsx from "clsx";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { get } from "lodash-es"; import { get } from "lodash-es";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -7,7 +6,6 @@ import { useAtom } from "jotai";
import { import {
Button, Button,
Checkbox, Checkbox,
Divider,
Grid, Grid,
InputAdornment, InputAdornment,
List, List,
@@ -21,7 +19,6 @@ import {
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import { IConnectorSelectProps } from "."; import { IConnectorSelectProps } from ".";
import useStyles from "./styles";
import Loading from "@src/components/Loading"; import Loading from "@src/components/Loading";
import { getLabel } from "@src/components/fields/Connector/utils"; import { getLabel } from "@src/components/fields/Connector/utils";
import { useSnackbar } from "notistack"; import { useSnackbar } from "notistack";
@@ -37,7 +34,7 @@ export default function PopupContents({
value = [], value = [],
onChange, onChange,
column, column,
docRef, _rowy_ref,
}: IPopupContentsProps) { }: IPopupContentsProps) {
const [rowyRun] = useAtom(rowyRunAtom, projectScope); const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
@@ -48,8 +45,6 @@ export default function PopupContents({
const elementId = config.elementId; const elementId = config.elementId;
const multiple = Boolean(config.multiple); const multiple = Boolean(config.multiple);
const { classes } = useStyles();
// Webservice search query // Webservice search query
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// Webservice response // Webservice response
@@ -75,7 +70,7 @@ export default function PopupContents({
columnKey: column.key, columnKey: column.key,
query: query, query: query,
schemaDocPath: getTableSchemaPath(tableSettings), schemaDocPath: getTableSchemaPath(tableSettings),
rowDocPath: docRef.path, rowDocPath: _rowy_ref.path,
}, },
}); });
setResponse(resp); setResponse(resp);
@@ -105,93 +100,88 @@ export default function PopupContents({
const clearSelection = () => onChange([]); const clearSelection = () => onChange([]);
return ( return (
<Grid container direction="column" className={classes.grid}> <Grid container direction="column" sx={{ p: 1, height: "100%" }}>
<Grid item className={classes.searchRow}> <Grid item>
<TextField <TextField
value={query} value={query}
type="search"
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
fullWidth fullWidth
variant="filled" variant="filled"
margin="dense"
label="Search items" label="Search items"
className={classes.noMargins} hiddenLabel
placeholder="Search items"
InputProps={{ InputProps={{
endAdornment: ( startAdornment: (
<InputAdornment position="end"> <InputAdornment position="start">
<SearchIcon /> <SearchIcon />
</InputAdornment> </InputAdornment>
), ),
}} }}
InputLabelProps={{ className: "visually-hidden" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
/> />
</Grid> </Grid>
<Grid item xs className={classes.listRow}> <Grid item xs>
<List className={classes.list}> <List sx={{ overflowY: "auto" }}>
{hits.map((hit) => { {hits.map((hit) => {
const isSelected = selectedValues.some((v) => v === hit[elementId]); const isSelected = selectedValues.some((v) => v === hit[elementId]);
return ( return (
<React.Fragment key={get(hit, elementId)}> <MenuItem
<MenuItem key={get(hit, elementId)}
dense onClick={isSelected ? deselect(hit) : select(hit)}
onClick={isSelected ? deselect(hit) : select(hit)} disabled={!isSelected && multiple && value.length >= config.max}
disabled={ disableGutters
!isSelected && multiple && value.length >= config.max style={{ margin: 0, width: "100%" }}
} >
> <ListItemIcon>
<ListItemIcon className={classes.checkboxContainer}> {multiple ? (
{multiple ? ( <Checkbox
<Checkbox edge="start"
edge="start" checked={isSelected}
checked={isSelected} tabIndex={-1}
tabIndex={-1} color="secondary"
color="secondary" disableRipple
className={classes.checkbox} inputProps={{
disableRipple "aria-labelledby": `label-${get(hit, elementId)}`,
inputProps={{ }}
"aria-labelledby": `label-${get(hit, elementId)}`, sx={{ py: 0 }}
}} />
/> ) : (
) : ( <Radio
<Radio edge="start"
edge="start" checked={isSelected}
checked={isSelected} tabIndex={-1}
tabIndex={-1} color="secondary"
color="secondary" disableRipple
className={classes.checkbox} inputProps={{
disableRipple "aria-labelledby": `label-${get(hit, elementId)}`,
inputProps={{ }}
"aria-labelledby": `label-${get(hit, elementId)}`, sx={{ py: 0 }}
}} />
/> )}
)} </ListItemIcon>
</ListItemIcon> <ListItemText
<ListItemText id={`label-${get(hit, elementId)}`}
id={`label-${get(hit, elementId)}`} primary={getLabel(config, hit)}
primary={getLabel(config, hit)} />
/> </MenuItem>
</MenuItem>
<Divider className={classes.divider} />
</React.Fragment>
); );
})} })}
</List> </List>
</Grid> </Grid>
{multiple && ( {multiple && (
<Grid item className={clsx(classes.footerRow, classes.selectedRow)}> <Grid item>
<Grid <Grid
container container
direction="row" direction="row"
justifyContent="space-between" justifyContent="space-between"
alignItems="center" alignItems="center"
> >
<Typography <Typography variant="button" color="textSecondary" sx={{ ml: 1 }}>
variant="button"
color="textSecondary"
className={classes.selectedNum}
>
{value?.length} of {hits?.length} {value?.length} of {hits?.length}
</Typography> </Typography>
@@ -199,9 +189,9 @@ export default function PopupContents({
disabled={!value || value.length === 0} disabled={!value || value.length === 0}
onClick={clearSelection} onClick={clearSelection}
color="primary" color="primary"
className={classes.selectAllButton} variant="text"
> >
Clear selection Clear
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -24,7 +24,7 @@ export interface IConnectorSelectProps {
className?: string; className?: string;
/** Override any props of the root MUI `TextField` component */ /** Override any props of the root MUI `TextField` component */
TextFieldProps?: Partial<TextFieldProps>; TextFieldProps?: Partial<TextFieldProps>;
docRef: TableRowRef; _rowy_ref: TableRowRef;
disabled?: boolean; disabled?: boolean;
} }
@@ -56,7 +56,11 @@ export default function ConnectorSelect({
// prop for this component to a comma-separated string // prop for this component to a comma-separated string
MenuProps: { MenuProps: {
classes: { paper: classes.paper, list: classes.menuChild }, classes: { paper: classes.paper, list: classes.menuChild },
MenuListProps: { disablePadding: true }, MenuListProps: {
disablePadding: true,
style: { padding: 0 },
component: "div",
} as any,
anchorOrigin: { vertical: "bottom", horizontal: "center" }, anchorOrigin: { vertical: "bottom", horizontal: "center" },
transformOrigin: { vertical: "top", horizontal: "center" }, transformOrigin: { vertical: "top", horizontal: "center" },
...TextFieldProps.SelectProps?.MenuProps, ...TextFieldProps.SelectProps?.MenuProps,

View File

@@ -11,7 +11,6 @@ export default function Connector({
column, column,
_rowy_ref, _rowy_ref,
value, value,
onDirty,
onChange, onChange,
onSubmit, onSubmit,
disabled, disabled,
@@ -32,7 +31,7 @@ export default function Connector({
column={column} column={column}
value={value} value={value}
onChange={onChange} onChange={onChange}
docRef={_rowy_ref as any} _rowy_ref={_rowy_ref}
TextFieldProps={{ TextFieldProps={{
label: "", label: "",
hiddenLabel: true, hiddenLabel: true,
@@ -50,7 +49,6 @@ export default function Connector({
<Grid container spacing={0.5} style={{ marginTop: 2 }}> <Grid container spacing={0.5} style={{ marginTop: 2 }}>
{value.map((item) => { {value.map((item) => {
const key = get(item, config.elementId); const key = get(item, config.elementId);
console.log(key, item);
return ( return (
<Grid item key={key}> <Grid item key={key}>
<Chip <Chip

View File

@@ -1,14 +1,13 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import ConnectorIcon from "@mui/icons-material/Cable";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
import InlineCell from "./InlineCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
const PopoverCell = lazy( import ConnectorIcon from "@mui/icons-material/Cable";
import DisplayCell from "./DisplayCell";
const EditorCell = lazy(
() => () =>
import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */) import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */)
); );
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
@@ -30,11 +29,9 @@ export const config: IFieldConfig = {
icon: <ConnectorIcon />, icon: <ConnectorIcon />,
description: description:
"Connects to any table or API to fetch a list of results based on a text query or row data.", "Connects to any table or API to fetch a list of results based on a text query or row data.",
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
anchorOrigin: { horizontal: "left", vertical: "bottom" }, disablePadding: true,
transparent: true,
}), }),
TableEditor: NullEditor as any,
SideDrawerField, SideDrawerField,
requireConfiguration: true, requireConfiguration: true,
settings: Settings, settings: Settings,

View File

@@ -1,9 +1,9 @@
import { IHeavyCellProps } from "@src/components/fields/types"; import { IDisplayCellProps } from "@src/components/fields/types";
import { format } from "date-fns"; import { format } from "date-fns";
import { DATE_TIME_FORMAT } from "@src/constants/dates"; import { DATE_TIME_FORMAT } from "@src/constants/dates";
export default function UpdatedAt({ column, value }: IHeavyCellProps) { export default function CreatedAt({ column, value }: IDisplayCellProps) {
if (!value) return null; if (!value) return null;
const dateLabel = format( const dateLabel = format(
value.toDate ? value.toDate() : value, value.toDate ? value.toDate() : value,

View File

@@ -1,14 +1,10 @@
import { lazy } from "react"; import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types"; import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import { CreatedAt as CreatedAtIcon } from "@src/assets/icons"; import { CreatedAt as CreatedAtIcon } from "@src/assets/icons";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; import DisplayCell from "./DisplayCell";
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
const TableCell = lazy(
() => import("./TableCell" /* webpackChunkName: "TableCell-CreatedAt" */)
);
const SideDrawerField = lazy( const SideDrawerField = lazy(
() => () =>
import( import(
@@ -28,8 +24,7 @@ export const config: IFieldConfig = {
initialValue: null, initialValue: null,
icon: <CreatedAtIcon />, icon: <CreatedAtIcon />,
description: "Displays the timestamp of when the row was created. Read-only.", description: "Displays the timestamp of when the row was created. Read-only.",
TableCell: withHeavyCell(BasicCell, TableCell), TableCell: withRenderTableCell(DisplayCell, null),
TableEditor: withSideDrawerEditor(TableCell),
SideDrawerField, SideDrawerField,
settings: Settings, settings: Settings,
}; };

View File

@@ -1,11 +1,11 @@
import { IHeavyCellProps } from "@src/components/fields/types"; import { IDisplayCellProps } from "@src/components/fields/types";
import { Tooltip, Stack, Avatar } from "@mui/material"; import { Tooltip, Stack, Avatar } from "@mui/material";
import { format } from "date-fns"; import { format } from "date-fns";
import { DATE_TIME_FORMAT } from "@src/constants/dates"; import { DATE_TIME_FORMAT } from "@src/constants/dates";
export default function CreatedBy({ column, value }: IHeavyCellProps) { export default function CreatedBy({ column, value }: IDisplayCellProps) {
if (!value || !value.displayName || !value.timestamp) return null; if (!value || !value.displayName || !value.timestamp) return null;
const dateLabel = format( const dateLabel = format(
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,

Some files were not shown because too many files have changed in this diff Show More