diff --git a/packages/redux-devtools-inspector-monitor/demo/config/webpack.config.ts b/packages/redux-devtools-inspector-monitor/demo/config/webpack.config.ts index c7b08800..b4999104 100644 --- a/packages/redux-devtools-inspector-monitor/demo/config/webpack.config.ts +++ b/packages/redux-devtools-inspector-monitor/demo/config/webpack.config.ts @@ -17,7 +17,7 @@ module.exports = { './demo/src/js/index', ], output: { - path: path.join(__dirname, 'demo/dist'), + path: path.join(__dirname, './dist'), filename: 'js/bundle.js', }, module: { @@ -60,5 +60,5 @@ module.exports = { }, historyApiFallback: true, }, - devtool: 'eval-source-map', + devtool: isProduction ? 'sourcemap' : 'eval-source-map', }; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/components/Pokemon.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/components/Pokemon.tsx new file mode 100644 index 00000000..a263d502 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/components/Pokemon.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { useGetPokemonByNameQuery } from '../rtk-query/pokemonApi'; +import type { PokemonName } from '../rtk-query/pokemon.data'; + +const intervalOptions = [ + { label: 'Off', value: 0 }, + { label: '3s', value: 3000 }, + { label: '5s', value: 5000 }, + { label: '10s', value: 10000 }, + { label: '1m', value: 60000 }, +]; + +export const Pokemon = ({ name }: { name: PokemonName }) => { + const [pollingInterval, setPollingInterval] = React.useState(60000); + + const { data, error, isLoading, isFetching, refetch } = + useGetPokemonByNameQuery(name, { + pollingInterval, + }); + + return ( +
+ {error ? ( + <>Oh no, there was an error loading {name} + ) : isLoading ? ( + <>Loading... + ) : data ? ( + <> +

{data.species.name}

+
+ {data.species.name} +
+
+ + +
+
+ +
+ + ) : ( + 'No Data' + )} +
+ ); +}; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/components/PokemonView.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/components/PokemonView.tsx new file mode 100644 index 00000000..67314f61 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/components/PokemonView.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { PokemonName, POKEMON_NAMES } from '../rtk-query/pokemon.data'; +import { Pokemon } from './Pokemon'; + +const getRandomPokemonName = () => + POKEMON_NAMES[Math.floor(Math.random() * POKEMON_NAMES.length)]; + +export function PokemonView() { + const [pokemon, setPokemon] = React.useState(['bulbasaur']); + + return ( +
+
+ + +
+ + {pokemon.map((name, index) => ( + + ))} +
+ ); +} diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/DemoApp.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DemoApp.tsx similarity index 96% rename from packages/redux-devtools-inspector-monitor/demo/src/js/DemoApp.tsx rename to packages/redux-devtools-inspector-monitor/demo/src/js/containers/DemoApp.tsx index 83903219..e242e95b 100644 --- a/packages/redux-devtools-inspector-monitor/demo/src/js/DemoApp.tsx +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DemoApp.tsx @@ -1,6 +1,6 @@ import React, { CSSProperties } from 'react'; import { connect } from 'react-redux'; -import pkg from '../../../package.json'; +import pkg from '../../../../package.json'; import Button from 'react-bootstrap/Button'; import FormGroup from 'react-bootstrap/FormGroup'; import FormControl from 'react-bootstrap/FormControl'; @@ -11,9 +11,10 @@ import InputGroup from 'react-bootstrap/InputGroup'; import Row from 'react-bootstrap/Row'; import * as base16 from 'base16'; import { push as pushRoute } from 'connected-react-router'; +import { PokemonView } from '../components/PokemonView'; import { Path } from 'history'; -import * as inspectorThemes from '../../../src/themes'; -import getOptions, { Options } from './getOptions'; +import * as inspectorThemes from '../../../../src/themes'; +import getOptions, { Options } from '../getOptions'; import { AddFunctionAction, AddHugeObjectAction, @@ -34,7 +35,7 @@ import { ShuffleArrayAction, TimeoutUpdateAction, ToggleTimeoutUpdateAction, -} from './reducers'; +} from '../reducers'; const styles: { wrapper: CSSProperties; @@ -56,9 +57,10 @@ const styles: { header: {}, content: { display: 'flex', + flexFlow: 'column', alignItems: 'center', - justifyContent: 'center', - height: '50%', + justifyContent: 'space-evenly', + padding: 8, }, buttons: { display: 'flex', @@ -251,6 +253,7 @@ class DemoApp extends React.Component { Shuffle Array +
diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/DevTools.tsx b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DevTools.tsx similarity index 86% rename from packages/redux-devtools-inspector-monitor/demo/src/js/DevTools.tsx rename to packages/redux-devtools-inspector-monitor/demo/src/js/containers/DevTools.tsx index 2d5b7c84..658e3054 100644 --- a/packages/redux-devtools-inspector-monitor/demo/src/js/DevTools.tsx +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/containers/DevTools.tsx @@ -3,10 +3,10 @@ import { connect } from 'react-redux'; import { createDevTools } from '@redux-devtools/core'; import DockMonitor from '@redux-devtools/dock-monitor'; import { Location } from 'history'; -import DevtoolsInspector from '../../../src/DevtoolsInspector'; -import getOptions from './getOptions'; -import { base16Themes } from '../../../src/utils/createStylingFromTheme'; -import { DemoAppState } from './reducers'; +import DevtoolsInspector from '../../../../src/DevtoolsInspector'; +import getOptions from '../getOptions'; +import { base16Themes } from '../../../../src/utils/createStylingFromTheme'; +import { DemoAppState } from '../reducers'; const CustomComponent = () => (
0 ? matches[1] : null; } -const ROOT = - process.env.NODE_ENV === 'production' - ? '/redux-devtools-inspector-monitor/' - : '/'; +const ROOT = '/'; const DevTools = getDevTools(window.location); @@ -51,7 +49,16 @@ const enhancer = compose( persistState(getDebugSessionKey()) ); -const store = createStore(createRootReducer(history), enhancer); +const store = configureStore({ + reducer: createRootReducer(history), + devTools: false, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + immutableCheck: false, + serializableCheck: false, + }).concat([pokemonApi.middleware]), + enhancers: [enhancer], +}); // createStore(createRootReducer(history), enhancer); render( diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts b/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts index 3fdbe4fc..20d87ad5 100644 --- a/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/reducers.ts @@ -7,6 +7,7 @@ import { RouterState, } from 'connected-react-router'; import { History } from 'history'; +import { pokemonApi } from './rtk-query/pokemonApi'; type Nested = { long: { nested: { path: { to: { a: string } } }[] } }; @@ -187,6 +188,7 @@ export interface DemoAppState { addFunction: { f: (a: number, b: number, c: number) => number } | null; addSymbol: { s: symbol; error: Error } | null; shuffleArray: unknown[]; + [pokemonApi.reducerPath]: ReturnType; } const createRootReducer = ( @@ -259,6 +261,7 @@ const createRootReducer = ( : state, shuffleArray: (state = DEFAULT_SHUFFLE_ARRAY, action) => action.type === 'SHUFFLE_ARRAY' ? shuffle(state) : state, + [pokemonApi.reducerPath]: pokemonApi.reducer, }); export default createRootReducer; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemon.data.ts b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemon.data.ts new file mode 100644 index 00000000..83150cb7 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemon.data.ts @@ -0,0 +1,155 @@ +export const POKEMON_NAMES = [ + 'bulbasaur', + 'ivysaur', + 'venusaur', + 'charmander', + 'charmeleon', + 'charizard', + 'squirtle', + 'wartortle', + 'blastoise', + 'caterpie', + 'metapod', + 'butterfree', + 'weedle', + 'kakuna', + 'beedrill', + 'pidgey', + 'pidgeotto', + 'pidgeot', + 'rattata', + 'raticate', + 'spearow', + 'fearow', + 'ekans', + 'arbok', + 'pikachu', + 'raichu', + 'sandshrew', + 'sandslash', + 'nidoran', + 'nidorina', + 'nidoqueen', + 'nidoran', + 'nidorino', + 'nidoking', + 'clefairy', + 'clefable', + 'vulpix', + 'ninetales', + 'jigglypuff', + 'wigglytuff', + 'zubat', + 'golbat', + 'oddish', + 'gloom', + 'vileplume', + 'paras', + 'parasect', + 'venonat', + 'venomoth', + 'diglett', + 'dugtrio', + 'meowth', + 'persian', + 'psyduck', + 'golduck', + 'mankey', + 'primeape', + 'growlithe', + 'arcanine', + 'poliwag', + 'poliwhirl', + 'poliwrath', + 'abra', + 'kadabra', + 'alakazam', + 'machop', + 'machoke', + 'machamp', + 'bellsprout', + 'weepinbell', + 'victreebel', + 'tentacool', + 'tentacruel', + 'geodude', + 'graveler', + 'golem', + 'ponyta', + 'rapidash', + 'slowpoke', + 'slowbro', + 'magnemite', + 'magneton', + "farfetch'd", + 'doduo', + 'dodrio', + 'seel', + 'dewgong', + 'grimer', + 'muk', + 'shellder', + 'cloyster', + 'gastly', + 'haunter', + 'gengar', + 'onix', + 'drowzee', + 'hypno', + 'krabby', + 'kingler', + 'voltorb', + 'electrode', + 'exeggcute', + 'exeggutor', + 'cubone', + 'marowak', + 'hitmonlee', + 'hitmonchan', + 'lickitung', + 'koffing', + 'weezing', + 'rhyhorn', + 'rhydon', + 'chansey', + 'tangela', + 'kangaskhan', + 'horsea', + 'seadra', + 'goldeen', + 'seaking', + 'staryu', + 'starmie', + 'mr. mime', + 'scyther', + 'jynx', + 'electabuzz', + 'magmar', + 'pinsir', + 'tauros', + 'magikarp', + 'gyarados', + 'lapras', + 'ditto', + 'eevee', + 'vaporeon', + 'jolteon', + 'flareon', + 'porygon', + 'omanyte', + 'omastar', + 'kabuto', + 'kabutops', + 'aerodactyl', + 'snorlax', + 'articuno', + 'zapdos', + 'moltres', + 'dratini', + 'dragonair', + 'dragonite', + 'mewtwo', + 'mew', +] as const; + +export type PokemonName = typeof POKEMON_NAMES[number]; diff --git a/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemonApi.ts b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemonApi.ts new file mode 100644 index 00000000..22dbe3d4 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/demo/src/js/rtk-query/pokemonApi.ts @@ -0,0 +1,20 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import type { PokemonName } from './pokemon.data'; + +export const pokemonApi = createApi({ + reducerPath: 'pokemonApi', + baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), + tagTypes: ['pokemon'], + endpoints: (builder) => ({ + getPokemonByName: builder.query({ + query: (name: PokemonName) => `pokemon/${name}`, + providesTags: (result, error, name: PokemonName) => [ + { type: 'pokemon' }, + { type: 'pokemon', id: name }, + ], + }), + }), +}); + +// Export hooks for usage in functional components +export const { useGetPokemonByNameQuery } = pokemonApi; diff --git a/packages/redux-devtools-inspector-monitor/package.json b/packages/redux-devtools-inspector-monitor/package.json index 8518b00c..0911e904 100644 --- a/packages/redux-devtools-inspector-monitor/package.json +++ b/packages/redux-devtools-inspector-monitor/package.json @@ -24,7 +24,7 @@ "scripts": { "start": "webpack-dev-server --config demo/config/webpack.config.ts", "stats": "webpack --profile --json > stats.json", - "build:demo": "NODE_ENV=production webpack -p", + "build:demo": "cross-env NODE_ENV=production webpack -p --config demo/config/webpack.config.ts", "build": "npm run build:types && npm run build:js", "build:types": "tsc --emitDeclarationOnly", "build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline", @@ -51,11 +51,13 @@ "react-base16-styling": "^0.8.0", "react-dragula": "^1.1.17", "react-json-tree": "^0.15.0", - "redux-devtools-themes": "^1.0.0" + "redux-devtools-themes": "^1.0.0", + "reselect": "^4.0.0" }, "devDependencies": { "@redux-devtools/core": "^3.9.0", "@redux-devtools/dock-monitor": "^1.4.0", + "@reduxjs/toolkit": "^1.6.0", "@types/dateformat": "^3.0.1", "@types/hex-rgba": "^1.0.1", "@types/history": "^4.7.9", diff --git a/packages/redux-devtools-inspector-monitor/src/ActionList.tsx b/packages/redux-devtools-inspector-monitor/src/ActionList.tsx index 22085b0e..1f545ad9 100644 --- a/packages/redux-devtools-inspector-monitor/src/ActionList.tsx +++ b/packages/redux-devtools-inspector-monitor/src/ActionList.tsx @@ -6,6 +6,7 @@ import { PerformAction } from '@redux-devtools/core'; import { StylingFunction } from 'react-base16-styling'; import ActionListRow from './ActionListRow'; import ActionListHeader from './ActionListHeader'; +import { ActionForm } from './redux'; function getTimestamps>( actions: { [actionId: number]: PerformAction }, @@ -21,15 +22,16 @@ function getTimestamps>( }; } -interface Props> { +interface Props> { actions: { [actionId: number]: PerformAction }; actionIds: number[]; isWideLayout: boolean; - searchValue: string | undefined; selectedActionId: number | null; startActionId: number | null; skippedActionIds: number[]; draggableActions: boolean; + actionForm: ActionForm; + filteredActionIds: number[]; hideMainButtons: boolean | undefined; hideActionButtons: boolean | undefined; styling: StylingFunction; @@ -40,18 +42,20 @@ interface Props> { onCommit: () => void; onSweep: () => void; onReorderAction: (actionId: number, beforeActionId: number) => void; + onActionFormChange: (formValues: Partial) => void; currentActionId: number; lastActionId: number; } export default class ActionList< + S, A extends Action -> extends PureComponent> { +> extends PureComponent> { node?: HTMLDivElement | null; scrollDown?: boolean; drake?: Drake; - UNSAFE_componentWillReceiveProps(nextProps: Props) { + UNSAFE_componentWillReceiveProps(nextProps: Props) { const node = this.node; if (!node) { this.scrollDown = true; @@ -119,24 +123,16 @@ export default class ActionList< startActionId, onSelect, onSearch, - searchValue, currentActionId, hideMainButtons, hideActionButtons, onCommit, onSweep, + onActionFormChange, onJumpToState, + actionForm, + filteredActionIds, } = this.props; - const lowerSearchValue = searchValue && searchValue.toLowerCase(); - const filteredActionIds = searchValue - ? actionIds.filter( - (id) => - (actions[id].action.type as string) - .toLowerCase() - .indexOf(lowerSearchValue as string) !== -1 - ) - : actionIds; - return (
0} hasStagedActions={actionIds.length > 1} />
- {filteredActionIds.map((actionId) => ( + {filteredActionIds.map((actionId: number) => ( [hasSkippedActions && 'Sweep', 'Commit'].filter( (a): a is 'Sweep' | 'Commit' => !!a ); +const toggleButton = { + label: { + rtkq(val: boolean) { + return val ? 'Show rtk-query actions' : 'Hide rtk-query actions'; + }, + noop(val: boolean) { + return val ? 'Show noop actions' : 'Hide noop actions'; + }, + invertSearch(val: boolean) { + return val ? 'Disable inverse search' : 'Activate inverse search'; + }, + }, +}; + interface Props { styling: StylingFunction; onSearch: (value: string) => void; @@ -16,6 +31,8 @@ interface Props { hideMainButtons: boolean | undefined; hasSkippedActions: boolean; hasStagedActions: boolean; + actionForm: ActionForm; + onActionFormChange: (formValues: Partial) => void; } const ActionListHeader: FunctionComponent = ({ @@ -26,41 +43,94 @@ const ActionListHeader: FunctionComponent = ({ onCommit, onSweep, hideMainButtons, -}) => ( -
- onSearch(e.target.value)} - placeholder="filter..." - /> - {!hideMainButtons && ( -
- -
- {getActiveButtons(hasSkippedActions).map((btn) => ( -
- ({ - Commit: onCommit, - Sweep: onSweep, - }[btn]()) - } - {...styling( - ['selectorButton', 'selectorButtonSmall'], - false, - true - )} - > - {btn} -
- ))} -
-
+ actionForm, + onActionFormChange, +}) => { + const { isNoopFilterActive, isRtkQueryFilterActive, isInvertSearchActive } = + actionForm; + + return ( + <> +
+ onSearch(e.target.value)} + placeholder="filter..." + /> +
+ + + +
- )} -
-); + {!hideMainButtons && ( +
+
+ +
+ {getActiveButtons(hasSkippedActions).map((btn) => ( +
+ ({ + Commit: onCommit, + Sweep: onSweep, + }[btn]()) + } + {...styling( + ['selectorButton', 'selectorButtonSmall'], + false, + true + )} + > + {btn} +
+ ))} +
+
+
+
+ )} + + ); +}; ActionListHeader.propTypes = { styling: PropTypes.func.isRequired, @@ -70,6 +140,13 @@ ActionListHeader.propTypes = { hideMainButtons: PropTypes.bool, hasSkippedActions: PropTypes.bool.isRequired, hasStagedActions: PropTypes.bool.isRequired, + actionForm: PropTypes.shape({ + searchValue: PropTypes.string.isRequired, + isNoopFilterActive: PropTypes.bool.isRequired, + isRtkQueryFilterActive: PropTypes.bool.isRequired, + isInvertSearchActive: PropTypes.bool.isRequired, + }).isRequired, + onActionFormChange: PropTypes.func.isRequired, }; export default ActionListHeader; diff --git a/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx b/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx index aa9803fa..a2f815f2 100644 --- a/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx +++ b/packages/redux-devtools-inspector-monitor/src/DevtoolsInspector.tsx @@ -21,12 +21,17 @@ import ActionList from './ActionList'; import ActionPreview, { Tab } from './ActionPreview'; import getInspectedState from './utils/getInspectedState'; import createDiffPatcher from './createDiffPatcher'; +import debounce from 'lodash.debounce'; import { + ActionForm, + changeActionFormValues, DevtoolsInspectorAction, DevtoolsInspectorState, reducer, updateMonitorState, } from './redux'; +import { makeSelectFilteredActions } from './utils/filters'; +import { computeSelectorSource } from './utils/selectors'; // eslint-disable-next-line @typescript-eslint/unbound-method const { @@ -233,6 +238,8 @@ class DevtoolsInspector> extends PureComponent< updateSizeTimeout?: number; inspectorRef?: HTMLDivElement | null; + selectorsSource = computeSelectorSource(this.props); + componentDidMount() { this.updateSizeMode(); this.updateSizeTimeout = window.setInterval( @@ -249,6 +256,10 @@ class DevtoolsInspector> extends PureComponent< this.props.dispatch(updateMonitorState(monitorState)); }; + handleActionFormChange = (formValues: Partial) => { + this.props.dispatch(changeActionFormValues(formValues)); + }; + updateSizeMode() { const isWideLayout = this.inspectorRef!.offsetWidth > 500; @@ -286,6 +297,8 @@ class DevtoolsInspector> extends PureComponent< this.inspectorRef = node; }; + selectFilteredActions = makeSelectFilteredActions(); + render() { const { stagedActionIds: actionIds, @@ -301,7 +314,7 @@ class DevtoolsInspector> extends PureComponent< hideMainButtons, hideActionButtons, } = this.props; - const { selectedActionId, startActionId, searchValue, tabName } = + const { selectedActionId, startActionId, actionForm, tabName } = monitorState; const inspectedPathType = tabName === 'Action' ? 'inspectedActionPath' : 'inspectedStatePath'; @@ -309,6 +322,12 @@ class DevtoolsInspector> extends PureComponent< this.state; const { base16Theme, styling } = themeState; + this.selectorsSource = computeSelectorSource( + this.props, + this.selectorsSource + ); + const filteredActionIds = this.selectFilteredActions(this.selectorsSource); + return (
> extends PureComponent< actions, actionIds, isWideLayout, - searchValue, selectedActionId, startActionId, skippedActionIds, @@ -332,7 +350,10 @@ class DevtoolsInspector> extends PureComponent< hideActionButtons, styling, }} + actionForm={actionForm} + filteredActionIds={filteredActionIds} onSearch={this.handleSearch} + onActionFormChange={this.handleActionFormChange} onSelect={this.handleSelectAction} onToggleAction={this.handleToggleAction} onJumpToState={this.handleJumpToState} @@ -399,9 +420,9 @@ class DevtoolsInspector> extends PureComponent< this.props.dispatch(sweep()); }; - handleSearch = (val: string) => { - this.updateMonitorState({ searchValue: val }); - }; + handleSearch = debounce((val: string) => { + this.handleActionFormChange({ searchValue: val }); + }, 200); handleSelectAction = ( e: React.MouseEvent, diff --git a/packages/redux-devtools-inspector-monitor/src/redux.ts b/packages/redux-devtools-inspector-monitor/src/redux.ts index 93b2835d..2553042d 100644 --- a/packages/redux-devtools-inspector-monitor/src/redux.ts +++ b/packages/redux-devtools-inspector-monitor/src/redux.ts @@ -4,17 +4,40 @@ import { DevtoolsInspectorProps } from './DevtoolsInspector'; const UPDATE_MONITOR_STATE = '@@redux-devtools-inspector-monitor/UPDATE_MONITOR_STATE'; +const ACTION_FORM_VALUE_CHANGE = + '@@redux-devtools-inspector-monitor/ACTION_FORM_VALUE_CHANGE'; + +export interface ActionForm { + readonly searchValue: string; + readonly isNoopFilterActive: boolean; + readonly isRtkQueryFilterActive: boolean; + readonly isInvertSearchActive: boolean; +} export interface UpdateMonitorStateAction { type: typeof UPDATE_MONITOR_STATE; monitorState: Partial; } + +export interface ChangeActionFormAction { + type: typeof ACTION_FORM_VALUE_CHANGE; + formValues: Partial; +} + export function updateMonitorState( monitorState: Partial ): UpdateMonitorStateAction { return { type: UPDATE_MONITOR_STATE, monitorState }; } -export type DevtoolsInspectorAction = UpdateMonitorStateAction; +export function changeActionFormValues( + formValues: Partial +): ChangeActionFormAction { + return { type: ACTION_FORM_VALUE_CHANGE, formValues }; +} + +export type DevtoolsInspectorAction = + | UpdateMonitorStateAction + | ChangeActionFormAction; export interface DevtoolsInspectorState { selectedActionId: number | null; @@ -22,7 +45,7 @@ export interface DevtoolsInspectorState { inspectedActionPath: (string | number)[]; inspectedStatePath: (string | number)[]; tabName: string; - searchValue?: string; + actionForm: ActionForm; } export const DEFAULT_STATE: DevtoolsInspectorState = { @@ -31,18 +54,35 @@ export const DEFAULT_STATE: DevtoolsInspectorState = { inspectedActionPath: [], inspectedStatePath: [], tabName: 'Diff', + actionForm: { + searchValue: '', + isNoopFilterActive: false, + isRtkQueryFilterActive: false, + isInvertSearchActive: false, + }, }; -function reduceUpdateState( +function internalMonitorActionReducer( state: DevtoolsInspectorState, action: DevtoolsInspectorAction -) { - return action.type === UPDATE_MONITOR_STATE - ? { +): DevtoolsInspectorState { + switch (action.type) { + case UPDATE_MONITOR_STATE: + return { ...state, ...action.monitorState, - } - : state; + }; + case ACTION_FORM_VALUE_CHANGE: + return { + ...state, + actionForm: { + ...state.actionForm, + ...action.formValues, + }, + }; + default: + return state; + } } export function reducer>( @@ -51,6 +91,6 @@ export function reducer>( action: DevtoolsInspectorAction ) { return { - ...reduceUpdateState(state, action), + ...internalMonitorActionReducer(state, action), }; } diff --git a/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts b/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts index a8e5f88f..f38f59c8 100644 --- a/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts +++ b/packages/redux-devtools-inspector-monitor/src/utils/createStylingFromTheme.ts @@ -1,4 +1,4 @@ -import jss, { Styles, StyleSheet } from 'jss'; +import jss, { StyleSheet } from 'jss'; import preset from 'jss-preset-default'; import { createStyling } from 'react-base16-styling'; import rgba from 'hex-rgba'; @@ -33,6 +33,8 @@ const colorMap = (theme: Base16Theme) => ({ LINK_COLOR: rgba(theme.base0E, 90), LINK_HOVER_COLOR: theme.base0E, ERROR_COLOR: theme.base08, + TOGGLE_BUTTON_BACKGROUND: rgba(theme.base00, 70), + TOGGLE_BUTTON_SELECTED_BACKGROUND: theme.base04, }); type Color = keyof ReturnType; @@ -83,6 +85,11 @@ const getSheetFromColorMap = (map: ColorMap) => ({ 'border-color': map.LIST_BORDER_COLOR, }, + actionListHeaderSecondRow: { + padding: '5px 10px', + justifyContent: 'flex-end', + }, + actionListRows: { overflow: 'auto', @@ -106,7 +113,6 @@ const getSheetFromColorMap = (map: ColorMap) => ({ actionListHeaderSelector: { display: 'inline-flex', - 'margin-right': '10px', }, actionListWide: { @@ -330,6 +336,55 @@ const getSheetFromColorMap = (map: ColorMap) => ({ 'background-color': map.TAB_BACK_SELECTED_COLOR, }, + toggleButtonWrapper: { + display: 'flex', + height: 20, + margin: 0, + padding: '0 10px 0 0', + '& > *': { + height: '100%', + }, + }, + + toggleButton: { + color: 'inherit', + cursor: 'pointer', + position: 'relative', + padding: '0px 4px', + fontSize: '0.7em', + letterSpacing: '-0.7px', + outline: 'none', + boxShadow: 'none', + fontWeight: '700', + 'border-style': 'solid', + 'border-width': '1px', + 'border-left-width': 0, + + '&:first-child': { + 'border-left-width': '1px', + 'border-top-left-radius': '3px', + 'border-bottom-left-radius': '3px', + }, + + '&:last-child': { + 'border-top-right-radius': '3px', + 'border-bottom-right-radius': '3px', + }, + + '&:hover': { + 'background-color': map.TAB_BACK_SELECTED_COLOR, + }, + + 'background-color': map.TOGGLE_BUTTON_BACKGROUND, + + 'border-color': map.TAB_BORDER_COLOR, + + '&[aria-pressed="true"]': { + color: map.BACKGROUND_COLOR, + backgroundColor: map.TOGGLE_BUTTON_SELECTED_BACKGROUND, + }, + }, + diff: { padding: '2px 3px', 'border-radius': '3px', diff --git a/packages/redux-devtools-inspector-monitor/src/utils/filters.ts b/packages/redux-devtools-inspector-monitor/src/utils/filters.ts new file mode 100644 index 00000000..ec3f8008 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/filters.ts @@ -0,0 +1,152 @@ +import type { Action } from 'redux'; +import type { LiftedState, PerformAction } from '@redux-devtools/core'; +import { ActionForm } from '../redux'; +import { makeSelectRtkQueryActionRegex } from './rtk-query'; +import { createShallowEqualSelector, SelectorsSource } from './selectors'; + +type ComputedStates = LiftedState< + S, + Action, + unknown +>['computedStates']; + +function isNoopAction( + actionId: number, + computedStates: ComputedStates +): boolean { + return ( + actionId === 0 || + computedStates[actionId]?.state === computedStates[actionId - 1]?.state + ); +} + +function filterStateChangingAction( + actionIds: number[], + computedStates: ComputedStates +): number[] { + return actionIds.filter( + (actionId) => !isNoopAction(actionId, computedStates) + ); +} + +function filterActionsBySearchValue>( + searchValue: string | undefined, + actionIds: number[], + actions: Record> +): number[] { + const lowerSearchValue = searchValue && searchValue.toLowerCase(); + + if (!lowerSearchValue || !actionIds.length) { + return actionIds; + } + + return actionIds.filter((id) => { + const type = actions[id].action.type; + + return ( + type != null && + `${type as string}`.toLowerCase().includes(lowerSearchValue) + ); + }); +} + +function filterOutRtkQueryActions( + actionIds: number[], + actions: Record>>, + rtkQueryRegex: RegExp | null +) { + if (!rtkQueryRegex || actionIds.length === 0) { + return actionIds; + } + + return actionIds.filter((actionId) => { + const type = actions[actionId].action.type; + + return typeof type !== 'string' || !rtkQueryRegex.test(type); + }); +} + +function invertSearchResults( + actionIds: number[], + filteredActionIds: number[] +): number[] { + if ( + actionIds.length === 0 || + actionIds.length === filteredActionIds.length || + filteredActionIds.length === 0 + ) { + return actionIds; + } + + const filteredSet = new Set(filteredActionIds); + + return actionIds.filter((actionId) => !filteredSet.has(actionId)); +} + +export interface FilterActionsPayload> { + readonly actionIds: number[]; + readonly actions: Record>; + readonly computedStates: ComputedStates; + readonly actionForm: ActionForm; + readonly rtkQueryRegex: RegExp | null; +} + +function filterActions>({ + actionIds, + actions, + computedStates, + actionForm, + rtkQueryRegex, +}: FilterActionsPayload): number[] { + let output = filterActionsBySearchValue( + actionForm.searchValue, + actionIds, + actions + ); + + if (actionForm.isNoopFilterActive) { + output = filterStateChangingAction(output, computedStates); + } + + if (actionForm.isRtkQueryFilterActive && rtkQueryRegex) { + output = filterOutRtkQueryActions(output, actions, rtkQueryRegex); + } + + if (actionForm.isInvertSearchActive) { + output = invertSearchResults(actionIds, output); + } + + return output; +} + +export interface SelectFilteredActions> { + (selectorsSource: SelectorsSource): number[]; +} + +/** + * Creates a selector that given `SelectorsSource` returns + * a list of filtered `actionsIds`. + * @returns {number[]} + */ +export function makeSelectFilteredActions< + S, + A extends Action +>(): SelectFilteredActions { + const selectRegex = makeSelectRtkQueryActionRegex(); + + return createShallowEqualSelector( + (selectorsSource: SelectorsSource): FilterActionsPayload => { + const actionForm = selectorsSource.monitorState.actionForm; + const { actionIds, actions, computedStates } = selectorsSource; + + return { + actionIds, + actions, + computedStates, + actionForm, + rtkQueryRegex: selectRegex(selectorsSource), + }; + }, + filterActions + ); +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/object.ts b/packages/redux-devtools-inspector-monitor/src/utils/object.ts new file mode 100644 index 00000000..98b886f6 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/object.ts @@ -0,0 +1,28 @@ +/** + * Borrowed from `react-redux`. + * @param {unknown} obj The object to inspect. + * @returns {boolean} True if the argument appears to be a plain object. + * @see https://github.com/reduxjs/react-redux/blob/2c7ef25a0704efcf10e41112d88ae9867e946d10/src/utils/isPlainObject.js + */ +export function isPlainObject(obj: unknown): obj is Record { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const proto = Object.getPrototypeOf(obj); + + if (proto === null) { + return true; + } + + let baseProto = proto; + while (Object.getPrototypeOf(baseProto) !== null) { + baseProto = Object.getPrototypeOf(baseProto); + } + + return proto === baseProto; +} + +export function identity(val: T): T { + return val; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/regexp.ts b/packages/redux-devtools-inspector-monitor/src/utils/regexp.ts new file mode 100644 index 00000000..5901ecb2 --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/regexp.ts @@ -0,0 +1,17 @@ +// https://stackoverflow.com/a/9310752 +export function escapeRegExpSpecialCharacter(text: string): string { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + +/** + * ```ts + * const entries = ['a', 'b', 'c', 'd.']; + * + * oneOfGroup(entries) // returns "(a|b|c|d\\.)" + * ``` + * @param onOf + * @returns + */ +export function oneOfGroup(onOf: string[]): string { + return `(${onOf.map(escapeRegExpSpecialCharacter).join('|')})`; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/rtk-query.ts b/packages/redux-devtools-inspector-monitor/src/utils/rtk-query.ts new file mode 100644 index 00000000..de4cefcd --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/rtk-query.ts @@ -0,0 +1,107 @@ +import { Action } from 'redux'; +import { createSelector } from 'reselect'; +import { isPlainObject } from './object'; +import { oneOfGroup } from './regexp'; +import { createShallowEqualSelector, SelectorsSource } from './selectors'; + +interface RtkQueryApiState { + queries: Record; + mutations: Record; + config: Record; + provided: Record; + subscriptions: Record; +} + +const rtkqueryApiStateKeys: ReadonlyArray = [ + 'queries', + 'mutations', + 'config', + 'provided', + 'subscriptions', +]; + +/** + * Type guard used to select apis from the user store state. + * @param val + * @returns {boolean} + */ +export function isApiSlice(val: unknown): val is RtkQueryApiState { + if (!isPlainObject(val)) { + return false; + } + + for (let i = 0, len = rtkqueryApiStateKeys.length; i < len; i++) { + if (!isPlainObject(val[rtkqueryApiStateKeys[i]])) { + return false; + } + } + + return true; +} + +function getApiReducerPaths(currentUserState: unknown): string[] | null { + if (!isPlainObject(currentUserState)) { + return null; + } + + const userStateKeys = Object.keys(currentUserState); + const output: string[] = []; + + for (const key of userStateKeys) { + if (isApiSlice(currentUserState[key])) { + output.push(key); + } + } + + return output; +} + +const knownRtkQueryActionPrefixes = oneOfGroup([ + 'executeQuery', + 'executeMutation', + 'config', + 'subscriptions', + 'invalidation', + 'mutations', + 'queries', +]); + +/** + * Returns a regex that matches rtk query actions from an array of api + * `reducerPaths`. + * @param reducerPaths list of rtkQuery reducerPaths in user state. + * @returns + */ +function generateRtkQueryActionRegex( + reducerPaths: string[] | null +): RegExp | null { + if (!reducerPaths?.length) { + return null; + } + + return new RegExp( + `^${oneOfGroup(reducerPaths)}/${knownRtkQueryActionPrefixes}` + ); +} + +export interface SelectRTKQueryActionRegex> { + (selectorsSource: SelectorsSource): RegExp | null; +} + +export function makeSelectRtkQueryActionRegex< + S, + A extends Action +>(): SelectRTKQueryActionRegex { + const selectApiReducerPaths = createSelector( + (source: SelectorsSource) => + source.computedStates[source.currentStateIndex]?.state, + getApiReducerPaths + ); + + const selectRtkQueryActionRegex = createShallowEqualSelector( + selectApiReducerPaths, + generateRtkQueryActionRegex + ); + + return selectRtkQueryActionRegex; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/selectors.ts b/packages/redux-devtools-inspector-monitor/src/utils/selectors.ts new file mode 100644 index 00000000..790e83dc --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/selectors.ts @@ -0,0 +1,55 @@ +import { shallowEqual } from 'react-redux'; +import { Action } from 'redux'; +import type { LiftedState, PerformAction } from '@redux-devtools/core'; +import { createSelectorCreator, defaultMemoize } from 'reselect'; +import { DevtoolsInspectorState } from '../redux'; +import { DevtoolsInspectorProps } from '../DevtoolsInspector'; + +/** + * @see https://github.com/reduxjs/reselect#customize-equalitycheck-for-defaultmemoize + */ +export const createShallowEqualSelector = createSelectorCreator( + defaultMemoize, + shallowEqual +); + +type ComputedStates = LiftedState< + S, + Action, + unknown +>['computedStates']; + +export interface SelectorsSource> { + readonly actionIds: number[]; + readonly actions: Record>; + readonly computedStates: ComputedStates; + readonly monitorState: DevtoolsInspectorState; + readonly currentStateIndex: number; +} + +export function computeSelectorSource>( + props: DevtoolsInspectorProps, + previous: SelectorsSource | null = null +): SelectorsSource { + const { + computedStates, + currentStateIndex, + monitorState, + stagedActionIds, + actionsById, + } = props; + + const next: SelectorsSource = { + currentStateIndex, + monitorState, + computedStates, + actions: actionsById, + actionIds: stagedActionIds, + }; + + if (previous && shallowEqual(next, previous)) { + return previous; + } + + return next; +} diff --git a/packages/redux-devtools-inspector-monitor/src/utils/shallowEqual.ts b/packages/redux-devtools-inspector-monitor/src/utils/shallowEqual.ts new file mode 100644 index 00000000..536ffd1d --- /dev/null +++ b/packages/redux-devtools-inspector-monitor/src/utils/shallowEqual.ts @@ -0,0 +1,47 @@ +function is(x: unknown, y: unknown): boolean { + if (x === y) { + return x !== 0 || y !== 0 || 1 / x === 1 / y; + } else { + return x !== x && y !== y; + } +} + +/** + * Shallow equal algorithm borrowed from react-redux. + * @see https://github.com/reduxjs/react-redux/blob/2c7ef25a0704efcf10e41112d88ae9867e946d10/src/utils/shallowEqual.js + */ +export default function shallowEqual(objA: unknown, objB: unknown): boolean { + if (is(objA, objB)) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + !is( + (objA as Record)[keysA[i]], + (objB as Record)[keysA[i]] + ) + ) { + return false; + } + } + + return true; +} diff --git a/yarn.lock b/yarn.lock index d8b3b22b..9267007f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3992,6 +3992,7 @@ __metadata: dependencies: "@redux-devtools/core": ^3.9.0 "@redux-devtools/dock-monitor": ^1.4.0 + "@reduxjs/toolkit": ^1.6.0 "@types/dateformat": ^3.0.1 "@types/dragula": ^3.7.1 "@types/hex-rgba": ^1.0.1 @@ -4026,6 +4027,7 @@ __metadata: redux: ^4.1.1 redux-devtools-themes: ^1.0.0 redux-logger: ^3.0.6 + reselect: ^4.0.0 seamless-immutable: ^7.1.4 peerDependencies: "@redux-devtools/core": ^3.7.0 @@ -4124,6 +4126,26 @@ __metadata: languageName: unknown linkType: soft +"@reduxjs/toolkit@npm:^1.6.0": + version: 1.6.1 + resolution: "@reduxjs/toolkit@npm:1.6.1" + dependencies: + immer: ^9.0.1 + redux: ^4.1.0 + redux-thunk: ^2.3.0 + reselect: ^4.0.0 + peerDependencies: + react: ^16.14.0 || ^17.0.0 + react-redux: ^7.2.1 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: fc7f8211a74e4ccb246870e9f3dddbd2f9a79ce50ed3c3bb68a59af2b279712e0cba0690479416ef3fea6cfcc1e8d257da8e6a4a49a306d38d83d80182329cfb + languageName: node + linkType: hard + "@restart/context@npm:^2.1.4": version: 2.1.4 resolution: "@restart/context@npm:2.1.4" @@ -15552,6 +15574,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"immer@npm:^9.0.1": + version: 9.0.5 + resolution: "immer@npm:9.0.5" + checksum: a7fa984fa1887a33ce6d44a7a505fd5ac76009336d8b1c99d34f59aaefc28aadf93ab1e5db27513acd15be454a8a89d8151e915d9b0b6e86e72acbd28218410b + languageName: node + linkType: hard + "immutable@npm:^3.8.1 || ^4.0.0-rc.1, immutable@npm:^4.0.0-rc.12": version: 4.0.0-rc.12 resolution: "immutable@npm:4.0.0-rc.12" @@ -24067,6 +24096,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"reselect@npm:^4.0.0": + version: 4.0.0 + resolution: "reselect@npm:4.0.0" + checksum: 3480930929f673f12962cdde140dce48ea8ba171cd428bb2c7639672e41770bd6b64e935bc0400f47cfa960f617c7ac068c4309527373825d11e27262f08c0a3 + languageName: node + linkType: hard + "resolve-cwd@npm:^2.0.0": version: 2.0.0 resolution: "resolve-cwd@npm:2.0.0"