From 48fa206c757e28a31c2c574511d27796d3913b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Dudfield?= Date: Mon, 9 Oct 2023 12:13:09 +0200 Subject: [PATCH] frontend: redux/filterSlice: Refactor filter reducers/actions into slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved related filter functionality from lib/util into there too. There's also some tests in filterSlice.test.ts now. Signed-off-by: René Dudfield --- .../App/Notifications/List.stories.tsx | 2 +- .../components/Sidebar/Sidebar.stories.tsx | 2 +- frontend/src/components/cluster/Overview.tsx | 2 +- .../common/NamespacesAutocomplete.tsx | 2 +- .../components/common/SectionFilterHeader.tsx | 2 +- frontend/src/lib/util.ts | 96 +----------- frontend/src/redux/actions/actions.tsx | 15 -- frontend/src/redux/filterSlice.test.ts | 38 +++++ frontend/src/redux/filterSlice.ts | 145 ++++++++++++++++++ frontend/src/redux/reducers/filter.tsx | 30 ---- frontend/src/redux/reducers/reducers.tsx | 4 +- frontend/src/redux/stores/store.tsx | 2 +- 12 files changed, 196 insertions(+), 144 deletions(-) create mode 100644 frontend/src/redux/filterSlice.test.ts create mode 100644 frontend/src/redux/filterSlice.ts delete mode 100644 frontend/src/redux/reducers/filter.tsx diff --git a/frontend/src/components/App/Notifications/List.stories.tsx b/frontend/src/components/App/Notifications/List.stories.tsx index 57a4bba0f6..b534a5640b 100644 --- a/frontend/src/components/App/Notifications/List.stories.tsx +++ b/frontend/src/components/App/Notifications/List.stories.tsx @@ -3,7 +3,7 @@ import { Meta, Story } from '@storybook/react/types-6-0'; import helpers from '../../../helpers'; import { Notification } from '../../../lib/notification'; import { initialState as CONFIG_INITIAL_STATE } from '../../../redux/configSlice'; -import { INITIAL_STATE as FILTER_INITIAL_STATE } from '../../../redux/reducers/filter'; +import { initialState as FILTER_INITIAL_STATE } from '../../../redux/filterSlice'; import { INITIAL_STATE as UI_INITIAL_STATE } from '../../../redux/reducers/ui'; import { TestContext } from '../../../test'; import NotificationList from './List'; diff --git a/frontend/src/components/Sidebar/Sidebar.stories.tsx b/frontend/src/components/Sidebar/Sidebar.stories.tsx index 6e157d0ec7..0abe9e17d8 100644 --- a/frontend/src/components/Sidebar/Sidebar.stories.tsx +++ b/frontend/src/components/Sidebar/Sidebar.stories.tsx @@ -2,7 +2,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { Meta, Story } from '@storybook/react/types-6-0'; import { SnackbarProvider } from 'notistack'; import { initialState as CONFIG_INITIAL_STATE } from '../../redux/configSlice'; -import { INITIAL_STATE as FILTER_INITIAL_STATE } from '../../redux/reducers/filter'; +import { initialState as FILTER_INITIAL_STATE } from '../../redux/filterSlice'; import { INITIAL_STATE as UI_INITIAL_STATE, UIState } from '../../redux/reducers/ui'; import { TestContext } from '../../test'; import Sidebar, { DefaultSidebars, PureSidebar } from './Sidebar'; diff --git a/frontend/src/components/cluster/Overview.tsx b/frontend/src/components/cluster/Overview.tsx index cc21c967b2..c37866cc36 100644 --- a/frontend/src/components/cluster/Overview.tsx +++ b/frontend/src/components/cluster/Overview.tsx @@ -9,7 +9,7 @@ import Event, { KubeEvent } from '../../lib/k8s/event'; import Node from '../../lib/k8s/node'; import Pod from '../../lib/k8s/pod'; import { useFilterFunc } from '../../lib/util'; -import { setSearchFilter } from '../../redux/actions/actions'; +import { setSearchFilter } from '../../redux/filterSlice'; import { DateLabel, Link, StatusLabel } from '../common'; import Empty from '../common/EmptyContent'; import { PageGrid } from '../common/Resource'; diff --git a/frontend/src/components/common/NamespacesAutocomplete.tsx b/frontend/src/components/common/NamespacesAutocomplete.tsx index e2e4af4e26..ed6eb0b42b 100644 --- a/frontend/src/components/common/NamespacesAutocomplete.tsx +++ b/frontend/src/components/common/NamespacesAutocomplete.tsx @@ -12,7 +12,7 @@ import { useHistory, useLocation } from 'react-router-dom'; import helpers, { addQuery } from '../../helpers'; import { useCluster } from '../../lib/k8s'; import Namespace from '../../lib/k8s/namespace'; -import { setNamespaceFilter } from '../../redux/actions/actions'; +import { setNamespaceFilter } from '../../redux/filterSlice'; import { useTypedSelector } from '../../redux/reducers/reducers'; export interface PureNamespacesAutocompleteProps { diff --git a/frontend/src/components/common/SectionFilterHeader.tsx b/frontend/src/components/common/SectionFilterHeader.tsx index 2d204b98fa..b2b6b9b5d5 100644 --- a/frontend/src/components/common/SectionFilterHeader.tsx +++ b/frontend/src/components/common/SectionFilterHeader.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { addQuery, getFilterValueByNameFromURL } from '../../helpers'; -import { resetFilter, setNamespaceFilter, setSearchFilter } from '../../redux/actions/actions'; +import { resetFilter, setNamespaceFilter, setSearchFilter } from '../../redux/filterSlice'; import { useTypedSelector } from '../../redux/reducers/reducers'; import { NamespacesAutocomplete } from './NamespacesAutocomplete'; import SectionHeader, { SectionHeaderProps } from './SectionHeader'; diff --git a/frontend/src/lib/util.ts b/frontend/src/lib/util.ts index 1acfac8802..f4642a5f81 100644 --- a/frontend/src/lib/util.ts +++ b/frontend/src/lib/util.ts @@ -1,8 +1,8 @@ import humanizeDuration from 'humanize-duration'; -import { JSONPath } from 'jsonpath-plus'; import React from 'react'; import { matchPath, useHistory } from 'react-router'; import helpers from '../helpers'; +import { filterGeneric, filterResource } from '../redux/filterSlice'; import { useTypedSelector } from '../redux/reducers/reducers'; import store from '../redux/stores/store'; import { ApiError } from './k8s/apiProxy'; @@ -11,7 +11,8 @@ import { KubeEvent } from './k8s/event'; import Node from './k8s/node'; import { parseCpu, parseRam, unparseCpu, unparseRam } from './units'; -// @todo: these are exported to window.pluginLib. +// Exported to keep compatibility for plugins that may have used them. +export { filterGeneric, filterResource }; const humanize = humanizeDuration.humanizer(); humanize.languages['en-mini'] = { @@ -144,98 +145,11 @@ export function getResourceMetrics( return [used, capacity]; } -export interface FilterState { - namespaces: Set; - search: string; -} - -export function filterResource( - item: KubeObjectInterface | KubeEvent, - filter: FilterState, - matchCriteria?: string[] -) { - let matches: boolean = true; - - if (item.metadata.namespace && filter.namespaces.size > 0) { - matches = filter.namespaces.has(item.metadata.namespace); - } - - if (!matches) { - return false; - } - - if (filter.search) { - const filterString = filter.search.toLowerCase(); - const usedMatchCriteria = [ - item.metadata.uid.toLowerCase(), - item.metadata.namespace ? item.metadata.namespace.toLowerCase() : '', - item.metadata.name.toLowerCase(), - ...Object.keys(item.metadata.labels || {}).map(item => item.toLowerCase()), - ...Object.values(item.metadata.labels || {}).map(item => item.toLowerCase()), - ]; - - matches = !!usedMatchCriteria.find(item => item.includes(filterString)); - if (matches) { - return true; - } - - matches = filterGeneric(item, filter, matchCriteria); - } - - return matches; -} - -/** Filters a generic item based on the filter state. - * The item is considered to match if any of the matchCriteria (described as JSONPath) matches the filter.search contents. Case matching is insensitive. +/** + * @returns A filter function that can be used to filter a list of items. * - * @param item - The item to filter. - * @param filter - The filter state. * @param matchCriteria - The JSONPath criteria to match. */ -export function filterGeneric( - item: T, - filter: FilterState, - matchCriteria?: string[] -) { - if (!filter.search) { - return true; - } - - const filterString = filter.search.toLowerCase(); - const usedMatchCriteria: string[] = []; - - // Use the custom matchCriteria if any - (matchCriteria || []).forEach(jsonPath => { - let values: any[]; - try { - values = JSONPath({ path: '$' + jsonPath, json: item }); - } catch (err) { - console.debug( - `Failed to get value from JSONPath when filtering ${jsonPath} on item ${item}; skipping criteria` - ); - return; - } - - // Include matches values in the criteria - values.forEach((value: any) => { - if (typeof value === 'string' || typeof value === 'number') { - // Don't use empty string, otherwise it'll match everything - if (value !== '') { - usedMatchCriteria.push(value.toString().toLowerCase()); - } - } else if (Array.isArray(value)) { - value.forEach((elem: any) => { - if (!!elem && typeof elem === 'string') { - usedMatchCriteria.push(elem.toLowerCase()); - } - }); - } - }); - }); - - return !!usedMatchCriteria.find(item => item.includes(filterString)); -} - export function useFilterFunc< T extends { [key: string]: any } | KubeObjectInterface | KubeEvent = | KubeObjectInterface diff --git a/frontend/src/redux/actions/actions.tsx b/frontend/src/redux/actions/actions.tsx index c5edd2de43..022d173e10 100644 --- a/frontend/src/redux/actions/actions.tsx +++ b/frontend/src/redux/actions/actions.tsx @@ -8,9 +8,6 @@ import { Notification } from '../../lib/notification'; import { Route } from '../../lib/router'; import { UIState } from '../reducers/ui'; -export const FILTER_RESET = 'FILTER_RESET'; -export const FILTER_SET_NAMESPACE = 'FILTER_SET_NAMESPACE'; -export const FILTER_SET_SEARCH = 'FILTER_SET_SEARCH'; export const CLUSTER_ACTION = 'CLUSTER_ACTION'; export const CLUSTER_ACTION_UPDATE = 'CLUSTER_ACTION_UPDATE'; export const CLUSTER_ACTION_CANCEL = 'CLUSTER_ACTION_CANCEL'; @@ -98,18 +95,6 @@ export type TableColumnsProcessor = { }) => ResourceTableProps['columns']; }; -export function setNamespaceFilter(namespaces: string[]) { - return { type: FILTER_SET_NAMESPACE, namespaces: namespaces }; -} - -export function setSearchFilter(searchTerms: string) { - return { type: FILTER_SET_SEARCH, search: searchTerms }; -} - -export function resetFilter() { - return { type: FILTER_RESET }; -} - export function clusterAction( callback: CallbackAction['callback'], actionOptions: CallbackActionOptions = {} diff --git a/frontend/src/redux/filterSlice.test.ts b/frontend/src/redux/filterSlice.test.ts new file mode 100644 index 0000000000..cc74a6a83c --- /dev/null +++ b/frontend/src/redux/filterSlice.test.ts @@ -0,0 +1,38 @@ +import filterReducer, { + FilterState, + initialState, + resetFilter, + setNamespaceFilter, + setSearchFilter, +} from './filterSlice'; + +describe('filterSlice', () => { + let state: FilterState; + + beforeEach(() => { + state = initialState; + }); + + it('should handle setNamespaceFilter', () => { + const namespaces = ['default', 'kube-system']; + state = filterReducer(state, setNamespaceFilter(namespaces)); + expect(state.namespaces).toEqual(new Set(namespaces)); + }); + + it('should handle setSearchFilter', () => { + const search = 'pod'; + state = filterReducer(state, setSearchFilter(search)); + expect(state.search).toEqual(search); + }); + + it('should handle resetFilter', () => { + state = { + ...state, + namespaces: new Set(['default']), + search: 'pod', + }; + + state = filterReducer(state, resetFilter()); + expect(state).toEqual(initialState); + }); +}); diff --git a/frontend/src/redux/filterSlice.ts b/frontend/src/redux/filterSlice.ts new file mode 100644 index 0000000000..04655d8836 --- /dev/null +++ b/frontend/src/redux/filterSlice.ts @@ -0,0 +1,145 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { JSONPath } from 'jsonpath-plus'; +import { KubeObjectInterface } from '../lib/k8s/cluster'; +import { KubeEvent } from '../lib/k8s/event'; + +export interface FilterState { + /** The namespaces to filter on. */ + namespaces: Set; + /** The search string to filter on. */ + search: string; +} + +export const initialState: FilterState = { + namespaces: new Set(), + search: '', +}; + +/** + * Filters a resource based on the filter state. + * + * @param item - The item to filter. + * @param filter - The filter state. + * @param matchCriteria - The JSONPath criteria to match. + * + * @returns True if the item matches the filter, false otherwise. + */ +export function filterResource( + item: KubeObjectInterface | KubeEvent, + filter: FilterState, + matchCriteria?: string[] +) { + let matches: boolean = true; + + if (item.metadata.namespace && filter.namespaces.size > 0) { + matches = filter.namespaces.has(item.metadata.namespace); + } + + if (!matches) { + return false; + } + + if (filter.search) { + const filterString = filter.search.toLowerCase(); + const usedMatchCriteria = [ + item.metadata.uid.toLowerCase(), + item.metadata.namespace ? item.metadata.namespace.toLowerCase() : '', + item.metadata.name.toLowerCase(), + ...Object.keys(item.metadata.labels || {}).map(item => item.toLowerCase()), + ...Object.values(item.metadata.labels || {}).map(item => item.toLowerCase()), + ]; + + matches = !!usedMatchCriteria.find(item => item.includes(filterString)); + if (matches) { + return true; + } + + matches = filterGeneric(item, filter, matchCriteria); + } + + return matches; +} + +/** + * Filters a generic item based on the filter state. + * + * The item is considered to match if any of the matchCriteria (described as JSONPath) + * matches the filter.search contents. Case matching is insensitive. + * + * @param item - The item to filter. + * @param filter - The filter state. + * @param matchCriteria - The JSONPath criteria to match. + */ +export function filterGeneric( + item: T, + filter: FilterState, + matchCriteria?: string[] +) { + if (!filter.search) { + return true; + } + + const filterString = filter.search.toLowerCase(); + const usedMatchCriteria: string[] = []; + + // Use the custom matchCriteria if any + (matchCriteria || []).forEach(jsonPath => { + let values: any[]; + try { + values = JSONPath({ path: '$' + jsonPath, json: item }); + } catch (err) { + console.debug( + `Failed to get value from JSONPath when filtering ${jsonPath} on item ${item}; skipping criteria` + ); + return; + } + + // Include matches values in the criteria + values.forEach((value: any) => { + if (typeof value === 'string' || typeof value === 'number') { + // Don't use empty string, otherwise it'll match everything + if (value !== '') { + usedMatchCriteria.push(value.toString().toLowerCase()); + } + } else if (Array.isArray(value)) { + value.forEach((elem: any) => { + if (!!elem && typeof elem === 'string') { + usedMatchCriteria.push(elem.toLowerCase()); + } + }); + } + }); + }); + + return !!usedMatchCriteria.find(item => item.includes(filterString)); +} + +const filterSlice = createSlice({ + name: 'filter', + initialState, + reducers: { + /** + * Sets the namespace filter with an array of strings. + */ + setNamespaceFilter(state, action: PayloadAction) { + state.namespaces = new Set(action.payload); + }, + /** + * Sets the search filter with a string. + */ + setSearchFilter(state, action: PayloadAction) { + state.search = action.payload; + }, + /** + * Resets the filter state. + */ + resetFilter(state) { + state.namespaces = new Set(); + state.search = ''; + }, + }, +}); + +export const { setNamespaceFilter, setSearchFilter, resetFilter } = filterSlice.actions; + +export default filterSlice.reducer; diff --git a/frontend/src/redux/reducers/filter.tsx b/frontend/src/redux/reducers/filter.tsx deleted file mode 100644 index bbd98d2693..0000000000 --- a/frontend/src/redux/reducers/filter.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import _ from 'lodash'; -import { FilterState } from '../../lib/util'; -import { Action, FILTER_RESET, FILTER_SET_NAMESPACE, FILTER_SET_SEARCH } from '../actions/actions'; - -export const INITIAL_STATE: FilterState = { - namespaces: new Set(), - search: '', -}; - -function filter(filters = _.cloneDeep(INITIAL_STATE), action: Action) { - let newFilters = { ..._.cloneDeep(filters) }; - switch (action.type) { - case FILTER_SET_NAMESPACE: - newFilters.namespaces = new Set(action.namespaces); - break; - case FILTER_SET_SEARCH: - newFilters.search = action.search; - break; - case FILTER_RESET: - newFilters = { ...INITIAL_STATE }; - break; - - default: - break; - } - - return newFilters; -} - -export default filter; diff --git a/frontend/src/redux/reducers/reducers.tsx b/frontend/src/redux/reducers/reducers.tsx index a779b66834..046dd25086 100644 --- a/frontend/src/redux/reducers/reducers.tsx +++ b/frontend/src/redux/reducers/reducers.tsx @@ -4,12 +4,12 @@ import pluginsReducer from '../../plugin/pluginsSlice'; import actionButtons from '../actionButtonsSlice'; import configReducer from '../configSlice'; import detailsViewSectionsSlice from '../detailsViewSectionsSlice'; +import filterReducer from '../filterSlice'; import clusterAction from './clusterAction'; -import filter from './filter'; import uiReducer from './ui'; const reducers = combineReducers({ - filter: filter, + filter: filterReducer, ui: uiReducer, clusterAction: clusterAction, config: configReducer, diff --git a/frontend/src/redux/stores/store.tsx b/frontend/src/redux/stores/store.tsx index 5ce261b8d8..c063a98b2c 100644 --- a/frontend/src/redux/stores/store.tsx +++ b/frontend/src/redux/stores/store.tsx @@ -1,7 +1,7 @@ import { configureStore } from '@reduxjs/toolkit'; import createSagaMiddleware from 'redux-saga'; import { initialState as CONFIG_INITIAL_STATE } from '../configSlice'; -import { INITIAL_STATE as FILTER_INITIAL_STATE } from '../reducers/filter'; +import { initialState as FILTER_INITIAL_STATE } from '../filterSlice'; import reducers from '../reducers/reducers'; import { INITIAL_STATE as UI_INITIAL_STATE } from '../reducers/ui'; import rootSaga from '../sagas/sagas';