diff --git a/src/components/Table/ColumnHeader/ColumnHeader.tsx b/src/components/Table/ColumnHeader/ColumnHeader.tsx index 84239a40..40e5c5a1 100644 --- a/src/components/Table/ColumnHeader/ColumnHeader.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeader.tsx @@ -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) => ( - -))(({ 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> { header: Header; @@ -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({ )} - - + {column.type !== FieldType.id && ( ({ + 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) => ( + + ) +)(({ 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), + }, +})); diff --git a/src/components/Table/Styled/StyledDot.tsx b/src/components/Table/Styled/StyledDot.tsx new file mode 100644 index 00000000..bd942a8f --- /dev/null +++ b/src/components/Table/Styled/StyledDot.tsx @@ -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; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index fe1447c4..2d412787 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -49,13 +49,34 @@ const columnHelper = createColumnHelper(); 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(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( diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 674d8d96..45eef9a3 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -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; + /** Used in `useVirtualization` */ leafColumns: Column[]; + /** Current table rows with context from TanStack Table state */ rows: Row[]; - + /** 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 ( - ; + 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 ( + setIsDirty(dirty ?? true)} + onChange={(v) => { + setIsDirty(true); + setLocalValue(v); + }} + onSubmit={handleSubmit} + /> + ); +} diff --git a/src/components/Table/TableCell/TableCell.tsx b/src/components/Table/TableCell/TableCell.tsx index 48fb7b93..1842602b 100644 --- a/src/components/Table/TableCell/TableCell.tsx +++ b/src/components/Table/TableCell/TableCell.tsx @@ -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; + /** Current cell with context from TanStack Table state */ cell: Cell; + /** 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 }) => } + render={({ 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 }) => } + render={({ openTooltip }) => } /> ); } diff --git a/src/components/Table/TableCell/withRenderTableCell.tsx b/src/components/Table/TableCell/withRenderTableCell.tsx index a8f10f83..0d215b19 100644 --- a/src/components/Table/TableCell/withRenderTableCell.tsx +++ b/src/components/Table/TableCell/withRenderTableCell.tsx @@ -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; } +/** Received from `TableCell` */ export interface IRenderedTableCellProps extends CellContext { value: TValue; @@ -42,18 +35,61 @@ export interface IRenderedTableCellProps } /** - * 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, EditorCellComponent: React.ComponentType | 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 ? ( - { 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; - 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 ( - setIsDirty(dirty ?? true)} - onChange={(v) => { - setIsDirty(true); - setLocalValue(v); - }} - onSubmit={handleSubmit} - /> - ); -} diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index ac93d566..43eb8833 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -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[]; + /** 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({ ); })} + {/* Required by react-beautiful-dnd */} {provided.placeholder} )} diff --git a/src/components/Table/useSaveColumnSizing.tsx b/src/components/Table/useSaveColumnSizing.tsx index 2bcf9c59..af1a6e37 100644 --- a/src/components/Table/useSaveColumnSizing.tsx +++ b/src/components/Table/useSaveColumnSizing.tsx @@ -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, diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index b43b6d82..50a05f8a 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -42,6 +42,7 @@ export interface IFieldConfig { csvImportParser?: (value: string, config?: any) => any; } +/** See {@link IRenderedTableCellProps | `withRenderTableCell` } for guidance */ export interface IDisplayCellProps { value: T; type: FieldType; @@ -51,11 +52,16 @@ export interface IDisplayCellProps { /** 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 extends IDisplayCellProps { /** Call when the user has input but changes have not been saved */ onDirty: (dirty?: boolean) => void; diff --git a/src/pages/Table/ProvidedSubTablePage.tsx b/src/pages/Table/ProvidedSubTablePage.tsx index 860748c2..e4659d33 100644 --- a/src/pages/Table/ProvidedSubTablePage.tsx +++ b/src/pages/Table/ProvidedSubTablePage.tsx @@ -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(); diff --git a/src/pages/Table/ProvidedTablePage.tsx b/src/pages/Table/ProvidedTablePage.tsx index 8f86be90..cdb7d19c 100644 --- a/src/pages/Table/ProvidedTablePage.tsx +++ b/src/pages/Table/ProvidedTablePage.tsx @@ -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(); diff --git a/src/pages/Table/TablePage.tsx b/src/pages/Table/TablePage.tsx index 3dce7445..6008c835 100644 --- a/src/pages/Table/TablePage.tsx +++ b/src/pages/Table/TablePage.tsx @@ -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,