import React, { useCallback, useRef } from 'react';
import useDeepCompareEffect from 'use-deep-compare-effect';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { isEqual, isEmpty } from 'lodash';
import SearchBar from 'foremanReact/components/SearchBar';
import { STATUS, getControllerSearchProps } from 'foremanReact/constants';
import { noop } from 'foremanReact/common/helpers';
import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext';
import { PaginationVariant, Flex, FlexItem } from '@patternfly/react-core';
import { translate as __ } from 'foremanReact/common/I18n';
import PageControls from './PageControls';
import MainTable from './MainTable';
import { getPageStats } from './helpers';
import SelectAllCheckbox from '../SelectAllCheckbox';
import { orgId } from '../../services/api';
import { useClearSearch } from '../extensions/SearchBar/SearchBarHooks';
/* Patternfly 4 table wrapper */
const TableWrapper = ({
actionButtons,
alwaysShowActionButtons,
alwaysShowToggleGroup,
toggleGroup,
children,
metadata,
fetchItems,
autocompleteEndpoint,
autocompleteQueryParams,
searchQuery,
updateSearchQuery,
searchPlaceholderText,
additionalListeners,
activeFilters,
displaySelectAllCheckbox,
selectAll,
selectAllMode,
selectNone,
selectPage,
areAllRowsOnPageSelected,
areAllRowsSelected,
selectedCount,
selectedResults,
clearSelectedResults,
emptySearchBody,
hideSearch,
alwaysHideToolbar,
hidePagination,
nodesBelowSearch,
bookmarkController,
readOnlyBookmarks,
...allTableProps
}) => {
const dispatch = useDispatch();
const foremanPerPage = useForemanSettings().perPage || 20;
const perPage = Number(metadata?.per_page ?? foremanPerPage);
const page = Number(metadata?.page ?? 1);
const total = Number(metadata?.subtotal ?? 0);
const totalSelectableCount = Number(metadata?.selectable ?? total);
const { pageRowCount } = getPageStats({ total, page, perPage });
const unresolvedStatus = !!allTableProps?.status && allTableProps.status !== STATUS.RESOLVED;
const unresolvedStatusOrNoRows = unresolvedStatus || pageRowCount === 0;
const showPagination = !unresolvedStatusOrNoRows && !hidePagination;
const filtersAreActive = activeFilters?.length &&
!isEqual(new Set(activeFilters), new Set(allTableProps.defaultFilters));
const hideToolbar = alwaysHideToolbar || (!searchQuery && !filtersAreActive &&
allTableProps.status === STATUS.RESOLVED && total === 0);
const showActionButtons = actionButtons && (alwaysShowActionButtons || !hideToolbar);
const showToggleGroup = toggleGroup && (alwaysShowToggleGroup || !hideToolbar);
const paginationParams = useCallback(() =>
({ per_page: perPage, page }), [perPage, page]);
const prevRequest = useRef({});
const prevSearch = useRef('');
const prevAdditionalListeners = useRef([]);
const prevActiveFilters = useRef([]);
const paginationChangePending = useRef(null);
const hasChanged = (oldValue, newValue) => !isEqual(oldValue, newValue);
const spawnFetch = useCallback((paginationData) => {
const fetchWithParams = (allParams = {}) => {
const newRequest = {
...(paginationData ?? paginationParams()),
...allParams,
};
const pagParamsHaveChanged = (newPagParams, oldPagParams) =>
(newPagParams.page && hasChanged(newPagParams.page, oldPagParams.page)) ||
(newPagParams.per_page && hasChanged(newPagParams.per_page, oldPagParams.per_page));
const newRequestHasStalePagination = !!(paginationChangePending.current &&
pagParamsHaveChanged(newRequest, paginationChangePending.current));
const newRequestHasChanged = hasChanged(newRequest, prevRequest.current);
const additionalListenersHaveChanged =
hasChanged(additionalListeners, prevAdditionalListeners.current);
// If a pagination change is in-flight,
// don't send another request with stale data
if (newRequestHasStalePagination && !additionalListenersHaveChanged) return;
paginationChangePending.current = null;
if (newRequestHasChanged || additionalListenersHaveChanged) {
// don't fire the same request twice in a row
prevRequest.current = newRequest;
prevAdditionalListeners.current = additionalListeners;
dispatch(fetchItems(newRequest));
}
};
let paramsOverride;
const activeFiltersHaveChanged = hasChanged(activeFilters, prevActiveFilters.current);
const searchQueryHasChanged = hasChanged(searchQuery, prevSearch.current);
if (searchQuery && !hideSearch) paramsOverride = { search: searchQuery };
if (!hideSearch && (searchQueryHasChanged || activeFiltersHaveChanged)) {
// Reset page back to 1 when filter or search changes
prevSearch.current = searchQuery;
prevActiveFilters.current = activeFilters;
paramsOverride = { search: searchQuery, page: 1 };
}
if (paramsOverride) {
// paramsOverride may have both page and search, or just search
const pageOverride = !!paramsOverride.page;
if (pageOverride) paginationChangePending.current = null;
fetchWithParams(paramsOverride);
if (pageOverride) paginationChangePending.current = paramsOverride;
} else {
fetchWithParams();
}
}, [
hideSearch,
activeFilters,
dispatch,
fetchItems,
paginationParams,
searchQuery,
additionalListeners,
]);
useDeepCompareEffect(() => {
spawnFetch();
}, [searchQuery, spawnFetch, additionalListeners]);
const searchBarKey = useClearSearch({ updateSearchQuery });
// If the new page wouldn't exist because of a perPage change,
// we should set the current page to the last page.
const validatePagination = (data) => {
const mergedData = { ...paginationParams(), ...data };
const { page: requestedPage, per_page: newPerPage } = mergedData;
const { lastPage } = getPageStats({
page: requestedPage,
perPage: newPerPage,
total,
});
const result = {};
if (requestedPage) {
const newPage = (requestedPage > lastPage) ? lastPage : requestedPage;
result.page = Number(newPage);
}
if (newPerPage) result.per_page = Number(newPerPage);
return result;
};
const onPaginationUpdate = (updatedPagination) => {
const pagData = validatePagination(updatedPagination);
paginationChangePending.current = null;
spawnFetch(pagData);
paginationChangePending.current = pagData;
};
const extraSearchProps = (isEmpty(bookmarkController)) ?
{ bookmarks: {} } :
{ controller: bookmarkController };
const apiParams = { ...autocompleteQueryParams, organization_id: orgId() };
const searchDataProp = {
...getControllerSearchProps(autocompleteEndpoint, `searchBar-${bookmarkController}`, !readOnlyBookmarks, apiParams),
...extraSearchProps,
isDisabled: unresolvedStatusOrNoRows && !searchQuery,
};
return (
<>
{displaySelectAllCheckbox && !hideToolbar &&
}
{!hideSearch && !hideToolbar &&
updateSearchQuery(search)}
key={searchBarKey}
/>
}
{showToggleGroup &&
{toggleGroup}
}
{showActionButtons &&
{actionButtons}
}
{showPagination &&
}
{nodesBelowSearch &&
{nodesBelowSearch}
}
{children}
{showPagination &&
}
>
);
};
TableWrapper.propTypes = {
// ouiaId is needed on all tables for automation testing
ouiaId: PropTypes.string.isRequired,
searchQuery: PropTypes.string.isRequired,
updateSearchQuery: PropTypes.func.isRequired,
fetchItems: PropTypes.func.isRequired,
metadata: PropTypes.shape({
selectable: PropTypes.number,
total: PropTypes.number,
page: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string, // The API can sometimes return strings
]),
subtotal: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string, // The API can sometimes return strings
]),
per_page: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
]),
search: PropTypes.string,
}),
autocompleteEndpoint: PropTypes.string.isRequired,
autocompleteQueryParams: PropTypes.shape({}),
searchPlaceholderText: PropTypes.string,
actionButtons: PropTypes.node,
alwaysShowActionButtons: PropTypes.bool,
alwaysShowToggleGroup: PropTypes.bool,
toggleGroup: PropTypes.node,
children: PropTypes.node,
// additionalListeners are anything that should trigger another API call, e.g. a filter
additionalListeners: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
PropTypes.bool,
])),
activeFilters: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
])),
defaultFilters: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
])),
displaySelectAllCheckbox: PropTypes.bool,
selectedCount: PropTypes.number,
selectedResults: PropTypes.arrayOf(PropTypes.shape({})),
clearSelectedResults: PropTypes.func,
selectAll: PropTypes.func,
selectAllMode: PropTypes.bool,
selectNone: PropTypes.func,
selectPage: PropTypes.func,
areAllRowsOnPageSelected: PropTypes.func,
areAllRowsSelected: PropTypes.func,
emptySearchBody: PropTypes.string,
hideSearch: PropTypes.bool,
alwaysHideToolbar: PropTypes.bool,
hidePagination: PropTypes.bool,
nodesBelowSearch: PropTypes.node,
bookmarkController: PropTypes.string,
readOnlyBookmarks: PropTypes.bool,
resetFilters: PropTypes.func,
};
TableWrapper.defaultProps = {
metadata: { subtotal: 0, selectable: 0 },
children: null,
additionalListeners: [],
activeFilters: [],
defaultFilters: [],
searchPlaceholderText: undefined,
actionButtons: null,
alwaysShowActionButtons: true,
alwaysShowToggleGroup: false,
toggleGroup: null,
displaySelectAllCheckbox: false,
selectedCount: 0,
selectedResults: [],
clearSelectedResults: noop,
selectAll: undefined,
selectAllMode: false,
selectNone: undefined,
selectPage: undefined,
areAllRowsOnPageSelected: noop,
areAllRowsSelected: noop,
emptySearchBody: __('Try changing your search settings.'),
hideSearch: false,
alwaysHideToolbar: false,
hidePagination: false,
nodesBelowSearch: null,
bookmarkController: undefined,
readOnlyBookmarks: false,
resetFilters: undefined,
autocompleteQueryParams: undefined,
};
export default TableWrapper;