From 93bef4b9cfa4b34b3e2b58165b6bda764096da00 Mon Sep 17 00:00:00 2001 From: Dominik Vagner Date: Wed, 13 May 2026 11:02:12 +0200 Subject: [PATCH] fix: make filter reset behavior consistent This fixes some of the incorrect behavior with filter clear or reset buttons. They should now only be shown when the state differs from default and use correct terms for restoring and clearing (depends on default filters). --- playwright/test-utils/helpers/filters.ts | 7 +- src/Messages.ts | 2 +- .../TableView/TableView.js | 16 +- .../TableView/TableView.test.js | 64 +++++- src/SmartComponents/Advisories/Advisories.js | 2 + .../AdvisoryDetail/CvesModal.tsx | 2 + .../AdvisorySystems/AdvisorySystemsTable.js | 15 +- .../AdvisorySystemsTable.test.js | 21 +- .../PackageSystems/PackageSystems.js | 20 +- .../PackageSystems/PackageSystems.test.js | 73 ++++++- src/SmartComponents/Packages/Packages.js | 11 +- .../SystemAdvisories/SystemAdvisories.js | 3 +- .../SystemPackages/SystemPackages.js | 4 +- .../Systems/SystemTable.test.js | 195 +++++++++++++++++- .../Systems/SystemsMainContent.js | 2 +- .../Systems/SystemsMainContent.test.js | 24 ++- src/SmartComponents/Systems/SystemsTable.js | 48 ++++- src/Utilities/Helpers.js | 108 ++++++++++ src/Utilities/Helpers.test.js | 83 +++++++- src/Utilities/SystemsHelpers.js | 25 +-- src/Utilities/constants.js | 15 ++ src/Utilities/hooks/Hooks.js | 35 +++- src/Utilities/hooks/Hooks.test.js | 84 ++++++++ 23 files changed, 776 insertions(+), 83 deletions(-) diff --git a/playwright/test-utils/helpers/filters.ts b/playwright/test-utils/helpers/filters.ts index d7d4e5ea1..082803f0d 100644 --- a/playwright/test-utils/helpers/filters.ts +++ b/playwright/test-utils/helpers/filters.ts @@ -332,6 +332,11 @@ export const removeFilter = async (page: Page, filter: FilterConfig) => { export const resetFilters = async (page: Page) => { // Close any open dropdowns first await page.keyboard.press('Escape'); - await page.getByRole('button', { name: /Reset filters/i }).click(); + try { + await page.getByRole('button', { name: /(Reset|Clear) filters/i }).waitFor({ timeout: 5000 }); + } catch { + return; + } + await page.getByRole('button', { name: /(Reset|Clear) filters/i }).click(); await waitForTableLoad(page); }; diff --git a/src/Messages.ts b/src/Messages.ts index fd5b9eea7..543a5f58a 100644 --- a/src/Messages.ts +++ b/src/Messages.ts @@ -166,7 +166,7 @@ export default defineMessages({ labelsFiltersClear: { id: 'labelsFiltersClear', description: 'label for remove filter chips', - defaultMessage: 'Reset filters', + defaultMessage: 'Clear filters', }, labelsFiltersCvesSearchPlaceHolder: { id: 'labelsFiltersCvesSearch', diff --git a/src/PresentationalComponents/TableView/TableView.js b/src/PresentationalComponents/TableView/TableView.js index 4eb76821f..988bb9657 100644 --- a/src/PresentationalComponents/TableView/TableView.js +++ b/src/PresentationalComponents/TableView/TableView.js @@ -4,11 +4,9 @@ import { PrimaryToolbar } from '@redhat-cloud-services/frontend-components/Prima import { SkeletonTable } from '@redhat-cloud-services/frontend-components/SkeletonTable'; import PropTypes from 'prop-types'; import React from 'react'; -import messages from '../../Messages'; import AsyncRemediationButton from '../../SmartComponents/Remediation/AsyncRemediationButton'; -import { arrayFromObj, buildFilterChips, convertLimitOffset } from '../../Utilities/Helpers'; +import { arrayFromObj, buildActiveFilterConfig, convertLimitOffset } from '../../Utilities/Helpers'; import { useRemoveFilter, useBulkSelectConfig } from '../../Utilities/hooks'; -import { intl } from '../../Utilities/IntlProvider'; import TableFooter from './TableFooter'; import ErrorHandler from '../../PresentationalComponents/Snippets/ErrorHandler'; import { Skeleton, ToolbarItem } from '@patternfly/react-core'; @@ -50,6 +48,10 @@ const TableView = ({ const selectedCount = selectedRows && arrayFromObj(selectedRows).length; const { code, hasError, isLoading } = status; const bulkSelectConfig = useBulkSelectConfig(selectedCount, onSelect, metadata, rows, onCollapse); + const activeFiltersConfig = React.useMemo( + () => buildActiveFilterConfig(filter, search, deleteFilters, searchChipLabel, defaultFilters), + [defaultFilters, deleteFilters, filter, search, searchChipLabel], + ); const [isColumnMgmtModalOpen, setColumnMgmtModalOpen] = React.useState(false); const [appliedColumns, setAppliedColumns] = React.useState(columns); @@ -102,13 +104,7 @@ const TableView = ({ ) } filterConfig={filterConfig} - activeFiltersConfig={{ - filters: buildFilterChips(filter, search, searchChipLabel), - onDelete: deleteFilters, - deleteTitle: intl.formatMessage( - (defaultFilters && messages.labelsFiltersReset) || messages.labelsFiltersClear, - ), - }} + activeFiltersConfig={activeFiltersConfig} actionsConfig={{ actions: [ remediationProvider && ( diff --git a/src/PresentationalComponents/TableView/TableView.test.js b/src/PresentationalComponents/TableView/TableView.test.js index e31574797..fe592fdcb 100644 --- a/src/PresentationalComponents/TableView/TableView.test.js +++ b/src/PresentationalComponents/TableView/TableView.test.js @@ -2,9 +2,10 @@ import TableView from './TableView'; import { render, screen, waitFor } from '@testing-library/react'; import { Provider, useSelector } from 'react-redux'; import configureStore from 'redux-mock-store'; -import { storeListDefaults } from '../../Utilities/constants'; +import { pageDefaultFilters, storeListDefaults } from '../../Utilities/constants'; import { systemPackages } from '../../Utilities/RawDataForTesting'; import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; const testObj = { columns: [], @@ -134,6 +135,67 @@ describe('TableView', () => { expect(asFragment()).toMatchSnapshot(); }); + it('should keep default filter chips visible while hiding reset at the default state', () => { + render( + + + , + ); + + expect(screen.getByText('Systems with patches available')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /reset filters/i })).not.toBeInTheDocument(); + }); + + it('should show a reset button when active filters differ from defaults', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: /reset filters/i })).toBeInTheDocument(); + }); + + it('should show a clear button for pages without defaults', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(); + }); + it('Should unselect', async () => { await render( diff --git a/src/SmartComponents/Advisories/Advisories.js b/src/SmartComponents/Advisories/Advisories.js index f3aa73163..0c0828075 100644 --- a/src/SmartComponents/Advisories/Advisories.js +++ b/src/SmartComponents/Advisories/Advisories.js @@ -16,6 +16,7 @@ import { selectAdvisoryRow, } from '../../store/Actions/Actions'; import { exportAdvisoriesCSV, exportAdvisoriesJSON } from '../../Utilities/api/api'; +import { pageDefaultFilters } from '../../Utilities/constants'; import { createAdvisoriesRows } from '../../Utilities/DataMappers'; import { createSortBy, @@ -200,6 +201,7 @@ const Advisories = () => { rebootFilter(apply, queryParams?.filter), ], }} + defaultFilters={pageDefaultFilters.advisories} searchChipLabel={intl.formatMessage(messages.labelsFiltersSearchAdvisoriesTitle)} isRemediationLoading={isRemediationLoading} hasColumnManagement diff --git a/src/SmartComponents/AdvisoryDetail/CvesModal.tsx b/src/SmartComponents/AdvisoryDetail/CvesModal.tsx index 85580339d..590ae2bdd 100644 --- a/src/SmartComponents/AdvisoryDetail/CvesModal.tsx +++ b/src/SmartComponents/AdvisoryDetail/CvesModal.tsx @@ -6,6 +6,7 @@ import TableView from '../../PresentationalComponents/TableView/TableView'; import searchFilter from '../../PresentationalComponents/Filters/SearchFilter'; import { cvesTableColumns } from '../../PresentationalComponents/TableView/TableViewAssets'; import { fetchCves } from '../../store/Actions/VulnerabilityActions'; +import { pageDefaultFilters } from '../../Utilities/constants'; import { createCvesRows } from '../../Utilities/DataMappers'; import { sortCves } from '../../Utilities/Helpers'; import { SortByDirection } from '@patternfly/react-table'; @@ -108,6 +109,7 @@ const CvesModal = ({ cveIds }: CvesModalProps) => { status, queryParams: { filter: {}, search }, }} + defaultFilters={pageDefaultFilters.cves} filterConfig={{ items: [ searchFilter( diff --git a/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.js b/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.js index d4dd37778..63caacabf 100644 --- a/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.js +++ b/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.js @@ -17,7 +17,7 @@ import { exportAdvisorySystemsJSON, fetchAdvisorySystems, } from '../../Utilities/api/api'; -import { remediationIdentifiers } from '../../Utilities/constants'; +import { pageDefaultFilters, remediationIdentifiers } from '../../Utilities/constants'; import { arrayFromObj, persistantParams, @@ -64,7 +64,11 @@ const AdvisorySystemsTable = ({ (newColumns) => setAppliedColumns(newColumns), ); - const [deleteFilters] = useRemoveFilter({ search, ...filter }, apply); + const [deleteFilters] = useRemoveFilter( + { search, ...filter }, + apply, + pageDefaultFilters.advisorySystems, + ); const filterConfig = { items: [ @@ -78,7 +82,12 @@ const AdvisorySystemsTable = ({ ], }; - const activeFiltersConfig = buildActiveFiltersConfig(filter, search, deleteFilters); + const activeFiltersConfig = buildActiveFiltersConfig( + filter, + search, + deleteFilters, + pageDefaultFilters.advisorySystems, + ); const onSelect = useOnSelect(systems, selectedRows, { endpoint: ID_API_ENDPOINTS.advisorySystems(advisoryName), diff --git a/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.test.js b/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.test.js index 266494f7d..5f974ec2f 100644 --- a/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.test.js +++ b/src/SmartComponents/AdvisorySystems/AdvisorySystemsTable.test.js @@ -31,6 +31,10 @@ const initStore = (state) => { return mockStore(state); }; +beforeEach(() => { + InventoryTable.mockClear(); +}); + const renderComponent = async (mockedStore) => { render( @@ -179,6 +183,21 @@ describe('AdvisorySystemsTable.js', () => { ); }); + it('should keep active filters empty when the page is at its default state', async () => { + await renderComponent(mockState); + + expect(InventoryTable).toHaveBeenCalledWith( + expect.objectContaining({ + activeFiltersConfig: { + deleteTitle: 'Clear filters', + filters: [], + onDelete: expect.any(Function), + }, + }), + {}, + ); + }); + it('should provide activeFilters config', async () => { const filteredState = { ...mockState, @@ -194,7 +213,7 @@ describe('AdvisorySystemsTable.js', () => { expect(InventoryTable).toHaveBeenCalledWith( expect.objectContaining({ activeFiltersConfig: { - deleteTitle: 'Reset filters', + deleteTitle: 'Clear filters', filters: [ { category: 'Status', diff --git a/src/SmartComponents/PackageSystems/PackageSystems.js b/src/SmartComponents/PackageSystems/PackageSystems.js index 5df63a000..f13fec26f 100644 --- a/src/SmartComponents/PackageSystems/PackageSystems.js +++ b/src/SmartComponents/PackageSystems/PackageSystems.js @@ -26,10 +26,10 @@ import { fetchPackageSystems, fetchPackageVersions, } from '../../Utilities/api/api'; -import { remediationIdentifiers } from '../../Utilities/constants'; +import { pageDefaultFilters, remediationIdentifiers } from '../../Utilities/constants'; import { arrayFromObj, - buildFilterChips, + buildActiveFilterConfig, decodeQueryparams, filterRemediatablePackageSystems, persistantParams, @@ -91,7 +91,11 @@ const PackageSystems = ({ packageName }) => { (newColumns) => setAppliedColumns(newColumns), ); - const [deleteFilters] = useRemoveFilter({ ...filter, search }, apply); + const [deleteFilters] = useRemoveFilter( + { ...filter, search }, + apply, + pageDefaultFilters.packageSystems, + ); const filterConfig = { items: [ @@ -107,15 +111,15 @@ const PackageSystems = ({ packageName }) => { }; const activeFiltersConfig = useMemo( - () => ({ - filters: buildFilterChips( + () => + buildActiveFilterConfig( filter, search, + deleteFilters, intl.formatMessage(messages.labelsFiltersSystemsSearchTitle), + pageDefaultFilters.packageSystems, ), - onDelete: deleteFilters, - }), - [filter, search], + [deleteFilters, filter, search], ); const constructFilename = (system) => `${system.available_evra}`; diff --git a/src/SmartComponents/PackageSystems/PackageSystems.test.js b/src/SmartComponents/PackageSystems/PackageSystems.test.js index ead3c75ab..c7228ded0 100644 --- a/src/SmartComponents/PackageSystems/PackageSystems.test.js +++ b/src/SmartComponents/PackageSystems/PackageSystems.test.js @@ -3,7 +3,8 @@ import { systemRows } from '../../Utilities/RawDataForTesting'; import { initMocks } from '../../Utilities/unitTestingUtilities.js'; import PackageSystems from './PackageSystems'; import { ComponentWithContext } from '../../Utilities/TestingUtilities.js'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { InventoryTable } from '@redhat-cloud-services/frontend-components/Inventory'; initMocks(); @@ -61,26 +62,82 @@ const mockState = { }, }; -const initStore = () => { +const initStore = (state = mockState) => { const mockStore = configureStore([]); - return mockStore(mockState); + return mockStore(state); }; -const store = initStore(mockState); - -beforeEach(() => { +const renderComponent = async (state = mockState) => { render( - + , ); + + await waitFor(() => { + expect(screen.getByTestId('inventory-mock-component')).toBeVisible(); + }); +}; + +beforeEach(() => { + InventoryTable.mockClear(); }); // TODO: find a meaningful way of testing InventoryTable fed module describe('PackageSystems.js', () => { - it('Should render inventory table', () => { + it('Should render inventory table', async () => { + await renderComponent(); expect(screen.getByTestId('inventory-mock-component')).toBeVisible(); }); + + it('should keep active filters empty when there are no non-default filters', async () => { + await renderComponent(); + + expect(InventoryTable).toHaveBeenCalledWith( + expect.objectContaining({ + activeFiltersConfig: { + deleteTitle: 'Clear filters', + filters: [], + onDelete: expect.any(Function), + }, + }), + {}, + ); + }); + + it('should provide a clear filters action when filters are active', async () => { + await renderComponent({ + ...mockState, + PackageSystemsStore: { + queryParams: { + filter: { status: ['Applicable'] }, + }, + }, + }); + + expect(InventoryTable).toHaveBeenCalledWith( + expect.objectContaining({ + activeFiltersConfig: { + deleteTitle: 'Clear filters', + filters: [ + { + category: 'Status', + chips: [ + { + id: 'status', + name: 'Applicable', + value: 'Applicable', + }, + ], + id: 'status', + }, + ], + onDelete: expect.any(Function), + }, + }), + {}, + ); + }); // it('Should dispatch change package systems params action once only', () => { // const dispatchedActions = store.getActions(); // expect(dispatchedActions.filter(item => item.type === 'CHANGE_PACKAGE_SYSTEMS_PARAMS')).toHaveLength(1); diff --git a/src/SmartComponents/Packages/Packages.js b/src/SmartComponents/Packages/Packages.js index 7bb7bdcfd..e3a90ca30 100644 --- a/src/SmartComponents/Packages/Packages.js +++ b/src/SmartComponents/Packages/Packages.js @@ -9,15 +9,10 @@ import TableView from '../../PresentationalComponents/TableView/TableView'; import { packagesColumns } from '../../PresentationalComponents/TableView/TableViewAssets'; import { changePackagesListParams, fetchPackagesAction } from '../../store/Actions/Actions'; import { exportPackagesCSV, exportPackagesJSON } from '../../Utilities/api/api'; -import { packagesListDefaultFilters } from '../../Utilities/constants'; +import { pageDefaultFilters } from '../../Utilities/constants'; import { createPackagesRows } from '../../Utilities/DataMappers'; import { createSortBy, decodeQueryparams, encodeURLParams } from '../../Utilities/Helpers'; -import { - useOnExport, - usePerPageSelect, - useSetPage, - useSortColumn, -} from '../../Utilities/hooks'; +import { useOnExport, usePerPageSelect, useSetPage, useSortColumn } from '../../Utilities/hooks'; import { intl } from '../../Utilities/IntlProvider'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import { useSearchParams } from 'react-router-dom'; @@ -98,7 +93,7 @@ const Packages = () => { remediationButtonOUIA='toolbar-remediation-button' tableOUIA='package-details-table' paginationOUIA='package-details-pagination' - defaultFilters={packagesListDefaultFilters} + defaultFilters={pageDefaultFilters.packages} searchChipLabel={intl.formatMessage(messages.labelsFiltersPackagesSearchTitle)} hasColumnManagement /> diff --git a/src/SmartComponents/SystemAdvisories/SystemAdvisories.js b/src/SmartComponents/SystemAdvisories/SystemAdvisories.js index 416238d5d..0aa96feb0 100644 --- a/src/SmartComponents/SystemAdvisories/SystemAdvisories.js +++ b/src/SmartComponents/SystemAdvisories/SystemAdvisories.js @@ -18,7 +18,7 @@ import { selectSystemAdvisoryRow, } from '../../store/Actions/Actions'; import { exportSystemAdvisoriesCSV, exportSystemAdvisoriesJSON } from '../../Utilities/api/api'; -import { remediationIdentifiers } from '../../Utilities/constants'; +import { pageDefaultFilters, remediationIdentifiers } from '../../Utilities/constants'; import { createSystemAdvisoriesRows } from '../../Utilities/DataMappers'; import { arrayFromObj, @@ -167,6 +167,7 @@ const SystemAdvisories = ({ handleNoSystemData, inventoryId, shouldRefresh }) => ], }} errorState={errorState} + defaultFilters={pageDefaultFilters.systemAdvisories} searchChipLabel={intl.formatMessage(messages.labelsFiltersSearchAdvisoriesTitle)} hasColumnManagement /> diff --git a/src/SmartComponents/SystemPackages/SystemPackages.js b/src/SmartComponents/SystemPackages/SystemPackages.js index c7de221ad..ce9e2b5fe 100644 --- a/src/SmartComponents/SystemPackages/SystemPackages.js +++ b/src/SmartComponents/SystemPackages/SystemPackages.js @@ -15,7 +15,7 @@ import { selectSystemPackagesRow, } from '../../store/Actions/Actions'; import { exportSystemPackagesCSV, exportSystemPackagesJSON } from '../../Utilities/api/api'; -import { remediationIdentifiers, systemPackagesDefaultFilters } from '../../Utilities/constants'; +import { pageDefaultFilters, remediationIdentifiers } from '../../Utilities/constants'; import { createSystemPackagesRows } from '../../Utilities/DataMappers'; import { arrayFromObj, createSortBy, remediationProvider } from '../../Utilities/Helpers'; import { @@ -134,7 +134,7 @@ const SystemPackages = ({ handleNoSystemData, inventoryId, shouldRefresh }) => { statusFilter(apply, queryParams.filter), ], }} - defaultFilters={systemPackagesDefaultFilters} + defaultFilters={pageDefaultFilters.systemPackages} remediationButtonOUIA='toolbar-remediation-button' tableOUIA='system-packages-table' paginationOUIA='system-packages-pagination' diff --git a/src/SmartComponents/Systems/SystemTable.test.js b/src/SmartComponents/Systems/SystemTable.test.js index 3b4803f73..e76ad5880 100644 --- a/src/SmartComponents/Systems/SystemTable.test.js +++ b/src/SmartComponents/Systems/SystemTable.test.js @@ -2,9 +2,17 @@ import configureStore from 'redux-mock-store'; import { systemRows } from '../../Utilities/RawDataForTesting'; import { initMocks } from '../../Utilities/unitTestingUtilities'; import Systems from './SystemsTable'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { ComponentWithContext } from '../../Utilities/TestingUtilities'; import { InventoryTable } from '@redhat-cloud-services/frontend-components/Inventory'; +import { fetchSystems } from '../../Utilities/api/api'; + +jest.mock('../../Utilities/api/api', () => ({ + exportSystemsCSV: jest.fn(), + exportSystemsJSON: jest.fn(), + fetchSystems: jest.fn(), +})); + initMocks(); const mockState = { @@ -27,10 +35,27 @@ const initStore = (state) => { return mockStore(state); }; -const renderComponent = async (mockedStore) => { +beforeEach(() => { + InventoryTable.mockClear(); + fetchSystems.mockReset(); + fetchSystems.mockResolvedValue({ + data: [], + meta: { + total_items: 0, + }, + }); +}); + +const renderComponent = async (mockedStore, props = {}) => { render( - + , ); @@ -198,8 +223,59 @@ describe('SystemsTable', () => { ); }); - it('should provide activeFilters config', async () => { - await renderComponent(mockState); + it('should keep default systems filters visible while hiding reset at baseline', async () => { + const filteredState = { + ...mockState, + SystemsStore: { + queryParams: { + filter: { stale: [true, false] }, + }, + }, + }; + + await renderComponent(filteredState); + expect(InventoryTable).toHaveBeenCalledWith( + expect.objectContaining({ + activeFiltersConfig: { + deleteTitle: 'Reset filters', + filters: [ + { + category: 'Status', + chips: [ + { + id: true, + name: 'Stale', + value: true, + }, + { + id: false, + name: 'Fresh', + value: false, + }, + ], + id: 'stale', + }, + ], + onDelete: expect.any(Function), + showDeleteButton: false, + }, + }), + {}, + ); + }); + + it('should show reset only when at least one filter differs from defaults', async () => { + const filteredState = { + ...mockState, + SystemsStore: { + queryParams: { + filter: { packages_updatable: 'eq:0', stale: [true, false] }, + }, + }, + }; + + await renderComponent(filteredState); + expect(InventoryTable).toHaveBeenCalledWith( expect.objectContaining({ activeFiltersConfig: { @@ -216,14 +292,123 @@ describe('SystemsTable', () => { ], id: 'packages_updatable', }, + { + category: 'Status', + chips: [ + { + id: true, + name: 'Stale', + value: true, + }, + { + id: false, + name: 'Fresh', + value: false, + }, + ], + id: 'stale', + }, ], onDelete: expect.any(Function), + showDeleteButton: true, }, }), {}, ); }); + it('should show reset when inventory-based operating system filters are active', async () => { + const filteredState = { + ...mockState, + SystemsStore: { + queryParams: { + filter: { os: ['RHEL 8.8'], stale: [true, false] }, + }, + }, + }; + + await renderComponent(filteredState); + + expect(InventoryTable).toHaveBeenCalledWith( + expect.objectContaining({ + activeFiltersConfig: expect.objectContaining({ + deleteTitle: 'Reset filters', + showDeleteButton: true, + }), + customFilters: expect.objectContaining({ + filters: [ + { + osFilter: { + 'RHEL-8': { + 'RHEL-8-8.8': true, + }, + }, + }, + ], + }), + }), + {}, + ); + }); + + it('should show reset before an inventory-backed fetch resolves', async () => { + let resolveFetch; + fetchSystems.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + await renderComponent(mockState); + + const inventoryProps = InventoryTable.mock.calls[InventoryTable.mock.calls.length - 1][0]; + let request; + + act(() => { + request = inventoryProps.getEntities([], { + orderBy: 'display_name', + orderDirection: 'ASC', + page: 1, + per_page: 20, + patchParams: { + filter: { stale: [true, false] }, + selectedTags: [], + systemProfile: {}, + }, + filters: { + osFilter: { + 'RHEL-8': { + 'RHEL-8-8.8': true, + }, + }, + }, + }); + }); + + await waitFor(() => + expect(InventoryTable).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeFiltersConfig: expect.objectContaining({ + deleteTitle: 'Reset filters', + showDeleteButton: true, + }), + }), + {}, + ), + ); + + await act(async () => { + resolveFetch({ + data: [], + meta: { + total_items: 0, + }, + }); + await request; + }); + }); + it('should provide bulkSelect config', async () => { await renderComponent(mockState); expect(InventoryTable).toHaveBeenCalledWith( diff --git a/src/SmartComponents/Systems/SystemsMainContent.js b/src/SmartComponents/Systems/SystemsMainContent.js index 5b01a164c..560c91c70 100644 --- a/src/SmartComponents/Systems/SystemsMainContent.js +++ b/src/SmartComponents/Systems/SystemsMainContent.js @@ -56,7 +56,7 @@ const SystemsMainContent = () => { apply={apply} setSearchParams={setSearchParams} activateRemediationModal={activateRemediationModal} - decodedParams={decodeQueryparams} + decodedParams={decodedParams} /> diff --git a/src/SmartComponents/Systems/SystemsMainContent.test.js b/src/SmartComponents/Systems/SystemsMainContent.test.js index 222be9821..799859670 100644 --- a/src/SmartComponents/Systems/SystemsMainContent.test.js +++ b/src/SmartComponents/Systems/SystemsMainContent.test.js @@ -1,6 +1,7 @@ import configureStore from 'redux-mock-store'; import { initMocks } from '../../Utilities/unitTestingUtilities'; import Systems from './SystemsMainContent'; +import SystemsTable from './SystemsTable'; import { render, screen, waitFor } from '@testing-library/react'; import { ComponentWithContext } from '../../Utilities/TestingUtilities'; import '@testing-library/jest-dom'; @@ -75,9 +76,9 @@ const initStore = (state) => { return mockStore(state); }; -const renderComponent = async (mockedStore) => { +const renderComponent = async (mockedStore, renderOptions = {}) => { render( - + , ); @@ -85,6 +86,10 @@ const renderComponent = async (mockedStore) => { const user = userEvent.setup(); describe('SystemsTable', () => { + beforeEach(() => { + SystemsTable.mockClear(); + }); + it('Should display systems table when there are no errors', async () => { renderComponent(mockState); expect(screen.getByTestId('systems-table-mock')).toBeVisible(); @@ -137,4 +142,19 @@ describe('SystemsTable', () => { await user.click(screen.getByTestId('active-remediation-modal')); expect(screen.getByTestId('remediation-wizard-mock')).toBeVisible(); }); + + it('should pass parsed decoded params into SystemsTable', async () => { + renderComponent(mockState, { initialEntries: ['/?search=test-search'] }); + + await waitFor(() => { + expect(SystemsTable).toHaveBeenCalledWith( + expect.objectContaining({ + decodedParams: { + search: 'test-search', + }, + }), + {}, + ); + }); + }); }); diff --git a/src/SmartComponents/Systems/SystemsTable.js b/src/SmartComponents/Systems/SystemsTable.js index 76f3e82d0..ed53f3c21 100644 --- a/src/SmartComponents/Systems/SystemsTable.js +++ b/src/SmartComponents/Systems/SystemsTable.js @@ -1,6 +1,7 @@ import React, { Fragment, useRef, useState } from 'react'; import { TableVariant } from '@patternfly/react-table'; import { InventoryTable } from '@redhat-cloud-services/frontend-components/Inventory'; +import isDeepEqualReact from 'fast-deep-equal/react'; import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux'; import { defaultReducers } from '../../store'; import { changeSystemsMetadata, changeTags, systemSelectAction } from '../../store/Actions/Actions'; @@ -9,8 +10,8 @@ import { modifyInventory, } from '../../store/Reducers/InventoryEntitiesReducer'; import { exportSystemsCSV, exportSystemsJSON, fetchSystems } from '../../Utilities/api/api'; -import { systemsListDefaultFilters, NO_ADVISORIES_TEXT } from '../../Utilities/constants'; -import { arrayFromObj, persistantParams } from '../../Utilities/Helpers'; +import { pageDefaultFilters, NO_ADVISORIES_TEXT } from '../../Utilities/constants'; +import { arrayFromObj, hasActiveInventoryFilters, persistantParams } from '../../Utilities/Helpers'; import { useBulkSelectConfig, useGetEntities, @@ -31,6 +32,12 @@ import { import { combineReducers } from 'redux'; import propTypes from 'prop-types'; +const buildInventorySnapshot = (filters = {}, selectedTags = [], systemProfile = {}) => ({ + filters, + selectedTags: selectedTags || [], + systemProfile: systemProfile || {}, +}); + const SystemsTable = ({ apply, setSearchParams, activateRemediationModal, decodedParams }) => { const store = useStore(); const inventory = useRef(null); @@ -76,6 +83,13 @@ const SystemsTable = ({ apply, setSearchParams, activateRemediationModal, decode }, {}), }, ]; + const [inventorySnapshot, setInventorySnapshot] = useState(() => + buildInventorySnapshot( + operatingSystemFilter ? { osFilter: osFilter?.[0]?.osFilter || {} } : {}, + selectedTags, + systemProfile, + ), + ); const applyMetadata = (metadata) => { dispatch(changeSystemsMetadata(metadata)); @@ -85,10 +99,33 @@ const SystemsTable = ({ apply, setSearchParams, activateRemediationModal, decode dispatch(changeTags(tags)); }; - const [deleteFilters] = useRemoveFilter({ search, ...filter }, apply, systemsListDefaultFilters); + const [deleteFilters] = useRemoveFilter({ search, ...filter }, apply, pageDefaultFilters.systems); const filterConfig = buildFilterConfig(search, filter, apply); + const applyInventorySnapshot = React.useCallback((nextSnapshot) => { + setInventorySnapshot((previousSnapshot) => + isDeepEqualReact(previousSnapshot, nextSnapshot) ? previousSnapshot : nextSnapshot, + ); + }, []); + const hasInventoryFilterDeviation = + hasActiveInventoryFilters(inventorySnapshot.filters) || + Boolean(inventorySnapshot.selectedTags?.length) || + Boolean( + inventorySnapshot.systemProfile && Object.keys(inventorySnapshot.systemProfile).length > 0, + ); + + const activeFiltersConfig = React.useMemo(() => { + const config = buildActiveFiltersConfig( + filter, + search, + deleteFilters, + pageDefaultFilters.systems, + ); - const activeFiltersConfig = buildActiveFiltersConfig(filter, search, deleteFilters); + return { + ...config, + showDeleteButton: config.showDeleteButton || hasInventoryFilterDeviation, + }; + }, [deleteFilters, filter, hasInventoryFilterDeviation, search]); const onSelect = useOnSelect(systems, selectedRows, { endpoint: ID_API_ENDPOINTS.systems, @@ -114,6 +151,7 @@ const SystemsTable = ({ apply, setSearchParams, activateRemediationModal, decode setSearchParams, applyMetadata, applyGlobalFilter, + applyInventorySnapshot, ); const remediationDataProvider = useRemediationDataProvider( @@ -216,6 +254,6 @@ SystemsTable.propTypes = { apply: propTypes.func.isRequired, setSearchParams: propTypes.func.isRequired, activateRemediationModal: propTypes.func.isRequired, - decodedParams: propTypes.func.isRequired, + decodedParams: propTypes.object.isRequired, }; export default SystemsTable; diff --git a/src/Utilities/Helpers.js b/src/Utilities/Helpers.js index 26f1c1197..1bfd9ea4a 100644 --- a/src/Utilities/Helpers.js +++ b/src/Utilities/Helpers.js @@ -17,6 +17,7 @@ import messages from '../Messages'; import AdvisoriesIcon from '../PresentationalComponents/Snippets/AdvisoriesIcon'; import { defaultCompoundSortValues, + emptyDefaultFilters, filterCategories, multiValueFilters, SEVERITY_NONE, @@ -356,6 +357,87 @@ export const decodeQueryparams = (queryString, parsers = {}) => { const getFilterStringFromApi = (value) => (value === null ? 'null' : String(value)); +const compareNormalizedValues = (left, right) => + getFilterStringFromApi(left).localeCompare(getFilterStringFromApi(right)); + +const normalizeFilterComparisonValue = (category, value) => { + if (Array.isArray(value)) { + return [...value] + .map((item) => normalizeFilterComparisonValue(category, item)) + .sort(compareNormalizedValues); + } + + if (multiValueFilters.includes(category) && typeof value === 'string') { + return value.split(',').sort(compareNormalizedValues); + } + + return value; +}; + +const areFilterValuesEqual = (category, currentValue, defaultValue) => + JSON.stringify(normalizeFilterComparisonValue(category, currentValue)) === + JSON.stringify(normalizeFilterComparisonValue(category, defaultValue)); + +const sanitizeFilterState = (filters = {}) => + Object.entries(filters).reduce((activeFilters, [category, value]) => { + if (value !== undefined && value !== '' && [].concat(value).length !== 0) { + activeFilters[category] = value; + } + + return activeFilters; + }, {}); + +const hasActiveNestedFilterValue = (value) => { + if (Array.isArray(value)) { + return value.some(hasActiveNestedFilterValue); + } + + if (value && typeof value === 'object') { + return Object.values(value).some(hasActiveNestedFilterValue); + } + + return ![undefined, null, '', false].includes(value); +}; + +export const getDefaultFilterState = (defaultFilters = emptyDefaultFilters) => ({ + filter: sanitizeFilterState(defaultFilters?.filter ?? {}), + search: defaultFilters?.search ?? '', +}); + +export const hasActiveInventoryFilters = (filters = {}) => hasActiveNestedFilterValue(filters); + +export const hasDefaultFilterState = (defaultFilters = emptyDefaultFilters) => { + const { filter, search } = getDefaultFilterState(defaultFilters); + + return Object.keys(filter).length > 0 || search !== ''; +}; + +export const getActiveFilterState = ( + filters = {}, + search = '', + defaultFilters = emptyDefaultFilters, +) => { + const sanitizedFilters = sanitizeFilterState(filters); + const { filter: defaultFilter, search: defaultSearch } = getDefaultFilterState(defaultFilters); + + const activeFilters = Object.keys(sanitizedFilters).reduce((currentFilters, category) => { + const currentValue = sanitizedFilters[category]; + const defaultValue = defaultFilter[category]; + + if (!areFilterValuesEqual(category, currentValue, defaultValue)) { + currentFilters[category] = currentValue; + } + + return currentFilters; + }, {}); + + return { + filter: activeFilters, + hasDefaultFilterState: hasDefaultFilterState(defaultFilters), + search: (search ?? '') === defaultSearch ? '' : search, + }; +}; + export const buildFilterChips = (filters, search, searchChipLabel = 'Search', parsers = {}) => { let filterConfig = []; const buildChips = (filters, category) => { @@ -432,6 +514,32 @@ export const buildFilterChips = (filters, search, searchChipLabel = 'Search', pa return filterConfig; }; +export const buildActiveFilterConfig = ( + filters, + search, + deleteFilters, + searchChipLabel = 'Search', + defaultFilters = emptyDefaultFilters, +) => { + const visibleFilters = sanitizeFilterState(filters); + const visibleSearch = search ?? ''; + const { + filter: deviatedFilters, + hasDefaultFilterState, + search: deviatedSearch, + } = getActiveFilterState(filters, search, defaultFilters); + const hasDeviation = Object.keys(deviatedFilters).length > 0 || Boolean(deviatedSearch); + + return { + filters: buildFilterChips(visibleFilters, visibleSearch, searchChipLabel), + onDelete: deleteFilters, + deleteTitle: intl.formatMessage( + hasDefaultFilterState ? messages.labelsFiltersReset : messages.labelsFiltersClear, + ), + ...(hasDefaultFilterState && { showDeleteButton: hasDeviation }), + }; +}; + export const buildOsFilter = (osFilter = {}) => { const osVersions = Object.entries(osFilter).reduce( (acc, [, osGroupValues]) => [ diff --git a/src/Utilities/Helpers.test.js b/src/Utilities/Helpers.test.js index f6ce15e04..04af6b16c 100644 --- a/src/Utilities/Helpers.test.js +++ b/src/Utilities/Helpers.test.js @@ -1,10 +1,11 @@ /* eslint-disable */ import { SortByDirection } from '@patternfly/react-table'; -import { publicDateOptions, remediationIdentifiers } from '../Utilities/constants'; +import { pageDefaultFilters, publicDateOptions, remediationIdentifiers } from '../Utilities/constants'; import { addOrRemoveItemFromSet, arrayFromObj, buildApiFilters, + buildActiveFilterConfig, buildFilterChips, changeListParams, convertLimitOffset, @@ -14,6 +15,7 @@ import { encodeApiParams, encodeParams, encodeURLParams, + hasActiveInventoryFilters, getFilterValue, getLimitFromPageSize, getNewSelectedItems, @@ -311,6 +313,85 @@ describe('Helpers tests', () => { }, ); + it('buildActiveFilterConfig: should keep default filters visible while hiding reset at baseline', () => { + expect( + buildActiveFilterConfig( + { systems_applicable: ['gt:0'] }, + '', + jest.fn(), + 'Package', + pageDefaultFilters.packages, + ), + ).toEqual({ + deleteTitle: 'Reset filters', + filters: [ + { + category: 'Status', + chips: [{ id: 'gt:0', name: 'Systems with patches available', value: 'gt:0' }], + id: 'systems_applicable', + }, + ], + onDelete: expect.any(Function), + showDeleteButton: false, + }); + }); + + it('buildActiveFilterConfig: should show reset when current state differs from defaults', () => { + expect( + buildActiveFilterConfig( + { systems_applicable: ['eq:0'] }, + '', + jest.fn(), + 'Package', + pageDefaultFilters.packages, + ), + ).toEqual({ + deleteTitle: 'Reset filters', + filters: [ + { + category: 'Status', + chips: [{ id: 'eq:0', name: 'Systems up to date', value: 'eq:0' }], + id: 'systems_applicable', + }, + ], + onDelete: expect.any(Function), + showDeleteButton: true, + }); + }); + + it('buildActiveFilterConfig: should show clear filters when a page has no defaults', () => { + expect( + buildActiveFilterConfig( + { advisory_type_name: 'bugfix' }, + '', + jest.fn(), + 'Advisory', + pageDefaultFilters.advisories, + ), + ).toEqual({ + deleteTitle: 'Clear filters', + filters: [ + { + category: 'Advisory type', + chips: [{ id: 'bugfix', name: 'Bugfix', value: 'bugfix' }], + id: 'advisory_type_name', + }, + ], + onDelete: expect.any(Function), + }); + }); + + it.each` + filters | result + ${{}} | ${false} + ${{ hostGroupFilter: [], osFilter: {}, tagFilters: [] }} | ${false} + ${{ osFilter: { 'RHEL-8': { 'RHEL-8-8.8': true } } }} | ${true} + ${{ tagFilters: [{ category: 'env', values: [{ tagKey: 'stage', value: 'prod' }] }] }} | ${true} + ${{ workspaceFilter: { workspaces: [{ id: 'workspace-1', name: 'Workspace 1' }] } }} | ${true} + `('hasActiveInventoryFilters: should detect inventory filter activity', ({ filters, result }) => { + expect(hasActiveInventoryFilters(filters)).toEqual(result); + }); + it.each` oldParams | newParams | result ${{ param: 'Hey!' }} | ${{ param: 'Yo!' }} | ${{ param: 'Yo!' }} diff --git a/src/Utilities/SystemsHelpers.js b/src/Utilities/SystemsHelpers.js index c0bad8547..3fae2b1ec 100644 --- a/src/Utilities/SystemsHelpers.js +++ b/src/Utilities/SystemsHelpers.js @@ -1,7 +1,7 @@ import searchFilter from '../PresentationalComponents/Filters/SearchFilter'; import staleFilter from '../PresentationalComponents/Filters/SystemStaleFilter'; import systemsUpdatableFilter from '../PresentationalComponents/Filters/SystemsUpdatableFilter'; -import { buildFilterChips } from './Helpers'; +import { buildActiveFilterConfig } from './Helpers'; import { intl } from './IntlProvider'; import messages from '../Messages'; import { defaultCompoundSortValues } from './constants'; @@ -19,21 +19,14 @@ export const buildFilterConfig = (search, filter, apply) => ({ ], }); -export const buildActiveFiltersConfig = (filter, search, deleteFilters) => { - if (filter?.group_name?.length === 0) { - delete filter.group_name; - } - - return { - filters: buildFilterChips( - filter, - search, - intl.formatMessage(messages.labelsFiltersSystemsSearchTitle), - ), - onDelete: deleteFilters, - deleteTitle: intl.formatMessage(messages.labelsFiltersReset), - }; -}; +export const buildActiveFiltersConfig = (filter, search, deleteFilters, defaultFilters) => + buildActiveFilterConfig( + filter, + search, + deleteFilters, + intl.formatMessage(messages.labelsFiltersSystemsSearchTitle), + defaultFilters, + ); export const mergeInventoryColumns = (patchmanColumns, inventoryColumns) => patchmanColumns.map((column) => ({ diff --git a/src/Utilities/constants.js b/src/Utilities/constants.js index 624042816..dfd630940 100644 --- a/src/Utilities/constants.js +++ b/src/Utilities/constants.js @@ -57,6 +57,21 @@ export const systemsListDefaultFilters = { filter: { stale: [true, false] }, }; +export const emptyDefaultFilters = { + filter: {}, +}; + +export const pageDefaultFilters = { + advisories: emptyDefaultFilters, + advisorySystems: emptyDefaultFilters, + cves: emptyDefaultFilters, + packages: packagesListDefaultFilters, + packageSystems: emptyDefaultFilters, + systemAdvisories: emptyDefaultFilters, + systemPackages: systemPackagesDefaultFilters, + systems: systemsListDefaultFilters, +}; + export const publicDateOptions = [ { apiValue: `gt:${subtractDate(7)}`, diff --git a/src/Utilities/hooks/Hooks.js b/src/Utilities/hooks/Hooks.js index 44e5963f7..f083ba6c3 100644 --- a/src/Utilities/hooks/Hooks.js +++ b/src/Utilities/hooks/Hooks.js @@ -13,6 +13,7 @@ import { buildApiFilters, getOffsetFromPageLimit, encodeURLParams, + getDefaultFilterState, mapGlobalFilters, } from '../Helpers'; import { intl } from '../IntlProvider'; @@ -72,6 +73,9 @@ export const useSortColumn = ( }; export const useRemoveFilter = (filters, callback, defaultFilters = { filter: {} }) => { + const { filter: defaultFilterState, search: defaultSearch } = + getDefaultFilterState(defaultFilters); + const removeFilter = React.useCallback((selected, resetFilters, shouldReset) => { let newParams = { filter: {} }; selected.forEach((selectedItem) => { @@ -81,11 +85,11 @@ export const useRemoveFilter = (filters, callback, defaultFilters = { filter: {} let activeFilter = filters[categoryId]; const toRemove = chips.map((item) => item.id?.toString()); if (Array.isArray(activeFilter)) { - newParams.filter[categoryId] = activeFilter.filter( - (item) => !toRemove.includes(item.toString()), - ); + const nextValue = activeFilter.filter((item) => !toRemove.includes(item.toString())); + newParams.filter[categoryId] = + nextValue.length > 0 ? nextValue : defaultFilterState[categoryId]; } else { - newParams.filter[categoryId] = undefined; + newParams.filter[categoryId] = defaultFilterState[categoryId]; } } else if (multiValueFilters.includes(categoryId)) { const filterValues = @@ -94,14 +98,16 @@ export const useRemoveFilter = (filters, callback, defaultFilters = { filter: {} filters[categoryId])) || []; - newParams.filter[categoryId] = + const nextValue = (filterValues.length !== 1 && filterValues .filter((filterValue) => !chips.find((chip) => chip.value === filterValue)) .join(',')) || undefined; + + newParams.filter[categoryId] = nextValue ?? defaultFilterState[categoryId]; } else { - newParams.search = ''; + newParams.search = defaultSearch; } }); @@ -118,8 +124,8 @@ export const useRemoveFilter = (filters, callback, defaultFilters = { filter: {} const deleteFilters = (__, selected, shouldReset) => { const resetFilters = (currentFilters) => { - if (Object.keys(defaultFilters.filter).length > 0) { - currentFilters.filter = { ...currentFilters.filter, ...defaultFilters.filter }; + if (Object.keys(defaultFilterState).length > 0) { + currentFilters.filter = { ...currentFilters.filter, ...defaultFilterState }; } return currentFilters; @@ -215,6 +221,7 @@ export const useGetEntities = ( setSearchParams, applyMetadata, applyGlobalFilter, + applyInventorySnapshot, ) => { const { id, packageName } = config || {}; const mounted = useRef(true); @@ -228,12 +235,22 @@ export const useGetEntities = ( const sort = createSystemsSortBy(orderBy, orderDirection, packageName); const filter = buildApiFilters(patchParams.filter, filters); + const nextSelectedTags = [...activeTags, ...selectedTags]; + + applyInventorySnapshot && + applyInventorySnapshot({ + filter, + filters, + selectedTags: nextSelectedTags, + systemProfile: patchParams.systemProfile || {}, + }); + const items = await fetchApi({ page, perPage, ...patchParams, filter, - selectedTags: [...activeTags, ...selectedTags], + selectedTags: nextSelectedTags, sort, ...((id && { id }) || {}), ...((packageName && { package_name: packageName }) || {}), diff --git a/src/Utilities/hooks/Hooks.test.js b/src/Utilities/hooks/Hooks.test.js index 41321ccdc..69ea66515 100644 --- a/src/Utilities/hooks/Hooks.test.js +++ b/src/Utilities/hooks/Hooks.test.js @@ -1,6 +1,7 @@ import { SortByDirection } from '@patternfly/react-table'; import { useEntitlements, + useGetEntities, useHandleRefresh, usePagePerPage, usePerPageSelect, @@ -127,6 +128,19 @@ describe('Custom hooks tests', () => { }, ); + it('useRemoveFilter: should restore category defaults when removing a deviated default filter', () => { + const apply = jest.fn(); + const { result } = renderHook(() => + useRemoveFilter({ systems_applicable: ['eq:0'] }, apply, packagesListDefaultFilters), + ); + + result.current[0]({}, [{ id: 'systems_applicable', chips: [{ id: 'eq:0' }] }]); + + expect(apply).toHaveBeenCalledWith({ + filter: { systems_applicable: ['gt:0'] }, + }); + }); + it.each` metadata | apply | input | finalResult ${{ limit: 10, offset: 0 }} | ${jest.fn()} | ${{ page: 2, per_page: 10 }} | ${{ offset: 10 }} @@ -142,6 +156,76 @@ describe('Custom hooks tests', () => { } }); + it('useGetEntities: should publish the inventory snapshot before the fetch resolves', async () => { + let resolveFetch; + const fetchApi = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const apply = jest.fn(); + const setSearchParams = jest.fn(); + const applyMetadata = jest.fn(); + const applyGlobalFilter = jest.fn(); + const applyInventorySnapshot = jest.fn(); + const params = { + orderBy: 'display_name', + orderDirection: 'ASC', + page: 1, + per_page: 20, + patchParams: { + filter: { stale: [true, false] }, + selectedTags: ['tags=owner%2Fteam%3Dplatform'], + systemProfile: { ansible: { controller_version: 'not_nil' } }, + }, + filters: { + hostGroupFilter: ['web'], + osFilter: { + 'RHEL-8': { + 'RHEL-8-8.8': true, + }, + }, + tagFilters: [ + { + category: 'env', + values: [{ tagKey: 'stage', value: 'prod' }], + }, + ], + }, + }; + const { result } = renderHook(() => + useGetEntities( + fetchApi, + apply, + {}, + setSearchParams, + applyMetadata, + applyGlobalFilter, + applyInventorySnapshot, + ), + ); + + const request = result.current([], params); + + expect(applyInventorySnapshot).toHaveBeenCalledWith({ + filter: { + stale: [true, false], + group_name: ['web'], + os: 'RHEL 8.8', + }, + filters: params.filters, + selectedTags: ['tags=owner%2Fteam%3Dplatform', ['tags=env%2Fstage%3Dprod']], + systemProfile: { ansible: { controller_version: 'not_nil' } }, + }); + expect(applyInventorySnapshot.mock.invocationCallOrder[0]).toBeLessThan( + fetchApi.mock.invocationCallOrder[0], + ); + + resolveFetch({ data: [], meta: { total_items: 0 } }); + await request; + }); + it('useEntitlements, should return correct entitlements', async () => { const { result } = renderHook(() => useEntitlements()); const finalResult = await result.current();