diff --git a/packages/react-json-tree/src/ItemRange.tsx b/packages/react-json-tree/src/ItemRange.tsx index 52abad4a..b80f86b0 100644 --- a/packages/react-json-tree/src/ItemRange.tsx +++ b/packages/react-json-tree/src/ItemRange.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react'; import JSONArrow from './JSONArrow'; -import type { CircularCache, CommonInternalProps } from './types'; +import type { CircularCache, CommonInternalProps, KeyPath } from './types'; interface Props extends CommonInternalProps { data: unknown; @@ -13,12 +13,25 @@ interface Props extends CommonInternalProps { } export default function ItemRange(props: Props) { - const { styling, from, to, renderChildNodes, nodeType } = props; + const { + styling, + from, + to, + renderChildNodes, + nodeType, + keyPath, + searchResultPath, + level, + } = props; - const [expanded, setExpanded] = useState(false); + const [userExpanded, setUserExpanded] = useState(false); const handleClick = useCallback(() => { - setExpanded(!expanded); - }, [expanded]); + setUserExpanded(!userExpanded); + }, [userExpanded]); + + const expanded = + userExpanded || + containsSearchResult(keyPath, from, to, searchResultPath, level); return expanded ? (
@@ -37,3 +50,15 @@ export default function ItemRange(props: Props) {
); } + +const containsSearchResult = ( + ownKeyPath: KeyPath, + from: number, + to: number, + resultKeyPath: KeyPath, + level: number, +): boolean => { + const searchLevel = level > 1 ? level - 1 : level; + const nextKey = Number(resultKeyPath[searchLevel]); + return !isNaN(nextKey) && nextKey >= from && nextKey <= to; +}; diff --git a/packages/react-json-tree/src/JSONNestedNode.tsx b/packages/react-json-tree/src/JSONNestedNode.tsx index e62d6532..7d75e759 100644 --- a/packages/react-json-tree/src/JSONNestedNode.tsx +++ b/packages/react-json-tree/src/JSONNestedNode.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useState } from 'react'; -import JSONArrow from './JSONArrow'; -import getCollectionEntries from './getCollectionEntries'; -import JSONNode from './JSONNode'; import ItemRange from './ItemRange'; -import type { CircularCache, CommonInternalProps } from './types'; +import JSONArrow from './JSONArrow'; +import JSONNode from './JSONNode'; +import getCollectionEntries from './getCollectionEntries'; +import type { CircularCache, CommonInternalProps, KeyPath } from './types'; /** * Renders nested values (eg. objects, arrays, lists, etc.) @@ -110,17 +110,21 @@ export default function JSONNestedNode(props: Props) { nodeType, nodeTypeIndicator, shouldExpandNodeInitially, + searchResultPath, styling, } = props; - const [expanded, setExpanded] = useState( + const [userExpanded, setUserExpanded] = useState( // calculate individual node expansion if necessary isCircular ? false : shouldExpandNodeInitially(keyPath, data, level), ); const handleClick = useCallback(() => { - if (expandable) setExpanded(!expanded); - }, [expandable, expanded]); + if (expandable) setUserExpanded(!userExpanded); + }, [expandable, userExpanded]); + + const expanded = + userExpanded || containsSearchResult(keyPath, searchResultPath, level); const renderedChildren = expanded || (hideRoot && level === 0) @@ -175,3 +179,15 @@ export default function JSONNestedNode(props: Props) { ); } + +const containsSearchResult = ( + ownKeyPath: KeyPath, + resultKeyPath: KeyPath, + level: number, +): boolean => { + const searchLevel = level > 0 ? level - 1 : level; + const currKey = [...ownKeyPath].reverse()[searchLevel]; + return resultKeyPath && currKey !== undefined + ? resultKeyPath[searchLevel] === currKey.toString() + : false; +}; diff --git a/packages/react-json-tree/src/index.tsx b/packages/react-json-tree/src/index.tsx index 5fc23dae..23065be6 100644 --- a/packages/react-json-tree/src/index.tsx +++ b/packages/react-json-tree/src/index.tsx @@ -4,10 +4,10 @@ // port by Daniele Zannotti http://www.github.com/dzannotti import React, { useMemo } from 'react'; +import type { StylingValue, Theme } from 'react-base16-styling'; +import { invertTheme } from 'react-base16-styling'; import JSONNode from './JSONNode'; import createStylingFromTheme from './createStylingFromTheme'; -import { invertTheme } from 'react-base16-styling'; -import type { StylingValue, Theme } from 'react-base16-styling'; import type { CommonExternalProps, GetItemString, @@ -41,6 +41,8 @@ export function JSONTree({ labelRenderer = defaultLabelRenderer, valueRenderer = identity, shouldExpandNodeInitially = expandRootNode, + isSearchInProgress = false, + searchResultPath = [], hideRoot = false, getItemString = defaultItemString, postprocessValue = identity, @@ -64,6 +66,8 @@ export function JSONTree({ labelRenderer={labelRenderer} valueRenderer={valueRenderer} shouldExpandNodeInitially={shouldExpandNodeInitially} + searchResultPath={searchResultPath} + isSearchInProgress={isSearchInProgress} hideRoot={hideRoot} getItemString={getItemString} postprocessValue={postprocessValue} @@ -75,16 +79,16 @@ export function JSONTree({ } export type { + CommonExternalProps, + GetItemString, + IsCustomNode, Key, KeyPath, - GetItemString, LabelRenderer, - ValueRenderer, - ShouldExpandNodeInitially, PostprocessValue, - IsCustomNode, + ShouldExpandNodeInitially, SortObjectKeys, Styling, - CommonExternalProps, + ValueRenderer, } from './types'; export type { StylingValue }; diff --git a/packages/react-json-tree/src/types.ts b/packages/react-json-tree/src/types.ts index e0461261..e687d84a 100644 --- a/packages/react-json-tree/src/types.ts +++ b/packages/react-json-tree/src/types.ts @@ -47,6 +47,8 @@ export interface CommonExternalProps { labelRenderer: LabelRenderer; valueRenderer: ValueRenderer; shouldExpandNodeInitially: ShouldExpandNodeInitially; + searchResultPath: KeyPath; + isSearchInProgress: boolean; hideRoot: boolean; getItemString: GetItemString; postprocessValue: PostprocessValue; diff --git a/packages/redux-devtools-app/src/actions/index.ts b/packages/redux-devtools-app/src/actions/index.ts index ae65f1d5..5e7c9060 100644 --- a/packages/redux-devtools-app/src/actions/index.ts +++ b/packages/redux-devtools-app/src/actions/index.ts @@ -1,31 +1,33 @@ +import { LiftedAction, LiftedState } from '@redux-devtools/core'; import { SchemeName, ThemeName } from '@redux-devtools/ui'; -import { AuthStates, States } from 'socketcluster-client/lib/clientsocket'; +import { Action } from 'redux'; import { REHYDRATE } from 'redux-persist'; +import { AuthStates, States } from 'socketcluster-client/lib/clientsocket'; import { CHANGE_SECTION, + CHANGE_STATE_TREE_SETTINGS, CHANGE_THEME, - SELECT_INSTANCE, - SELECT_MONITOR, - UPDATE_MONITOR_STATE, + CLEAR_NOTIFICATION, + ERROR, + EXPORT, + GET_REPORT_ERROR, + GET_REPORT_REQUEST, + GET_REPORT_SUCCESS, LIFTED_ACTION, MONITOR_ACTION, - EXPORT, - TOGGLE_SYNC, - TOGGLE_SLIDER, + REMOVE_INSTANCE, + SELECT_INSTANCE, + SELECT_MONITOR, + SET_PERSIST, + SET_STATE, + SHOW_NOTIFICATION, TOGGLE_DISPATCHER, TOGGLE_PERSIST, - GET_REPORT_REQUEST, - SHOW_NOTIFICATION, - CLEAR_NOTIFICATION, - UPDATE_STATE, + TOGGLE_SLIDER, + TOGGLE_SYNC, + UPDATE_MONITOR_STATE, UPDATE_REPORTS, - REMOVE_INSTANCE, - SET_STATE, - GET_REPORT_ERROR, - GET_REPORT_SUCCESS, - ERROR, - SET_PERSIST, - CHANGE_STATE_TREE_SETTINGS, + UPDATE_STATE, } from '../constants/actionTypes'; import { AUTH_ERROR, @@ -43,12 +45,9 @@ import { SUBSCRIBE_SUCCESS, UNSUBSCRIBE, } from '../constants/socketActionTypes'; -import { Action } from 'redux'; import { Features, State } from '../reducers/instances'; import { MonitorStateMonitorState } from '../reducers/monitor'; -import { LiftedAction } from '@redux-devtools/core'; import { Data } from '../reducers/reports'; -import { LiftedState } from '@redux-devtools/core'; let monitorReducer: ( monitorProps: unknown, @@ -86,6 +85,7 @@ export function changeTheme(data: ChangeThemeData): ChangeThemeAction { interface ChangeStateTreeSettingsFormData { readonly sortAlphabetically: boolean; readonly disableCollection: boolean; + readonly enableSearchPanel: boolean; } interface ChangeStateTreeSettingsData { @@ -96,6 +96,7 @@ export interface ChangeStateTreeSettingsAction { readonly type: typeof CHANGE_STATE_TREE_SETTINGS; readonly sortAlphabetically: boolean; readonly disableCollection: boolean; + readonly enableSearchPanel: boolean; } export function changeStateTreeSettings( diff --git a/packages/redux-devtools-app/src/components/Settings/StateTree.tsx b/packages/redux-devtools-app/src/components/Settings/StateTree.tsx index 4a70ab0e..099ca2fc 100644 --- a/packages/redux-devtools-app/src/components/Settings/StateTree.tsx +++ b/packages/redux-devtools-app/src/components/Settings/StateTree.tsx @@ -1,6 +1,6 @@ -import React, { Component } from 'react'; -import { connect, ResolveThunks } from 'react-redux'; import { Container, Form } from '@redux-devtools/ui'; +import React, { Component } from 'react'; +import { ResolveThunks, connect } from 'react-redux'; import { changeStateTreeSettings } from '../../actions'; import { StoreState } from '../../reducers'; @@ -14,6 +14,7 @@ export class StateTree extends Component { const formData = { sortAlphabetically: stateTree.sortAlphabetically, disableCollection: stateTree.disableCollection, + enableSearchPanel: stateTree.enableSearchPanel, }; return ( @@ -30,6 +31,10 @@ export class StateTree extends Component { title: 'Disable collapsing of nodes', type: 'boolean', }, + enableSearchPanel: { + title: 'Show search panel in State tab', + type: 'boolean', + }, }, }} formData={formData} diff --git a/packages/redux-devtools-app/src/containers/DevTools.tsx b/packages/redux-devtools-app/src/containers/DevTools.tsx index db197332..fb308b18 100644 --- a/packages/redux-devtools-app/src/containers/DevTools.tsx +++ b/packages/redux-devtools-app/src/containers/DevTools.tsx @@ -1,13 +1,13 @@ -import React, { Component } from 'react'; -import { withTheme } from 'styled-components'; import { LiftedAction, LiftedState } from '@redux-devtools/core'; +import { ThemeFromProvider } from '@redux-devtools/ui'; +import React, { Component } from 'react'; import { Action } from 'redux'; -import getMonitor from '../utils/getMonitor'; +import { withTheme } from 'styled-components'; import { InitMonitorAction } from '../actions'; import { Features, State } from '../reducers/instances'; import { MonitorStateMonitorState } from '../reducers/monitor'; -import { ThemeFromProvider } from '@redux-devtools/ui'; import { StateTreeSettings } from '../reducers/stateTreeSettings'; +import getMonitor from '../utils/getMonitor'; interface Props { monitor: string; @@ -118,6 +118,7 @@ class DevTools extends Component { disableStateTreeCollection={ this.props.stateTreeSettings.disableCollection } + enableSearchPanel={this.props.stateTreeSettings.enableSearchPanel} /> ); diff --git a/packages/redux-devtools-app/src/reducers/stateTreeSettings.ts b/packages/redux-devtools-app/src/reducers/stateTreeSettings.ts index c3ab0325..043c5f45 100644 --- a/packages/redux-devtools-app/src/reducers/stateTreeSettings.ts +++ b/packages/redux-devtools-app/src/reducers/stateTreeSettings.ts @@ -1,15 +1,17 @@ -import { CHANGE_STATE_TREE_SETTINGS } from '../constants/actionTypes'; import { StoreAction } from '../actions'; +import { CHANGE_STATE_TREE_SETTINGS } from '../constants/actionTypes'; export interface StateTreeSettings { readonly sortAlphabetically: boolean; readonly disableCollection: boolean; + readonly enableSearchPanel: boolean; } export function stateTreeSettings( state: StateTreeSettings = { sortAlphabetically: false, disableCollection: false, + enableSearchPanel: false, }, action: StoreAction, ) { @@ -17,6 +19,7 @@ export function stateTreeSettings( return { sortAlphabetically: action.sortAlphabetically, disableCollection: action.disableCollection, + enableSearchPanel: action.enableSearchPanel, }; } return state; diff --git a/packages/redux-devtools-inspector-monitor/src/ActionPreview.tsx b/packages/redux-devtools-inspector-monitor/src/ActionPreview.tsx index 40bfa36d..97f86728 100644 --- a/packages/redux-devtools-inspector-monitor/src/ActionPreview.tsx +++ b/packages/redux-devtools-inspector-monitor/src/ActionPreview.tsx @@ -1,15 +1,15 @@ -import React, { Component } from 'react'; -import { Base16Theme } from 'redux-devtools-themes'; -import { Action } from 'redux'; -import type { StylingFunction } from 'react-base16-styling'; -import type { LabelRenderer } from 'react-json-tree'; import { PerformAction } from '@redux-devtools/core'; import { Delta } from 'jsondiffpatch'; -import { DEFAULT_STATE, DevtoolsInspectorState } from './redux'; +import React, { Component } from 'react'; +import type { StylingFunction } from 'react-base16-styling'; +import type { LabelRenderer } from 'react-json-tree'; +import { Action } from 'redux'; +import { Base16Theme } from 'redux-devtools-themes'; import ActionPreviewHeader from './ActionPreviewHeader'; +import { DEFAULT_STATE, DevtoolsInspectorState } from './redux'; +import ActionTab from './tabs/ActionTab'; import DiffTab from './tabs/DiffTab'; import StateTab from './tabs/StateTab'; -import ActionTab from './tabs/ActionTab'; export interface TabComponentProps> { labelRenderer: LabelRenderer; @@ -23,6 +23,7 @@ export interface TabComponentProps> { isWideLayout: boolean; sortStateTreeAlphabetically: boolean; disableStateTreeCollection: boolean; + enableSearchPanel: boolean; dataTypeKey: string | symbol | undefined; delta: Delta | null | undefined | false; action: A; @@ -74,6 +75,7 @@ interface Props> { onSelectTab: (tabName: string) => void; sortStateTreeAlphabetically: boolean; disableStateTreeCollection: boolean; + enableSearchPanel: boolean; } class ActionPreview> extends Component< @@ -107,6 +109,7 @@ class ActionPreview> extends Component< updateMonitorState, sortStateTreeAlphabetically, disableStateTreeCollection, + enableSearchPanel, } = this.props; const renderedTabs: Tab[] = @@ -141,6 +144,7 @@ class ActionPreview> extends Component< isWideLayout, sortStateTreeAlphabetically, disableStateTreeCollection, + enableSearchPanel, dataTypeKey, delta, action, diff --git a/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx b/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx index acf17c15..054e5c40 100644 --- a/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx +++ b/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx @@ -1,25 +1,20 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { Base16Theme } from 'redux-devtools-themes'; -import { - getBase16Theme, - invertTheme, - StylingFunction, -} from 'react-base16-styling'; import { ActionCreators, LiftedAction, LiftedState, } from '@redux-devtools/core'; -import { Action, Dispatch } from 'redux'; import { Delta, DiffContext } from 'jsondiffpatch'; +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; import { - createStylingFromTheme, - base16Themes, -} from './utils/createStylingFromTheme'; + StylingFunction, + getBase16Theme, + invertTheme, +} from 'react-base16-styling'; +import { Action, Dispatch } from 'redux'; +import { Base16Theme } from 'redux-devtools-themes'; import ActionList from './ActionList'; import ActionPreview, { Tab } from './ActionPreview'; -import getInspectedState from './utils/getInspectedState'; import createDiffPatcher from './createDiffPatcher'; import { DevtoolsInspectorAction, @@ -27,6 +22,11 @@ import { reducer, updateMonitorState, } from './redux'; +import { + base16Themes, + createStylingFromTheme, +} from './utils/createStylingFromTheme'; +import getInspectedState from './utils/getInspectedState'; const { // eslint-disable-next-line @typescript-eslint/unbound-method @@ -153,6 +153,7 @@ export interface ExternalProps> { invertTheme: boolean; sortStateTreeAlphabetically: boolean; disableStateTreeCollection: boolean; + enableSearchPanel: boolean; dataTypeKey?: string | symbol; tabs: Tab[] | ((tabs: Tab[]) => Tab[]); } @@ -181,6 +182,7 @@ export interface DevtoolsInspectorProps> hideActionButtons?: boolean; sortStateTreeAlphabetically: boolean; disableStateTreeCollection: boolean; + enableSearchPanel: boolean; invertTheme: boolean; dataTypeKey?: string | symbol; tabs: Tab[] | ((tabs: Tab[]) => Tab[]); @@ -226,6 +228,7 @@ class DevtoolsInspector> extends PureComponent< invertTheme: PropTypes.bool, sortStateTreeAlphabetically: PropTypes.bool, disableStateTreeCollection: PropTypes.bool, + enableSearchPanel: PropTypes.bool, skippedActionIds: PropTypes.array, dataTypeKey: PropTypes.any, tabs: PropTypes.oneOfType([PropTypes.array, PropTypes.func]), @@ -313,6 +316,7 @@ class DevtoolsInspector> extends PureComponent< hideActionButtons, sortStateTreeAlphabetically, disableStateTreeCollection, + enableSearchPanel, } = this.props; const { selectedActionId, startActionId, searchValue, tabName } = monitorState; @@ -374,6 +378,7 @@ class DevtoolsInspector> extends PureComponent< dataTypeKey, sortStateTreeAlphabetically, disableStateTreeCollection, + enableSearchPanel, }} monitorState={this.props.monitorState} updateMonitorState={this.updateMonitorState} diff --git a/packages/redux-devtools-inspector-monitor/src/searchPanel/JumpSearchResultButton.tsx b/packages/redux-devtools-inspector-monitor/src/searchPanel/JumpSearchResultButton.tsx new file mode 100644 index 00000000..b4dce52e --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/searchPanel/JumpSearchResultButton.tsx @@ -0,0 +1,45 @@ +import { StylingFunction } from 'react-base16-styling'; +import React, { ReactElement } from 'react'; + +export const BUTTON_DIRECTION = { + LEFT: 'left', + RIGHT: 'right', +} as const; + +interface JumpSearchResultButtonProps { + buttonDirection: string; + buttonDisabled: boolean; + styling: StylingFunction; + jumpToNewResult: () => void; +} + +function JumpSearchResultButton({ + buttonDirection, + buttonDisabled, + styling, + jumpToNewResult, +}: JumpSearchResultButtonProps): ReactElement { + return ( + + ); +} + +export default JumpSearchResultButton; diff --git a/packages/redux-devtools-inspector-monitor/src/searchPanel/SearchBar.tsx b/packages/redux-devtools-inspector-monitor/src/searchPanel/SearchBar.tsx new file mode 100644 index 00000000..f08764ca --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/searchPanel/SearchBar.tsx @@ -0,0 +1,30 @@ +import React, { ReactElement } from 'react'; +import { StylingFunction } from 'react-base16-styling'; + +export interface SearchBarProps { + onChange: (s: string) => void; + text: string; + className?: string; + styling: StylingFunction; +} + +function SearchBar({ + onChange, + text, + className, + styling, +}: SearchBarProps): ReactElement { + return ( +
+ onChange(e.target.value)} + /> +
+ ); +} + +export default SearchBar; diff --git a/packages/redux-devtools-inspector-monitor/src/searchPanel/SearchPanel.tsx b/packages/redux-devtools-inspector-monitor/src/searchPanel/SearchPanel.tsx new file mode 100644 index 00000000..e7957f13 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/searchPanel/SearchPanel.tsx @@ -0,0 +1,163 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import { StylingFunction } from 'react-base16-styling'; +import { searchInObject } from '../utils/objectSearch'; +import { Value } from '../utils/searchWorker'; +import JumpSearchResultButton, { + BUTTON_DIRECTION, +} from './JumpSearchResultButton'; +import SearchBar from './SearchBar'; + +export interface SearchQuery { + queryText: string; + location: { + keys: boolean; + values: boolean; + }; +} + +const INITIAL_QUERY: SearchQuery = { + queryText: '', + location: { + keys: true, + values: true, + }, +}; + +export interface SearchPanelProps { + state: Value; + onSubmit: (result: { + searchResult: string[]; + searchInProgress: boolean; + }) => void; + onReset: () => void; + styling: StylingFunction; +} + +type SearchStatus = 'done' | 'pending' | 'unset'; + +function SearchPanel({ + onSubmit, + onReset, + styling, + state, +}: SearchPanelProps): ReactElement { + const [query, setQuery] = useState(INITIAL_QUERY); + const [searchStatus, setSearchStatus] = useState('unset'); + const [results, setResults] = useState(undefined); + const [resultIndex, setResultIndex] = useState(0); + + async function handleSubmit() { + setSearchStatus('pending'); + const result = await searchInObject(state, query); + setResults(result.map((r) => r.split('.'))); + setResultIndex(0); + setSearchStatus('done'); + } + + function reset() { + setQuery(INITIAL_QUERY); + setSearchStatus('unset'); + setResults(undefined); + setResultIndex(0); + onReset(); + } + + useEffect(() => { + results && + onSubmit({ searchResult: results[0] || [], searchInProgress: true }); + }, [results, onSubmit]); + + return ( +
+ setQuery({ ...query, queryText: text })} + styling={styling} + /> +
+ + setQuery({ + ...query, + location: { ...query.location, keys: event.target.checked }, + }) + } + /> + Keys +
+
+ + setQuery({ + ...query, + location: { ...query.location, values: event.target.checked }, + }) + } + /> + Values +
+ + {searchStatus === 'pending' && 'Searching...'} + {searchStatus === 'done' && ( + <> +
+ { + if (!results) { + return; + } + const newIndex = + resultIndex - 1 < 0 ? results.length - 1 : resultIndex - 1; + setResultIndex(newIndex); + onSubmit({ + searchResult: results[newIndex] || [], + searchInProgress: true, + }); + }} + /> + { + if (!results) { + return; + } + const newIndex = (resultIndex + 1) % results.length || 0; + setResultIndex(newIndex); + onSubmit({ + searchResult: results[newIndex] || [], + searchInProgress: true, + }); + }} + /> + {results && + `${results.length ? resultIndex + 1 : 0}/${results.length}`} +
+ + + )} +
+ ); +} + +export default SearchPanel; diff --git a/packages/redux-devtools-inspector-monitor/src/tabs/StateTab.tsx b/packages/redux-devtools-inspector-monitor/src/tabs/StateTab.tsx index 5dffc3c6..ed166c42 100644 --- a/packages/redux-devtools-inspector-monitor/src/tabs/StateTab.tsx +++ b/packages/redux-devtools-inspector-monitor/src/tabs/StateTab.tsx @@ -1,10 +1,16 @@ -import React from 'react'; import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; import { JSONTree } from 'react-json-tree'; import { Action } from 'redux'; +import { TabComponentProps } from '../ActionPreview'; +import SearchPanel from '../searchPanel/SearchPanel'; import getItemString from './getItemString'; import getJsonTreeTheme from './getJsonTreeTheme'; -import { TabComponentProps } from '../ActionPreview'; + +interface SearchState { + searchResult: string[]; + searchInProgress: boolean; +} const StateTab: React.FunctionComponent< TabComponentProps> @@ -18,20 +24,86 @@ const StateTab: React.FunctionComponent< isWideLayout, sortStateTreeAlphabetically, disableStateTreeCollection, -}) => ( - - getItemString(styling, type, data, dataTypeKey, isWideLayout) + enableSearchPanel, +}) => { + const [searchState, setSearchState] = useState({ + searchResult: [], + searchInProgress: false, + }); + + const displayedResult = React.useRef(null); + + useEffect(() => { + if ( + enableSearchPanel && + searchState.searchInProgress && + displayedResult.current + ) { + displayedResult.current.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + setSearchState({ ...searchState, searchInProgress: false }); } - invertTheme={invertTheme} - hideRoot - sortObjectKeys={sortStateTreeAlphabetically} - {...(disableStateTreeCollection ? { collectionLimit: 0 } : {})} - /> -); + }, [searchState, setSearchState, enableSearchPanel]); + + return ( + <> + {enableSearchPanel && ( + + setSearchState({ + searchResult: [], + searchInProgress: false, + }) + } + styling={styling} + state={nextState} + /> + )} + { + return isMatch(searchState.searchResult, [...keyPath].reverse()) ? ( + + {labelRenderer(keyPath, nodeType, expanded, expandable)} + + ) : ( + labelRenderer(keyPath, nodeType, expanded, expandable) + ); + }} + theme={getJsonTreeTheme(base16Theme)} + data={nextState} + getItemString={(type, data) => + getItemString(styling, type, data, dataTypeKey, isWideLayout) + } + invertTheme={invertTheme} + hideRoot + sortObjectKeys={sortStateTreeAlphabetically} + {...(disableStateTreeCollection ? { collectionLimit: 0 } : {})} + isSearchInProgress={searchState.searchInProgress} + searchResultPath={searchState.searchResult} + valueRenderer={(raw, value, ...keyPath) => { + return isMatch(searchState.searchResult, [...keyPath].reverse()) ? ( + + {raw as string} + + ) : ( + {raw as string} + ); + }} + /> + + ); +}; + +const isMatch = (resultPath: string[], nodePath: (string | number)[]) => { + return ( + resultPath.length === nodePath.length && + resultPath.every((result, index) => result === nodePath[index].toString()) + ); +}; StateTab.propTypes = { nextState: PropTypes.any.isRequired, diff --git a/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts b/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts index 9bd45ea3..3952d221 100644 --- a/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts +++ b/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts @@ -17,6 +17,7 @@ const colorMap = (theme: Base16Theme) => ({ SELECTED_BACKGROUND_COLOR: rgba(theme.base03, 20), SKIPPED_BACKGROUND_COLOR: rgba(theme.base03, 10), HEADER_BACKGROUND_COLOR: rgba(theme.base03, 30), + HEADER_BACKGROUND_COLOR_OPAQUE: rgba(theme.base03, 100), HEADER_BORDER_COLOR: rgba(theme.base03, 20), BORDER_COLOR: rgba(theme.base03, 50), LIST_BORDER_COLOR: rgba(theme.base03, 50), @@ -34,6 +35,7 @@ const colorMap = (theme: Base16Theme) => ({ LINK_COLOR: rgba(theme.base0E, 90), LINK_HOVER_COLOR: theme.base0E, ERROR_COLOR: theme.base08, + SEARCH_BUTTON_COLOR: rgba(theme.base00, 50), }); type Color = keyof ReturnType; @@ -267,6 +269,95 @@ const getSheetFromColorMap = (map: ColorMap) => ({ 'border-bottom-color': map.HEADER_BORDER_COLOR, }, + searchPanel: { + display: 'flex', + 'flex-wrap': 'wrap', + position: 'sticky', + top: 0, + width: '100%', + 'z-index': 1, + gap: '1em', + padding: '5px 10px', + 'align-items': 'center', + 'border-bottom-width': '1px', + 'border-bottom-style': 'solid', + + 'background-color': map.HEADER_BACKGROUND_COLOR_OPAQUE, + 'border-bottom-color': map.HEADER_BORDER_COLOR, + }, + + searchPanelParameterSelection: { + display: 'inline-grid', + color: map.SEARCH_BUTTON_COLOR, + width: '1.15em', + height: '1.15em', + border: '0.15em solid currentColor', + 'border-radius': '0.15em', + transform: 'translateY(-0.075em)', + 'place-content': 'center', + appearance: 'none', + 'background-color': map.SEARCH_BUTTON_COLOR, + + '&::before': { + 'background-color': map.TEXT_COLOR, + 'transform-origin': 'bottom left', + 'clip-path': + 'polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%)', + content: '""', + width: '0.65em', + height: '0.65em', + transform: 'scale(0)', + transition: '120ms transform ease-in-out', + }, + '&:checked::before': { + transform: 'scale(1)', + }, + }, + + searchInput: { + 'background-color': 'white', + opacity: 0.9, + 'border-color': 'transparent', + 'border-radius': '4px', + height: '1.2rem', + }, + + searchButton: { + 'background-color': map.SEARCH_BUTTON_COLOR, + 'border-color': 'transparent', + 'border-radius': '4px', + height: '1.56rem', + color: map.TEXT_COLOR, + }, + + jumpResultContainer: { + display: 'flex', + gap: '1em', + 'align-items': 'center', + }, + + jumpResultButton: { + 'background-color': map.SEARCH_BUTTON_COLOR, + 'border-color': 'transparent', + 'border-radius': '4px', + }, + + jumpResultButtonArrow: { + cursor: 'hand', + fill: map.TEXT_COLOR, + width: '0.6rem', + height: '1rem', + }, + + queryResult: { + 'background-color': '#FFFF00', + }, + + queryResultLabel: { + 'background-color': '#FFFF00', + 'text-indent': 0, + }, + tabSelector: { position: 'relative', display: 'inline-flex', diff --git a/packages/redux-devtools-inspector-monitor/src/utils/objectSearch.ts b/packages/redux-devtools-inspector-monitor/src/utils/objectSearch.ts new file mode 100644 index 00000000..1ad0bfca --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/objectSearch.ts @@ -0,0 +1,18 @@ +import { SearchQuery } from '../searchPanel/SearchPanel'; +import { Value } from './searchWorker'; + +export function searchInObject( + objectToSearch: Value, + query: SearchQuery, +): Promise { + return new Promise((resolve) => { + const worker = new Worker(new URL('./searchWorker.js', import.meta.url)); + + worker.onmessage = (event: MessageEvent) => { + resolve(event.data); + worker.terminate(); + }; + + worker.postMessage({ objectToSearch, query }); + }); +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/searchWorker.ts b/packages/redux-devtools-inspector-monitor/src/utils/searchWorker.ts new file mode 100644 index 00000000..58a2ddd1 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/searchWorker.ts @@ -0,0 +1,85 @@ +import { SearchQuery } from '../searchPanel/SearchPanel'; + +type PrimitiveValue = string | number | boolean | undefined | null; +export type Value = + | PrimitiveValue + | Value[] + | { [key: string | number]: Value }; + +function getKeys(object: Value): string[] { + if (object === undefined || object === null) { + return []; + } + + if (['string', 'number', 'boolean'].includes(typeof object)) { + return []; + } + + if (Array.isArray(object)) { + return [...object.keys()].map((el) => el.toString()); + } + + return Object.keys(object); +} + +function getKeyPaths(object: Value): string[] { + const keys = getKeys(object) as (keyof object)[]; + const appendKey = (path: string, branch: string | number): string => + path ? `${path}.${branch}` : `${branch}`; + + return keys.flatMap((key) => [ + key, + ...getKeyPaths(object?.[key]).map((subKey) => appendKey(key, subKey)), + ]); +} + +const isMatchable = (value: Value): boolean => { + return ( + value === undefined || + value === null || + ['string', 'number', 'boolean'].includes(typeof value) + ); +}; + +function doSearch(objectToSearch: Value, query: SearchQuery): string[] { + const { + queryText, + location: { keys: matchKeys, values: matchValues }, + } = query; + + if (!matchKeys && !matchValues) { + return []; + } + + const keyPaths = getKeyPaths(objectToSearch); + const getValueFromKeyPath = (obj: Value, path: string): Value => { + return path + .split('.') + .reduce( + (intermediateObj, key) => intermediateObj?.[key as keyof object], + obj, + ); + }; + const match = (value: Value, searchText: string): boolean => { + return isMatchable(value) && String(value).includes(searchText); + }; + + return keyPaths.filter((keyPath) => { + const valuesToCheck = []; + matchKeys && valuesToCheck.push(keyPath.split('.').splice(-1)[0]); + matchValues && + valuesToCheck.push(getValueFromKeyPath(objectToSearch, keyPath)); + + return valuesToCheck.some((value) => match(value, queryText)); + }); +} + +self.onmessage = ( + event: MessageEvent<{ objectToSearch: Value; query: SearchQuery }>, +) => { + const { objectToSearch, query } = event.data; + const result = doSearch(objectToSearch, query); + self.postMessage(result); +}; + +export {}; diff --git a/packages/redux-devtools-inspector-monitor/tsconfig.json b/packages/redux-devtools-inspector-monitor/tsconfig.json index 67c88df5..e3405f6b 100644 --- a/packages/redux-devtools-inspector-monitor/tsconfig.json +++ b/packages/redux-devtools-inspector-monitor/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.react.base.json", "compilerOptions": { + "module": "ES2022", "outDir": "lib/types", "resolveJsonModule": true },