UNPKG

@airplane/views

Version:

A React library for building Airplane views. Views components are optimized in style and functionality to produce internal apps that are easy to build and maintain.

537 lines (536 loc) 20.1 kB
import { jsx, jsxs } from "react/jsx-runtime"; import { ActionIcon, createStyles, Menu } from "@mantine/core"; import { isEqual } from "lodash-es"; import { useRef, useState, useEffect, useCallback, useMemo, useImperativeHandle } from "react"; import { useTable, useFlexLayout, useGlobalFilter, useSortBy, usePagination, useRowSelect } from "react-table"; import { useSticky } from "react-table-sticky"; import { Button } from "../button/Button.js"; import { CheckboxComponent } from "../checkbox/Checkbox.js"; import { Heading } from "../heading/Heading.js"; import { ArrowDownIconMini, ArrowUpIconMini, ArrowDownTrayIconMini, PencilSquareIconOutline, EllipsisVerticalIconSolid } from "@airplane/views/icons/index.js"; import { useCommonLayoutStyle } from "../layout/useCommonLayoutStyle.js"; import { Skeleton } from "../Skeleton.js"; import { Stack } from "../stack/Stack.js"; import { Tooltip } from "../tooltip/Tooltip.js"; import { dateTimeSort, Cell, getDefaultCellType } from "./Cell.js"; import { ROW_ACTION_COLUMN_ID, CHECKBOX_ACTION_COLUMN_ID } from "./Column.js"; import { dataToCSVLink } from "./dataToCSV.js"; import Filter from "./Filter.js"; import { Pagination } from "./Pagination.js"; import { useStyles } from "./Table.styles.js"; import { OverflowText } from "./useIsOverflow.js"; import { useResizeColumns } from "./useResizeColumns.js"; const LOADING_ROW_COUNT = 10; const LOADING_COL_COUNT = 4; const DEFAULT_ROW_MENU_WIDTH = 160; function TableComponent({ columns, data, onRowSelectionChanged, onToggleAllRows, onToggleRow, loading, error, noData = "No data", rowSelection, rowActions, rowActionsMenu, rowActionsWidth, rowActionsMenuWidth, defaultPageSize = 10, title, hiddenColumns, tableRef, showFilter = true, selectAll = true, rowID, isSelectedRow, isDefaultSelectedRow, freezeRowActions = true, enableCSVDownload, className, style, width, height, grow }) { var _a; const rowActionRef = useRef(null); const { classes, cx } = useStyles(); const [dirtyCells, setDirtyCells] = useState({}); const { classes: layoutClasses } = useCommonLayoutStyle({ width, height, grow }); const [tableData, setData] = useState([]); const [columnTypes, setColumnTypes] = useState({}); useEffect(() => { if (loading) { setData(Array(LOADING_ROW_COUNT).fill({})); } else { setData(data); } }, [loading, data]); const [skipDirtyColumnIDReset, setSkipDirtyColumnIDReset] = useState(false); useEffect(() => { setSkipDirtyColumnIDReset(false); }, [tableData]); const [tableColumns, setTableColumns] = useState([]); useEffect(() => { const hiddenSet = new Set(hiddenColumns); const newTableColumns = columns.filter((c) => typeof c.accessor === "string" && !hiddenSet.has(c.accessor)).map((c) => { const id = c.accessor; let type = c.type; if (!type && id && columnTypes[id]) { type = columnTypes[id]; } const col = { ...c, id, type, // Pass in the accessor as a string to know if the columns have changed _accessor: c.accessor, accessor: (data2) => data2[c.accessor], // eslint-disable-next-line @typescript-eslint/no-explicit-any Component: c.Component }; if (type === "boolean") { col.sortType = "basic"; } else if (type === "date" || type === "datetime") { col.sortType = dateTimeSort; } return col; }); if (loading || error || !columns.length) { const cs = columns.length ? newTableColumns : Array.from({ length: LOADING_COL_COUNT }, (_, i) => ({ id: String(i) })); const loadingColumns = cs.map((column) => ({ ...column, Cell: /* @__PURE__ */ jsx(Skeleton, { height: 8, mt: 16, mx: 16, width: "25%", radius: "sm" }) })); setTableColumns(loadingColumns); } else if (didColumnsChange(newTableColumns, tableColumns)) { setTableColumns(newTableColumns); } }, [loading, columns, error, columnTypes, hiddenColumns]); const getRowId = useCallback((row, relativeIndex) => { const id = rowID ?? "id"; if (id in row) { return String(row[id]); } else if (rowID) { console.warn(`Row ID ${rowID} not found in row data. Falling back to the row's index.`); } return relativeIndex.toString(); }, [rowID]); const updateData = useCallback((row, columnId, value) => { const rowIndex = row.index; const initialValue = data[rowIndex][columnId]; let dirty = !isEqual(value, initialValue); if (typeof value === "boolean" && !value && !initialValue) { dirty = false; } const dirtyCellsCopy = new Set(dirtyCells[row.id] ?? []); if (dirty) { dirtyCellsCopy.add(columnId); } else { dirtyCellsCopy.delete(columnId); } setDirtyCells({ ...dirtyCells, [row.id]: dirtyCellsCopy }); setSkipDirtyColumnIDReset(true); setData((old) => { const currentVal = old[rowIndex][columnId]; if (currentVal === value) { return old; } const oldCopy = [...old]; oldCopy[rowIndex] = { ...oldCopy[rowIndex], [columnId]: value }; return oldCopy; }); }, [data, dirtyCells]); const defaultColumn = useMemo(() => ({ Cell, minWidth: 50, // minWidth is only used as a limit for resizing width: 150, // width is used for both the flex-basis and flex-grow maxWidth: 300 // maxWidth is only used as a limit for resizing }), []); function addCheckboxSelection(hooks) { if (rowSelection === "checkbox" && !loading) { hooks.visibleColumns.push((columns2) => [{ id: CHECKBOX_ACTION_COLUMN_ID, width: "auto", disableResizing: true, Header: ({ getToggleAllRowsSelectedProps, onToggleAllRows: onToggleAllRows2, rows: rows2 }) => selectAll && (!isSelectedRow || onToggleAllRows2) ? /* @__PURE__ */ jsx(CheckboxComponent, { ...getToggleAllRowsSelectedProps(), onChange: (value) => { toggleAllRowsSelected(value); if (onToggleAllRows2) { onToggleAllRows2(value); } }, className: classes.checkbox }) : null, Cell: ({ row, onToggleRow: onToggleRow2 }) => { const toggleRowSelectedProps = row.getToggleRowSelectedProps(); return /* @__PURE__ */ jsx(CheckboxComponent, { ...toggleRowSelectedProps, onChange: (checked) => { row.toggleRowSelected(checked); onToggleRow2 == null ? void 0 : onToggleRow2(row.original, row.index); }, className: classes.checkbox }); } }, ...columns2]); } } function addActions(hooks) { var _a2; if ((rowActions == null ? void 0 : rowActions.length) || (rowActionsMenu == null ? void 0 : rowActionsMenu.length) && !loading) { const contentWidth = (_a2 = rowActionRef.current) == null ? void 0 : _a2.offsetWidth; const actionsColumn = { id: ROW_ACTION_COLUMN_ID, width: rowActionsWidth || contentWidth, sticky: freezeRowActions ? "right" : void 0, // Overwrite maxWidth for actions column with arbitrarily large value maxWidth: 1e4, Cell: ({ row }) => { return /* @__PURE__ */ jsxs("div", { className: classes.actionContainer, ref: rowActionRef, children: [ rowActions == null ? void 0 : rowActions.map((RowActionComponent, i) => /* @__PURE__ */ jsx(RowActionComponent, { row: row.original }, i)), !!(rowActionsMenu == null ? void 0 : rowActionsMenu.length) && /* @__PURE__ */ jsx(RowActionsMenu, { width: Math.max(DEFAULT_ROW_MENU_WIDTH, rowActionsMenuWidth || 0), rowActionsMenu, row }) ] }); } }; hooks.visibleColumns.push((columns2) => [...columns2, actionsColumn]); } } function rowSelectorToRowIds(data2, rowSelector) { if (rowSelector === void 0) { return {}; } const rowIds = data2.map((row, idx) => [row, idx]).filter(([row, idx]) => rowSelector(row, idx)).map(([row, idx]) => getRowId(row, idx)); const ret = {}; if (rowSelection === "single" && rowIds.length) { ret[rowIds[0]] = true; } else { rowIds.forEach((rowId) => { ret[rowId] = true; }); } return ret; } const defaultSelectedRowIds = useMemo(() => { if (loading) { return {}; } return rowSelectorToRowIds(data, isDefaultSelectedRow); }, [loading]); const headerGroupOffsetWidths = useRef([]); const { getTableProps, getTableBodyProps, headerGroups, // rows, prepareRow, rows: allRows, // pagination page: rows, gotoPage, canPreviousPage, canNextPage, nextPage, previousPage, // filtering setGlobalFilter, state: { pageIndex, pageSize, globalFilter }, // row selection selectedFlatRows, toggleAllRowsSelected, visibleColumns } = useTable({ columns: tableColumns, data: tableData, initialState: { pageIndex: 0, pageSize: defaultPageSize, selectedRowIds: defaultSelectedRowIds }, defaultColumn, autoResetPage: false, autoResetSortBy: false, autoResetGlobalFilter: false, autoResetSelectedRows: false, getRowId, // updateData and dirtyCells aren't part of the API, but anything we put into these options will // be available to call on our cell renderer updateData, dirtyCells, rowOffsetWidth: ((_a = headerGroupOffsetWidths.current) == null ? void 0 : _a.reduce((sum, width2) => sum + width2, 0)) ?? 0, // onToggleRow and onToggleAllRows are passed so that checkbox-based // row selection can use them if needed. onToggleRow, onToggleAllRows, useControlledState: (state) => useMemo(() => { if (!isSelectedRow) { return state; } return { ...state, selectedRowIds: rowSelectorToRowIds(data, isSelectedRow) }; }, [state, data, isSelectedRow]) }, useFlexLayout, useResizeColumns, useGlobalFilter, useSortBy, usePagination, useRowSelect, useSticky, (hooks) => { addCheckboxSelection(hooks); addActions(hooks); }); const setFilter = useCallback((val) => { setGlobalFilter(val); gotoPage(0); }, [setGlobalFilter, gotoPage]); useEffect(() => { let cTypes = columnTypes; allRows.forEach((r) => { var _a2; (_a2 = r.allCells) == null ? void 0 : _a2.forEach((c) => { const cid = c.column.id; let cellType = c.column.type || getDefaultCellType(c.value, c.column.typeOptions); if (cid in cTypes && cellType !== cTypes[cid]) { cellType = "string"; } if (cTypes[cid] !== cellType) { cTypes = { ...cTypes, [cid]: cellType }; } }); }); setColumnTypes(cTypes); }, [allRows]); useEffect(() => { if (onRowSelectionChanged) { onRowSelectionChanged(selectedFlatRows.map((r) => r.original)); } }, [selectedFlatRows, onRowSelectionChanged]); useImperativeHandle(tableRef, () => ({ toggleAllRowsSelected })); const dataAsCSVLink = useMemo(() => { if (!enableCSVDownload) { return ""; } return dataToCSVLink(visibleColumns, allRows); }, [allRows, visibleColumns, enableCSVDownload]); useEffect(() => { headerGroupOffsetWidths.current = headerGroupOffsetWidths.current.slice(0, headerGroups.length); }, [headerGroups.length]); useEffect(() => { if (!skipDirtyColumnIDReset) { setDirtyCells({}); } if (pageIndex > 0) { const startItemNum = pageIndex * pageSize + 1; if (startItemNum > allRows.length) { gotoPage(0); } } }, [tableData]); const hasPagination = canNextPage || canPreviousPage; return /* @__PURE__ */ jsxs("div", { style, className: cx(classes.tableContainer, layoutClasses.style, className), children: [ (title || showFilter) && /* @__PURE__ */ jsxs("div", { className: classes.tableChrome, children: [ /* @__PURE__ */ jsx(Heading, { level: 6, style: { fontWeight: 600 }, children: title }), /* @__PURE__ */ jsx("div", { className: classes.tableActions, children: showFilter && /* @__PURE__ */ jsx(Filter, { initialValue: globalFilter, setValue: setFilter }) }) ] }), /* @__PURE__ */ jsxs("div", { ...getTableProps((props) => ({ ...props, // Override the calculated minWidth to prevent double scroll bars. See AIR-4272 for more details. style: { ...props.style, minWidth: 0 } })), className: "table", children: [ /* @__PURE__ */ jsx("div", { className: "thead", children: headerGroups.map((headerGroup, headerGroupIdx) => ( // eslint-disable-next-line react/jsx-key /* @__PURE__ */ jsx("div", { ...headerGroup.getHeaderGroupProps({}), className: "tr", ref: (elem) => headerGroupOffsetWidths.current[headerGroupIdx] = (elem == null ? void 0 : elem.offsetWidth) ?? 0, children: headerGroup.headers.map((column, i) => { const columnProps = fixActionCol(column.getHeaderProps(column.getSortByToggleProps({ title: void 0 }))); const nextColumn = headerGroup.headers[i + 1]; return ( // eslint-disable-next-line react/jsx-key /* @__PURE__ */ jsxs("div", { ...columnProps, className: cx("th", { [classes.headerWithLabel]: !!column.label }), children: [ /* @__PURE__ */ jsx(OverflowText, { weight: "medium", value: column.label }), column.isSorted && /* @__PURE__ */ jsx("div", { className: "sortIcon", children: column.isSortedDesc ? /* @__PURE__ */ jsx(ArrowDownIconMini, {}) : /* @__PURE__ */ jsx(ArrowUpIconMini, {}) }), column.canEdit && /* @__PURE__ */ jsx(EditIcon, {}), column.canResize && (nextColumn == null ? void 0 : nextColumn.id) !== ROW_ACTION_COLUMN_ID && /* @__PURE__ */ jsx("div", { ...column.getResizerProps(), className: cx("resizer", { isResizing: column.isResizing }), onClick: (e) => { e.stopPropagation(); } }), !column.label && column.render("Header") ] }) ); }) }) )) }), /* @__PURE__ */ jsxs("div", { ...getTableBodyProps(), className: "tbody", children: [ !error && allRows.length === 0 && !loading && /* @__PURE__ */ jsx("div", { className: classes.noData, children: noData }), error && /* @__PURE__ */ jsx("div", { className: classes.noData, children: error }), rows.map((row) => { prepareRow(row); return ( // eslint-disable-next-line react/jsx-key /* @__PURE__ */ jsx("div", { ...row.getRowProps(), onClick: () => { if (rowSelection === "single") { const wasRowSelected = row.isSelected; toggleAllRowsSelected(false); if (!wasRowSelected) { row.toggleRowSelected(); } onToggleRow == null ? void 0 : onToggleRow(row.original, row.index); } }, className: cx("tr", { [classes.selectableRow]: rowSelection === "single", [classes.selectedRow]: row.isSelected }), children: row.cells.map((cell) => { const cellProps = fixActionCol(cell.getCellProps()); return ( // eslint-disable-next-line react/jsx-key /* @__PURE__ */ jsx("div", { ...cellProps, className: cx("td", { [classes.cellEditIcon]: !row.isSelected, [classes.cellEditIconSelected]: row.isSelected }), children: cell.render("Cell") }) ); }) }) ); }) ] }) ] }), (hasPagination || enableCSVDownload) && /* @__PURE__ */ jsxs("div", { className: classes.tableFooter, children: [ /* @__PURE__ */ jsx("div", { children: !!enableCSVDownload && /* @__PURE__ */ jsx("a", { href: dataAsCSVLink, download: getCSVFileName(enableCSVDownload), "data-testid": "csvDownload", children: /* @__PURE__ */ jsx(ActionIcon, { size: "sm", children: /* @__PURE__ */ jsx(ArrowDownTrayIconMini, {}) }) }) }), /* @__PURE__ */ jsx("div", { className: classes.paginationContainer, children: hasPagination && /* @__PURE__ */ jsx(Pagination, { hasPrevPage: canPreviousPage, hasNextPage: canNextPage, onNext: nextPage, onPrev: previousPage, pageIndex, total: allRows.length, pageSize }) }) ] }) ] }); } const EditIcon = () => { const { classes } = useStyles(); return /* @__PURE__ */ jsx("div", { className: classes.headerEditIcon, children: /* @__PURE__ */ jsx(Tooltip, { position: "right", label: /* @__PURE__ */ jsx(EditTooltip, {}), children: /* @__PURE__ */ jsx(PencilSquareIconOutline, {}) }) }); }; const useTooltipStyles = createStyles((theme) => { return { info: { color: theme.colors.dark[2] }, header: { background: theme.colors.dark[7], // Negative margin to compensate for the padding on the tooltip margin: "-4px -8px 0 -8px", padding: "4px 8px" }, shortcut: { display: "flex", justifyContent: "space-between" }, command: { backgroundColor: theme.colors.gray[6], borderRadius: 2, padding: "0 4px" } }; }); const EditTooltip = () => { const { classes } = useTooltipStyles(); return /* @__PURE__ */ jsxs(Stack, { spacing: "xs", children: [ /* @__PURE__ */ jsx("span", { className: classes.header, children: "This column is editable" }), /* @__PURE__ */ jsxs("div", { className: classes.shortcut, children: [ /* @__PURE__ */ jsx("span", { children: "Save" }), /* @__PURE__ */ jsx("span", { className: classes.command, children: "⇧ + ⏎" }) ] }), /* @__PURE__ */ jsxs("div", { className: classes.shortcut, children: [ /* @__PURE__ */ jsx("span", { children: "Cancel" }), /* @__PURE__ */ jsx("kbd", { className: classes.command, children: "Esc" }) ] }) ] }); }; const RowActionsMenu = ({ width, rowActionsMenu, row }) => { const { classes } = useStyles(); return /* @__PURE__ */ jsxs(Menu, { width, position: "bottom-end", classNames: { dropdown: classes.dropdown }, zIndex: 150, withinPortal: true, children: [ /* @__PURE__ */ jsx(Menu.Target, { children: /* @__PURE__ */ jsx(Button, { variant: "subtle", compact: true, radius: "xs", disableFocusRing: true, className: classes.ellipsisMenuButton, stopPropagation: true, children: /* @__PURE__ */ jsx(EllipsisVerticalIconSolid, { size: "lg", color: "gray.4" }) }) }), /* @__PURE__ */ jsx(Menu.Dropdown, { onClick: (e) => e.stopPropagation(), children: rowActionsMenu == null ? void 0 : rowActionsMenu.map((RowActionComponent, i) => /* @__PURE__ */ jsx("div", { role: "button", children: /* @__PURE__ */ jsx(RowActionComponent, { row: row.original }) }, i)) }) ] }); }; const didColumnsChange = (currColumns, newColumns) => { if (currColumns.length !== newColumns.length) { return true; } for (let i = 0; i < currColumns.length; i++) { const currColumn = { ...currColumns[i] }; const newColumn = { ...newColumns[i] }; delete currColumn.accessor; delete newColumn.accessor; if (!isEqual(currColumn, newColumn)) { return true; } } return false; }; const fixActionCol = (props) => { if (props.key.toString().endsWith(ROW_ACTION_COLUMN_ID) && props.style) { props.style.flex = "0 0 auto"; } return props; }; const getCSVFileName = (enableCSVDownload) => { if (typeof enableCSVDownload === "boolean") { return "table_data.csv"; } if (enableCSVDownload.endsWith(".csv")) { return enableCSVDownload; } return `${enableCSVDownload}.csv`; }; export { TableComponent }; //# sourceMappingURL=TableComponent.js.map