feature(redux-devtools-app/ui): add search feature to state

This commit is contained in:
Avinash Thakur 2022-12-29 01:45:14 +05:30
parent 40b024a308
commit 9983459703
No known key found for this signature in database
GPG Key ID: AA139BE260D7527B
14 changed files with 333 additions and 20 deletions

View File

@ -8,6 +8,7 @@ import {
socket,
theme,
StoreState,
stateFilter,
} from '@redux-devtools/app';
import instances from './instancesReducer';
import type { WindowStoreAction } from './windowStore';
@ -22,6 +23,7 @@ const rootReducer: Reducer<StoreState, WindowStoreAction> =
section,
theme,
connection,
stateFilter,
});
export default rootReducer;

View File

@ -56,6 +56,7 @@
"javascript-stringify": "^2.1.0",
"jsan": "^3.1.14",
"jsondiffpatch": "^0.4.1",
"jsonpath-plus": "github:80avin/JSONPath#3f59301410bad3a15e4e0f5160353d082db97faa",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",

View File

@ -25,6 +25,7 @@ import {
GET_REPORT_SUCCESS,
ERROR,
SET_PERSIST,
SET_STATE_FILTER,
} from '../constants/actionTypes';
import {
AUTH_ERROR,
@ -48,6 +49,7 @@ import { MonitorStateMonitorState } from '../reducers/monitor';
import { LiftedAction } from '@redux-devtools/core';
import { Data } from '../reducers/reports';
import { LiftedState } from '@redux-devtools/core';
import { StateFilterState } from '../reducers/stateFilter';
let monitorReducer: (
monitorProps: unknown,
@ -82,6 +84,20 @@ export function changeTheme(data: ChangeThemeData): ChangeThemeAction {
return { type: CHANGE_THEME, ...data.formData };
}
export interface SetStateFilterAction {
readonly type: typeof SET_STATE_FILTER;
stateFilter: Partial<StateFilterState>;
}
export function setStateFilter(
stateFilter: SetStateFilterAction['stateFilter']
): SetStateFilterAction {
return {
type: SET_STATE_FILTER,
stateFilter,
};
}
export interface InitMonitorAction {
type: '@@INIT_MONITOR';
newMonitorState: unknown;
@ -568,6 +584,7 @@ interface ReduxPersistRehydrateAction {
export type StoreActionWithoutUpdateStateOrLiftedAction =
| ChangeSectionAction
| ChangeThemeAction
| SetStateFilterAction
| MonitorActionAction
| SelectInstanceAction
| SelectMonitorAction

View File

@ -17,6 +17,7 @@ export const TOGGLE_DISPATCHER = 'devTools/TOGGLE_DISPATCHER';
export const EXPORT = 'devTools/EXPORT';
export const SHOW_NOTIFICATION = 'devTools/SHOW_NOTIFICATION';
export const CLEAR_NOTIFICATION = 'devTools/CLEAR_NOTIFICATION';
export const SET_STATE_FILTER = 'devTools/SET_STATE_FILTER';
export const UPDATE_REPORTS = 'reports/UPDATE';
export const GET_REPORT_REQUEST = 'reports/GET_REPORT_REQUEST';

View File

@ -8,12 +8,14 @@ import {
DiffTab,
} from '@redux-devtools/inspector-monitor';
import { Action } from 'redux';
import { selectMonitorTab } from '../../../actions';
import { selectMonitorTab, setStateFilter } from '../../../actions';
import RawTab from './RawTab';
import ChartTab from './ChartTab';
import VisualDiffTab from './VisualDiffTab';
import { StoreState } from '../../../reducers';
import { Delta } from 'jsondiffpatch';
import { filter } from '../../../utils/searchUtils';
import { StateFilterValue } from '@redux-devtools/ui/lib/types/StateFilter/StateFilter';
type StateProps = ReturnType<typeof mapStateToProps>;
type DispatchProps = ResolveThunks<typeof actionCreators>;
@ -35,17 +37,35 @@ class SubTabs extends Component<Props> {
}
}
selector = () => {
switch (this.props.parentTab) {
case 'Action':
return { data: this.props.action };
case 'Diff':
return { data: this.props.delta };
default:
return { data: this.props.nextState };
}
filteredData = () => {
const [data, _error] = filter(this.props.nextState as object, this.props.stateFilter);
return data;
};
treeSelector = (): Props => {
const props = {
...this.props,
};
if (this.props.nextState) props.nextState = this.filteredData();
return props;
};
selectorCreator =
(parentTab: Props['parentTab'], selected: Props['selected']) => () => {
if (selected === 'Tree')
// FIXME change to (this.props.nextState ? { nextState: data } : {})
return this.treeSelector();
switch (parentTab) {
case 'Action':
return { data: this.props.action };
case 'Diff':
return { data: this.props.delta };
default:
return { data: this.filteredData() };
}
// {a: 1, b: {c: 2}, e: 5, c: {d: 1}, d: 2}
};
updateTabs(props: Props) {
const parentTab = props.parentTab;
@ -54,12 +74,17 @@ class SubTabs extends Component<Props> {
{
name: 'Tree',
component: DiffTab,
selector: () => this.props,
selector: this.selectorCreator(
this.props.parentTab,
'Tree'
) as () => Props,
},
{
name: 'Raw',
component: VisualDiffTab,
selector: this.selector as () => { data?: Delta },
selector: this.selectorCreator(this.props.parentTab, 'Raw') as () => {
data?: Delta;
},
},
];
return;
@ -69,21 +94,37 @@ class SubTabs extends Component<Props> {
{
name: 'Tree',
component: parentTab === 'Action' ? ActionTab : StateTab,
selector: () => this.props,
selector: this.selectorCreator(
this.props.parentTab,
'Tree'
) as () => Props,
},
{
name: 'Chart',
component: ChartTab,
selector: this.selector,
selector: this.selectorCreator(this.props.parentTab, 'Chart') as () => {
data: unknown;
},
},
{
name: 'Raw',
component: RawTab,
selector: this.selector,
selector: this.selectorCreator(this.props.parentTab, 'Raw') as () => {
data: Delta;
},
},
];
}
setFilter = (value: StateFilterValue) => {
this.setState({
stateFilter: {
isJsonPath: value.isJsonPath,
searchString: value.searchString,
},
});
};
render() {
let selected = this.props.selected;
if (selected === 'Chart' && this.props.parentTab === 'Diff')
@ -94,6 +135,8 @@ class SubTabs extends Component<Props> {
tabs={this.tabs! as any}
selected={selected || 'Tree'}
onClick={this.props.selectMonitorTab}
setFilter={this.props.setStateFilter}
stateFilterValue={this.props.stateFilter}
/>
);
}
@ -102,10 +145,12 @@ class SubTabs extends Component<Props> {
const mapStateToProps = (state: StoreState) => ({
parentTab: state.monitor.monitorState!.tabName,
selected: state.monitor.monitorState!.subTabName,
stateFilter: state.stateFilter,
});
const actionCreators = {
selectMonitorTab,
setStateFilter,
};
export default connect(mapStateToProps, actionCreators)(SubTabs);

View File

@ -65,5 +65,6 @@ export * from './reducers/reports';
export * from './reducers/section';
export * from './reducers/socket';
export * from './reducers/theme';
export * from './reducers/stateFilter';
export * from './utils/monitorActions';
export * from './utils/stringifyJSON';

View File

@ -7,6 +7,7 @@ import { notification, NotificationState } from './notification';
import { instances, InstancesState } from './instances';
import { reports, ReportsState } from './reports';
import { theme, ThemeState } from './theme';
import { stateFilter, StateFilterState } from './stateFilter';
import { StoreAction } from '../actions';
export interface StoreState {
@ -18,6 +19,7 @@ export interface StoreState {
readonly instances: InstancesState;
readonly reports: ReportsState;
readonly notification: NotificationState;
readonly stateFilter: StateFilterState;
}
export const rootReducer = combineReducers<StoreState, StoreAction>({
@ -29,4 +31,5 @@ export const rootReducer = combineReducers<StoreState, StoreAction>({
instances,
reports,
notification,
stateFilter,
});

View File

@ -0,0 +1,18 @@
import { StateFilterValue } from '@redux-devtools/ui/lib/types/StateFilter/StateFilter';
import { StoreAction } from '../actions';
import { SET_STATE_FILTER } from '../constants/actionTypes';
export type StateFilterState = StateFilterValue;
export function stateFilter(
state: StateFilterState = {
isJsonPath: false,
searchString: '',
},
action: StoreAction
): StateFilterState {
if (action.type === SET_STATE_FILTER) {
return { ...state, ...action.stateFilter };
}
return state;
}

View File

@ -0,0 +1,78 @@
import { StateFilterValue } from '@redux-devtools/ui/lib/types/StateFilter/StateFilter';
import { JSONPath } from 'jsonpath-plus';
import _ from 'lodash';
type Path = string[];
const _filterByPaths = (obj: any, paths: Path[]) => {
if (typeof obj !== 'object' || obj === null) return obj;
if (paths.length === 1 && !paths[0].length) return obj;
// groupBy top level key and,
// remove that key from grouped path values
// [['a', 'b'], ['a', 'c']] => {'a': ['b', 'c']}
const groupedTopPaths = _.mapValues(_.groupBy(paths, 0), (val) =>
val.map((v) => v.slice(1))
);
const topKeys = Object.keys(groupedTopPaths);
if (Array.isArray(obj)) {
obj = obj.flatMap((_, i) =>
i in groupedTopPaths ? _filterByPaths(obj[i], groupedTopPaths[i]) : []
);
return obj;
}
if (typeof obj === 'object') obj = _.pick(obj, topKeys);
for (const k of topKeys) {
if (!(k in obj)) continue;
obj[k] = _filterByPaths(obj[k], groupedTopPaths[k]);
}
return obj;
};
const filterByPaths = (obj: any, paths: Path[]) => {
const sortedUniqPaths = _.sortBy(_.uniqBy(paths, JSON.stringify), ['length']);
// remove unnecessary depths
// [['a'], ['a', 'b']] => [['a']]
const filteredPaths = sortedUniqPaths.filter((s, i, arr) => {
if (i === 0) return true;
if (!_.isEqual(arr[i].slice(0, arr[i - 1].length), arr[i - 1])) return true;
return false;
});
return _filterByPaths(obj, filteredPaths);
};
export const filterByJsonPath = (obj: any, jsonpath: string) => {
const paths = JSONPath({
json: obj,
path: jsonpath,
resultType: 'path',
evalType: 'safe',
}).map((jp: string) => JSONPath.toPathArray(jp).slice(1)) as Path[];
if (paths.some((path: Path) => !path.length)) return obj;
const result = filterByPaths(obj, paths);
return result;
};
export type FilterType = 'jsonpath' | 'regexp-glob';
export type FilterResult = [any, Error | null];
export const filter = (
obj: any,
stateFilter: StateFilterValue
): FilterResult => {
try {
const { isJsonPath, searchString } = stateFilter;
if (!searchString) return [obj, null];
if (isJsonPath) return [filterByJsonPath(obj, searchString), null];
else return [filterByJsonPath(obj, `$..${searchString}`), null];
} catch (error) {
console.error('Filter Error', error);
return [{}, error as Error];
}
};

View File

@ -0,0 +1,61 @@
import React, {
ChangeEvent,
MouseEventHandler,
PureComponent,
ReactNode,
SyntheticEvent,
} from 'react';
import createStyledComponent from '../utils/createStyledComponent';
import styles from './styles';
export interface StateFilterValue {
searchString: string;
isJsonPath: boolean;
}
export interface StateFilterProps {
value: StateFilterValue;
onChange: (value: Partial<StateFilterValue>) => void;
}
const FormContainer = createStyledComponent(styles, 'form');
const searchPlaceholder = 'filter state...';
export class StateFilter extends PureComponent<StateFilterProps> {
handleSubmit = (e: SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
e.preventDefault();
};
handleChange = (e: ChangeEvent<HTMLInputElement>) => {
this.props.onChange({
searchString: e.currentTarget.value,
});
};
toggleJSONPath: MouseEventHandler<HTMLButtonElement> = (e) => {
this.props.onChange({
isJsonPath: !this.props.value.isJsonPath,
});
};
render(): ReactNode {
const { searchString, isJsonPath } = this.props.value;
return (
<FormContainer onSubmit={this.handleSubmit}>
<input
type="search"
onChange={this.handleChange}
value={searchString}
placeholder={searchPlaceholder}
/>
{/* <button type="reset" onClick={this.handleClear}>x</button> */}
<button
aria-pressed={isJsonPath}
title="Use JSONPath to filter state"
type="button"
onClick={this.toggleJSONPath}
>
{'{;}'}
</button>
</FormContainer>
);
}
}

View File

@ -0,0 +1,30 @@
import { css, ThemedStyledProps } from 'styled-components';
import { Theme } from '../../themes/default';
// eslint-disable-next-line @typescript-eslint/ban-types
export default ({ theme }: ThemedStyledProps<{}, Theme>) => css`
align-self: center;
height: 100%;
border-bottom: 0.5px solid;
input {
background: transparent;
border: none;
outline: none;
color: ${theme.base06};
}
input::-webkit-input-placeholder {
color: ${theme.base06};
opacity: 0.6;
}
input::-moz-placeholder {
color: ${theme.base06};
opacity: 0.6;
}
button[aria-pressed='true'] {
filter: invert();
}
button:focus {
}
`;

View File

@ -1,7 +1,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import TabsHeader, { ReactButtonElement, Tab } from './TabsHeader';
import TabsHeader, { Tab } from './TabsHeader';
import { TabsContainer } from './styles/common';
import { StateFilterValue } from '../StateFilter/StateFilter';
export type Position = 'left' | 'right' | 'center';
@ -12,6 +13,8 @@ export interface TabsProps<P> {
onClick: (value: string) => void;
collapsible?: boolean;
position: Position;
setFilter?: (value: Partial<StateFilterValue>) => void;
stateFilterValue?: StateFilterValue;
}
export default class Tabs<P extends object> extends Component<TabsProps<P>> {
@ -64,6 +67,8 @@ export default class Tabs<P extends object> extends Component<TabsProps<P>> {
onClick={this.props.onClick}
selected={this.props.selected}
position={this.props.position}
setFilter={this.props.setFilter}
stateFilterValue={this.props.stateFilterValue}
/>
);

View File

@ -5,6 +5,7 @@ import { FaAngleDoubleRight } from 'react-icons/fa';
import ContextMenu from '../ContextMenu';
import createStyledComponent from '../utils/createStyledComponent';
import * as styles from './styles';
import { StateFilter, StateFilterValue } from '../StateFilter/StateFilter';
const TabsWrapper = createStyledComponent(styles);
@ -20,7 +21,7 @@ export interface Tab<P> {
selector?: (tab: this) => P;
}
interface Props<P> {
interface PropsWithoutFilter<P> {
tabs: ReactButtonElement[];
items: Tab<P>[];
main: boolean | undefined;
@ -30,6 +31,13 @@ interface Props<P> {
selected: string | undefined;
}
interface PropsWithFilter<P> extends PropsWithoutFilter<P> {
setFilter: (value: Partial<StateFilterValue>) => void;
stateFilterValue: StateFilterValue;
}
type Props<P> = PropsWithoutFilter<P> | PropsWithFilter<P>;
interface State {
visibleTabs: ReactButtonElement[];
hiddenTabs: ReactButtonElement[];
@ -221,6 +229,12 @@ export default class TabsHeader<P> extends Component<Props<P>, State> {
<FaAngleDoubleRight />
</button>
)}
{'setFilter' in this.props && this.props.setFilter ? (
<StateFilter
value={this.props.stateFilterValue}
onChange={this.props.setFilter}
/>
) : null}
</div>
{this.props.collapsible && contextMenu && (
<ContextMenu

View File

@ -834,6 +834,7 @@ importers:
jest-environment-jsdom: ^29.3.1
jsan: ^3.1.14
jsondiffpatch: ^0.4.1
jsonpath-plus: github:80avin/JSONPath#3f59301410bad3a15e4e0f5160353d082db97faa
localforage: ^1.10.0
lodash: ^4.17.21
prop-types: ^15.8.1
@ -871,6 +872,7 @@ importers:
javascript-stringify: 2.1.0
jsan: 3.1.14
jsondiffpatch: 0.4.1
jsonpath-plus: github.com/80avin/JSONPath/3f59301410bad3a15e4e0f5160353d082db97faa
localforage: 1.10.0
lodash: 4.17.21
prop-types: 15.8.1
@ -6041,6 +6043,24 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@jsep-plugin/assignment/1.2.1_jsep@1.3.8:
resolution: {integrity: sha512-gaHqbubTi29aZpVbBlECRpmdia+L5/lh2BwtIJTmtxdbecEyyX/ejAOg7eQDGNvGOUmPY7Z2Yxdy9ioyH/VJeA==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
dependencies:
jsep: 1.3.8
dev: false
/@jsep-plugin/regex/1.0.3_jsep@1.3.8:
resolution: {integrity: sha512-XfZgry4DwEZvSFtS/6Y+R48D7qJYJK6R9/yJFyUFHCIUMEEHuJ4X95TDgJp5QkmzfLYvapMPzskV5HpIDrREug==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
dependencies:
jsep: 1.3.8
dev: false
/@leichtgewicht/ip-codec/2.0.4:
resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==}
dev: true
@ -12718,7 +12738,7 @@ packages:
'@typescript-eslint/eslint-plugin': 5.47.0_ncmi6noazr3nzas7jxykisekym
'@typescript-eslint/utils': 5.47.0_lzzuuodtsqwxnvqeq4g4likcqa
eslint: 8.30.0
jest: 29.3.1_@types+node@18.11.17
jest: 29.3.1
transitivePeerDependencies:
- supports-color
- typescript
@ -15981,6 +16001,11 @@ packages:
- utf-8-validate
dev: true
/jsep/1.3.8:
resolution: {integrity: sha512-qofGylTGgYj9gZFsHuyWAN4jr35eJ66qJCK4eKDnldohuUoQFbU3iZn2zjvEbd9wOAhP9Wx5DsAAduTyE1PSWQ==}
engines: {node: '>= 10.16.0'}
dev: false
/jsesc/0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
@ -16057,6 +16082,7 @@ packages:
chalk: 2.4.2
diff-match-patch: 1.0.5
dev: false
bundledDependencies: []
/jsonfile/4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@ -16587,7 +16613,7 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
dependencies:
chalk: 4.1.1
chalk: 4.1.2
is-unicode-supported: 0.1.0
dev: false
@ -17768,7 +17794,7 @@ packages:
engines: {node: '>=10'}
dependencies:
bl: 4.1.0
chalk: 4.1.1
chalk: 4.1.2
cli-cursor: 3.1.0
cli-spinners: 2.7.0
is-interactive: 1.0.0
@ -22697,6 +22723,17 @@ packages:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
dev: true
github.com/80avin/JSONPath/3f59301410bad3a15e4e0f5160353d082db97faa:
resolution: {tarball: https://codeload.github.com/80avin/JSONPath/tar.gz/3f59301410bad3a15e4e0f5160353d082db97faa}
name: jsonpath-plus
version: 7.2.0
engines: {node: '>=12.0.0'}
dependencies:
'@jsep-plugin/assignment': 1.2.1_jsep@1.3.8
'@jsep-plugin/regex': 1.0.3_jsep@1.3.8
jsep: 1.3.8
dev: false
github.com/Methuselah96/chalk/7e66d0ff681fc10462ce327f1c4f82bfa13193e2:
resolution: {tarball: https://codeload.github.com/Methuselah96/chalk/tar.gz/7e66d0ff681fc10462ce327f1c4f82bfa13193e2}
name: chalk