This commit is contained in:
Avinash Thakur 2024-09-30 17:15:41 +00:00 committed by GitHub
commit b30cb9ea5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2599 additions and 1786 deletions

View File

@ -10,6 +10,7 @@ import {
stateTreeSettings,
StoreAction,
StoreState,
stateFilter,
theme,
} from '@redux-devtools/app';
@ -26,6 +27,7 @@ const rootReducer: Reducer<
socket,
theme,
connection,
stateFilter,
stateTreeSettings,
}) as any;

View File

@ -49,6 +49,8 @@
"javascript-stringify": "^2.1.0",
"jsan": "^3.1.14",
"jsondiffpatch": "^0.6.0",
"jsonpath-plus": "github:80avin/JSONPath#de0566aee8abf14b7f0f57e2711dbea06cf997fa",
"lodash-es": "^4.17.21",
"react-icons": "^5.3.0",
"react-is": "^18.3.1"
},
@ -69,6 +71,7 @@
"@types/jest": "^29.5.13",
"@types/jsan": "^3.1.5",
"@types/json-schema": "^7.0.15",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.16.5",
"@types/react": "^18.3.6",
"@types/react-dom": "^18.3.0",

View File

@ -24,6 +24,7 @@ import {
GET_REPORT_SUCCESS,
ERROR,
SET_PERSIST,
SET_STATE_FILTER,
CHANGE_STATE_TREE_SETTINGS,
CLEAR_INSTANCES,
} from '../constants/actionTypes';
@ -33,6 +34,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,
@ -67,6 +69,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,
};
}
interface ChangeStateTreeSettingsFormData {
readonly sortAlphabetically: boolean;
readonly disableCollection: boolean;
@ -487,6 +503,7 @@ export interface ReduxPersistRehydrateAction {
export type CoreStoreActionWithoutUpdateStateOrLiftedAction =
| ChangeSectionAction
| ChangeThemeAction
| SetStateFilterAction
| ChangeStateTreeSettingsAction
| MonitorActionAction
| SelectInstanceAction

View File

@ -19,6 +19,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 { CoreStoreState } from '../../../reducers';
import type { 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,38 @@ 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 +77,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 +97,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 +138,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 +148,12 @@ class SubTabs extends Component<Props> {
const mapStateToProps = (state: CoreStoreState) => ({
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

@ -22,5 +22,6 @@ export * from './reducers/notification';
export * from './reducers/reports';
export * from './reducers/section';
export * from './reducers/theme';
export * from './reducers/stateFilter';
export * from './reducers/stateTreeSettings';
export * from './utils/stringifyJSON';

View File

@ -4,6 +4,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 { stateTreeSettings, StateTreeSettings } from './stateTreeSettings';
export interface CoreStoreState {
@ -14,6 +15,7 @@ export interface CoreStoreState {
readonly instances: InstancesState;
readonly reports: ReportsState;
readonly notification: NotificationState;
readonly stateFilter: StateFilterState;
}
export const coreReducers = {
@ -24,4 +26,5 @@ export const coreReducers = {
instances,
reports,
notification,
stateFilter,
};

View File

@ -0,0 +1,18 @@
import { StateFilterValue } from '@redux-devtools/ui/lib/types/StateFilter/StateFilter';
import { CoreStoreAction } from '../actions';
import { SET_STATE_FILTER } from '../constants/actionTypes';
export type StateFilterState = StateFilterValue;
export function stateFilter(
state: StateFilterState = {
isJsonPath: false,
searchString: '',
},
action: CoreStoreAction,
): 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-es';
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',
eval: '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,6 +1,7 @@
import React, { Component } from 'react';
import TabsHeader, { ReactButtonElement, Tab } from './TabsHeader';
import { TabsContainer } from './styles/common';
import { StateFilterValue } from '../StateFilter/StateFilter';
export type Position = 'left' | 'right' | 'center';
@ -11,6 +12,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>> {
@ -63,6 +66,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

@ -4,6 +4,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);
@ -19,7 +20,7 @@ export interface Tab<P> {
selector?: (tab: this) => P;
}
interface Props<P> {
interface PropsWithoutFilter<P> {
tabs: ReactButtonElement[];
items: Tab<P>[];
main: boolean | undefined;
@ -29,6 +30,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[];
@ -220,6 +228,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

File diff suppressed because it is too large Load Diff