/* 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; 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; /** 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 }, b: { values: Record }, id: string ) => sort.booleanAsc(a.values[id], b.values[id]), caseInsensitive: ( a: { values: Record }, b: { values: Record }, id: string ) => sort.caseInsensitiveAsc(a.values[id], b.values[id]), dateStrings: ( a: { values: Record }, b: { values: Record }, id: string ) => sort.dateStringsAsc(a.values[id], b.values[id]), hasLength: ( a: { values: Record }, b: { values: Record }, id: string ) => { return sort.hasLength(a.values[id], b.values[id]); }, hostPolicyStatus: ( a: { values: Record }, b: { values: Record }, 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 (
{column.render("Header")} {column.Filter && column.render("Filter")}
); }; const renderSelectedCount = (): JSX.Element => { const selectedCount = Object.entries(selectedRowIds).filter( ([, value]) => value ).length; return (

{selectedCount} {isAllPagesSelected && "+"} {" "} selected

); }; const renderAreAllSelected = (): JSX.Element | null => { if (isAllPagesSelected) { return

All matching {resultsTitle} are selected

; } if (isAllRowsSelected) { return

All {resultsTitle} on this page are selected

; } return null; }; const renderActionButton = ( actionButtonProps: IActionButtonProps ): JSX.Element => { const key = kebabCase(actionButtonProps.name); return (
); }; 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 = () => ( {headerGroups[0].headers[0].render("Header")}
{renderSelectedCount()}
{secondarySelectActions && renderSecondarySelectActions()}
{primarySelectAction && renderPrimarySelectAction()}
{toggleAllPagesSelected && renderAreAllSelected()} {shouldRenderToggleAllPages && ( )}
); 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 (
{isLoading && (
)}
{!suppressHeaderActions && Object.keys(selectedRowIds).length !== 0 && renderHeaderWithActions()} {headerGroups.map((headerGroup) => ( {headerGroup.headers.map((column) => { return ( ); })} ))} {pageOrRows.map((row: Row) => { prepareRow(row); const rowStyles = classnames({ "single-row": disableMultiRowSelect, "disable-highlight": disableHighlightOnHover, "clickable-row": !!onClickRow, }); return ( { (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 ( ); })} ); })}
{column.canSort ? ( ) : ( renderColumnHeader(column) )}
{cell.render("Cell")}
{shouldShowFooter && (
{renderTableHelpText && !!rows?.length && (
{renderTableHelpText()}
)} {isClientSidePagination ? ( { !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() )}
)}
); }; export default DataTable;