Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: redux/filterSlice: Refactor filter reducers/actions into slice #1445

Merged
merged 1 commit into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 38 additions & 0 deletions frontend/src/redux/filterSlice.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
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';

export 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;
Loading
Loading