unify withTableCell HOCs into single HOC with memo

This commit is contained in:
Sidney Alcantara
2022-11-08 16:18:33 +11:00
parent e436e2083a
commit 796c980337
30 changed files with 509 additions and 140 deletions

View File

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

View File

@@ -79,7 +79,11 @@ export default function SideDrawer({
if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1;
const newPath = tableRows[rowIndex]._rowy_ref.path;
setCell((cell) => ({ columnKey: cell!.columnKey, path: newPath }));
setCell((cell) => ({
columnKey: cell!.columnKey,
path: newPath,
focusInside: false,
}));
const columnIndex = visibleColumnKeys.indexOf(cell!.columnKey || "");
dataGridRef?.current?.selectCell(

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { styled } from "@mui/material/styles";
import ErrorIcon from "@mui/icons-material/ErrorOutline";
import WarningIcon from "@mui/icons-material/WarningAmber";
@@ -18,7 +19,7 @@ const Dot = styled("div")(({ theme }) => ({
borderRadius: "50%",
backgroundColor: theme.palette.error.main,
boxShadow: `0 0 0 4px var(--background-color)`,
boxShadow: `0 0 0 4px var(--cell-background-color)`,
"[role='row']:hover &": {
boxShadow: `0 0 0 4px var(--row-hover-background-color)`,
},
@@ -34,7 +35,7 @@ export interface ICellValidationProps
validationRegex?: string;
}
export default function CellValidation({
export const CellValidation = memo(function MemoizedCellValidation({
value,
required,
validationRegex,
@@ -73,4 +74,6 @@ export default function CellValidation({
);
return <StyledCell {...props}>{children}</StyledCell>;
}
});
export default CellValidation;

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { useAtom, useSetAtom } from "jotai";
import type { TableCellProps } from "@src/components/Table";
@@ -21,7 +22,10 @@ import {
contextMenuTargetAtom,
} from "@src/atoms/tableScope";
export default function FinalColumn({ row, focusInsideCell }: TableCellProps) {
export const FinalColumn = memo(function FinalColumn({
row,
focusInsideCell,
}: TableCellProps) {
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
const confirm = useSetAtom(confirmDialogAtom, projectScope);
@@ -47,7 +51,7 @@ export default function FinalColumn({ row, focusInsideCell }: TableCellProps) {
<Stack
direction="row"
alignItems="center"
style={{ height: "100%" }}
className="cell-contents"
gap={0.5}
>
<Tooltip title="Row menu">
@@ -145,4 +149,5 @@ export default function FinalColumn({ row, focusInsideCell }: TableCellProps) {
</Tooltip>
</Stack>
);
}
});
export default FinalColumn;

View File

@@ -1,31 +1,29 @@
import { colord } from "colord";
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)",
lineHeight: "calc(var(--row-height) - 1px)",
width: "100%",
height: "100%",
contain: "strict",
overflow: "hidden",
},
overflow: "visible",
contain: "strict",
position: "relative",
backgroundColor: theme.palette.background.paper,
backgroundColor: "var(--cell-background-color)",
border: `1px solid ${theme.palette.divider}`,
borderTop: "none",
"& + &": { borderLeft: "none" },
"[role='row']:hover &": {
backgroundColor: colord(theme.palette.background.paper)
.mix(theme.palette.action.hover, theme.palette.action.hoverOpacity)
.alpha(1)
.toHslString(),
backgroundColor: "var(--row-hover-background-color)",
},
"[data-out-of-order='true'] + [role='row'] &": {

View File

@@ -1,10 +1,7 @@
import { styled, alpha } from "@mui/material";
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
export const StyledRow = styled("div")(({ theme }) => ({
display: "flex",
height: DEFAULT_ROW_HEIGHT,
position: "relative",
"& > *": {
@@ -31,19 +28,24 @@ export const StyledRow = styled("div")(({ theme }) => ({
},
},
"& .MuiIconButton-root.row-hover-iconButton, .MuiIconButton-root.row-hover-iconButton:focus":
{
color: theme.palette.text.disabled,
transitionDuration: "0s",
},
"&:hover .MuiIconButton-root.row-hover-iconButton, .MuiIconButton-root.row-hover-iconButton:focus":
{
color: theme.palette.text.primary,
backgroundColor: alpha(
theme.palette.action.hover,
theme.palette.action.hoverOpacity * 1.5
),
},
"& .row-hover-iconButton, .row-hover-iconButton:focus": {
color: theme.palette.text.disabled,
transitionDuration: "0s",
flexShrink: 0,
borderRadius: theme.shape.borderRadius,
padding: (32 - 20) / 2,
boxSizing: "content-box",
"&.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";

View File

@@ -1,6 +1,19 @@
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",

View File

@@ -14,6 +14,7 @@ import {
Droppable,
Draggable,
} from "react-beautiful-dnd";
import { get } from "lodash-es";
import { Portal } from "@mui/material";
import { ErrorBoundary } from "react-error-boundary";
@@ -63,6 +64,8 @@ export const DEBOUNCE_DELAY = 500;
export type TableCellProps = CellContext<TableRow, any> & {
focusInsideCell: boolean;
setFocusInsideCell: (focusInside: boolean) => void;
disabled: boolean;
};
declare module "@tanstack/table-core" {
@@ -95,6 +98,7 @@ export default function Table({
const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope);
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
const focusInsideCell = selectedCell?.focusInside ?? false;
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
@@ -109,7 +113,7 @@ export default function Table({
// Hide column for all users using table schema
.filter((column) => !column.hidden)
.map((columnConfig) =>
columnHelper.accessor(columnConfig.fieldName, {
columnHelper.accessor((row) => get(row, columnConfig.fieldName), {
id: columnConfig.fieldName,
meta: columnConfig,
size: columnConfig.width,
@@ -119,7 +123,7 @@ export default function Table({
})
);
if (canAddColumn || !tableSettings.readOnly) {
if (canAddColumn || canEditCell) {
_columns.push(
columnHelper.display({
id: "_rowy_column_actions",
@@ -129,7 +133,7 @@ export default function Table({
}
return _columns;
}, [tableColumnsOrdered, canAddColumn, tableSettings.readOnly]);
}, [tableColumnsOrdered, canAddColumn, canEditCell]);
// Get users hidden columns from props and memoize into a VisibilityState
const columnVisibility = useMemo(() => {
@@ -171,7 +175,7 @@ export default function Table({
const { rows } = table.getRowModel();
const leafColumns = table.getVisibleLeafColumns();
const { handleKeyDown, focusInsideCell } = useKeyboardNavigation({
const { handleKeyDown } = useKeyboardNavigation({
gridRef,
tableRows,
leafColumns,
@@ -228,7 +232,7 @@ export default function Table({
<StyledTable
ref={gridRef}
role="grid"
aria-readonly={tableSettings.readOnly}
aria-readonly={!canEditCell}
aria-colcount={columns.length}
aria-rowcount={tableRows.length + 1}
style={
@@ -320,15 +324,24 @@ export default function Table({
zIndex: header.column.getIsPinned() ? 11 : 10,
}}
width={header.getSize()}
sx={[
sx={
snapshot.isDragging
? {}
: { "& + &": { borderLeft: "none" } },
]}
? undefined
: { "& + &": { borderLeft: "none" } }
}
onClick={(e) => {
setSelectedCell({
path: "_rowy_header",
columnKey: header.id,
focusInside: false,
});
(e.target as HTMLDivElement).focus();
}}
onDoubleClick={(e) => {
setSelectedCell({
path: "_rowy_header",
columnKey: header.id,
focusInside: true,
});
(e.target as HTMLDivElement).focus();
}}
@@ -425,11 +438,11 @@ export default function Table({
tabIndex={isSelectedCell && !focusInsideCell ? 0 : -1}
aria-colindex={cellIndex + 1}
aria-readonly={
!canEditCell &&
!canEditCell ||
cell.column.columnDef.meta?.editable === false
}
aria-required={Boolean(
cell.column.columnDef.meta!.config?.required
cell.column.columnDef.meta?.config?.required
)}
aria-selected={isSelectedCell}
aria-describedby="rowy-table-cell-description"
@@ -456,6 +469,15 @@ export default function Table({
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();
}}
@@ -464,27 +486,32 @@ export default function Table({
setSelectedCell({
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
focusInside: false,
});
(e.target as HTMLDivElement).focus();
setContextMenuTarget(e.target as HTMLElement);
}}
value={cell.getValue()}
required={cell.column.columnDef.meta!.config?.required}
required={cell.column.columnDef.meta?.config?.required}
validationRegex={
cell.column.columnDef.meta!.config?.validationRegex
cell.column.columnDef.meta?.config?.validationRegex
}
>
<div
className="cell-contents"
style={{ height: tableSchema.rowHeight }}
>
<ErrorBoundary fallbackRender={InlineErrorFallback}>
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
focusInsideCell: isSelectedCell && focusInsideCell,
})}
</ErrorBoundary>
</div>
<ErrorBoundary fallbackRender={InlineErrorFallback}>
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
focusInsideCell: isSelectedCell && focusInsideCell,
setFocusInsideCell: (focusInside: boolean) =>
setSelectedCell({
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
focusInside,
}),
disabled:
!canEditCell ||
cell.column.columnDef.meta?.editable === false,
})}
</ErrorBoundary>
</CellValidation>
);
})}

View File

@@ -43,7 +43,7 @@ export const TableContainer = styled("div", {
"--color": theme.palette.text.primary,
"--border-color": theme.palette.divider,
// "--summary-border-color": "#aaa",
"--background-color":
"--cell-background-color":
theme.palette.mode === "light"
? theme.palette.background.paper
: colord(theme.palette.background.paper)

View File

@@ -81,7 +81,7 @@ export default function TextEditor({ row, column }: EditorProps<any>) {
sx={{
width: "100%",
height: "100%",
backgroundColor: "var(--background-color)",
backgroundColor: "var(--cell-background-color)",
"& .MuiInputBase-root": {
height: "100%",

View File

@@ -1,4 +1,3 @@
import { useState } from "react";
import { useSetAtom } from "jotai";
import { Column } from "@tanstack/react-table";
@@ -23,7 +22,6 @@ export function useKeyboardNavigation({
leafColumns,
}: IUseKeyboardNavigationProps) {
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
const [focusInsideCell, setFocusInsideCell] = useState(false);
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// Block default browser behavior for arrow keys (scroll) and other keys
@@ -43,7 +41,7 @@ export function useKeyboardNavigation({
// Esc: exit cell
if (e.key === "Escape") {
setFocusInsideCell(false);
setSelectedCell((c) => ({ ...c!, focusInside: false }));
(
gridRef.current?.querySelector("[aria-selected=true]") as HTMLDivElement
)?.focus();
@@ -63,7 +61,7 @@ export function useKeyboardNavigation({
// Enter: enter cell
if (e.key === "Enter") {
setFocusInsideCell(true);
setSelectedCell((c) => ({ ...c!, focusInside: true }));
(target.querySelector("[tabindex]") as HTMLElement)?.focus();
return;
}
@@ -125,6 +123,8 @@ export function useKeyboardNavigation({
? 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
@@ -139,12 +139,9 @@ export function useKeyboardNavigation({
// Focus the cell
if (newCellEl) setTimeout(() => (newCellEl as HTMLDivElement).focus());
// When selected cell changes, exit current cell
setFocusInsideCell(false);
};
return { handleKeyDown, focusInsideCell } as const;
return { handleKeyDown } as const;
}
export default useKeyboardNavigation;

View File

@@ -39,7 +39,7 @@ export function useVirtualization(
} = useVirtual({
parentRef: containerRef,
size: tableRows.length,
overscan: 10,
overscan: 5,
paddingEnd: TABLE_PADDING,
estimateSize: useCallback(
(index: number) =>
@@ -58,7 +58,7 @@ export function useVirtualization(
parentRef: containerRef,
horizontal: true,
size: leafColumns.length,
overscan: 10,
overscan: 5,
paddingStart: TABLE_PADDING,
paddingEnd: TABLE_PADDING,
estimateSize: useCallback(

View File

@@ -0,0 +1,198 @@
import { memo, Suspense, useState, useEffect, useRef } from "react";
import { useSetAtom } from "jotai";
import { get, isEqual } from "lodash-es";
import type { TableCellProps } from "@src/components/Table";
import {
IDisplayCellProps,
IEditorCellProps,
} from "@src/components/fields/types";
import { Popover, PopoverProps } from "@mui/material";
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
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 */
transparent?: boolean;
/** Props to pass to MUI Popover component */
popoverProps?: Partial<PopoverProps>;
}
/**
* HOC to render table cells.
* Renders read-only DisplayCell while scrolling for scroll performance.
* Defers render for inline EditorCell.
* @param DisplayCellComponent - The lighter cell component to display values
* @param EditorCellComponent - The heavier cell component to edit inline
* @param editorMode - When to display the EditorCell
* - "focus" (default) - when the cell is focused (Enter or double-click)
* - "inline" - inline with deferred render
* - "popover" - as a popover
* @param options - {@link ICellOptions}
*/
export default function withTableCell(
DisplayCellComponent: React.ComponentType<IDisplayCellProps>,
EditorCellComponent: React.ComponentType<IEditorCellProps>,
editorMode: "focus" | "inline" | "popover" = "focus",
options: ICellOptions = {}
) {
return memo(
function TableCell({
row,
column,
getValue,
focusInsideCell,
setFocusInsideCell,
disabled,
}: TableCellProps) {
const value = getValue();
const updateField = useSetAtom(updateFieldAtom, tableScope);
// 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!,
docRef: row.original._rowy_ref,
disabled: column.columnDef.meta!.editable === false,
showPopoverCell,
setFocusInsideCell,
};
// 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;
// This is where we update the documents
const handleSubmit = (value: any) => {
if (disabled) return;
updateField({
path: row.original._rowy_ref.path,
fieldName: column.id,
value,
deleteField: value === undefined,
});
};
const editorCell = (
<EditorCellComponent
{...basicCellProps}
tabIndex={focusInsideCell ? 0 : -1}
onSubmit={handleSubmit}
parentRef={parentRef}
/>
);
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}
<Suspense fallback={null}>
<Popover
open={popoverOpen}
anchorEl={parentRef}
onClose={() => showPopoverCell(false)}
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
{...options.popoverProps}
sx={
options.transparent
? {
"& .MuiPopover-paper": {
backgroundColor: "transparent",
},
}
: {}
}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{editorCell}
</Popover>
</Suspense>
</>
);
// Should not reach this line
return null;
},
(prev, next) => {
const valueEqual = isEqual(
get(prev.row.original, prev.column.columnDef.meta!.fieldName),
get(next.row.original, next.column.columnDef.meta!.fieldName)
);
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

@@ -12,7 +12,7 @@ import {
} from "@src/atoms/tableScope";
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
const ROW_HEIGHTS = [32, 40, 64, 96, 128, 160];
const ROW_HEIGHTS = [32, 40, 64, 96, 128, 160].map((x) => x + 1);
export default function RowHeight() {
const theme = useTheme();
@@ -63,7 +63,7 @@ export default function RowHeight() {
<ListSubheader>Row height</ListSubheader>
{ROW_HEIGHTS.map((height) => (
<MenuItem key={height} value={height}>
{height}px
{height - 1}px
</MenuItem>
))}
</TextField>

View File

@@ -0,0 +1,30 @@
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} disabled color="success" tabIndex={-1} />
}
label={column.name as string}
labelPlacement="start"
sx={{
m: 0,
width: "100%",
alignItems: "center",
"& .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 { get } from "lodash-es";
@@ -17,7 +17,8 @@ export default function Checkbox({
value,
onSubmit,
disabled,
}: IHeavyCellProps) {
tabIndex,
}: IEditorCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const handleChange = () => {
@@ -43,6 +44,7 @@ export default function Checkbox({
onChange={handleChange}
disabled={disabled}
color="success"
tabIndex={tabIndex}
/>
}
label={column.name as string}

View File

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

View File

@@ -1,5 +1,4 @@
import { forwardRef } from "react";
import { IPopoverInlineCellProps } from "@src/components/fields/types";
import { IDisplayCellProps } from "@src/components/fields/types";
import { ButtonBase, Grid } from "@mui/material";
import { ChevronDown } from "@src/assets/icons";
@@ -7,22 +6,21 @@ import { ChevronDown } from "@src/assets/icons";
import { sanitiseValue } from "./utils";
import ChipList from "@src/components/Table/formatters/ChipList";
import FormattedChip from "@src/components/FormattedChip";
import { ConvertStringToArray } from "./ConvertStringToArray";
export const MultiSelect = forwardRef(function MultiSelect(
{ value, showPopoverCell, disabled, onSubmit }: IPopoverInlineCellProps,
ref: React.Ref<any>
) {
if (typeof value === "string" && value !== "")
return <ConvertStringToArray value={value} onSubmit={onSubmit} />;
export default function MultiSelect({
value,
showPopoverCell,
disabled,
}: IDisplayCellProps) {
// if (typeof value === "string" && value !== "")
// return <ConvertStringToArray value={value} onSubmit={onSubmit} />;
return (
<ButtonBase
onClick={() => showPopoverCell(true)}
ref={ref}
disabled={disabled}
className="cell-collapse-padding"
sx={{
style={{
width: "100%",
height: "100%",
font: "inherit",
color: "inherit !important",
@@ -42,20 +40,7 @@ export const MultiSelect = forwardRef(function MultiSelect(
)}
</ChipList>
{!disabled && (
<ChevronDown
className="row-hover-iconButton"
sx={{
flexShrink: 0,
mr: 0.5,
borderRadius: 1,
p: (32 - 20) / 2 / 8,
boxSizing: "content-box !important",
}}
/>
)}
{!disabled && <ChevronDown className="row-hover-iconButton end" />}
</ButtonBase>
);
});
export default MultiSelect;
}

View File

@@ -1,4 +1,4 @@
import { IPopoverCellProps } from "@src/components/fields/types";
import { IEditorCellProps } from "@src/components/fields/types";
import MultiSelectComponent from "@rowy/multiselect";
@@ -11,7 +11,7 @@ export default function MultiSelect({
parentRef,
showPopoverCell,
disabled,
}: IPopoverCellProps) {
}: IEditorCellProps) {
const config = column.config ?? {};
return (

View File

@@ -1,10 +1,9 @@
import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell";
import withTableCell from "@src/components/Table/withTableCell";
import { MultiSelect as MultiSelectIcon } from "@src/assets/icons";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
import InlineCell from "./InlineCell";
import DisplayCell from "./DisplayCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
import { filterOperators } from "./Filter";
const PopoverCell = lazy(
@@ -34,9 +33,8 @@ export const config: IFieldConfig = {
icon: <MultiSelectIcon />,
description:
"Multiple values from predefined options. Options are searchable and users can optionally input custom values.",
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transparent: true,
TableCell: withTableCell(DisplayCell, PopoverCell, "popover", {
disablePadding: true,
}),
TableEditor: NullEditor as any,
SideDrawerField,

View File

@@ -61,7 +61,7 @@ export default function TextEditor({ row, column }: EditorProps<any>) {
sx={{
width: "100%",
height: "100%",
backgroundColor: "var(--background-color)",
backgroundColor: "var(--cell-background-color)",
"& .MuiInputBase-root": {
height: "100%",

View File

@@ -0,0 +1,61 @@
import type { IEditorCellProps } from "@src/components/fields/types";
import { useSaveOnUnmount } from "@src/hooks/useSaveOnUnmount";
import { InputBase } from "@mui/material";
export default function ShortText({
column,
value,
onSubmit,
setFocusInsideCell,
}: IEditorCellProps<string>) {
const [localValue, setLocalValue] = useSaveOnUnmount(value, onSubmit);
const maxLength = column.config?.maxLength;
return (
<InputBase
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
fullWidth
inputProps={{ maxLength }}
sx={{
width: "100%",
height: "calc(100% - 1px)",
marginTop: "1px",
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: 3 / 8,
},
}}
autoFocus
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.stopPropagation();
}
if (e.key === "Escape") {
// Escape removes focus inside cell, this runs before save on unmount
setLocalValue(value);
}
if (e.key === "Enter") {
// Removes focus from inside cell, triggering save on unmount
setFocusInsideCell(false);
}
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
/>
);
}

View File

@@ -1,10 +1,10 @@
import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell";
import withTableCell from "@src/components/Table/withTableCell";
import ShortTextIcon from "@mui/icons-material/ShortText";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue";
import TextEditor from "@src/components/Table/editors/TextEditor";
import EditorCell from "./EditorCell";
import { filterOperators } from "./Filter";
import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions";
@@ -30,8 +30,7 @@ export const config: IFieldConfig = {
icon: <ShortTextIcon />,
description: "Text displayed on a single line.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(BasicCell),
TableEditor: TextEditor,
TableCell: withTableCell(BasicCell, EditorCell),
SideDrawerField,
settings: Settings,
filter: {

View File

@@ -1,3 +1,3 @@
export default function BasicCellNull() {
return null;
export default function BasicCellNull(props: any) {
return <div {...props} style={{ position: "absolute", inset: 0 }} />;
}

View File

@@ -1,4 +1,4 @@
import { Suspense, useState, useEffect } from "react";
import { Suspense, useState, useEffect, startTransition } from "react";
import { useSetAtom } from "jotai";
import { get } from "lodash-es";
import type { TableCellProps } from "@src/components/Table";
@@ -22,13 +22,14 @@ export default function withHeavyCell(
return function HeavyCell({ row, column, getValue }: TableCellProps) {
const updateField = useSetAtom(updateFieldAtom, tableScope);
// const displayedComponent = "heavy";
// Initially display BasicCell to improve scroll performance
const [displayedComponent, setDisplayedComponent] = useState<
"basic" | "heavy"
>("basic");
// Then switch to HeavyCell once completed
useEffect(() => {
setTimeout(() => {
startTransition(() => {
setDisplayedComponent("heavy");
});
}, []);
@@ -45,6 +46,8 @@ export default function withHeavyCell(
value: localValue,
name: column.columnDef.meta!.name,
type: column.columnDef.meta!.type,
onMouseOver: () => setDisplayedComponent("heavy"),
onMouseLeave: () => setDisplayedComponent("basic"),
};
const basicCell = <BasicCellComponent {...basicCellProps} />;

View File

@@ -43,13 +43,13 @@ export default function withPopoverCell(
// Initially display BasicCell to improve scroll performance
const [displayedComponent, setDisplayedComponent] = useState<
"basic" | "inline" | "popover"
>("basic");
>("inline");
// Then switch to heavier InlineCell once completed
useEffect(() => {
setTimeout(() => {
setDisplayedComponent("inline");
});
}, []);
// useEffect(() => {
// setTimeout(() => {
// setDisplayedComponent("inline");
// });
// }, []);
// Store Popover open state here so we can add delay for close transition
const [popoverOpen, setPopoverOpen] = useState(false);

View File

@@ -30,7 +30,8 @@ export interface IFieldConfig {
reset: () => void
) => IContextMenuItem[];
TableCell: React.ComponentType<TableCellProps>;
TableEditor: React.ComponentType<EditorProps<TableRow, any>>;
/** @deprecated TODO: REMOVE */
TableEditor?: React.ComponentType<EditorProps<TableRow, any>>;
SideDrawerField: React.ComponentType<ISideDrawerFieldProps>;
settings?: React.ComponentType<ISettingsProps>;
settingsValidator?: (config: Record<string, any>) => Record<string, string>;
@@ -45,11 +46,13 @@ export interface IFieldConfig {
csvImportParser?: (value: string, config?: any) => any;
}
/** @deprecated TODO: REMOVE */
export interface IBasicCellProps {
value: any;
type: FieldType;
name: string;
}
/** @deprecated TODO: REMOVE */
export interface IHeavyCellProps extends IBasicCellProps {
row: TableRow;
column: ColumnConfig;
@@ -57,14 +60,32 @@ export interface IHeavyCellProps extends IBasicCellProps {
docRef: TableRowRef;
disabled: boolean;
}
/** @deprecated TODO: REMOVE */
export interface IPopoverInlineCellProps extends IHeavyCellProps {
showPopoverCell: React.Dispatch<React.SetStateAction<boolean>>;
}
/** @deprecated TODO: REMOVE */
export interface IPopoverCellProps extends IPopoverInlineCellProps {
parentRef: PopoverProps["anchorEl"];
}
export interface IDisplayCellProps<T = any> {
value: T;
type: FieldType;
name: string;
row: TableRow;
column: ColumnConfig;
docRef: TableRowRef;
disabled: boolean;
showPopoverCell: (value: boolean) => void;
setFocusInsideCell: (focusInside: boolean) => void;
}
export interface IEditorCellProps<T = any> extends IDisplayCellProps<T> {
onSubmit: (value: T) => void;
tabIndex: number;
parentRef: PopoverProps["anchorEl"];
}
/** Props to be passed to all SideDrawerFields */
export interface ISideDrawerFieldProps {
/** The column config */

View File

@@ -0,0 +1,19 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useLayoutEffect } from "react";
import useState from "react-usestateref";
export function useSaveOnUnmount<T>(
initialValue: T,
onSave: (value: T) => void
) {
const [localValue, setLocalValue, localValueRef] = useState(initialValue);
useLayoutEffect(() => {
return () => {
onSave(localValueRef.current);
};
}, []);
return [localValue, setLocalValue, localValueRef] as const;
}
export default useSaveOnUnmount;

View File

@@ -8,11 +8,11 @@ import reportWebVitals from "./reportWebVitals";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(
// <StrictMode>
<Providers>
<App />
</Providers>
// </StrictMode>
<StrictMode>
<Providers>
<App />
</Providers>
</StrictMode>
);
// If you want to start measuring performance in your app, pass a function

View File

@@ -2,7 +2,7 @@ import { useRef, Suspense, lazy } from "react";
import { useAtom } from "jotai";
import { DataGridHandle } from "react-data-grid";
import { ErrorBoundary } from "react-error-boundary";
import { isEmpty } from "lodash-es";
import { isEmpty, intersection } from "lodash-es";
import { Box, Fade } from "@mui/material";
import ErrorFallback, {
@@ -72,7 +72,10 @@ export default function TablePage({
// shouldnt access projectScope at all, to separate concerns.
const canAddColumn = userRoles.includes("ADMIN");
const canEditColumn = userRoles.includes("ADMIN");
const canEditCell = userRoles.includes("ADMIN") || !tableSettings.readOnly;
const canEditCell =
userRoles.includes("ADMIN") ||
(!tableSettings.readOnly &&
intersection(userRoles, tableSettings.roles).length > 0);
// Warn user about leaving when they have a table modal open
useBeforeUnload(columnModalAtom, tableScope);