@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
JavaScript
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