mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41111 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] QA'd all new/changed functionality manually ### Before <img width="1477" height="590" alt="Screenshot 2026-03-09 at 11 50 49 AM" src="https://github.com/user-attachments/assets/f180371f-c83c-4bee-bfa1-8c78afd46d90" /> ### After <img width="1471" height="531" alt="Screenshot 2026-03-09 at 11 50 29 AM" src="https://github.com/user-attachments/assets/341f7f6c-97b2-4a55-8543-dda0e7f3e867" />
723 lines
24 KiB
TypeScript
723 lines
24 KiB
TypeScript
/* eslint-disable react/prop-types */
|
|
// disable this rule as it was throwing an error in Header and Cell component
|
|
// definitions for the selection row for some reason when we dont really need it.
|
|
import React, {
|
|
useMemo,
|
|
useEffect,
|
|
useCallback,
|
|
useContext,
|
|
useRef,
|
|
} from "react";
|
|
import classnames from "classnames";
|
|
import {
|
|
Column,
|
|
HeaderGroup,
|
|
Row,
|
|
useFilters,
|
|
useGlobalFilter,
|
|
usePagination,
|
|
useRowSelect,
|
|
useSortBy,
|
|
useTable,
|
|
} from "react-table";
|
|
import { kebabCase, noop } from "lodash";
|
|
import { useDebouncedCallback } from "use-debounce";
|
|
|
|
import useDeepEffect from "hooks/useDeepEffect";
|
|
import sort from "utilities/sort";
|
|
import { AppContext } from "context/app";
|
|
|
|
import Button from "components/buttons/Button";
|
|
import Spinner from "components/Spinner";
|
|
import Pagination from "components/Pagination";
|
|
import ActionButton from "./ActionButton";
|
|
import { IActionButtonProps } from "./ActionButton/ActionButton";
|
|
|
|
const baseClass = "data-table-block";
|
|
|
|
interface IDataTableProps {
|
|
columns: Column[];
|
|
data: any;
|
|
filters?: Record<string, string | number | boolean>;
|
|
isLoading: boolean;
|
|
manualSortBy?: boolean;
|
|
sortHeader: any;
|
|
sortDirection: any;
|
|
onSort: any; // TODO: an event type
|
|
disableMultiRowSelect: boolean;
|
|
keyboardSelectableRows?: boolean;
|
|
showMarkAllPages: boolean;
|
|
isAllPagesSelected: boolean; // TODO: make dependent on showMarkAllPages
|
|
toggleAllPagesSelected?: any; // TODO: an event type and make it dependent on showMarkAllPages
|
|
resultsTitle?: string;
|
|
defaultPageSize: number;
|
|
defaultPageIndex?: number;
|
|
defaultSelectedRows?: Record<string, boolean>;
|
|
/** Default: true (same as useTable default)
|
|
* False prevents unnecessary page resets when a column ordering changes
|
|
* e.g. when clicking on an action that modifies the data
|
|
*/
|
|
autoResetPage?: boolean;
|
|
primarySelectAction?: IActionButtonProps;
|
|
secondarySelectActions?: IActionButtonProps[];
|
|
isClientSidePagination?: boolean;
|
|
onClientSidePaginationChange?: (pageIndex: number) => void; // Used to set URL to correct path and include page query param
|
|
isClientSideFilter?: boolean;
|
|
disableHighlightOnHover?: boolean;
|
|
searchQuery?: string;
|
|
searchQueryColumn?: string;
|
|
selectedDropdownFilter?: string;
|
|
/** Set to true to persist row selection across client-side filters and pagination */
|
|
persistSelectedRows?: boolean;
|
|
/** Set to `true` to not display the footer section of the table */
|
|
hideFooter?: boolean;
|
|
onSelectSingleRow?: (value: Row) => void;
|
|
onClickRow?: (value: any) => void;
|
|
onResultsCountChange?: (value: number) => void;
|
|
/** Optional help text to render on bottom-left of the table. Hidden when table is loading and no
|
|
* rows of data are present. */
|
|
renderTableHelpText?: () => JSX.Element | null;
|
|
renderPagination?: () => JSX.Element | null;
|
|
setExportRows?: (rows: Row[]) => void;
|
|
onClearSelection?: () => void;
|
|
suppressHeaderActions?: boolean;
|
|
}
|
|
|
|
interface IHeaderGroup extends HeaderGroup {
|
|
title?: string;
|
|
}
|
|
|
|
// This data table uses react-table for implementation. The relevant v7 documentation of the library
|
|
// can be found here https://react-table-v7-docs.netlify.app/docs/api/usetable
|
|
|
|
const DataTable = ({
|
|
columns: tableColumns,
|
|
data: tableData,
|
|
filters: tableFilters,
|
|
isLoading,
|
|
manualSortBy = false,
|
|
sortHeader,
|
|
sortDirection,
|
|
onSort,
|
|
disableMultiRowSelect,
|
|
keyboardSelectableRows,
|
|
showMarkAllPages,
|
|
isAllPagesSelected,
|
|
toggleAllPagesSelected,
|
|
resultsTitle = "results",
|
|
defaultPageSize,
|
|
defaultPageIndex,
|
|
defaultSelectedRows = {},
|
|
autoResetPage = true,
|
|
primarySelectAction,
|
|
secondarySelectActions,
|
|
isClientSidePagination,
|
|
onClientSidePaginationChange,
|
|
isClientSideFilter,
|
|
disableHighlightOnHover,
|
|
searchQuery,
|
|
searchQueryColumn,
|
|
selectedDropdownFilter,
|
|
persistSelectedRows = false,
|
|
hideFooter = false,
|
|
onSelectSingleRow,
|
|
onClickRow,
|
|
onResultsCountChange,
|
|
renderTableHelpText,
|
|
renderPagination,
|
|
setExportRows,
|
|
onClearSelection = noop,
|
|
suppressHeaderActions,
|
|
}: IDataTableProps): JSX.Element => {
|
|
// used to track the initial mount of the component.
|
|
const isInitialRender = useRef(true);
|
|
|
|
const { isOnlyObserver } = useContext(AppContext);
|
|
|
|
const columns = useMemo(() => {
|
|
return tableColumns;
|
|
}, [tableColumns]);
|
|
|
|
// The table data needs to be ordered by the order we received from the API.
|
|
const data = useMemo(() => {
|
|
return tableData;
|
|
}, [tableData]);
|
|
|
|
const initialSortBy = useMemo(() => {
|
|
return [{ id: sortHeader, desc: sortDirection === "desc" }];
|
|
}, [sortHeader, sortDirection]);
|
|
|
|
// Decide the page index value to pass to useTable
|
|
const controlledPageIndex =
|
|
isClientSidePagination && !!onClientSidePaginationChange
|
|
? defaultPageIndex ?? 0
|
|
: undefined; // undefined lets react-table manage internally (Keeps internal mode working)
|
|
|
|
const {
|
|
headerGroups,
|
|
rows,
|
|
prepareRow,
|
|
selectedFlatRows,
|
|
toggleAllRowsSelected,
|
|
isAllRowsSelected,
|
|
state: tableState,
|
|
page, // Instead of using 'rows', we'll use page,
|
|
// which has only the rows for the active page
|
|
|
|
// The rest of these things are super handy, too ;)
|
|
canPreviousPage,
|
|
canNextPage,
|
|
// pageOptions,
|
|
// pageCount,
|
|
gotoPage,
|
|
nextPage,
|
|
previousPage,
|
|
setPageSize,
|
|
setFilter, // sets a specific column-level filter
|
|
setAllFilters, // sets all of the column-level filters; rows are included in filtered results only if each column filter return true
|
|
setGlobalFilter, // sets the global filter; this serves as a global free text search across all columns (excluding only those where `disableGlobalFilter: true`)
|
|
} = useTable(
|
|
{
|
|
columns,
|
|
data,
|
|
// Use a stable row ID when available (row.id), otherwise fall back to the index-based ID (default of react-table)
|
|
getRowId: (row: any, index: number) =>
|
|
row && row.id != null ? String(row.id) : String(index),
|
|
initialState: {
|
|
sortBy: initialSortBy,
|
|
pageIndex: defaultPageIndex,
|
|
selectedRowIds: defaultSelectedRows,
|
|
},
|
|
// For onClientSidePaginationChange (URL-controlled mode) we inject pageIndex, otherwise leave undefined so it's internal
|
|
// NOTE: This specifically prevents quick flicker of incorrect page data for clientside pagination with
|
|
// external source of truth (URL bar) such as the self-service page when searching or changing categories
|
|
// TODO: Figure out flickering on self-service page internal sort buttons
|
|
state:
|
|
controlledPageIndex !== undefined
|
|
? { pageIndex: controlledPageIndex }
|
|
: undefined,
|
|
disableMultiSort: true,
|
|
disableSortRemove: true,
|
|
manualSortBy,
|
|
autoResetPage,
|
|
// Resets row selection on pagination
|
|
autoResetSelectedRows: !persistSelectedRows,
|
|
// Expands the enumerated `filterTypes` for react-table
|
|
// (see https://github.com/TanStack/react-table/blob/alpha/packages/react-table/src/filterTypes.ts)
|
|
// with custom `filterTypes` defined for this `useTable` instance
|
|
filterTypes: useMemo(
|
|
() => ({
|
|
hasLength: (
|
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
rows: Row[],
|
|
columnIds: string[],
|
|
filterValue: boolean
|
|
) => {
|
|
return !filterValue
|
|
? rows
|
|
: rows?.filter((row) => {
|
|
return columnIds?.some((id) => row?.values?.[id]?.length);
|
|
});
|
|
},
|
|
}),
|
|
[]
|
|
),
|
|
autoResetFilters: false,
|
|
// Expands the enumerated `sortTypes` for react-table
|
|
// (see https://github.com/tannerlinsley/react-table/blob/master/src/sortTypes.js)
|
|
// with custom `sortTypes` defined for this `useTable` instance
|
|
sortTypes: useMemo(
|
|
() => ({
|
|
boolean: (
|
|
a: { values: Record<string, unknown> },
|
|
b: { values: Record<string, unknown> },
|
|
id: string
|
|
) => sort.booleanAsc(a.values[id], b.values[id]),
|
|
|
|
caseInsensitive: (
|
|
a: { values: Record<string, unknown> },
|
|
b: { values: Record<string, unknown> },
|
|
id: string
|
|
) => sort.caseInsensitiveAsc(a.values[id], b.values[id]),
|
|
|
|
dateStrings: (
|
|
a: { values: Record<string, string> },
|
|
b: { values: Record<string, string> },
|
|
id: string
|
|
) => sort.dateStringsAsc(a.values[id], b.values[id]),
|
|
|
|
hasLength: (
|
|
a: { values: Record<string, unknown[]> },
|
|
b: { values: Record<string, unknown[]> },
|
|
id: string
|
|
) => {
|
|
return sort.hasLength(a.values[id], b.values[id]);
|
|
},
|
|
hostPolicyStatus: (
|
|
a: { values: Record<string, unknown[]> },
|
|
b: { values: Record<string, unknown[]> },
|
|
id: string
|
|
) => sort.hostPolicyStatus(a.values[id], b.values[id]),
|
|
}),
|
|
[]
|
|
),
|
|
},
|
|
useGlobalFilter, // order of these hooks matters; here we first apply the global filter (if any); this could be reversed depending on where we want to target performance
|
|
useFilters, // react-table applies column-level filters after first applying the global filter (if any)
|
|
useSortBy,
|
|
usePagination,
|
|
useRowSelect
|
|
);
|
|
|
|
const { sortBy, selectedRowIds, pageIndex } = tableState;
|
|
|
|
useEffect(() => {
|
|
if (tableFilters) {
|
|
const filtersToSet = tableFilters;
|
|
const global = filtersToSet.global;
|
|
setGlobalFilter(global);
|
|
delete filtersToSet.global;
|
|
const allFilters = Object.entries(filtersToSet).map(([id, value]) => ({
|
|
id,
|
|
value,
|
|
}));
|
|
!!allFilters.length && setAllFilters(allFilters);
|
|
setExportRows && setExportRows(rows);
|
|
}
|
|
}, [tableFilters]);
|
|
|
|
useEffect(() => {
|
|
setExportRows && setExportRows(rows);
|
|
}, [tableState.filters, rows.length]);
|
|
|
|
// Listen for changes to filters if clientSideFilter is enabled
|
|
|
|
const setDebouncedClientFilter = useDebouncedCallback(
|
|
(column: string, query: string) => {
|
|
setFilter(column, query);
|
|
},
|
|
300
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isClientSideFilter && onResultsCountChange) {
|
|
onResultsCountChange(rows.length);
|
|
}
|
|
}, [isClientSideFilter, onResultsCountChange, rows.length]);
|
|
|
|
useEffect(() => {
|
|
if (!isInitialRender.current && isClientSideFilter && searchQueryColumn) {
|
|
setDebouncedClientFilter(searchQueryColumn, searchQuery || "");
|
|
}
|
|
|
|
// we only want to reset the selected rows if we are not persisting them
|
|
// across table data filters
|
|
if (!isInitialRender.current && !persistSelectedRows) {
|
|
toggleAllRowsSelected(false); // Resets row selection on query change (client-side)
|
|
}
|
|
isInitialRender.current = false;
|
|
}, [searchQuery, searchQueryColumn]);
|
|
|
|
useEffect(() => {
|
|
if (isClientSideFilter && selectedDropdownFilter) {
|
|
toggleAllRowsSelected(false); // Resets row selection on filter change (client-side)
|
|
selectedDropdownFilter === "all"
|
|
? setDebouncedClientFilter("platforms", "")
|
|
: setDebouncedClientFilter("platforms", selectedDropdownFilter);
|
|
}
|
|
}, [selectedDropdownFilter]);
|
|
|
|
// track previous sort state
|
|
const prevSort = useRef<{ id?: string; desc?: boolean }>({
|
|
id: undefined,
|
|
desc: undefined, // desc as in descending
|
|
});
|
|
|
|
// This is used to listen for changes to sort. If there is a change
|
|
// Then the sortHandler change is fired.
|
|
useEffect(() => {
|
|
const column = sortBy[0];
|
|
const prev = prevSort.current;
|
|
const newId = column?.id;
|
|
const newDesc = column?.desc;
|
|
|
|
if (column !== undefined) {
|
|
if (
|
|
column.id !== sortHeader ||
|
|
column.desc !== (sortDirection === "desc")
|
|
) {
|
|
onSort(column.id, column.desc);
|
|
}
|
|
} else {
|
|
onSort(undefined);
|
|
}
|
|
|
|
// Only reset to page 0 if sort column/direction actually changes
|
|
// Prevents unnecessary page resets when a column ordering changes
|
|
// e.g. when clicking on an action that modifies the data
|
|
const hasSortChanged =
|
|
(!prev && (newId || newDesc !== undefined)) ||
|
|
(prev && (prev.id !== newId || prev.desc !== newDesc));
|
|
|
|
if (isClientSidePagination && hasSortChanged) {
|
|
gotoPage(0); // Just this, no defaultPageIndex/etc!
|
|
}
|
|
prevSort.current = column
|
|
? { id: newId, desc: newDesc }
|
|
: { id: undefined, desc: undefined };
|
|
}, [sortBy, sortHeader, onSort, sortDirection, isClientSidePagination]);
|
|
|
|
/** For onClientSidePaginationChange only:
|
|
* Prevents bug where URL page + table page mismatch
|
|
* Whenever defaultPageIndex (the value from props, e.g. queryParams.page) changes,
|
|
* ensure we call gotoPage so react-table reflects the correct visible page.
|
|
*/
|
|
useEffect(() => {
|
|
if (
|
|
isClientSidePagination &&
|
|
!!onClientSidePaginationChange &&
|
|
typeof defaultPageIndex === "number" &&
|
|
pageIndex !== defaultPageIndex
|
|
) {
|
|
gotoPage(defaultPageIndex);
|
|
}
|
|
}, [
|
|
isClientSidePagination,
|
|
onClientSidePaginationChange,
|
|
defaultPageIndex,
|
|
gotoPage,
|
|
pageIndex,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (isAllPagesSelected) {
|
|
toggleAllRowsSelected(true);
|
|
}
|
|
}, [isAllPagesSelected, toggleAllRowsSelected]);
|
|
|
|
useEffect(() => {
|
|
setPageSize(defaultPageSize);
|
|
}, [setPageSize]);
|
|
|
|
useDeepEffect(() => {
|
|
if (
|
|
Object.keys(selectedRowIds).length < rows.length &&
|
|
toggleAllPagesSelected
|
|
) {
|
|
toggleAllPagesSelected(false);
|
|
}
|
|
}, [tableState.selectedRowIds, toggleAllPagesSelected]);
|
|
|
|
const onToggleAllPagesClick = useCallback(() => {
|
|
toggleAllPagesSelected();
|
|
}, [toggleAllPagesSelected]);
|
|
|
|
const onClearSelectionClick = useCallback(() => {
|
|
onClearSelection();
|
|
toggleAllRowsSelected?.(false);
|
|
toggleAllPagesSelected?.(false);
|
|
}, [onClearSelection, toggleAllPagesSelected, toggleAllRowsSelected]);
|
|
|
|
const onSelectRowClick = useCallback(
|
|
(row: any) => {
|
|
if (disableMultiRowSelect) {
|
|
row.toggleRowSelected();
|
|
onSelectSingleRow && onSelectSingleRow(row);
|
|
toggleAllRowsSelected(false);
|
|
}
|
|
},
|
|
[disableMultiRowSelect, onSelectSingleRow, toggleAllRowsSelected]
|
|
);
|
|
|
|
const renderColumnHeader = (column: IHeaderGroup) => {
|
|
return (
|
|
<div className="column-header">
|
|
{column.render("Header")}
|
|
{column.Filter && column.render("Filter")}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderSelectedCount = (): JSX.Element => {
|
|
const selectedCount = Object.entries(selectedRowIds).filter(
|
|
([, value]) => value
|
|
).length;
|
|
return (
|
|
<p>
|
|
<span>
|
|
{selectedCount}
|
|
{isAllPagesSelected && "+"}
|
|
</span>{" "}
|
|
selected
|
|
</p>
|
|
);
|
|
};
|
|
|
|
const renderAreAllSelected = (): JSX.Element | null => {
|
|
if (isAllPagesSelected) {
|
|
return <p>All matching {resultsTitle} are selected</p>;
|
|
}
|
|
|
|
if (isAllRowsSelected) {
|
|
return <p>All {resultsTitle} on this page are selected</p>;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderActionButton = (
|
|
actionButtonProps: IActionButtonProps
|
|
): JSX.Element => {
|
|
const key = kebabCase(actionButtonProps.name);
|
|
return (
|
|
<div className={`${baseClass}__${key}`}>
|
|
<ActionButton {...{ key, ...actionButtonProps }} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderPrimarySelectAction = (): JSX.Element | null => {
|
|
const targetIds = selectedFlatRows.map((row: any) => row.original.id);
|
|
const buttonText =
|
|
typeof primarySelectAction?.buttonText === "function"
|
|
? primarySelectAction?.buttonText(targetIds)
|
|
: primarySelectAction?.buttonText;
|
|
const name = buttonText ? kebabCase(buttonText) : "primary-select-action";
|
|
|
|
const actionProps = {
|
|
name,
|
|
buttonText: buttonText || "",
|
|
onClick: primarySelectAction?.onClick || noop,
|
|
targetIds,
|
|
variant: primarySelectAction?.variant,
|
|
iconSvg: primarySelectAction?.iconSvg,
|
|
};
|
|
|
|
return !buttonText ? null : renderActionButton(actionProps);
|
|
};
|
|
|
|
const renderSecondarySelectActions = (): JSX.Element[] | null => {
|
|
if (secondarySelectActions) {
|
|
const targetIds = selectedFlatRows.map((row: any) => row.original.id);
|
|
const buttons = secondarySelectActions.map((actionProps) => {
|
|
actionProps = { ...actionProps, targetIds };
|
|
return renderActionButton(actionProps);
|
|
});
|
|
return buttons;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const shouldRenderToggleAllPages =
|
|
Object.keys(selectedRowIds).length >= defaultPageSize &&
|
|
showMarkAllPages &&
|
|
!isAllPagesSelected;
|
|
|
|
const pageOrRows = isClientSidePagination ? page : rows;
|
|
|
|
const tableStyles = classnames({
|
|
"data-table__table": true,
|
|
"data-table__no-rows": !rows.length,
|
|
"is-observer": isOnlyObserver,
|
|
});
|
|
|
|
const renderHeaderWithActions = () => (
|
|
<thead className="active-selection">
|
|
<tr {...headerGroups[0].getHeaderGroupProps()}>
|
|
<th
|
|
className="active-selection__checkbox"
|
|
{...headerGroups[0].headers[0].getHeaderProps(
|
|
headerGroups[0].headers[0].getSortByToggleProps({
|
|
title: null,
|
|
})
|
|
)}
|
|
>
|
|
{headerGroups[0].headers[0].render("Header")}
|
|
</th>
|
|
<th className="active-selection__container">
|
|
<div className="active-selection__inner">
|
|
{renderSelectedCount()}
|
|
<div className="active-selection__inner-left">
|
|
{secondarySelectActions && renderSecondarySelectActions()}
|
|
</div>
|
|
<div className="active-selection__inner-right">
|
|
{primarySelectAction && renderPrimarySelectAction()}
|
|
</div>
|
|
{toggleAllPagesSelected && renderAreAllSelected()}
|
|
{shouldRenderToggleAllPages && (
|
|
<Button
|
|
onClick={onToggleAllPagesClick}
|
|
variant="inverse"
|
|
className="light-text"
|
|
size="small"
|
|
>
|
|
<>Select all matching {resultsTitle}</>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={onClearSelectionClick}
|
|
variant="inverse"
|
|
size="small"
|
|
>
|
|
Clear selection
|
|
</Button>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
);
|
|
|
|
const shouldShowFooter =
|
|
// footer is not explicitly hidden
|
|
!hideFooter &&
|
|
// and any of:
|
|
|
|
// table is client-side paginated with more than 1 page of rows
|
|
((isClientSidePagination && (canNextPage || canPreviousPage)) ||
|
|
// table's pagination is externally controlled
|
|
renderPagination?.() != null ||
|
|
// there is help text and at least 1 row of data
|
|
(renderTableHelpText?.() != null && !!rows?.length));
|
|
|
|
return (
|
|
<div className={baseClass}>
|
|
{isLoading && (
|
|
<div className="loading-overlay">
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
<div className="data-table data-table__wrapper">
|
|
<table className={tableStyles}>
|
|
{!suppressHeaderActions &&
|
|
Object.keys(selectedRowIds).length !== 0 &&
|
|
renderHeaderWithActions()}
|
|
<thead>
|
|
{headerGroups.map((headerGroup) => (
|
|
<tr {...headerGroup.getHeaderGroupProps()}>
|
|
{headerGroup.headers.map((column) => {
|
|
return (
|
|
<th
|
|
className={column.id ? `${column.id}__header` : ""}
|
|
{...column.getHeaderProps()}
|
|
>
|
|
{column.canSort ? (
|
|
<Button
|
|
variant="unstyled"
|
|
{...column.getSortByToggleProps({ title: null })}
|
|
aria-label={`Sort by ${column.Header} ${
|
|
column.isSortedDesc ? "descending" : "ascending"
|
|
}`}
|
|
tabIndex={0}
|
|
className="sortable-header"
|
|
>
|
|
{renderColumnHeader(column)}
|
|
{/* add arrow/icon as needed */}
|
|
</Button>
|
|
) : (
|
|
renderColumnHeader(column)
|
|
)}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{pageOrRows.map((row: Row) => {
|
|
prepareRow(row);
|
|
|
|
const rowStyles = classnames({
|
|
"single-row": disableMultiRowSelect,
|
|
"disable-highlight": disableHighlightOnHover,
|
|
"clickable-row": !!onClickRow,
|
|
});
|
|
return (
|
|
<tr
|
|
className={rowStyles}
|
|
{...row.getRowProps({
|
|
// @ts-ignore // TS complains about prop not existing
|
|
onClick: () => {
|
|
(onSelectRowClick &&
|
|
disableMultiRowSelect &&
|
|
onSelectRowClick(row)) ||
|
|
(disableMultiRowSelect &&
|
|
onClickRow &&
|
|
onClickRow(row));
|
|
},
|
|
// For accessibility when tabable
|
|
onKeyDown: (e: KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.stopPropagation();
|
|
(onSelectRowClick &&
|
|
disableMultiRowSelect &&
|
|
onSelectRowClick(row)) ||
|
|
(disableMultiRowSelect &&
|
|
onClickRow &&
|
|
onClickRow(row));
|
|
}
|
|
},
|
|
})}
|
|
// Can tab onto an entire row if a child element does not have the same onClick functionality as clicking the whole row
|
|
tabIndex={keyboardSelectableRows ? 0 : -1}
|
|
>
|
|
{row.cells.map((cell: any, index: number) => {
|
|
// Only allow row click behavior on first cell
|
|
// if the first cell is not a checkbox
|
|
const cellProps = cell.getCellProps();
|
|
const multiRowSelectEnabled = !disableMultiRowSelect;
|
|
|
|
return (
|
|
<td
|
|
key={cell.column.id}
|
|
className={
|
|
cell.column.id ? `${cell.column.id}__cell` : ""
|
|
}
|
|
style={
|
|
multiRowSelectEnabled ? { cursor: "initial" } : {}
|
|
}
|
|
{...cellProps}
|
|
>
|
|
{cell.render("Cell")}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{shouldShowFooter && (
|
|
<div className={`${baseClass}__footer`}>
|
|
{renderTableHelpText && !!rows?.length && (
|
|
<div className={`${baseClass}__table-help-text`}>
|
|
{renderTableHelpText()}
|
|
</div>
|
|
)}
|
|
{isClientSidePagination ? (
|
|
<Pagination
|
|
disablePrev={!canPreviousPage}
|
|
disableNext={!canNextPage}
|
|
onPrevPage={() => {
|
|
!persistSelectedRows && toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
|
|
onClientSidePaginationChange
|
|
? onClientSidePaginationChange(pageIndex - 1)
|
|
: previousPage();
|
|
}}
|
|
onNextPage={() => {
|
|
!persistSelectedRows && toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
|
|
onClientSidePaginationChange
|
|
? onClientSidePaginationChange(pageIndex + 1)
|
|
: nextPage();
|
|
}}
|
|
hidePagination={!canPreviousPage && !canNextPage}
|
|
/>
|
|
) : (
|
|
renderPagination && renderPagination()
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DataTable;
|