mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
278 lines
8.7 KiB
TypeScript
278 lines
8.7 KiB
TypeScript
import {
|
||
memo,
|
||
Suspense,
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useLayoutEffect,
|
||
} from "react";
|
||
import useStateRef from "react-usestateref";
|
||
import { useSetAtom } from "jotai";
|
||
import { get, isEqual } from "lodash-es";
|
||
import type { CellContext } from "@tanstack/react-table";
|
||
|
||
import { Popover, PopoverProps } from "@mui/material";
|
||
|
||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||
import { spreadSx } from "@src/utils/ui";
|
||
import type { TableRow } from "@src/types/table";
|
||
import type {
|
||
IDisplayCellProps,
|
||
IEditorCellProps,
|
||
} from "@src/components/fields/types";
|
||
|
||
export interface ICellOptions {
|
||
/** If the rest of the row’s data is used, set this to true for memoization */
|
||
usesRowData?: boolean;
|
||
/** Handle padding inside the cell component */
|
||
disablePadding?: boolean;
|
||
/** Set popover background to be transparent */
|
||
transparentPopover?: boolean;
|
||
/** Props to pass to MUI Popover component */
|
||
popoverProps?: Partial<PopoverProps>;
|
||
}
|
||
|
||
export interface ITableCellProps extends CellContext<TableRow, any> {
|
||
focusInsideCell: boolean;
|
||
setFocusInsideCell: (focusInside: boolean) => void;
|
||
disabled: boolean;
|
||
rowHeight: number;
|
||
}
|
||
|
||
/**
|
||
* 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> | null,
|
||
editorMode: "focus" | "inline" | "popover" = "focus",
|
||
options: ICellOptions = {}
|
||
) {
|
||
return memo(
|
||
function TableCell({
|
||
row,
|
||
column,
|
||
getValue,
|
||
focusInsideCell,
|
||
setFocusInsideCell,
|
||
disabled,
|
||
rowHeight,
|
||
}: ITableCellProps) {
|
||
// Get the latest value on every re-render of this component
|
||
const value = getValue();
|
||
|
||
// Render inline editor cell after timeout on mount
|
||
// to improve scroll performance
|
||
const [inlineEditorReady, setInlineEditorReady] = useState(false);
|
||
useEffect(() => {
|
||
if (editorMode === "inline")
|
||
setTimeout(() => setInlineEditorReady(true));
|
||
}, []);
|
||
|
||
// Store ref to rendered DisplayCell to get positioning for PopoverCell
|
||
const displayCellRef = useRef<HTMLDivElement>(null);
|
||
const parentRef = displayCellRef.current?.parentElement;
|
||
|
||
// Store Popover open state here so we can add delay for close transition
|
||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||
useEffect(() => {
|
||
if (focusInsideCell) setPopoverOpen(true);
|
||
}, [focusInsideCell]);
|
||
const showPopoverCell = (popover: boolean) => {
|
||
if (popover) {
|
||
setPopoverOpen(true);
|
||
// Need to call this after a timeout, since the cell’s `onClick`
|
||
// event is fired, which sets focusInsideCell false
|
||
setTimeout(() => setFocusInsideCell(true));
|
||
} else {
|
||
setPopoverOpen(false);
|
||
// Call after a timeout to allow the close transition to finish
|
||
setTimeout(() => {
|
||
setFocusInsideCell(false);
|
||
// Focus the cell. Otherwise, it focuses the body.
|
||
parentRef?.focus();
|
||
}, 300);
|
||
}
|
||
};
|
||
|
||
// Declare basicCell here so props can be reused by HeavyCellComponent
|
||
const basicCellProps: IDisplayCellProps = {
|
||
value,
|
||
name: column.columnDef.meta!.name,
|
||
type: column.columnDef.meta!.type,
|
||
row: row.original,
|
||
column: column.columnDef.meta!,
|
||
_rowy_ref: row.original._rowy_ref,
|
||
disabled: column.columnDef.meta!.editable === false,
|
||
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}>
|
||
<EditorCellManager
|
||
{...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;
|
||
},
|
||
(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;
|
||
}
|
||
);
|
||
}
|
||
|
||
interface IEditorCellManagerProps extends IDisplayCellProps {
|
||
EditorCellComponent: React.ComponentType<IEditorCellProps>;
|
||
parentRef: IEditorCellProps["parentRef"];
|
||
saveOnUnmount: boolean;
|
||
}
|
||
|
||
function EditorCellManager({
|
||
EditorCellComponent,
|
||
saveOnUnmount,
|
||
...props
|
||
}: IEditorCellManagerProps) {
|
||
const [localValue, setLocalValue, localValueRef] = useStateRef(props.value);
|
||
const [, setIsDirty, isDirtyRef] = useStateRef(false);
|
||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||
|
||
// This is where we update the documents
|
||
const handleSubmit = () => {
|
||
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) {
|
||
console.log("unmount", props._rowy_ref.path, props.column.fieldName);
|
||
handleSubmit();
|
||
}
|
||
};
|
||
// 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}
|
||
/>
|
||
);
|
||
}
|