diff --git a/src/AppConstants.js b/src/AppConstants.js
index d718c94b..d47a9163 100644
--- a/src/AppConstants.js
+++ b/src/AppConstants.js
@@ -247,6 +247,7 @@ export const CLUSTERS_LIST_COLUMNS = [
export const CLUSTER_NAME_CELL = 0;
export const CLUSTER_LAST_CHECKED_CELL = 6;
export const RECS_LIST_COLUMNS_KEYS = [
+ '', // reserved for expand button
'description',
'publish_date',
'tags',
diff --git a/src/Components/Common/Tables.js b/src/Components/Common/Tables.js
index 62b9d445..054d201c 100644
--- a/src/Components/Common/Tables.js
+++ b/src/Components/Common/Tables.js
@@ -182,7 +182,7 @@ export const paramParser = (search) => {
return Array.from(searchParams).reduce(
(acc, [key, value]) => ({
...acc,
- [key]: ['text', 'first'].includes(key)
+ [key]: ['text', 'first', 'rule_status', 'sort'].includes(key)
? value // just copy the full value
: value === 'true' || value === 'false'
? JSON.parse(value) // parse boolean
@@ -198,6 +198,13 @@ export const translateSortParams = (value) => ({
direction: value.startsWith('-') ? 'desc' : 'asc',
});
+export const translateSortValue = (index, indexMapping, direction) => {
+ if (!['desc', 'asc'].includes(direction)) {
+ console.error('Invalid sort parameters (is not asc nor desc)');
+ }
+ return `${direction === 'asc' ? '' : '-'}${indexMapping[index]}`;
+};
+
export const debounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
@@ -211,3 +218,23 @@ export const debounce = (value, delay) => {
return debouncedValue;
};
+
+export const updateSearchParams = (filters = {}, columnMapping) => {
+ const url = new URL(window.location.origin + window.location.pathname);
+ // separately check the sort param
+ url.searchParams.set(
+ 'sort',
+ translateSortValue(filters.sortIndex, columnMapping, filters.sortDirection)
+ );
+ // check the rest of filters
+ Object.entries(filters).forEach(([key, value]) => {
+ return (
+ key !== 'sortIndex' &&
+ key !== 'sortDirection' &&
+ key !== 'sort' &&
+ value !== '' &&
+ url.searchParams.set(key, value)
+ );
+ });
+ window.history.replaceState(null, null, url.href);
+};
diff --git a/src/Components/RecsListTable/RecsListTable.js b/src/Components/RecsListTable/RecsListTable.js
index 4f739102..98d46870 100644
--- a/src/Components/RecsListTable/RecsListTable.js
+++ b/src/Components/RecsListTable/RecsListTable.js
@@ -50,6 +50,7 @@ import {
passFilters,
paramParser,
translateSortParams,
+ updateSearchParams,
} from '../Common/Tables';
import DisableRule from '../Modals/DisableRule';
import { Delete } from '../../Utilities/Api';
@@ -72,8 +73,13 @@ const RecsListTable = ({ query }) => {
const notify = (data) => dispatch(addNotification(data));
const { search } = useLocation();
const [filterBuilding, setFilterBuilding] = useState(true);
+ // helps to distinguish the state when the API data received but not yet filtered
+ const [rowsFiltered, setRowsFiltered] = useState(false);
const updateFilters = (filters) => dispatch(updateRecsListFilters(filters));
const searchText = filters?.text || '';
+ const loadingState = isUninitialized || isFetching || !rowsFiltered;
+ const errorState = isError || (isSuccess && recs.length === 0);
+ const successState = isSuccess && recs.length > 0;
useEffect(() => {
setDisplayedRows(
@@ -89,6 +95,9 @@ const RecsListTable = ({ query }) => {
useEffect(() => {
setFilteredRows(buildFilteredRows(recs, filters));
+ if (isSuccess && !rowsFiltered) {
+ setRowsFiltered(true);
+ }
}, [data, filters]);
useEffect(() => {
@@ -96,7 +105,7 @@ const RecsListTable = ({ query }) => {
const paramsObject = paramParser(search);
if (paramsObject.sort) {
- const sortObj = translateSortParams(paramsObject.sort[0]);
+ const sortObj = translateSortParams(paramsObject.sort);
paramsObject.sortIndex = RECS_LIST_COLUMNS_KEYS.indexOf(sortObj.name);
paramsObject.sortDirection = sortObj.direction;
}
@@ -112,6 +121,12 @@ const RecsListTable = ({ query }) => {
setFilterBuilding(false);
}, []);
+ useEffect(() => {
+ if (!filterBuilding) {
+ updateSearchParams(filters, RECS_LIST_COLUMNS_KEYS);
+ }
+ }, [filters, filterBuilding]);
+
// constructs array of rows (from the initial data) checking currently applied filters
const buildFilteredRows = (allRows, filters) => {
return allRows
@@ -219,8 +234,8 @@ const RecsListTable = ({ query }) => {
const buildDisplayedRows = (rows, index, direction) => {
const sortingRows = [...rows].sort((firstItem, secondItem) => {
const d = direction === SortByDirection.asc ? 1 : -1;
- const fst = firstItem[0].rule[RECS_LIST_COLUMNS_KEYS[index - 1]];
- const snd = secondItem[0].rule[RECS_LIST_COLUMNS_KEYS[index - 1]];
+ const fst = firstItem[0].rule[RECS_LIST_COLUMNS_KEYS[index]];
+ const snd = secondItem[0].rule[RECS_LIST_COLUMNS_KEYS[index]];
if (index === 3) {
return extractCategories(fst)[0].localeCompare(
extractCategories(snd)[0]
@@ -556,18 +571,23 @@ const RecsListTable = ({ query }) => {
isCompact: true,
ouiaId: 'pager',
}}
- filterConfig={{ items: filterConfigItems }}
- activeFiltersConfig={activeFiltersConfig}
+ filterConfig={{
+ items: filterConfigItems,
+ isDisabled: loadingState || errorState,
+ }}
+ activeFiltersConfig={
+ loadingState || errorState ? undefined : activeFiltersConfig
+ }
/>
- {(isUninitialized || isFetching) && }
- {(isError || (isSuccess && recs.length === 0)) && (
+ {loadingState && }
+ {errorState && (
)}
- {!(isUninitialized || isFetching) && isSuccess && recs.length > 0 && (
+ {!loadingState && !errorState && successState && (
{
// TODO make sorting tests data independent
it('sort the data by Name', () => {
- cy.sortByCol(0);
+ cy.sortByCol(0).then(() => {
+ expect(window.location.search).to.contain('sort=description');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label=Name]')
.should('contain', '1Lorem');
- cy.sortByCol(0);
+ cy.sortByCol(0).then(() => {
+ expect(window.location.search).to.contain('sort=-description');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label=Name]')
@@ -219,12 +223,16 @@ describe('successful non-empty recommendations list table', () => {
});
it('sort the data by Modified', () => {
- cy.sortByCol(1);
+ cy.sortByCol(1).then(() => {
+ expect(window.location.search).to.contain('sort=publish_date');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label=Name]')
.should('contain', '1Lorem');
- cy.sortByCol(1);
+ cy.sortByCol(1).then(() => {
+ expect(window.location.search).to.contain('sort=-publish_date');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label=Name]')
@@ -236,12 +244,16 @@ describe('successful non-empty recommendations list table', () => {
//had to add \\ \\ to the Total risk, otherwise jQuery engine would throw an error
it('sort the data by Total Risk', () => {
- cy.sortByCol(3);
+ cy.sortByCol(3).then(() => {
+ expect(window.location.search).to.contain('sort=total_risk');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label="Total risk"]')
.should('contain', 'Moderate');
- cy.sortByCol(3);
+ cy.sortByCol(3).then(() => {
+ expect(window.location.search).to.contain('sort=-total_risk');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label="Total risk"]')
@@ -249,12 +261,18 @@ describe('successful non-empty recommendations list table', () => {
});
it('sort the data by Clusters', () => {
- cy.sortByCol(4);
+ cy.sortByCol(4).then(() => {
+ expect(window.location.search).to.contain('sort=impacted_clusters_count');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label="Clusters"]')
.should('contain', '1');
- cy.sortByCol(4);
+ cy.sortByCol(4).then(() => {
+ expect(window.location.search).to.contain(
+ 'sort=-impacted_clusters_count'
+ );
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label="Clusters"]')
@@ -262,7 +280,9 @@ describe('successful non-empty recommendations list table', () => {
});
it('include disabled rules', () => {
- cy.removeStatusFilter();
+ cy.removeStatusFilter().then(() => {
+ expect(window.location.search).to.not.contain('rule_status');
+ });
cy.getAllRows()
.should('have.length', 5)
.find('td[data-label="Name"]')
@@ -314,11 +334,14 @@ describe('successful non-empty recommendations list table', () => {
.eq(0)
.find('td[data-label="Total risk"]')
.contains('Critical');
+ expect(window.location.search).to.contain('sort=-total_risk');
});
// all tables must preserve original ordering
it('can sort by category', () => {
- cy.sortByCol(2);
+ cy.sortByCol(2).then(() => {
+ expect(window.location.search).to.contain('sort=tags');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label=Name]')
@@ -327,7 +350,9 @@ describe('successful non-empty recommendations list table', () => {
.eq(0)
.find('td[data-label=Category]')
.should('contain', 'Performance');
- cy.sortByCol(2);
+ cy.sortByCol(2).then(() => {
+ expect(window.location.search).to.contain('sort=-tags');
+ });
cy.getAllRows()
.eq(0)
.find('td[data-label=Category]')
@@ -341,29 +366,58 @@ describe('successful non-empty recommendations list table', () => {
cy.wrap(element);
element[0].click();
});
- cy.get('.pf-c-select__menu').find('label > input').eq(1).check();
+ cy.get('.pf-c-select__menu')
+ .find('label > input')
+ .eq(1)
+ .check()
+ .then(() => {
+ expect(window.location.search).to.contain('impacting=true%2Cfalse');
+ });
cy.get('.pf-c-chip-group__list-item').contains('1 or more');
cy.get(RECS_LIST_TABLE).find('button[class=pf-c-dropdown__toggle]').click();
cy.get(FILTERS_DROPDOWN).contains('Status').click();
cy.get(FILTER_TOGGLE).click({ force: true });
- cy.get('button[class=pf-c-select__menu-item]').contains('All').click();
+ cy.get('button[class=pf-c-select__menu-item]')
+ .contains('All')
+ .click()
+ .then(() => {
+ expect(window.location.search).to.contain('rule_status=all');
+ });
cy.get('.pf-c-chip-group__list-item').contains('1 or more');
});
it('clears text input after Name filter chip removal', () => {
- cy.get(TOOLBAR_FILTER).find('.pf-c-form-control').type('cc');
+ cy.get(TOOLBAR_FILTER)
+ .find('.pf-c-form-control')
+ .type('cc')
+ .then(() => {
+ expect(window.location.search).to.contain('text=cc');
+ });
// remove the chip
- getChipGroup('Name').find('button').click();
+ getChipGroup('Name')
+ .find('button')
+ .click()
+ .then(() => {
+ expect(window.location.search).to.not.contain('text=');
+ });
cy.get(TOOLBAR_FILTER).find('.pf-c-form-control').should('be.empty');
});
it('clears text input after resetting all filters', () => {
cy.get(TOOLBAR_FILTER).find('.pf-c-form-control').type('cc');
// reset all filters
- cy.get(TOOLBAR).find('button').contains('Reset filters').click();
+ cy.get(TOOLBAR)
+ .find('button')
+ .contains('Reset filters')
+ .click()
+ .then(() => {
+ expect(window.location.search).to.not.contain('text=');
+ });
cy.get(TOOLBAR_FILTER).find('.pf-c-form-control').should('be.empty');
});
+
+ // TODO: test search parameters with likelihood, impact, category filters
});
describe('empty recommendations list table', () => {