import React, { useState, useCallback, useRef, useEffect } from "react"; import classnames from "classnames"; import { Row } from "react-table"; import useDeepEffect from "hooks/useDeepEffect"; import { noop } from "lodash"; import SearchField from "components/forms/fields/SearchField"; import Pagination from "components/Pagination"; import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; import TooltipWrapper from "components/TooltipWrapper"; import DataTable from "./DataTable/DataTable"; 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 }; } // TODO - there are 2 `IActionButtonProps` - reconcile interface ITableContainerActionButtonProps extends IActionButtonProps { disabledTooltipContent?: React.ReactNode; } interface ITableContainerProps { columnConfigs: any; // TODO: Figure out type data: any; // TODO: Figure out type isLoading: boolean; manualSortBy?: boolean; defaultSortHeader?: string; defaultSortDirection?: string; defaultSearchQuery?: string; /** Used for client-side filtering with a search query controlled outside TableContainer */ searchQuery?: string; /** When page index is externally managed like from the URL, this prop must be set to control currentPageIndex */ pageIndex?: number; defaultSelectedRows?: Record; /** Button visible above the table container next to search bar */ actionButton?: ITableContainerActionButtonProps; inputPlaceHolder?: string; disableActionButton?: boolean; disableMultiRowSelect?: boolean; /** resultsTitle used in DataTable for matching results text */ resultsTitle?: string; 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; /** * Disables the "Next" button when the last page contains exactly the page size items. * This is determined using either the API's `meta.has_next_page` response * or by calculating `isLastPage` in the frontend. */ disableNextPage?: boolean; 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 searchToolTipText?: JSX.Element; // TODO - consolidate this functionality within `filters` searchQueryColumn?: string; // TODO - consolidate this functionality within `filters` selectedDropdownFilter?: string; isClientSidePagination?: boolean; /** Only used on self-service page client side pagination because clicking on a action re-orders the data and we don't want that to trigger reset to page 0 */ disableAutoResetPage?: boolean; /** Used to set URL to correct path and include page query param */ onClientSidePaginationChange?: (pageIndex: number) => void; /** Sets the table to filter the data on the client */ 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 | null; /** Filter button right of the search rendering alternative responsive design where search bar moves to new line but filter button remains inline with other table headers */ customFiltersButton?: () => 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; /** Used if users can click the row and another child element does not have the same onClick functionality */ keyboardSelectableRows?: boolean; /** Use for clientside filtering: Use key global for filtering on any column, or use column id as * key */ filters?: Record; renderCount?: () => JSX.Element | null; /** 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; setExportRows?: (rows: Row[]) => void; disableTableHeader?: boolean; /** 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; /** handler called when the `clear selection` button is called */ onClearSelection?: () => void; /** don't show the Clear selection button and selected item count when items are selected */ suppressHeaderActions?: boolean; getRowId?: (row: any, index: number) => string; } const baseClass = "table-container"; export const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_INDEX = 0; const TableContainer = ({ columnConfigs, data, filters, isLoading, manualSortBy = false, defaultSearchQuery = "", searchQuery: controlledSearchQuery, pageIndex = DEFAULT_PAGE_INDEX, defaultSortHeader = "name", defaultSortDirection = "asc", defaultSelectedRows, inputPlaceHolder = "Search", additionalQueries, resultsTitle, emptyComponent, className, disableActionButton, disableMultiRowSelect = false, actionButton, showMarkAllPages, isAllPagesSelected, toggleAllPagesSelected, searchable, wideSearch, disablePagination, disableNextPage, disableCount, primarySelectAction, secondarySelectActions, searchToolTipText, isClientSidePagination, disableAutoResetPage = false, onClientSidePaginationChange, isClientSideFilter, isMultiColumnFilter, disableHighlightOnHover, pageSize = DEFAULT_PAGE_SIZE, selectedDropdownFilter, searchQueryColumn, hideFooter, onQueryChange, customControl, customFiltersButton, stackControls, onSelectSingleRow, onClickRow, keyboardSelectableRows, renderCount, renderTableHelpText, setExportRows, disableTableHeader, persistSelectedRows, onClearSelection = noop, suppressHeaderActions, getRowId, }: ITableContainerProps) => { const isControlledSearchQuery = controlledSearchQuery !== undefined; const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [sortHeader, setSortHeader] = useState(defaultSortHeader || ""); const [sortDirection, setSortDirection] = useState( defaultSortDirection || "" ); const [currentPageIndex, setCurrentPageIndex] = useState(pageIndex); const [clientFilterCount, setClientFilterCount] = useState(); // If using a clientside search query outside of TableContainer, // we need to set the searchQuery state to the controlledSearchQuery prop anytime it changes useEffect(() => { if (isControlledSearchQuery) { setSearchQuery(controlledSearchQuery); } }, [controlledSearchQuery, isControlledSearchQuery]); // Client side pagination is being overridden to previous page without this useEffect(() => { if ( isClientSidePagination && currentPageIndex !== DEFAULT_PAGE_INDEX && !disableAutoResetPage ) { setCurrentPageIndex(DEFAULT_PAGE_INDEX); } }, [currentPageIndex, isClientSidePagination, disableAutoResetPage]); // pageIndex must update currentPageIndex anytime it's changed or else it causes bugs // e.g. bug of filter dd not reverting table to page 0 useEffect(() => { if (!isClientSidePagination) { setCurrentPageIndex(pageIndex); } }, [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 onPaginationChange = useCallback( (newPage: number) => { if (!isClientSidePagination) { setCurrentPageIndex(newPage); } }, [isClientSidePagination] ); useDeepEffect(() => { if (!onQueryChange) { return; } const queryData = { searchQuery, sortHeader, sortDirection, pageSize, pageIndex: currentPageIndex, }; if (prevPageIndex.current === currentPageIndex) { setCurrentPageIndex(0); } // NOTE: used to reset page number to 0 when modifying filters const newPageIndex = onQueryChange(queryData); if (newPageIndex === 0) { setCurrentPageIndex(0); } prevPageIndex.current = currentPageIndex; }, [ searchQuery, sortHeader, sortDirection, pageSize, currentPageIndex, additionalQueries, ]); /** This is server side pagination. Clientside pagination is handled in * data table using react-table builtins */ const renderServersidePagination = useCallback(() => { if (disablePagination || isClientSidePagination) { return null; } return ( onPaginationChange(pageIndex - 1)} onNextPage={() => onPaginationChange(pageIndex + 1)} hidePagination={ (disableNextPage || data.length < pageSize) && pageIndex === 0 } /> ); }, [ data, disablePagination, isClientSidePagination, disableNextPage, pageIndex, pageSize, onPaginationChange, ]); const renderFilterActionButton = () => { // always !!actionButton here, this is for type checker if (actionButton) { const button = ( ); return actionButton.disabledTooltipContent ? ( {button} ) : ( button ); } }; const renderFilters = useCallback(() => { const opacity = isLoading ? { opacity: 0.4 } : { opacity: 1 }; // New preferred pattern uses grid container/box to allow for more dynamic responsiveness // At low widths, right header stacks on top of left header if (stackControls) { return (
{renderCount && !disableCount && (
{renderCount()}
)}
{actionButton && !actionButton.hideButton && (
{renderFilterActionButton()}
)}
{customControl && customControl()} {searchable && !wideSearch && (
{searchToolTipText} } disableTooltip={!searchToolTipText} underline={false} position="top" tipOffset={8} showArrow >
)} {customFiltersButton && customFiltersButton()}
); } return ( <> {wideSearch && searchable && (
)} {!disableTableHeader && (
{renderCount && !disableCount && (
{renderCount()}
)} {actionButton && !actionButton.hideButton && renderFilterActionButton()} {customControl && customControl()}
{/* Render search bar only if not empty component */} {searchable && !wideSearch && (
{searchToolTipText} } disableTooltip={!searchToolTipText} underline={false} position="top" tipOffset={8} showArrow >
)}
)} ); }, [ actionButton, customControl, customFiltersButton, disableActionButton, disableCount, disableTableHeader, inputPlaceHolder, isLoading, renderCount, searchQuery, searchToolTipText, searchable, stackControls, wideSearch, ]); return (
{renderFilters()}
{/* No entities for this result. */} {(!isLoading && data.length === 0 && !isMultiColumnFilter) || (searchQuery.length && data.length === 0 && !isMultiColumnFilter && !isLoading) ? ( <> {/* This UI only shows if a user navigates to a table page with a URL page param that is outside the # of pages available */} {currentPageIndex !== 0 && (
onPaginationChange(currentPageIndex + 1)} onPrevPage={() => onPaginationChange(currentPageIndex - 1)} />
)} ) : ( <> {/* 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 && ( )}
)}
); }; export default TableContainer;