mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
add code comments & explanations
This commit is contained in:
@@ -7,12 +7,8 @@ import type {
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
import {
|
||||
styled,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
tooltipClasses,
|
||||
Fade,
|
||||
Stack,
|
||||
StackProps,
|
||||
IconButton,
|
||||
Typography,
|
||||
@@ -20,6 +16,10 @@ import {
|
||||
import DropdownIcon from "@mui/icons-material/MoreHoriz";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
|
||||
import {
|
||||
StyledColumnHeader,
|
||||
StyledColumnHeaderNameTooltip,
|
||||
} from "@src/components/Table/Styled/StyledColumnHeader";
|
||||
import ColumnHeaderSort, { SORT_STATES } from "./ColumnHeaderSort";
|
||||
import ColumnHeaderDragHandle from "./ColumnHeaderDragHandle";
|
||||
import ColumnHeaderResizer from "./ColumnHeaderResizer";
|
||||
@@ -39,53 +39,6 @@ import type { TableRow } from "@src/types/table";
|
||||
|
||||
export { COLUMN_HEADER_HEIGHT };
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
const LightTooltip = styled(({ className, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props} classes={{ popper: className }} />
|
||||
))(({ theme }) => ({
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
|
||||
margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`,
|
||||
padding: 0,
|
||||
paddingRight: theme.spacing(1.5),
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IColumnHeaderProps
|
||||
extends Partial<Omit<StackProps, "style" | "sx">> {
|
||||
header: Header<TableRow, any>;
|
||||
@@ -101,6 +54,18 @@ export interface IColumnHeaderProps
|
||||
isLastFrozen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -214,7 +179,7 @@ export const ColumnHeader = memo(function ColumnHeader({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<LightTooltip
|
||||
<StyledColumnHeaderNameTooltip
|
||||
title={
|
||||
<Typography
|
||||
sx={{
|
||||
@@ -261,7 +226,7 @@ export const ColumnHeader = memo(function ColumnHeader({
|
||||
column.name
|
||||
)}
|
||||
</Typography>
|
||||
</LightTooltip>
|
||||
</StyledColumnHeaderNameTooltip>
|
||||
|
||||
{column.type !== FieldType.id && (
|
||||
<ColumnHeaderSort
|
||||
|
||||
@@ -18,6 +18,10 @@ export interface IColumnHeaderSortProps {
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders button with current sort state.
|
||||
* On click, updates `tableSortsAtom` in `tableScope`.
|
||||
*/
|
||||
export const ColumnHeaderSort = memo(function ColumnHeaderSort({
|
||||
sortKey,
|
||||
currentSort,
|
||||
|
||||
58
src/components/Table/Styled/StyledColumnHeader.tsx
Normal file
58
src/components/Table/Styled/StyledColumnHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
styled,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
tooltipClasses,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Mock/Column";
|
||||
|
||||
export const StyledColumnHeader = styled(Stack)(({ theme }) => ({
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
"& + &": { borderLeftStyle: "none" },
|
||||
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(0, 0.5, 0, 1),
|
||||
"& svg, & button": { display: "block", zIndex: 1 },
|
||||
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.secondary,
|
||||
transition: theme.transitions.create("color", {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
"&:hover": { color: theme.palette.text.primary },
|
||||
|
||||
"& .MuiIconButton-root": {
|
||||
color: theme.palette.text.disabled,
|
||||
transition: theme.transitions.create(
|
||||
["background-color", "opacity", "color"],
|
||||
{ duration: theme.transitions.duration.short }
|
||||
),
|
||||
},
|
||||
[`&:hover .MuiIconButton-root,
|
||||
&:focus .MuiIconButton-root,
|
||||
&:focus-within .MuiIconButton-root,
|
||||
.MuiIconButton-root:focus`]: {
|
||||
color: theme.palette.text.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
export default StyledColumnHeader;
|
||||
|
||||
export const StyledColumnHeaderNameTooltip = styled(
|
||||
({ className, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props} classes={{ popper: className }} />
|
||||
)
|
||||
)(({ theme }) => ({
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
|
||||
margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`,
|
||||
padding: 0,
|
||||
paddingRight: theme.spacing(1.5),
|
||||
},
|
||||
}));
|
||||
22
src/components/Table/Styled/StyledDot.tsx
Normal file
22
src/components/Table/Styled/StyledDot.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { styled } from "@mui/material";
|
||||
|
||||
export const StyledDot = styled("div")(({ theme }) => ({
|
||||
position: "absolute",
|
||||
right: -5,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 1,
|
||||
|
||||
width: 12,
|
||||
height: 12,
|
||||
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.error.main,
|
||||
|
||||
boxShadow: `0 0 0 4px var(--cell-background-color)`,
|
||||
"[role='row']:hover &": {
|
||||
boxShadow: `0 0 0 4px var(--row-hover-background-color)`,
|
||||
},
|
||||
}));
|
||||
|
||||
export default StyledDot;
|
||||
@@ -49,13 +49,34 @@ 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({
|
||||
canAddColumns,
|
||||
canEditColumns,
|
||||
@@ -75,7 +96,7 @@ export default function Table({
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get column defs from table schema
|
||||
// Also add end column for admins
|
||||
// Also add end column for admins (canAddColumns || canEditCells)
|
||||
const columns = useMemo(() => {
|
||||
const _columns = tableColumnsOrdered
|
||||
// Hide column for all users using table schema
|
||||
@@ -103,13 +124,13 @@ export default function Table({
|
||||
return _columns;
|
||||
}, [tableColumnsOrdered, canAddColumns, canEditCells]);
|
||||
|
||||
// Get user’s hidden columns from props and memoize into a VisibilityState
|
||||
// Get user’s hidden columns from props and memoize into a `VisibilityState`
|
||||
const columnVisibility = useMemo(() => {
|
||||
if (!Array.isArray(hiddenColumns)) return {};
|
||||
return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
|
||||
}, [hiddenColumns]);
|
||||
|
||||
// Get frozen columns
|
||||
// Get frozen columns and memoize into a `ColumnPinningState`
|
||||
const columnPinning = useMemo(
|
||||
() => ({
|
||||
left: columns.filter((c) => c.meta?.fixed && c.id).map((c) => c.id!),
|
||||
@@ -128,7 +149,7 @@ export default function Table({
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
|
||||
// Store local columnSizing state so we can save it to table schema
|
||||
// Store local `columnSizing` state so we can save it to table schema
|
||||
// in `useSaveColumnSizing`. This could be generalized by storing the
|
||||
// entire table state.
|
||||
const [columnSizing, setColumnSizing] = useState(
|
||||
@@ -139,16 +160,18 @@ export default function Table({
|
||||
state: { ...prev.state, columnVisibility, columnPinning, columnSizing },
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
}));
|
||||
|
||||
// Get rows and columns for virtualization
|
||||
const { rows } = table.getRowModel();
|
||||
const leafColumns = table.getVisibleLeafColumns();
|
||||
|
||||
// Handle keyboard navigation
|
||||
const { handleKeyDown } = useKeyboardNavigation({
|
||||
gridRef,
|
||||
tableRows,
|
||||
leafColumns,
|
||||
});
|
||||
|
||||
// Handle prompt to save local column sizes if user `canEditColumns`
|
||||
useSaveColumnSizing(columnSizing, canEditColumns);
|
||||
|
||||
const handleDropColumn = useCallback(
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Column, Row, ColumnSizingState } from "@tanstack/react-table";
|
||||
|
||||
import StyledRow from "./Styled/StyledRow";
|
||||
import OutOfOrderIndicator from "./OutOfOrderIndicator";
|
||||
import CellValidation from "./TableCell";
|
||||
import TableCell from "./TableCell";
|
||||
import { RowsSkeleton } from "./TableSkeleton";
|
||||
|
||||
import {
|
||||
@@ -24,17 +24,28 @@ import {
|
||||
} from "./Table";
|
||||
|
||||
export interface ITableBodyProps {
|
||||
/** 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;
|
||||
|
||||
/** Re-render when local column sizing changes */
|
||||
/**
|
||||
* 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,
|
||||
@@ -98,7 +109,7 @@ export const TableBody = memo(function TableBody({
|
||||
fieldTypeGroup === "Auditing" || fieldTypeGroup === "Metadata";
|
||||
|
||||
return (
|
||||
<CellValidation
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
row={row}
|
||||
cell={cell}
|
||||
|
||||
90
src/components/Table/TableCell/EditorCellController.tsx
Normal file
90
src/components/Table/TableCell/EditorCellController.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import useStateRef from "react-usestateref";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import type {
|
||||
IDisplayCellProps,
|
||||
IEditorCellProps,
|
||||
} from "@src/components/fields/types";
|
||||
|
||||
interface IEditorCellControllerProps extends IDisplayCellProps {
|
||||
EditorCellComponent: React.ComponentType<IEditorCellProps>;
|
||||
parentRef: IEditorCellProps["parentRef"];
|
||||
saveOnUnmount: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a local state for the cell’s value, so that `EditorCell` doesn’t
|
||||
* immediately update the database when the user quickly makes changes to the
|
||||
* cell’s value (e.g. text input).
|
||||
*
|
||||
* Extracted from `withRenderTableCell()` so when the `DisplayCell` is
|
||||
* rendered, an unnecessary extra state is not created.
|
||||
*
|
||||
* - Defines function to update the field in db
|
||||
* - Tracks when the user has made the input “dirty”
|
||||
* - By default, saves to db when the component is unmounted and the input
|
||||
* is dirty
|
||||
* - Has an effect to change the local value state when it receives an update
|
||||
* from db and the field is not dirty. This is required to make inline
|
||||
* `EditorCell` work when they haven’t been interacted with, but prevent the
|
||||
* value changing while someone is editing a field, like Long Text.
|
||||
*/
|
||||
export default function EditorCellController({
|
||||
EditorCellComponent,
|
||||
saveOnUnmount,
|
||||
value,
|
||||
...props
|
||||
}: IEditorCellControllerProps) {
|
||||
// Store local value so we don’t immediately write to db when the user
|
||||
// types in a textbox, for example
|
||||
const [localValue, setLocalValue, localValueRef] = useStateRef(value);
|
||||
// Mark if the user has interacted with this cell and hasn’t saved yet
|
||||
const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
|
||||
// When this cell’s data has updated, update the local value if
|
||||
// it’s not dirty and the value is different
|
||||
useEffect(() => {
|
||||
if (!isDirty && !isEqual(value, localValueRef.current))
|
||||
setLocalValue(value);
|
||||
}, [isDirty, localValueRef, setLocalValue, value]);
|
||||
|
||||
// This is where we update the documents
|
||||
const handleSubmit = () => {
|
||||
// props.disabled should always be false as withRenderTableCell would
|
||||
// render DisplayCell instead of EditorCell
|
||||
if (props.disabled || !isDirtyRef.current) return;
|
||||
|
||||
updateField({
|
||||
path: props._rowy_ref.path,
|
||||
fieldName: props.column.fieldName,
|
||||
value: localValueRef.current,
|
||||
deleteField: localValueRef.current === undefined,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
if (saveOnUnmount) handleSubmit();
|
||||
};
|
||||
// Warns that `saveOnUnmount` and `handleSubmit` should be included, but
|
||||
// those don’t change across re-renders. We only want to run this on unmount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EditorCellComponent
|
||||
{...props}
|
||||
value={localValue}
|
||||
onDirty={(dirty?: boolean) => setIsDirty(dirty ?? true)}
|
||||
onChange={(v) => {
|
||||
setIsDirty(true);
|
||||
setLocalValue(v);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { Row, Cell } from "@tanstack/react-table";
|
||||
|
||||
import { styled } from "@mui/material/styles";
|
||||
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,
|
||||
@@ -20,39 +20,53 @@ import {
|
||||
import type { TableRow } from "@src/types/table";
|
||||
import type { IRenderedTableCellProps } from "./withRenderTableCell";
|
||||
|
||||
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(--cell-background-color)`,
|
||||
"[role='row']:hover &": {
|
||||
boxShadow: `0 0 0 4px var(--row-hover-background-color)`,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface ITableCellProps {
|
||||
/** Current row with context from TanStack Table state */
|
||||
row: Row<TableRow>;
|
||||
/** Current cell with context from TanStack Table state */
|
||||
cell: Cell<TableRow, any>;
|
||||
/** Virtual cell index (column index) */
|
||||
index: number;
|
||||
/** User has clicked or navigated to this cell */
|
||||
isSelectedCell: boolean;
|
||||
/** User has double-clicked or pressed Enter and this cell is selected */
|
||||
focusInsideCell: boolean;
|
||||
/**
|
||||
* Used to disable `aria-description` that says “Press Enter to edit”
|
||||
* for Auditing and Metadata cells. Need to find another way to do this.
|
||||
*/
|
||||
isReadOnlyCell: boolean;
|
||||
/** Determines if EditorCell can be displayed */
|
||||
canEditCells: boolean;
|
||||
/**
|
||||
* Pass current row height as a prop so we don’t access `tableSchema` here.
|
||||
* If that atom is listened to here, all table cells will re-render whenever
|
||||
* `tableSchema` changes, which is unnecessary.
|
||||
*/
|
||||
rowHeight: number;
|
||||
/** If true, renders a shadow */
|
||||
isLastFrozen: boolean;
|
||||
/** Pass width as a prop to get local column sizing state */
|
||||
width: number;
|
||||
/**
|
||||
* If provided, cell is pinned/frozen, and this value is used for
|
||||
* `position: sticky`.
|
||||
*/
|
||||
left?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the container div for each cell with accessibility attributes for
|
||||
* keyboard navigation.
|
||||
*
|
||||
* - Performs regex & missing value check and renders associated UI
|
||||
* - Provides children with value from `cell.getValue()` so they can work with
|
||||
* memoization
|
||||
* - Provides helpers as props to aid with memoization, so children components
|
||||
* don’t have to read atoms, which may cause unnecessary re-renders of many
|
||||
* cell components
|
||||
* - Renders `ErrorBoundary`
|
||||
*/
|
||||
export const TableCell = memo(function TableCell({
|
||||
row,
|
||||
cell,
|
||||
@@ -85,7 +99,7 @@ export const TableCell = memo(function TableCell({
|
||||
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} />}
|
||||
render={({ openTooltip }) => <StyledDot onClick={openTooltip} />}
|
||||
/>
|
||||
);
|
||||
} else if (isMissing) {
|
||||
@@ -95,7 +109,7 @@ export const TableCell = memo(function TableCell({
|
||||
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} />}
|
||||
render={({ openTooltip }) => <StyledDot onClick={openTooltip} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import {
|
||||
memo,
|
||||
Suspense,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import useStateRef from "react-usestateref";
|
||||
import { useSetAtom } from "jotai";
|
||||
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 { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import EditorCellController from "./EditorCellController";
|
||||
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
import type { TableRow } from "@src/types/table";
|
||||
import type {
|
||||
@@ -32,6 +24,7 @@ export interface ICellOptions {
|
||||
popoverProps?: Partial<PopoverProps>;
|
||||
}
|
||||
|
||||
/** Received from `TableCell` */
|
||||
export interface IRenderedTableCellProps<TValue = any>
|
||||
extends CellContext<TableRow, TValue> {
|
||||
value: TValue;
|
||||
@@ -42,18 +35,61 @@ export interface IRenderedTableCellProps<TValue = any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
* Higher-order component to render each field type’s cell components.
|
||||
* Handles when to render read-only `DisplayCell` and `EditorCell`.
|
||||
*
|
||||
* Memoized to re-render when value, column, focus, or disabled states change.
|
||||
* Optionally re-renders when entire row updates.
|
||||
*
|
||||
* - Renders inline `EditorCell` after a timeout to improve scroll performance
|
||||
* - Handles popovers
|
||||
* - Renders Suspense for lazy-loaded `EditorCell`
|
||||
* - Provides a `tabIndex` prop, so that interactive cell children (like
|
||||
* buttons) cannot be interacted with unless the user has focused in the
|
||||
* cell. Required for accessibility.
|
||||
*
|
||||
* @param DisplayCellComponent
|
||||
* - The lighter cell component to display values. Also displayed when the
|
||||
* column is disabled/read-only.
|
||||
*
|
||||
* - Keep these components lightweight, i.e. use base HTML or simple MUI
|
||||
* components. Avoid `Tooltip`, which is heavy.
|
||||
* - Avoid displaying disabled states (e.g. do not reduce opacity/grey out
|
||||
* toggles). This improves the experience of read-only tables for non-admins
|
||||
* - ⚠️ Make sure the disabled state does not render the buttons to open a
|
||||
* popover `EditorCell` (like Single/Multi Select).
|
||||
* - ⚠️ Make sure to use the `tabIndex` prop for buttons and other interactive
|
||||
* elements.
|
||||
* - {@link IDisplayCellProps}
|
||||
*
|
||||
* @param EditorCellComponent
|
||||
* - The heavier cell component to edit values
|
||||
*
|
||||
* - `EditorCell` should use the `value` and `onChange` props for the
|
||||
* rendered inputs. Avoid creating another local state here.
|
||||
* - `onSubmit` is available if `saveOnUnmount` does not work or if you want
|
||||
* to submit to the db before unmount.
|
||||
* - ✨ You can reuse your `SideDrawerField` as they take the same props. It
|
||||
* should probably be displayed in a popover.
|
||||
* - You can pass `null` to `withRenderTableCell()` to always display the
|
||||
* `DisplayCell`.
|
||||
* - ⚠️ Make sure to use the `tabIndex` prop for buttons, text fields, and
|
||||
* other interactive elements.
|
||||
* - {@link IEditorCellProps}
|
||||
*
|
||||
* @param editorMode
|
||||
* - When to display the `EditorCell`
|
||||
* 1. **focus** (default): the user has focused on the cell by pressing Enter or
|
||||
* double-clicking,
|
||||
* 2. **inline**: always displayed if the cell is editable, or
|
||||
* 3. **popover**: inside a popover when a user has focused on the cell
|
||||
* (as above) or clicked a button rendered by `DisplayCell`
|
||||
*
|
||||
* @param options
|
||||
* - Note this is OK to pass as an object since it’s not defined in runtime
|
||||
* - {@link ICellOptions}
|
||||
*/
|
||||
export default function withTableCell(
|
||||
export default function withRenderTableCell(
|
||||
DisplayCellComponent: React.ComponentType<IDisplayCellProps>,
|
||||
EditorCellComponent: React.ComponentType<IEditorCellProps> | null,
|
||||
editorMode: "focus" | "inline" | "popover" = "focus",
|
||||
@@ -137,7 +173,7 @@ export default function withTableCell(
|
||||
// Show displayCell as a fallback if intentionally null
|
||||
const editorCell = EditorCellComponent ? (
|
||||
<Suspense fallback={null}>
|
||||
<EditorCellManager
|
||||
<EditorCellController
|
||||
{...basicCellProps}
|
||||
EditorCellComponent={EditorCellComponent}
|
||||
parentRef={parentRef}
|
||||
@@ -201,6 +237,7 @@ export default function withTableCell(
|
||||
// Should not reach this line
|
||||
return null;
|
||||
},
|
||||
// Memo function
|
||||
(prev, next) => {
|
||||
const valueEqual = isEqual(prev.value, next.value);
|
||||
const columnEqual = isEqual(
|
||||
@@ -220,65 +257,3 @@ export default function withTableCell(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface IEditorCellManagerProps extends IDisplayCellProps {
|
||||
EditorCellComponent: React.ComponentType<IEditorCellProps>;
|
||||
parentRef: IEditorCellProps["parentRef"];
|
||||
saveOnUnmount: boolean;
|
||||
}
|
||||
|
||||
function EditorCellManager({
|
||||
EditorCellComponent,
|
||||
saveOnUnmount,
|
||||
value,
|
||||
...props
|
||||
}: IEditorCellManagerProps) {
|
||||
// Store local value so we don’t immediately write to db when the user
|
||||
// types in a textbox, for example
|
||||
const [localValue, setLocalValue, localValueRef] = useStateRef(value);
|
||||
// Mark if the user has interacted with this cell and hasn’t saved yet
|
||||
const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
|
||||
// When this cell’s data has updated, update the local value if
|
||||
// it’s not dirty and the value is different
|
||||
useEffect(() => {
|
||||
if (!isDirty && !isEqual(value, localValueRef.current))
|
||||
setLocalValue(value);
|
||||
}, [isDirty, localValueRef, setLocalValue, value]);
|
||||
|
||||
// This is where we update the documents
|
||||
const handleSubmit = () => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,27 @@ 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;
|
||||
|
||||
/** Re-render when local column sizing changes */
|
||||
/**
|
||||
* 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,
|
||||
@@ -93,6 +104,7 @@ export const TableHeader = memo(function TableHeader({
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{/* Required by react-beautiful-dnd */}
|
||||
{provided.placeholder}
|
||||
</StyledRow>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,8 @@ import { DEBOUNCE_DELAY } from "./Table";
|
||||
import { ColumnSizingState } from "@tanstack/react-table";
|
||||
|
||||
/**
|
||||
* Debounces columnSizing and asks admins if they want to save for all users
|
||||
* Debounces `columnSizing` and asks user if they want to save for all users,
|
||||
* if they have the `canEditColumns` permission
|
||||
*/
|
||||
export function useSaveColumnSizing(
|
||||
columnSizing: ColumnSizingState,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface IFieldConfig {
|
||||
csvImportParser?: (value: string, config?: any) => any;
|
||||
}
|
||||
|
||||
/** See {@link IRenderedTableCellProps | `withRenderTableCell` } for guidance */
|
||||
export interface IDisplayCellProps<T = any> {
|
||||
value: T;
|
||||
type: FieldType;
|
||||
@@ -51,11 +52,16 @@ export interface IDisplayCellProps<T = any> {
|
||||
/** The row’s _rowy_ref object */
|
||||
_rowy_ref: TableRowRef;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* ⚠️ Make sure to use the `tabIndex` prop for buttons and other interactive
|
||||
* elements.
|
||||
*/
|
||||
tabIndex: number;
|
||||
showPopoverCell: (value: boolean) => void;
|
||||
setFocusInsideCell: (focusInside: boolean) => void;
|
||||
rowHeight: number;
|
||||
}
|
||||
/** See {@link IRenderedTableCellProps | `withRenderTableCell` } for guidance */
|
||||
export interface IEditorCellProps<T = any> extends IDisplayCellProps<T> {
|
||||
/** Call when the user has input but changes have not been saved */
|
||||
onDirty: (dirty?: boolean) => void;
|
||||
|
||||
@@ -28,7 +28,12 @@ import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
|
||||
const TablePage = lazy(() => import("./TablePage" /* webpackChunkName: "TablePage" */));
|
||||
|
||||
/**
|
||||
* Wraps `TablePage` with the data for a top-level table.
|
||||
* Wraps `TablePage` with the data for a sub-table.
|
||||
*
|
||||
* Differences to `ProvidedTablePage`:
|
||||
* - Renders a `Modal`
|
||||
* - When this is a child of `ProvidedTablePage`, the `TablePage` rendered for
|
||||
* the root table has its modals disabled
|
||||
*/
|
||||
export default function ProvidedSubTablePage() {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -38,6 +38,13 @@ const TablePage = lazy(() => import("./TablePage" /* webpackChunkName: "TablePag
|
||||
/**
|
||||
* Wraps `TablePage` with the data for a top-level table.
|
||||
* `SubTablePage` is inserted in the outlet, alongside `TablePage`.
|
||||
*
|
||||
* Interfaces with `projectScope` atoms to find the correct table (or sub-table)
|
||||
* settings and schema.
|
||||
*
|
||||
* - Renders the Jotai `Provider` with `tableScope`
|
||||
* - Renders `TableSourceFirestore`, which queries Firestore and stores data in
|
||||
* atoms in `tableScope`
|
||||
*/
|
||||
export default function ProvidedTablePage() {
|
||||
const { id } = useParams();
|
||||
|
||||
@@ -46,7 +46,10 @@ import { formatSubTableName } from "@src/utils/table";
|
||||
const BuildLogsSnack = lazy(() => import("@src/components/TableModals/CloudLogsModal/BuildLogs/BuildLogsSnack" /* webpackChunkName: "TableModals-BuildLogsSnack" */));
|
||||
|
||||
export interface ITablePageProps {
|
||||
/** Disable modals on this table when a sub-table is open and it’s listening to URL state */
|
||||
/**
|
||||
* Disable modals on this table when a sub-table is open and it’s listening
|
||||
* to URL state
|
||||
*/
|
||||
disableModals?: boolean;
|
||||
/** Disable side drawer */
|
||||
disableSideDrawer?: boolean;
|
||||
@@ -55,6 +58,15 @@ export interface ITablePageProps {
|
||||
/**
|
||||
* TablePage renders all the UI for the table.
|
||||
* Must be wrapped by either `ProvidedTablePage` or `ProvidedSubTablePage`.
|
||||
*
|
||||
* Renders `Table`, `TableToolbar`, `SideDrawer`, `TableModals`, `ColumnMenu`,
|
||||
* Suspense fallback UI. These components are all independent of each other.
|
||||
*
|
||||
* - Renders empty state if no columns
|
||||
* - Defines empty state if no rows
|
||||
* - Defines permissions `canAddColumns`, `canEditColumns`, `canEditCells`
|
||||
* for `Table` using `userRolesAtom` in `projectScope`
|
||||
* - Provides `Table` with hidden columns array from user settings
|
||||
*/
|
||||
export default function TablePage({
|
||||
disableModals,
|
||||
|
||||
Reference in New Issue
Block a user