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

508 lines
17 KiB
TypeScript
Raw Normal View History

import { useMemo, useRef, useCallback, useState, useEffect } from "react";
2022-05-19 16:37:56 +10:00
import { useAtom, useSetAtom } from "jotai";
import {
useDebounce,
useDebouncedCallback,
useThrottledCallback,
} from "use-debounce";
import { useSnackbar } from "notistack";
import useMemoValue from "use-memo-value";
2022-05-19 16:37:56 +10:00
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { isEmpty, isEqual } from "lodash-es";
2022-05-19 16:37:56 +10:00
2022-10-14 16:15:41 +11:00
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
2022-10-18 17:53:47 +11:00
import { useVirtual } from "react-virtual";
2022-10-14 16:15:41 +11:00
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
import { StyledTable } from "./Styled/StyledTable";
import { StyledRow } from "./Styled/StyledRow";
import { StyledResizer } from "./Styled/StyledResizer";
2022-10-14 16:15:41 +11:00
import ColumnHeaderComponent from "./Column";
import { IconButton, LinearProgress, Button } from "@mui/material";
import { LoadingButton } from "@mui/lab";
2022-05-19 16:37:56 +10:00
import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer";
2022-05-27 15:10:06 +10:00
import ColumnHeader, { COLUMN_HEADER_HEIGHT } from "./ColumnHeader";
2022-05-19 16:37:56 +10:00
import FinalColumnHeader from "./FinalColumnHeader";
import FinalColumn from "./formatters/FinalColumn";
2022-10-14 16:15:41 +11:00
// import TableRow from "./TableRow";
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";
2022-05-27 15:10:06 +10:00
import AddRow from "@src/components/TableToolbar/AddRow";
import { AddRow as AddRowIcon } from "@src/assets/icons";
2022-06-06 15:58:17 +10:00
import Loading from "@src/components/Loading";
import ContextMenu from "./ContextMenu";
2022-05-19 16:37:56 +10:00
import {
2022-07-18 14:40:46 +10:00
projectScope,
2022-05-19 16:37:56 +10:00
userRolesAtom,
userSettingsAtom,
2022-07-18 14:40:46 +10:00
} from "@src/atoms/projectScope";
2022-05-19 16:37:56 +10:00
import {
tableScope,
tableIdAtom,
tableSettingsAtom,
tableSchemaAtom,
tableColumnsOrderedAtom,
tableRowsAtom,
tableNextPageAtom,
2022-05-19 16:37:56 +10:00
tablePageAtom,
updateColumnAtom,
updateFieldAtom,
selectedCellAtom,
2022-10-17 17:57:26 +11:00
SelectedCell,
2022-05-19 16:37:56 +10:00
} from "@src/atoms/tableScope";
2022-10-17 17:57:26 +11:00
import { COLLECTION_PAGE_SIZE } from "@src/config/db";
2022-05-19 16:37:56 +10:00
2022-05-24 20:34:28 +10:00
import { getFieldType, getFieldProp } from "@src/components/fields";
2022-05-19 16:37:56 +10:00
import { FieldType } from "@src/constants/fields";
import { formatSubTableName } from "@src/utils/table";
2022-10-14 16:15:41 +11:00
import { TableRow, ColumnConfig } from "@src/types/table";
import { StyledCell } from "./Styled/StyledCell";
2022-10-18 17:53:47 +11:00
import { useKeyboardNavigation } from "./useKeyboardNavigation";
2022-05-19 16:37:56 +10:00
export const DEFAULT_ROW_HEIGHT = 41;
export const DEFAULT_COL_WIDTH = 150;
export const MIN_COL_WIDTH = 32;
2022-10-21 14:58:25 +11:00
export const TABLE_PADDING = 16;
export const TABLE_GUTTER = 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
2022-10-14 16:15:41 +11:00
export default function TableComponent() {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
2022-07-18 14:40:46 +10:00
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [userSettings] = useAtom(userSettingsAtom, projectScope);
2022-05-19 16:37:56 +10:00
const [tableId] = useAtom(tableIdAtom, tableScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const setTablePage = useSetAtom(tablePageAtom, tableScope);
const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope);
2022-05-19 16:37:56 +10:00
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, 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
const canAddColumn = userRoles.includes("ADMIN");
2022-10-17 17:57:26 +11:00
const canEditColumn = userRoles.includes("ADMIN");
2022-05-19 16:37:56 +10:00
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(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-10-14 16:15:41 +11:00
// draggable: true,
// resizable: true,
// frozen: columnConfig.fixed,
// headerRenderer: ColumnHeader,
// formatter:
// getFieldProp("TableCell", getFieldType(columnConfig)) ??
// function InDev() {
// return null;
// },
// editor:
// getFieldProp("TableEditor", getFieldType(columnConfig)) ??
// function InDev() {
// return null;
// },
// ...columnConfig,
// editable:
// tableSettings.readOnly && !userRoles.includes("ADMIN")
// ? false
// : columnConfig.editable ?? true,
// width: columnConfig.width ?? DEFAULT_COL_WIDTH,
})
);
2022-10-19 12:41:20 +11:00
if (canAddColumn || !tableSettings.readOnly) {
_columns.push(
columnHelper.display({
id: "_rowy_column_actions",
header: () => "Actions",
2022-10-21 14:58:25 +11:00
cell: () => (
<>
<IconButton>M</IconButton>
<IconButton>D</IconButton>
<IconButton>X</IconButton>
</>
),
2022-10-19 12:41:20 +11:00
})
);
}
2022-05-19 16:37:56 +10:00
return _columns;
2022-10-19 12:41:20 +11:00
}, [tableColumnsOrdered, canAddColumn, tableSettings.readOnly]);
// Get users hidden columns from user document
const userDocHiddenFields =
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields;
// Memoize into a VisibilityState
const columnVisibility = useMemo(() => {
if (!Array.isArray(userDocHiddenFields)) return {};
return userDocHiddenFields.reduce((a, c) => ({ ...a, [c]: false }), {});
}, [userDocHiddenFields]);
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",
});
const [columnSizing, setColumnSizing] = useState(
table.initialState.columnSizing
);
table.setOptions((prev) => ({
...prev,
state: { ...prev.state, columnVisibility, columnPinning, columnSizing },
onColumnSizingChange: setColumnSizing,
}));
// Debounce for saving to schema
const [debouncedColumnSizing] = useDebounce(columnSizing, DEBOUNCE_DELAY, {
equalityFn: isEqual,
});
// Offer to save when column sizing changes
useEffect(() => {
if (!canEditColumn || isEmpty(debouncedColumnSizing)) return;
const snackbarId = enqueueSnackbar("Save column sizes for all users?", {
action: (
<LoadingButton
variant="contained"
color="primary"
onClick={handleSaveToSchema}
>
Save
</LoadingButton>
),
anchorOrigin: { horizontal: "center", vertical: "top" },
});
async function handleSaveToSchema() {
const promises = Object.entries(debouncedColumnSizing).map(
([key, value]) => updateColumn({ key, config: { width: value } })
);
await Promise.all(promises);
closeSnackbar(snackbarId);
}
return () => closeSnackbar(snackbarId);
}, [
debouncedColumnSizing,
canEditColumn,
enqueueSnackbar,
closeSnackbar,
updateColumn,
]);
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-18 14:49:17 +11:00
// console.log(table, selectedCell);
2022-10-17 17:57:26 +11:00
2022-10-18 17:53:47 +11:00
const {
virtualItems: virtualRows,
totalSize: totalHeight,
scrollToIndex: scrollToRowIndex,
} = useVirtual({
parentRef: containerRef,
size: tableRows.length,
overscan: 10,
2022-10-21 14:58:25 +11:00
paddingEnd: TABLE_PADDING,
2022-10-18 17:53:47 +11:00
estimateSize: useCallback(
() => tableSchema.rowHeight || DEFAULT_ROW_HEIGHT,
[tableSchema.rowHeight]
),
});
2022-10-18 14:49:17 +11:00
2022-10-18 17:53:47 +11:00
const {
virtualItems: virtualCols,
totalSize: totalWidth,
scrollToIndex: scrollToColIndex,
} = useVirtual({
parentRef: containerRef,
horizontal: true,
2022-10-19 12:41:20 +11:00
size: leafColumns.length,
2022-10-21 14:58:25 +11:00
overscan: 10,
paddingStart: TABLE_PADDING,
paddingEnd: TABLE_PADDING,
2022-10-18 17:53:47 +11:00
estimateSize: useCallback(
2022-10-19 12:41:20 +11:00
(index: number) => leafColumns[index].columnDef.size || DEFAULT_COL_WIDTH,
[leafColumns]
2022-10-18 17:53:47 +11:00
),
});
2022-10-18 14:49:17 +11:00
2022-10-18 17:53:47 +11:00
useEffect(() => {
if (!selectedCell) return;
if (selectedCell.path) {
const rowIndex = tableRows.findIndex(
(row) => row._rowy_ref.path === selectedCell.path
);
2022-10-21 14:58:25 +11:00
if (rowIndex > -1) scrollToRowIndex(rowIndex);
2022-10-17 17:57:26 +11:00
}
2022-10-18 17:53:47 +11:00
if (selectedCell.columnKey) {
2022-10-19 12:41:20 +11:00
const colIndex = leafColumns.findIndex(
2022-10-18 17:53:47 +11:00
(col) => col.id === selectedCell.columnKey
);
2022-10-21 14:58:25 +11:00
if (colIndex > -1) scrollToColIndex(colIndex);
2022-10-18 17:53:47 +11:00
}
2022-10-19 12:41:20 +11:00
}, [
selectedCell,
tableRows,
leafColumns,
scrollToRowIndex,
scrollToColIndex,
]);
2022-10-18 17:53:47 +11:00
const { handleKeyDown, focusInsideCell } = useKeyboardNavigation({
gridRef,
tableRows,
2022-10-19 12:41:20 +11:00
leafColumns,
2022-10-18 17:53:47 +11:00
});
2022-10-17 17:57:26 +11:00
2022-10-18 17:53:47 +11:00
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0
? totalHeight - (virtualRows?.[virtualRows.length - 1]?.end || 0)
: 0;
const paddingLeft = virtualCols.length > 0 ? virtualCols?.[0]?.start || 0 : 0;
const paddingRight =
virtualCols.length > 0
? totalWidth - (virtualCols?.[virtualCols.length - 1]?.end || 0)
: 0;
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
);
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-10-17 17:57:26 +11:00
aria-readonly={tableSettings.readOnly}
aria-colcount={columns.length}
aria-rowcount={tableRows.length + 1}
style={{ width: table.getTotalSize(), userSelect: "none" }}
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-10-14 16:15:41 +11:00
{table.getHeaderGroups().map((headerGroup) => (
2022-10-17 17:57:26 +11:00
<StyledRow key={headerGroup.id} role="row" aria-rowindex={1}>
{headerGroup.headers.map((header) => {
2022-10-18 14:49:17 +11:00
const isSelectedCell =
2022-10-17 17:57:26 +11:00
(!selectedCell && header.index === 0) ||
(selectedCell?.path === "_rowy_header" &&
selectedCell?.columnKey === header.id);
return (
<ColumnHeaderComponent
key={header.id}
2022-10-21 14:58:25 +11:00
data-row-id={"_rowy_header"}
data-col-id={header.id}
data-frozen={header.column.getIsPinned() || undefined}
data-frozen-last={lastFrozen === header.id || undefined}
2022-10-17 17:57:26 +11:00
role="columnheader"
2022-10-18 14:49:17 +11:00
tabIndex={isSelectedCell ? 0 : -1}
2022-10-17 17:57:26 +11:00
aria-colindex={header.index + 1}
2022-10-21 14:58:25 +11:00
aria-readonly={!canEditColumn}
2022-10-17 17:57:26 +11:00
// TODO: aria-sort={"none" | "ascending" | "descending" | "other" | undefined}
2022-10-18 14:49:17 +11:00
aria-selected={isSelectedCell}
2022-10-17 17:57:26 +11:00
label={header.column.columnDef.meta?.name || header.id}
type={header.column.columnDef.meta?.type}
2022-10-21 14:58:25 +11:00
style={{
width: header.getSize(),
left: header.column.getIsPinned()
? virtualCols[header.index].start - TABLE_PADDING
: undefined,
}}
sx={{ "& + &": { borderLeft: "none" } }}
2022-10-17 17:57:26 +11:00
onClick={(e) => {
setSelectedCell({
path: "_rowy_header",
columnKey: header.id,
});
(e.target as HTMLDivElement).focus();
}}
>
{header.column.getCanResize() && (
<StyledResizer
isResizing={header.column.getIsResizing()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
)}
2022-10-17 17:57:26 +11:00
</ColumnHeaderComponent>
);
})}
2022-10-14 16:15:41 +11:00
</StyledRow>
))}
</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];
return (
<StyledRow
key={row.id}
role="row"
aria-rowindex={row.index + 2}
style={{ height: tableSchema.rowHeight }}
>
2022-10-18 17:53:47 +11:00
{paddingLeft > 0 && (
<div
role="presentation"
style={{ width: `${paddingLeft}px` }}
/>
)}
{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;
return (
<StyledCell
key={cell.id}
2022-10-21 14:58:25 +11:00
data-row-id={row.id}
data-col-id={cell.column.id}
data-frozen={cell.column.getIsPinned() || undefined}
data-frozen-last={
lastFrozen === cell.column.id || undefined
}
2022-10-18 17:53:47 +11:00
role="gridcell"
tabIndex={isSelectedCell && !focusInsideCell ? 0 : -1}
aria-colindex={cellIndex + 1}
aria-readonly={
cell.column.columnDef.meta?.editable === false
}
aria-selected={isSelectedCell}
2022-10-21 14:58:25 +11:00
style={{
width: cell.column.getSize(),
left: cell.column.getIsPinned()
? virtualCell.start - TABLE_PADDING
: undefined,
backgroundColor:
cell.column.id === "_rowy_column_actions"
? "transparent"
: undefined,
borderBottomWidth:
cell.column.id === "_rowy_column_actions"
? 0
: undefined,
borderRightWidth:
cell.column.id === "_rowy_column_actions"
? 0
: undefined,
}}
2022-10-18 17:53:47 +11:00
onClick={(e) => {
setSelectedCell({
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
});
(e.target as HTMLDivElement).focus();
}}
2022-10-18 14:49:17 +11:00
>
2022-10-18 17:53:47 +11:00
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{/* <button
2022-10-18 17:53:47 +11:00
tabIndex={isSelectedCell && focusInsideCell ? 0 : -1}
>
{isSelectedCell ? "f" : "x"}
</button> */}
2022-10-18 17:53:47 +11:00
</StyledCell>
);
})}
{paddingRight > 0 && (
<div
role="presentation"
style={{ width: `${paddingRight}px` }}
/>
)}
</StyledRow>
);
})}
{paddingBottom > 0 && (
<div role="presentation" style={{ height: `${paddingBottom}px` }} />
)}
2022-10-14 16:15:41 +11:00
</div>
</StyledTable>
2022-10-18 17:53:47 +11:00
</div>
2022-05-19 16:37:56 +10:00
);
}