mirror of
https://github.com/fleetdm/fleet
synced 2026-05-15 04:58:25 +00:00
## Addresses #18881 and #18858 - Fix the bugs, memoize various props to optimize table rendering ## QA In addition to the bugs outlined in the addressed issue, this PR contains rendering optimizations. Please check these functionalities, which should be unaffected (and were so in my own testing) but should be double-checked: - [ ] create query - [ ] delete query - [ ] empty state - [ ] changing platform dropdown - [ ] different sort orders - [ ] delete query from table action - [ ] results count change ## Checklist for submitter - [x] Changes file added for user-visible changes in `changes/` - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
595 lines
18 KiB
TypeScript
595 lines
18 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 } 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";
|
|
// @ts-ignore
|
|
import FleetIcon from "components/icons/FleetIcon";
|
|
import Spinner from "components/Spinner";
|
|
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;
|
|
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;
|
|
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;
|
|
onSelectSingleRow?: (value: Row) => void;
|
|
onClickRow?: (value: any) => void;
|
|
onResultsCountChange?: (value: number) => void;
|
|
renderFooter?: () => JSX.Element | null;
|
|
renderPagination?: () => JSX.Element | null;
|
|
setExportRows?: (rows: Row[]) => void;
|
|
}
|
|
|
|
interface IHeaderGroup extends HeaderGroup {
|
|
title?: string;
|
|
}
|
|
|
|
const CLIENT_SIDE_DEFAULT_PAGE_SIZE = 20;
|
|
|
|
// 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,
|
|
showMarkAllPages,
|
|
isAllPagesSelected,
|
|
toggleAllPagesSelected,
|
|
resultsTitle,
|
|
defaultPageSize,
|
|
defaultPageIndex,
|
|
primarySelectAction,
|
|
secondarySelectActions,
|
|
isClientSidePagination,
|
|
onClientSidePaginationChange,
|
|
isClientSideFilter,
|
|
disableHighlightOnHover,
|
|
searchQuery,
|
|
searchQueryColumn,
|
|
selectedDropdownFilter,
|
|
onSelectSingleRow,
|
|
onClickRow,
|
|
onResultsCountChange,
|
|
renderFooter,
|
|
renderPagination,
|
|
setExportRows,
|
|
}: IDataTableProps): JSX.Element => {
|
|
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]);
|
|
|
|
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,
|
|
initialState: {
|
|
sortBy: initialSortBy,
|
|
pageIndex: defaultPageIndex,
|
|
},
|
|
disableMultiSort: true,
|
|
disableSortRemove: true,
|
|
manualSortBy,
|
|
// Resets row selection on (server-side) pagination
|
|
autoResetSelectedRows: true,
|
|
// 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]);
|
|
},
|
|
}),
|
|
[]
|
|
),
|
|
},
|
|
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 (isClientSideFilter && searchQueryColumn) {
|
|
toggleAllRowsSelected(false); // Resets row selection on query change (client-side)
|
|
setDebouncedClientFilter(searchQueryColumn, searchQuery || "");
|
|
}
|
|
}, [searchQuery, searchQueryColumn]);
|
|
|
|
useEffect(() => {
|
|
if (isClientSideFilter && selectedDropdownFilter) {
|
|
toggleAllRowsSelected(false); // Resets row selection on filter change (client-side)
|
|
selectedDropdownFilter === "all"
|
|
? setDebouncedClientFilter("platforms", "")
|
|
: setDebouncedClientFilter("platforms", selectedDropdownFilter);
|
|
}
|
|
}, [selectedDropdownFilter]);
|
|
|
|
// 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];
|
|
if (column !== undefined) {
|
|
if (
|
|
column.id !== sortHeader ||
|
|
column.desc !== (sortDirection === "desc")
|
|
) {
|
|
onSort(column.id, column.desc);
|
|
}
|
|
} else {
|
|
onSort(undefined);
|
|
}
|
|
if (isClientSidePagination) {
|
|
gotoPage(0); // Return to page 0 after changing sort clientside
|
|
}
|
|
}, [sortBy, sortHeader, onSort, sortDirection]);
|
|
|
|
useEffect(() => {
|
|
if (isAllPagesSelected) {
|
|
toggleAllRowsSelected(true);
|
|
}
|
|
}, [isAllPagesSelected, toggleAllRowsSelected]);
|
|
|
|
useEffect(() => {
|
|
setPageSize(defaultPageSize || CLIENT_SIDE_DEFAULT_PAGE_SIZE);
|
|
}, [setPageSize]);
|
|
|
|
useDeepEffect(() => {
|
|
if (
|
|
Object.keys(selectedRowIds).length < rows.length &&
|
|
toggleAllPagesSelected
|
|
) {
|
|
toggleAllPagesSelected(false);
|
|
}
|
|
}, [tableState.selectedRowIds, toggleAllPagesSelected]);
|
|
|
|
const onToggleAllPagesClick = useCallback(() => {
|
|
toggleAllPagesSelected();
|
|
}, [toggleAllPagesSelected]);
|
|
|
|
const onClearSelectionClick = useCallback(() => {
|
|
toggleAllRowsSelected(false);
|
|
toggleAllPagesSelected(false);
|
|
}, [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 => {
|
|
return (
|
|
<p>
|
|
<span>
|
|
{selectedFlatRows.length}
|
|
{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 {
|
|
name,
|
|
onActionButtonClick,
|
|
buttonText,
|
|
targetIds,
|
|
variant,
|
|
hideButton,
|
|
iconSvg,
|
|
iconPosition,
|
|
indicatePremiumFeature,
|
|
} = actionButtonProps;
|
|
return (
|
|
<div className={`${baseClass}__${kebabCase(name)}`}>
|
|
<ActionButton
|
|
key={kebabCase(name)}
|
|
name={name}
|
|
buttonText={buttonText}
|
|
onActionButtonClick={onActionButtonClick || noop}
|
|
targetIds={targetIds}
|
|
variant={variant}
|
|
hideButton={hideButton}
|
|
indicatePremiumFeature={indicatePremiumFeature}
|
|
iconSvg={iconSvg}
|
|
iconPosition={iconPosition}
|
|
/>
|
|
</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 || "",
|
|
onActionButtonClick: primarySelectAction?.onActionButtonClick || 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 previousButton = (
|
|
<>
|
|
<FleetIcon name="chevronleft" /> Previous
|
|
</>
|
|
);
|
|
const nextButton = (
|
|
<>
|
|
Next <FleetIcon name="chevronright" />
|
|
</>
|
|
);
|
|
|
|
const tableStyles = classnames({
|
|
"data-table__table": true,
|
|
"is-observer": isOnlyObserver,
|
|
});
|
|
|
|
return (
|
|
<div className={baseClass}>
|
|
{isLoading && (
|
|
<div className="loading-overlay">
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
<div className="data-table data-table__wrapper">
|
|
<table className={tableStyles}>
|
|
{Object.keys(selectedRowIds).length !== 0 && (
|
|
<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="text-link"
|
|
className="light-text"
|
|
>
|
|
<>Select all matching {resultsTitle}</>
|
|
</Button>
|
|
)}
|
|
<Button onClick={onClearSelectionClick} variant="text-link">
|
|
Clear selection
|
|
</Button>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
)}
|
|
<thead>
|
|
{headerGroups.map((headerGroup) => (
|
|
<tr {...headerGroup.getHeaderGroupProps()}>
|
|
{headerGroup.headers.map((column) => {
|
|
return (
|
|
<th
|
|
className={column.id ? `${column.id}__header` : ""}
|
|
{...column.getHeaderProps(
|
|
column.getSortByToggleProps({ title: null })
|
|
)}
|
|
>
|
|
{renderColumnHeader(column)}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{pageOrRows.map((row: Row) => {
|
|
prepareRow(row);
|
|
|
|
const rowStyles = classnames({
|
|
"single-row": disableMultiRowSelect,
|
|
"disable-highlight": disableHighlightOnHover,
|
|
});
|
|
return (
|
|
<tr
|
|
className={rowStyles}
|
|
{...row.getRowProps({
|
|
// @ts-ignore // TS complains about prop not existing
|
|
onClick: () => {
|
|
(onSelectRowClick &&
|
|
disableMultiRowSelect &&
|
|
onSelectRowClick(row)) ||
|
|
(onClickRow && onClickRow(row));
|
|
},
|
|
})}
|
|
>
|
|
{row.cells.map((cell: any) => {
|
|
return (
|
|
<td
|
|
key={cell.column.id}
|
|
className={
|
|
cell.column.id ? `${cell.column.id}__cell` : ""
|
|
}
|
|
{...cell.getCellProps()}
|
|
>
|
|
{cell.render("Cell")}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className={`${baseClass}__footer`}>
|
|
{renderFooter && (
|
|
<div className={`${baseClass}__footer-text`}>{renderFooter()}</div>
|
|
)}
|
|
{isClientSidePagination ? (
|
|
<div className={`${baseClass}__pagination`}>
|
|
<Button
|
|
variant="unstyled"
|
|
onClick={() => {
|
|
toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
|
|
onClientSidePaginationChange &&
|
|
onClientSidePaginationChange(pageIndex - 1);
|
|
previousPage();
|
|
}}
|
|
disabled={!canPreviousPage}
|
|
>
|
|
{previousButton}
|
|
</Button>
|
|
<Button
|
|
variant="unstyled"
|
|
onClick={() => {
|
|
toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
|
|
onClientSidePaginationChange &&
|
|
onClientSidePaginationChange(pageIndex + 1);
|
|
nextPage();
|
|
}}
|
|
disabled={!canNextPage}
|
|
>
|
|
{nextButton}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
renderPagination && renderPagination()
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DataTable;
|