From 99834597038086a9e526f2808bfee42672cf076d Mon Sep 17 00:00:00 2001 From: Avinash Thakur Date: Thu, 29 Dec 2022 01:45:14 +0530 Subject: [PATCH] feature(redux-devtools-app/ui): add search feature to state --- extension/src/window/store/windowReducer.ts | 2 + packages/redux-devtools-app/package.json | 1 + .../redux-devtools-app/src/actions/index.ts | 17 ++++ .../src/constants/actionTypes.ts | 1 + .../monitors/InspectorWrapper/SubTabs.tsx | 75 ++++++++++++++---- packages/redux-devtools-app/src/index.tsx | 1 + .../redux-devtools-app/src/reducers/index.ts | 3 + .../src/reducers/stateFilter.ts | 18 +++++ .../src/utils/searchUtils.ts | 78 +++++++++++++++++++ .../src/StateFilter/StateFilter.tsx | 61 +++++++++++++++ .../src/StateFilter/styles/index.ts | 30 +++++++ packages/redux-devtools-ui/src/Tabs/Tabs.tsx | 7 +- .../redux-devtools-ui/src/Tabs/TabsHeader.tsx | 16 +++- pnpm-lock.yaml | 43 +++++++++- 14 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 packages/redux-devtools-app/src/reducers/stateFilter.ts create mode 100644 packages/redux-devtools-app/src/utils/searchUtils.ts create mode 100644 packages/redux-devtools-ui/src/StateFilter/StateFilter.tsx create mode 100644 packages/redux-devtools-ui/src/StateFilter/styles/index.ts diff --git a/extension/src/window/store/windowReducer.ts b/extension/src/window/store/windowReducer.ts index 9a14cd3b..c855617a 100644 --- a/extension/src/window/store/windowReducer.ts +++ b/extension/src/window/store/windowReducer.ts @@ -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 = section, theme, connection, + stateFilter, }); export default rootReducer; diff --git a/packages/redux-devtools-app/package.json b/packages/redux-devtools-app/package.json index 9cd52756..7ea655ad 100644 --- a/packages/redux-devtools-app/package.json +++ b/packages/redux-devtools-app/package.json @@ -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", diff --git a/packages/redux-devtools-app/src/actions/index.ts b/packages/redux-devtools-app/src/actions/index.ts index 5f5fe4a0..7da4bb23 100644 --- a/packages/redux-devtools-app/src/actions/index.ts +++ b/packages/redux-devtools-app/src/actions/index.ts @@ -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; +} + +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 diff --git a/packages/redux-devtools-app/src/constants/actionTypes.ts b/packages/redux-devtools-app/src/constants/actionTypes.ts index 98b1aa38..382ea38f 100644 --- a/packages/redux-devtools-app/src/constants/actionTypes.ts +++ b/packages/redux-devtools-app/src/constants/actionTypes.ts @@ -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'; diff --git a/packages/redux-devtools-app/src/containers/monitors/InspectorWrapper/SubTabs.tsx b/packages/redux-devtools-app/src/containers/monitors/InspectorWrapper/SubTabs.tsx index f6e9d424..26fedf4f 100644 --- a/packages/redux-devtools-app/src/containers/monitors/InspectorWrapper/SubTabs.tsx +++ b/packages/redux-devtools-app/src/containers/monitors/InspectorWrapper/SubTabs.tsx @@ -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; type DispatchProps = ResolveThunks; @@ -35,17 +37,35 @@ class SubTabs extends Component { } } - 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 { { 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 { { 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 { 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 { 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); diff --git a/packages/redux-devtools-app/src/index.tsx b/packages/redux-devtools-app/src/index.tsx index 42d6e026..6d26eda8 100644 --- a/packages/redux-devtools-app/src/index.tsx +++ b/packages/redux-devtools-app/src/index.tsx @@ -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'; diff --git a/packages/redux-devtools-app/src/reducers/index.ts b/packages/redux-devtools-app/src/reducers/index.ts index 210b1c49..3285025b 100644 --- a/packages/redux-devtools-app/src/reducers/index.ts +++ b/packages/redux-devtools-app/src/reducers/index.ts @@ -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({ @@ -29,4 +31,5 @@ export const rootReducer = combineReducers({ instances, reports, notification, + stateFilter, }); diff --git a/packages/redux-devtools-app/src/reducers/stateFilter.ts b/packages/redux-devtools-app/src/reducers/stateFilter.ts new file mode 100644 index 00000000..99bbd947 --- /dev/null +++ b/packages/redux-devtools-app/src/reducers/stateFilter.ts @@ -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; +} diff --git a/packages/redux-devtools-app/src/utils/searchUtils.ts b/packages/redux-devtools-app/src/utils/searchUtils.ts new file mode 100644 index 00000000..b463dd56 --- /dev/null +++ b/packages/redux-devtools-app/src/utils/searchUtils.ts @@ -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]; + } +}; diff --git a/packages/redux-devtools-ui/src/StateFilter/StateFilter.tsx b/packages/redux-devtools-ui/src/StateFilter/StateFilter.tsx new file mode 100644 index 00000000..40b15a81 --- /dev/null +++ b/packages/redux-devtools-ui/src/StateFilter/StateFilter.tsx @@ -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) => void; +} + +const FormContainer = createStyledComponent(styles, 'form'); + +const searchPlaceholder = 'filter state...'; + +export class StateFilter extends PureComponent { + handleSubmit = (e: SyntheticEvent) => { + e.preventDefault(); + }; + handleChange = (e: ChangeEvent) => { + this.props.onChange({ + searchString: e.currentTarget.value, + }); + }; + toggleJSONPath: MouseEventHandler = (e) => { + this.props.onChange({ + isJsonPath: !this.props.value.isJsonPath, + }); + }; + render(): ReactNode { + const { searchString, isJsonPath } = this.props.value; + return ( + + + {/* */} + + + ); + } +} diff --git a/packages/redux-devtools-ui/src/StateFilter/styles/index.ts b/packages/redux-devtools-ui/src/StateFilter/styles/index.ts new file mode 100644 index 00000000..5cdbc57e --- /dev/null +++ b/packages/redux-devtools-ui/src/StateFilter/styles/index.ts @@ -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 { + } +`; diff --git a/packages/redux-devtools-ui/src/Tabs/Tabs.tsx b/packages/redux-devtools-ui/src/Tabs/Tabs.tsx index 2aaae2df..8c848482 100644 --- a/packages/redux-devtools-ui/src/Tabs/Tabs.tsx +++ b/packages/redux-devtools-ui/src/Tabs/Tabs.tsx @@ -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

{ onClick: (value: string) => void; collapsible?: boolean; position: Position; + setFilter?: (value: Partial) => void; + stateFilterValue?: StateFilterValue; } export default class Tabs

extends Component> { @@ -64,6 +67,8 @@ export default class Tabs

extends Component> { onClick={this.props.onClick} selected={this.props.selected} position={this.props.position} + setFilter={this.props.setFilter} + stateFilterValue={this.props.stateFilterValue} /> ); diff --git a/packages/redux-devtools-ui/src/Tabs/TabsHeader.tsx b/packages/redux-devtools-ui/src/Tabs/TabsHeader.tsx index 6ad33a90..af0aa843 100644 --- a/packages/redux-devtools-ui/src/Tabs/TabsHeader.tsx +++ b/packages/redux-devtools-ui/src/Tabs/TabsHeader.tsx @@ -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

{ selector?: (tab: this) => P; } -interface Props

{ +interface PropsWithoutFilter

{ tabs: ReactButtonElement[]; items: Tab

[]; main: boolean | undefined; @@ -30,6 +31,13 @@ interface Props

{ selected: string | undefined; } +interface PropsWithFilter

extends PropsWithoutFilter

{ + setFilter: (value: Partial) => void; + stateFilterValue: StateFilterValue; +} + +type Props

= PropsWithoutFilter

| PropsWithFilter

; + interface State { visibleTabs: ReactButtonElement[]; hiddenTabs: ReactButtonElement[]; @@ -221,6 +229,12 @@ export default class TabsHeader

extends Component, State> { )} + {'setFilter' in this.props && this.props.setFilter ? ( + + ) : null} {this.props.collapsible && contextMenu && ( = 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