mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +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>
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect } from "react";
|
|
import classnames from "classnames";
|
|
import { Row } from "react-table";
|
|
import ReactTooltip from "react-tooltip";
|
|
import useDeepEffect from "hooks/useDeepEffect";
|
|
|
|
import SearchField from "components/forms/fields/SearchField";
|
|
// @ts-ignore
|
|
import Pagination from "components/Pagination";
|
|
import Button from "components/buttons/Button";
|
|
import Icon from "components/Icon/Icon";
|
|
import { COLORS } from "styles/var/colors";
|
|
|
|
import DataTable from "./DataTable/DataTable";
|
|
import TableContainerUtils from "./utilities/TableContainerUtils";
|
|
import { IActionButtonProps } from "./DataTable/ActionButton/ActionButton";
|
|
|
|
export interface ITableQueryData {
|
|
pageIndex: number;
|
|
pageSize: number;
|
|
searchQuery: string;
|
|
sortHeader: string;
|
|
sortDirection: string;
|
|
}
|
|
interface IRowProps extends Row {
|
|
original: {
|
|
id?: number;
|
|
os_version_id?: string; // Required for onSelectSingleRow of SoftwareOSTable.tsx
|
|
cve?: string; // Required for onSelectSingleRow of SoftwareVulnerabilityTable.tsx
|
|
};
|
|
}
|
|
|
|
interface ITableContainerProps<T = any> {
|
|
columnConfigs: any; // TODO: Figure out type
|
|
data: any; // TODO: Figure out type
|
|
isLoading: boolean;
|
|
manualSortBy?: boolean;
|
|
defaultSortHeader?: string;
|
|
defaultSortDirection?: string;
|
|
defaultSearchQuery?: string;
|
|
defaultPageIndex?: number;
|
|
/** Button visible above the table container next to search bar */
|
|
actionButton?: IActionButtonProps;
|
|
inputPlaceHolder?: string;
|
|
disableActionButton?: boolean;
|
|
disableMultiRowSelect?: boolean;
|
|
resultsTitle: string;
|
|
resultsHtml?: JSX.Element;
|
|
additionalQueries?: string;
|
|
emptyComponent: React.ElementType;
|
|
className?: string;
|
|
showMarkAllPages: boolean;
|
|
isAllPagesSelected: boolean; // TODO: make dependent on showMarkAllPages
|
|
toggleAllPagesSelected?: any; // TODO: an event type and make it dependent on showMarkAllPages
|
|
searchable?: boolean;
|
|
wideSearch?: boolean;
|
|
disablePagination?: boolean;
|
|
disableNextPage?: boolean; // disableNextPage is a temporary workaround for the case
|
|
// where the number of items on the last page is equal to the page size.
|
|
// The old page controls for server-side pagination render a no results screen
|
|
// with a back button. This fix instead disables the next button in that case.
|
|
disableCount?: boolean;
|
|
/** Main button after selecting a row */
|
|
primarySelectAction?: IActionButtonProps;
|
|
/** Secondary button/s after selecting a row */
|
|
secondarySelectActions?: IActionButtonProps[]; // TODO: Combine with primarySelectAction as these are all rendered in the same spot
|
|
/**
|
|
* @deprecated please use renderCount instead
|
|
* */
|
|
filteredCount?: number;
|
|
searchToolTipText?: string;
|
|
// TODO - consolidate this functionality within `filters`
|
|
searchQueryColumn?: string;
|
|
// TODO - consolidate this functionality within `filters`
|
|
selectedDropdownFilter?: string;
|
|
isClientSidePagination?: boolean;
|
|
/** Used to set URL to correct path and include page query param */
|
|
onClientSidePaginationChange?: (pageIndex: number) => void;
|
|
isClientSideFilter?: boolean;
|
|
/** isMultiColumnFilter is used to preserve the table headers
|
|
in lieu of displaying the empty component when client-side filtering yields zero results */
|
|
isMultiColumnFilter?: boolean;
|
|
disableHighlightOnHover?: boolean;
|
|
pageSize?: number;
|
|
onQueryChange?:
|
|
| ((queryData: ITableQueryData) => void)
|
|
| ((queryData: ITableQueryData) => number);
|
|
customControl?: () => JSX.Element;
|
|
stackControls?: boolean;
|
|
onSelectSingleRow?: (value: Row | IRowProps) => void;
|
|
/** This is called when you click on a row. This was added as `onSelectSingleRow`
|
|
* only work if `disableMultiRowSelect` is also set to `true`. TODO: figure out
|
|
* if we want to keep this
|
|
*/
|
|
onClickRow?: (row: T) => void;
|
|
/** Use for clientside filtering: Use key global for filtering on any column, or use column id as
|
|
* key */
|
|
filters?: Record<string, string | number | boolean>;
|
|
renderCount?: () => JSX.Element | null;
|
|
renderFooter?: () => JSX.Element | null;
|
|
setExportRows?: (rows: Row[]) => void;
|
|
resetPageIndex?: boolean;
|
|
disableTableHeader?: boolean;
|
|
show0Count?: boolean;
|
|
}
|
|
|
|
const baseClass = "table-container";
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
const DEFAULT_PAGE_INDEX = 0;
|
|
|
|
const TableContainer = <T,>({
|
|
columnConfigs,
|
|
data,
|
|
filters,
|
|
isLoading,
|
|
manualSortBy = false,
|
|
defaultSearchQuery = "",
|
|
defaultPageIndex = DEFAULT_PAGE_INDEX,
|
|
defaultSortHeader = "name",
|
|
defaultSortDirection = "asc",
|
|
inputPlaceHolder = "Search",
|
|
additionalQueries,
|
|
resultsTitle,
|
|
resultsHtml,
|
|
emptyComponent,
|
|
className,
|
|
disableActionButton,
|
|
disableMultiRowSelect = false,
|
|
actionButton,
|
|
showMarkAllPages,
|
|
isAllPagesSelected,
|
|
toggleAllPagesSelected,
|
|
searchable,
|
|
wideSearch,
|
|
disablePagination,
|
|
disableNextPage,
|
|
disableCount,
|
|
primarySelectAction,
|
|
secondarySelectActions,
|
|
filteredCount,
|
|
searchToolTipText,
|
|
isClientSidePagination,
|
|
onClientSidePaginationChange,
|
|
isClientSideFilter,
|
|
isMultiColumnFilter,
|
|
disableHighlightOnHover,
|
|
pageSize = DEFAULT_PAGE_SIZE,
|
|
selectedDropdownFilter,
|
|
searchQueryColumn,
|
|
onQueryChange,
|
|
customControl,
|
|
stackControls,
|
|
onSelectSingleRow,
|
|
onClickRow,
|
|
renderCount,
|
|
renderFooter,
|
|
setExportRows,
|
|
resetPageIndex,
|
|
disableTableHeader,
|
|
show0Count,
|
|
}: ITableContainerProps<T>) => {
|
|
const [searchQuery, setSearchQuery] = useState(defaultSearchQuery);
|
|
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
|
|
const [sortDirection, setSortDirection] = useState(
|
|
defaultSortDirection || ""
|
|
);
|
|
const [pageIndex, setPageIndex] = useState<number>(defaultPageIndex);
|
|
const [clientFilterCount, setClientFilterCount] = useState<number>();
|
|
|
|
// Client side pagination is being overridden to previous page without this
|
|
useEffect(() => {
|
|
if (isClientSidePagination && pageIndex !== defaultPageIndex) {
|
|
setPageIndex(defaultPageIndex);
|
|
}
|
|
}, [defaultPageIndex, pageIndex, isClientSidePagination]);
|
|
|
|
const prevPageIndex = useRef(0);
|
|
|
|
const wrapperClasses = classnames(baseClass, className);
|
|
|
|
const EmptyComponent = emptyComponent;
|
|
|
|
const onSortChange = useCallback(
|
|
(id?: string, isDesc?: boolean) => {
|
|
if (id === undefined) {
|
|
setSortHeader(defaultSortHeader || "");
|
|
setSortDirection(defaultSortDirection || "");
|
|
} else {
|
|
setSortHeader(id);
|
|
const direction = isDesc ? "desc" : "asc";
|
|
setSortDirection(direction);
|
|
}
|
|
},
|
|
[defaultSortHeader, defaultSortDirection, setSortHeader, setSortDirection]
|
|
);
|
|
|
|
const onSearchQueryChange = (value: string) => {
|
|
setSearchQuery(value.trim());
|
|
};
|
|
|
|
const hasPageIndexChangedRef = useRef(false);
|
|
const onPaginationChange = useCallback(
|
|
(newPage: number) => {
|
|
if (!isClientSidePagination) {
|
|
setPageIndex(newPage);
|
|
hasPageIndexChangedRef.current = true;
|
|
}
|
|
},
|
|
[hasPageIndexChangedRef, isClientSidePagination]
|
|
);
|
|
|
|
// NOTE: used to reset page number to 0 when modifying filters
|
|
useEffect(() => {
|
|
if (pageIndex !== 0 && resetPageIndex && !isClientSidePagination) {
|
|
onPaginationChange(0);
|
|
}
|
|
}, [resetPageIndex, pageIndex, isClientSidePagination]);
|
|
|
|
useDeepEffect(() => {
|
|
if (!onQueryChange) {
|
|
return;
|
|
}
|
|
|
|
const queryData = {
|
|
searchQuery,
|
|
sortHeader,
|
|
sortDirection,
|
|
pageSize,
|
|
pageIndex,
|
|
};
|
|
|
|
if (prevPageIndex.current === pageIndex) {
|
|
setPageIndex(0);
|
|
}
|
|
|
|
// NOTE: used to reset page number to 0 when modifying filters
|
|
const newPageIndex = onQueryChange(queryData);
|
|
if (newPageIndex === 0) {
|
|
setPageIndex(0);
|
|
}
|
|
|
|
prevPageIndex.current = pageIndex;
|
|
}, [
|
|
searchQuery,
|
|
sortHeader,
|
|
sortDirection,
|
|
pageSize,
|
|
pageIndex,
|
|
additionalQueries,
|
|
]);
|
|
|
|
// TODO: refactor existing components relying on displayCount to use renderCount pattern
|
|
const displayCount = useCallback((): any => {
|
|
if (typeof filteredCount === "number") {
|
|
return filteredCount;
|
|
} else if (typeof clientFilterCount === "number") {
|
|
return clientFilterCount;
|
|
}
|
|
return data?.length || 0;
|
|
}, [filteredCount, clientFilterCount, data]);
|
|
|
|
const renderPagination = useCallback(() => {
|
|
if (disablePagination || isClientSidePagination) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Pagination
|
|
resultsOnCurrentPage={data.length}
|
|
currentPage={pageIndex}
|
|
resultsPerPage={pageSize}
|
|
onPaginationChange={onPaginationChange}
|
|
disableNextPage={disableNextPage}
|
|
/>
|
|
);
|
|
}, [
|
|
data,
|
|
disablePagination,
|
|
isClientSidePagination,
|
|
disableNextPage,
|
|
pageIndex,
|
|
pageSize,
|
|
onPaginationChange,
|
|
]);
|
|
|
|
const opacity = isLoading ? { opacity: 0.4 } : { opacity: 1 };
|
|
|
|
return (
|
|
<div className={wrapperClasses}>
|
|
{wideSearch && searchable && (
|
|
<div className={`${baseClass}__search-input wide-search`}>
|
|
<SearchField
|
|
placeholder={inputPlaceHolder}
|
|
defaultValue={searchQuery}
|
|
onChange={onSearchQueryChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
{!disableTableHeader && (
|
|
<div
|
|
className={`${baseClass}__header ${
|
|
stackControls ? "stack-table-controls" : ""
|
|
}`}
|
|
>
|
|
<div
|
|
className={`${baseClass}__header-left ${
|
|
stackControls ? "stack-table-controls" : ""
|
|
}`}
|
|
>
|
|
<span className="results-count">
|
|
{renderCount && (
|
|
<div
|
|
className={`${baseClass}__results-count ${
|
|
stackControls ? "stack-table-controls" : ""
|
|
}`}
|
|
style={opacity}
|
|
>
|
|
{renderCount()}
|
|
</div>
|
|
)}
|
|
{!renderCount &&
|
|
!disableCount &&
|
|
(isMultiColumnFilter || displayCount() || show0Count) ? (
|
|
<div
|
|
className={`${baseClass}__results-count ${
|
|
stackControls ? "stack-table-controls" : ""
|
|
}`}
|
|
style={opacity}
|
|
>
|
|
{TableContainerUtils.generateResultsCountText(
|
|
resultsTitle,
|
|
displayCount(),
|
|
show0Count
|
|
)}
|
|
{resultsHtml}
|
|
</div>
|
|
) : (
|
|
<div />
|
|
)}
|
|
</span>
|
|
<span className="controls">
|
|
{actionButton && !actionButton.hideButton && (
|
|
<Button
|
|
disabled={disableActionButton}
|
|
onClick={actionButton.onActionButtonClick}
|
|
variant={actionButton.variant || "brand"}
|
|
className={`${baseClass}__table-action-button`}
|
|
>
|
|
<>
|
|
{actionButton.buttonText}
|
|
{actionButton.iconSvg && (
|
|
<Icon name={actionButton.iconSvg} />
|
|
)}
|
|
</>
|
|
</Button>
|
|
)}
|
|
{customControl && customControl()}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Render search bar only if not empty component */}
|
|
{searchable && !wideSearch && (
|
|
<div className={`${baseClass}__search`}>
|
|
<div
|
|
className={`${baseClass}__search-input ${
|
|
stackControls ? "stack-table-controls" : ""
|
|
}`}
|
|
data-tip
|
|
data-for="search-tooltip"
|
|
data-tip-disable={!searchToolTipText}
|
|
>
|
|
<SearchField
|
|
placeholder={inputPlaceHolder}
|
|
defaultValue={searchQuery}
|
|
onChange={onSearchQueryChange}
|
|
/>
|
|
</div>
|
|
<ReactTooltip
|
|
effect="solid"
|
|
backgroundColor={COLORS["tooltip-bg"]}
|
|
id="search-tooltip"
|
|
data-html
|
|
>
|
|
<span className={`tooltip ${baseClass}__tooltip-text`}>
|
|
{searchToolTipText}
|
|
</span>
|
|
</ReactTooltip>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className={`${baseClass}__data-table-block`}>
|
|
{/* No entities for this result. */}
|
|
{(!isLoading && data.length === 0 && !isMultiColumnFilter) ||
|
|
(searchQuery.length &&
|
|
data.length === 0 &&
|
|
!isMultiColumnFilter &&
|
|
!isLoading) ? (
|
|
<>
|
|
<EmptyComponent pageIndex={pageIndex} />
|
|
{pageIndex !== 0 && (
|
|
<div className={`${baseClass}__empty-page`}>
|
|
<div className={`${baseClass}__previous`}>
|
|
<Pagination
|
|
resultsOnCurrentPage={data.length}
|
|
currentPage={pageIndex}
|
|
resultsPerPage={pageSize}
|
|
onPaginationChange={onPaginationChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* TODO: Fix this hacky solution to clientside search being 0 rendering emptycomponent but
|
|
no longer accesses rows.length because DataTable is not rendered */}
|
|
{!isLoading && clientFilterCount === 0 && !isMultiColumnFilter && (
|
|
<EmptyComponent pageIndex={pageIndex} />
|
|
)}
|
|
<div
|
|
className={
|
|
isClientSideFilter && !isMultiColumnFilter
|
|
? `client-result-count-${clientFilterCount}`
|
|
: ""
|
|
}
|
|
>
|
|
<DataTable
|
|
isLoading={isLoading}
|
|
columns={columnConfigs}
|
|
data={data}
|
|
filters={filters}
|
|
manualSortBy={manualSortBy}
|
|
sortHeader={sortHeader}
|
|
sortDirection={sortDirection}
|
|
onSort={onSortChange}
|
|
disableMultiRowSelect={disableMultiRowSelect}
|
|
showMarkAllPages={showMarkAllPages}
|
|
isAllPagesSelected={isAllPagesSelected}
|
|
toggleAllPagesSelected={toggleAllPagesSelected}
|
|
resultsTitle={resultsTitle}
|
|
defaultPageSize={pageSize}
|
|
defaultPageIndex={defaultPageIndex}
|
|
primarySelectAction={primarySelectAction}
|
|
secondarySelectActions={secondarySelectActions}
|
|
onSelectSingleRow={onSelectSingleRow}
|
|
onClickRow={onClickRow}
|
|
onResultsCountChange={setClientFilterCount}
|
|
isClientSidePagination={isClientSidePagination}
|
|
onClientSidePaginationChange={onClientSidePaginationChange}
|
|
isClientSideFilter={isClientSideFilter}
|
|
disableHighlightOnHover={disableHighlightOnHover}
|
|
searchQuery={searchQuery}
|
|
searchQueryColumn={searchQueryColumn}
|
|
selectedDropdownFilter={selectedDropdownFilter}
|
|
renderFooter={renderFooter}
|
|
renderPagination={
|
|
isClientSidePagination ? undefined : renderPagination
|
|
}
|
|
setExportRows={setExportRows}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TableContainer;
|