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();