Skip to content

Commit

Permalink
frontend: redux/filterSlice: Refactor filter reducers/actions into slice
Browse files Browse the repository at this point in the history
Moved related filter functionality from lib/util into there too.

Signed-off-by: René Dudfield <[email protected]>
  • Loading branch information
illume committed Oct 10, 2023
1 parent 41738df commit 4864d9d
Show file tree
Hide file tree
Showing 11 changed files with 158 additions and 144 deletions.
2 changes: 1 addition & 1 deletion frontend/src/components/App/Notifications/List.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Sidebar/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/cluster/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/NamespacesAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/SectionFilterHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
96 changes: 5 additions & 91 deletions frontend/src/lib/util.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'] = {
Expand Down Expand Up @@ -144,98 +145,11 @@ export function getResourceMetrics(
return [used, capacity];
}

export interface FilterState {
namespaces: Set<string>;
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<T extends { [key: string]: any } = { [key: string]: any }>(
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
Expand Down
15 changes: 0 additions & 15 deletions frontend/src/redux/actions/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {}
Expand Down
145 changes: 145 additions & 0 deletions frontend/src/redux/filterSlice.ts
Original file line number Diff line number Diff line change
@@ -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';

interface FilterState {
/** The namespaces to filter on. */
namespaces: Set<string>;
/** 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<T extends { [key: string]: any } = { [key: string]: any }>(
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<string[]>) {
state.namespaces = new Set(action.payload);
},
/**
* Sets the search filter with a string.
*/
setSearchFilter(state, action: PayloadAction<string>) {
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;
30 changes: 0 additions & 30 deletions frontend/src/redux/reducers/filter.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions frontend/src/redux/reducers/reducers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/redux/stores/store.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down

0 comments on commit 4864d9d

Please sign in to comment.