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

351 lines
11 KiB
TypeScript
Raw Normal View History

import { useMemo, useRef, useState, useEffect, useCallback } from "react";
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,
flexRender,
getCoreRowModel,
useReactTable,
2022-11-02 16:36:10 +11:00
CellContext,
2022-10-14 16:15:41 +11:00
} from "@tanstack/react-table";
import {
DragDropContext,
DropResult,
Droppable,
Draggable,
} from "react-beautiful-dnd";
import { get } from "lodash-es";
2022-11-02 16:36:10 +11:00
import { ErrorBoundary } from "react-error-boundary";
import { DragVertical } from "@src/assets/icons";
2022-10-14 16:15:41 +11:00
import StyledTable from "./Styled/StyledTable";
import StyledRow from "./Styled/StyledRow";
2022-11-15 16:53:20 +11:00
import TableHeaderGroup from "./TableHeaderGroup";
import ColumnHeader from "./ColumnHeader";
import StyledResizer from "./Styled/StyledResizer";
2022-10-31 17:56:54 +11:00
import FinalColumnHeader from "./FinalColumn/FinalColumnHeader";
import FinalColumn from "./FinalColumn/FinalColumn";
2022-10-24 15:38:35 +11:00
import OutOfOrderIndicator from "./OutOfOrderIndicator";
import ContextMenu from "./ContextMenu";
2022-11-02 16:36:10 +11:00
import CellValidation from "./CellValidation";
2022-10-14 16:15:41 +11:00
2022-05-27 15:10:06 +10:00
import EmptyState from "@src/components/EmptyState";
2022-11-02 16:36:10 +11:00
import { InlineErrorFallback } from "@src/components/ErrorFallback";
2022-05-19 16:37:56 +10:00
// import BulkActions from "./BulkActions";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
tableColumnsOrderedAtom,
tableRowsAtom,
tableNextPageAtom,
2022-05-19 16:37:56 +10:00
tablePageAtom,
updateColumnAtom,
updateFieldAtom,
selectedCellAtom,
2022-10-31 16:17:21 +11:00
contextMenuTargetAtom,
2022-05-19 16:37:56 +10:00
} from "@src/atoms/tableScope";
2022-05-24 20:34:28 +10:00
import { getFieldType, getFieldProp } from "@src/components/fields";
2022-10-14 16:15:41 +11:00
import { TableRow, ColumnConfig } from "@src/types/table";
2022-10-18 17:53:47 +11:00
import { useKeyboardNavigation } from "./useKeyboardNavigation";
2022-10-24 14:59:29 +11:00
import { useSaveColumnSizing } from "./useSaveColumnSizing";
import useVirtualization from "./useVirtualization";
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" {
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-15 16:53:20 +11:00
canAddColumns: boolean;
canEditColumns: boolean;
canEditCells: boolean;
hiddenColumns?: string[];
emptyState?: React.ReactNode;
}
2022-05-19 16:37:56 +10:00
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);
const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope);
2022-10-31 16:17:21 +11:00
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
const focusInsideCell = selectedCell?.focusInside ?? false;
2022-05-19 16:37:56 +10:00
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
2022-10-18 17:53:47 +11:00
const containerRef = useRef<HTMLDivElement>(null);
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
// Also add end column for admins
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
// Get users hidden columns from props and memoize into a VisibilityState
2022-10-19 12:41:20 +11:00
const columnVisibility = useMemo(() => {
if (!Array.isArray(hiddenColumns)) return {};
return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
}, [hiddenColumns]);
2022-10-21 14:58:25 +11:00
// Get frozen columns
const columnPinning = useMemo(
() => ({
left: columns.filter((c) => c.meta?.fixed && c.id).map((c) => c.id!),
}),
[columns]
);
const lastFrozen: string | undefined =
columnPinning.left[columnPinning.left.length - 1];
// Call TanStack Table
2022-10-14 16:15:41 +11:00
const table = useReactTable({
data: tableRows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId,
columnResizeMode: "onChange",
});
// 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-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
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 {
virtualRows,
virtualCols,
paddingTop,
paddingBottom,
paddingLeft,
paddingRight,
} = useVirtualization(containerRef, leafColumns);
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;
console.log(result.draggableId, result.destination.index);
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]);
2022-05-19 16:37:56 +10:00
return (
2022-10-18 17:53:47 +11:00
<div
ref={containerRef}
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
}
2022-10-14 16:15:41 +11:00
onKeyDown={handleKeyDown}
>
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 16:53:20 +11:00
<TableHeaderGroup
headerGroups={table.getHeaderGroups()}
handleDropColumn={handleDropColumn}
canAddColumns={canAddColumns}
canEditColumns={canEditColumns}
lastFrozen={lastFrozen}
/>
2022-10-14 16:15:41 +11:00
</div>
2022-10-17 17:57:26 +11:00
<div className="tbody" role="rowgroup">
2022-10-18 17:53:47 +11:00
{paddingTop > 0 && (
<div role="presentation" style={{ height: `${paddingTop}px` }} />
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
2022-10-24 15:38:35 +11:00
const outOfOrder = row.original._rowy_outOfOrder;
2022-10-18 17:53:47 +11:00
return (
<StyledRow
key={row.id}
role="row"
aria-rowindex={row.index + 2}
2022-10-24 15:19:28 +11:00
style={{
2022-10-24 15:38:35 +11:00
height: "auto",
marginBottom: outOfOrder ? OUT_OF_ORDER_MARGIN : 0,
2022-10-24 15:19:28 +11:00
}}
2022-10-24 15:38:35 +11:00
data-out-of-order={outOfOrder || undefined}
>
2022-10-18 17:53:47 +11:00
{paddingLeft > 0 && (
<div
role="presentation"
style={{ width: `${paddingLeft}px` }}
/>
)}
2022-10-24 15:38:35 +11:00
{outOfOrder && <OutOfOrderIndicator />}
2022-10-18 17:53:47 +11:00
{virtualCols.map((virtualCell) => {
const cellIndex = virtualCell.index;
const cell = row.getVisibleCells()[cellIndex];
const isSelectedCell =
selectedCell?.path === row.original._rowy_ref.path &&
selectedCell?.columnKey === cell.column.id;
2022-11-11 15:49:47 +11:00
const fieldTypeGroup = getFieldProp(
"group",
cell.column.columnDef.meta?.type
);
const isReadOnlyCell =
fieldTypeGroup === "Auditing" ||
fieldTypeGroup === "Metadata";
2022-10-18 17:53:47 +11:00
return (
2022-11-02 16:36:10 +11:00
<CellValidation
2022-10-18 17:53:47 +11:00
key={cell.id}
2022-11-15 16:53:20 +11:00
row={row}
cell={cell}
index={cellIndex}
left={
cell.column.getIsPinned()
2022-10-21 14:58:25 +11:00
? virtualCell.start - TABLE_PADDING
2022-11-15 16:53:20 +11:00
: undefined
2022-11-02 16:36:10 +11:00
}
2022-11-15 16:53:20 +11:00
isSelectedCell={isSelectedCell}
isReadOnlyCell={isReadOnlyCell}
canEditCells={canEditCells}
lastFrozen={lastFrozen}
rowHeight={tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}
/>
2022-10-18 17:53:47 +11:00
);
})}
{paddingRight > 0 && (
<div
role="presentation"
style={{ width: `${paddingRight}px` }}
/>
)}
</StyledRow>
);
})}
{paddingBottom > 0 && (
<div role="presentation" style={{ height: `${paddingBottom}px` }} />
)}
{tableRows.length === 0 &&
(emptyState ?? <EmptyState sx={{ py: 8 }} />)}
2022-10-14 16:15:41 +11:00
</div>
</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
);
}