Files
rowy/src/components/Table/Table.tsx

330 lines
10 KiB
TypeScript
Raw Normal View History

import { useMemo, useRef, useState, useEffect, useCallback } from "react";
2023-03-07 12:52:13 +05:30
// import useStateRef from "react-usestateref"; // testing with useStateWithRef
2022-05-19 16:37:56 +10:00
import { useAtom, useSetAtom } from "jotai";
import { useThrottledCallback } from "use-debounce";
2022-10-14 16:15:41 +11:00
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import type {
ColumnPinningState,
VisibilityState,
} from "@tanstack/react-table";
2022-11-15 17:36:58 +11:00
import { DropResult } from "react-beautiful-dnd";
import { get } from "lodash-es";
2022-10-14 16:15:41 +11:00
import StyledTable from "./Styled/StyledTable";
2022-11-15 18:14:44 +11:00
import TableHeader from "./TableHeader";
2022-11-15 17:36:58 +11:00
import TableBody from "./TableBody";
2022-10-31 17:56:54 +11:00
import FinalColumn from "./FinalColumn/FinalColumn";
import ContextMenu from "./ContextMenu";
2022-05-27 15:10:06 +10:00
import EmptyState from "@src/components/EmptyState";
2022-05-19 16:37:56 +10:00
// import BulkActions from "./BulkActions";
import {
tableScope,
tableSchemaAtom,
tableColumnsOrderedAtom,
tableRowsAtom,
tableNextPageAtom,
2022-05-19 16:37:56 +10:00
tablePageAtom,
updateColumnAtom,
selectedCellAtom,
2023-02-26 16:13:03 +05:30
tableSortsAtom,
tableIdAtom,
2022-05-19 16:37:56 +10:00
} from "@src/atoms/tableScope";
2023-02-26 16:17:46 +05:30
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
2022-05-24 20:34:28 +10:00
import { getFieldType, getFieldProp } from "@src/components/fields";
2022-10-18 17:53:47 +11:00
import { useKeyboardNavigation } from "./useKeyboardNavigation";
2023-01-14 11:35:32 +00:00
import { useMenuAction } from "./useMenuAction";
2022-10-24 14:59:29 +11:00
import { useSaveColumnSizing } from "./useSaveColumnSizing";
2023-01-14 11:35:32 +00:00
import useHotKeys from "./useHotKey";
import type { TableRow, ColumnConfig } from "@src/types/table";
2023-03-07 12:52:13 +05:30
import useStateWithRef from "./useStateWithRef"; // testing with useStateWithRef
2022-05-19 16:37:56 +10:00
export const DEFAULT_ROW_HEIGHT = 41;
export const DEFAULT_COL_WIDTH = 150;
2022-10-24 14:59:29 +11:00
export const MIN_COL_WIDTH = 80;
2022-10-21 14:58:25 +11:00
export const TABLE_PADDING = 16;
2022-10-24 15:19:28 +11:00
export const OUT_OF_ORDER_MARGIN = 8;
export const DEBOUNCE_DELAY = 500;
2022-05-19 16:37:56 +10:00
2022-10-14 16:15:41 +11:00
declare module "@tanstack/table-core" {
2022-11-15 17:36:58 +11:00
/** The `column.meta` property contains the column config from tableSchema */
2022-10-14 16:15:41 +11:00
interface ColumnMeta<TData, TValue> extends ColumnConfig {}
}
const columnHelper = createColumnHelper<TableRow>();
const getRowId = (row: TableRow) => row._rowy_ref.path || row._rowy_ref.id;
2022-05-19 16:37:56 +10:00
export interface ITableProps {
2022-11-18 17:31:03 +11:00
/** Determines if “Add column” button is displayed */
2022-11-15 16:53:20 +11:00
canAddColumns: boolean;
2022-11-18 17:31:03 +11:00
/** Determines if columns can be rearranged */
2022-11-15 16:53:20 +11:00
canEditColumns: boolean;
2022-11-18 17:31:03 +11:00
/**
* Determines if any cell can be edited.
* If false, `Table` only ever renders `EditorCell`.
*/
2022-11-15 16:53:20 +11:00
canEditCells: boolean;
2022-11-18 17:31:03 +11:00
/** The hidden columns saved to user settings */
hiddenColumns?: string[];
2022-11-18 17:31:03 +11:00
/**
* Displayed when `tableRows` is empty.
* Loading state handled by Suspense in parent component.
*/
emptyState?: React.ReactNode;
}
2022-05-19 16:37:56 +10:00
2022-11-18 17:31:03 +11:00
/**
* 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({
2022-11-15 16:53:20 +11:00
canAddColumns,
canEditColumns,
canEditCells,
hiddenColumns,
emptyState,
}: ITableProps) {
2022-05-19 16:37:56 +10:00
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
2022-05-19 16:37:56 +10:00
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
2023-02-26 16:17:46 +05:30
// Get user settings and tableId for applying sort sorting
2023-02-26 16:13:03 +05:30
const [userSettings] = useAtom(userSettingsAtom, projectScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
// Store a **state** and reference to the container element
// so the state can re-render `TableBody`, preventing virtualization
// not detecting scroll if the container element was initially `null`
const [containerEl, setContainerEl, containerRef] =
2023-03-07 12:52:13 +05:30
// useStateRef<HTMLDivElement | null>(null); // <-- older approach with useStateRef
useStateWithRef<HTMLDivElement | null>(null); // <-- newer approach with custom hook
2022-10-17 17:57:26 +11:00
const gridRef = useRef<HTMLDivElement>(null);
2022-10-14 16:15:41 +11:00
// Get column defs from table schema
2022-11-18 17:31:03 +11:00
// Also add end column for admins (canAddColumns || canEditCells)
2022-05-19 16:37:56 +10:00
const columns = useMemo(() => {
2022-10-14 16:15:41 +11:00
const _columns = tableColumnsOrdered
2022-10-19 12:41:20 +11:00
// Hide column for all users using table schema
.filter((column) => !column.hidden)
2022-10-14 16:15:41 +11:00
.map((columnConfig) =>
columnHelper.accessor((row) => get(row, columnConfig.fieldName), {
2022-10-17 17:57:26 +11:00
id: columnConfig.fieldName,
2022-10-14 16:15:41 +11:00
meta: columnConfig,
size: columnConfig.width,
enableResizing: columnConfig.resizable !== false,
minSize: MIN_COL_WIDTH,
2022-11-02 16:36:10 +11:00
cell: getFieldProp("TableCell", getFieldType(columnConfig)),
2022-10-14 16:15:41 +11:00
})
);
2022-11-15 16:53:20 +11:00
if (canAddColumns || canEditCells) {
2022-10-19 12:41:20 +11:00
_columns.push(
columnHelper.display({
id: "_rowy_column_actions",
2022-10-31 17:56:54 +11:00
cell: FinalColumn as any,
2022-10-19 12:41:20 +11:00
})
);
}
2022-05-19 16:37:56 +10:00
return _columns;
2022-11-15 16:53:20 +11:00
}, [tableColumnsOrdered, canAddColumns, canEditCells]);
2022-10-19 12:41:20 +11:00
2022-11-18 17:31:03 +11:00
// Get users hidden columns from props and memoize into a `VisibilityState`
const columnVisibility: VisibilityState = useMemo(() => {
if (!Array.isArray(hiddenColumns)) return {};
return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
}, [hiddenColumns]);
2022-11-18 17:31:03 +11:00
// Get frozen columns and memoize into a `ColumnPinningState`
const columnPinning: ColumnPinningState = useMemo(
2022-10-21 14:58:25 +11:00
() => ({
left: columns
.filter(
(c) => c.meta?.fixed && c.id && columnVisibility[c.id] !== false
)
.map((c) => c.id!),
2022-10-21 14:58:25 +11:00
}),
[columns, columnVisibility]
2022-10-21 14:58:25 +11:00
);
const lastFrozen: string | undefined =
columnPinning.left![columnPinning.left!.length - 1];
2022-10-21 14:58:25 +11:00
// Call TanStack Table
2022-10-14 16:15:41 +11:00
const table = useReactTable({
data: tableRows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId,
columnResizeMode: "onChange",
});
2022-11-18 17:31:03 +11:00
// 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(
table.initialState.columnSizing
);
table.setOptions((prev) => ({
...prev,
state: { ...prev.state, columnVisibility, columnPinning, columnSizing },
onColumnSizingChange: setColumnSizing,
}));
2022-11-18 17:31:03 +11:00
// Get rows and columns for virtualization
2022-10-18 17:53:47 +11:00
const { rows } = table.getRowModel();
2022-10-19 12:41:20 +11:00
const leafColumns = table.getVisibleLeafColumns();
2022-10-17 17:57:26 +11:00
2022-11-18 17:31:03 +11:00
// Handle keyboard navigation
const { handleKeyDown } = useKeyboardNavigation({
2022-10-18 17:53:47 +11:00
gridRef,
tableRows,
2022-10-19 12:41:20 +11:00
leafColumns,
2022-10-18 17:53:47 +11:00
});
const [selectedCell] = useAtom(selectedCellAtom, tableScope);
const { handleCopy, handlePaste, handleCut } = useMenuAction(selectedCell);
2023-01-14 11:35:32 +00:00
const { handler: hotKeysHandler } = useHotKeys([
["mod+C", handleCopy],
["mod+X", handleCut],
["mod+V", handlePaste],
]);
2022-11-15 17:36:58 +11:00
2022-11-18 17:31:03 +11:00
// Handle prompt to save local column sizes if user `canEditColumns`
2022-11-15 16:53:20 +11:00
useSaveColumnSizing(columnSizing, canEditColumns);
2022-10-18 17:53:47 +11:00
const handleDropColumn = useCallback(
(result: DropResult) => {
if (result.destination?.index === undefined || !result.draggableId)
return;
updateColumn({
key: result.draggableId,
index: result.destination.index,
config: {},
});
},
[updateColumn]
);
2022-10-18 17:53:47 +11:00
const fetchMoreOnBottomReached = useThrottledCallback(
(containerElement?: HTMLDivElement | null) => {
if (!containerElement) return;
const { scrollHeight, scrollTop, clientHeight } = containerElement;
if (scrollHeight - scrollTop - clientHeight < 300) {
setTablePage((p) => p + 1);
}
},
DEBOUNCE_DELAY
2022-10-18 17:53:47 +11:00
);
// Check on mount and after fetch to see if the table is at the bottom
// for large screen heights
useEffect(() => {
fetchMoreOnBottomReached(containerRef.current);
}, [
fetchMoreOnBottomReached,
tablePage,
tableNextPage.loading,
containerRef,
]);
2022-05-19 16:37:56 +10:00
2023-02-26 16:13:03 +05:30
// apply user default sort on first render
const [applySort, setApplySort] = useState(true);
useEffect(() => {
if (applySort && Object.keys(tableSchema).length) {
const userDefaultSort = userSettings.tables?.[tableId]?.sorts || [];
setTableSorts(
userDefaultSort.length ? userDefaultSort : tableSchema.sorts || []
);
setApplySort(false);
}
}, [tableSchema, userSettings, tableId, setTableSorts, applySort]);
2022-05-19 16:37:56 +10:00
return (
2022-10-18 17:53:47 +11:00
<div
2023-03-07 12:52:13 +05:30
ref={(el) => {
if (!el) return;
setContainerEl(el);
}}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
2022-10-18 17:53:47 +11:00
style={{ overflow: "auto", width: "100%", height: "100%" }}
>
2022-10-14 16:15:41 +11:00
<StyledTable
2022-10-17 17:57:26 +11:00
ref={gridRef}
2022-10-14 16:15:41 +11:00
role="grid"
2022-11-15 16:53:20 +11:00
aria-readonly={!canEditCells}
2022-10-17 17:57:26 +11:00
aria-colcount={columns.length}
aria-rowcount={tableRows.length + 1}
2022-10-24 15:38:35 +11:00
style={
{
width: table.getTotalSize(),
userSelect: "none",
"--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`,
} as any
}
onKeyDown={(e) => {
handleKeyDown(e);
2023-01-14 11:35:32 +00:00
hotKeysHandler(e);
}}
2022-10-14 16:15:41 +11:00
>
2022-10-17 17:57:26 +11:00
<div
className="thead"
role="rowgroup"
2022-10-21 14:58:25 +11:00
style={{
position: "sticky",
top: 0,
zIndex: 10,
padding: `0 ${TABLE_PADDING}px`,
}}
2022-10-17 17:57:26 +11:00
>
2022-11-15 18:14:44 +11:00
<TableHeader
2022-11-15 16:53:20 +11:00
headerGroups={table.getHeaderGroups()}
handleDropColumn={handleDropColumn}
canAddColumns={canAddColumns}
canEditColumns={canEditColumns}
lastFrozen={lastFrozen}
columnSizing={columnSizing}
2022-11-15 16:53:20 +11:00
/>
2022-10-14 16:15:41 +11:00
</div>
2022-11-15 17:36:58 +11:00
{tableRows.length === 0 ? (
emptyState ?? <EmptyState sx={{ py: 8 }} />
) : (
<TableBody
containerEl={containerEl}
containerRef={containerRef}
leafColumns={leafColumns}
rows={rows}
canEditCells={canEditCells}
lastFrozen={lastFrozen}
columnSizing={columnSizing}
/>
2022-11-15 17:36:58 +11:00
)}
2022-10-14 16:15:41 +11:00
</StyledTable>
2022-11-11 15:49:47 +11:00
<div
id="rowy-table-editable-cell-description"
style={{ display: "none" }}
>
Press Enter to edit.
</div>
2022-10-31 16:17:21 +11:00
<ContextMenu />
2022-10-18 17:53:47 +11:00
</div>
2022-05-19 16:37:56 +10:00
);
}