This commit is contained in:
Fabrizio Vitale 2021-08-26 15:45:29 +00:00 committed by GitHub
commit 4b699e2eba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1011 additions and 88 deletions

View File

@ -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',
};

View File

@ -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 (
<div
style={{
float: 'left',
textAlign: 'center',
...(isFetching ? { background: '#e6ffe8' } : {}),
}}
>
{error ? (
<>Oh no, there was an error loading {name}</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<div style={{ minWidth: 96, minHeight: 96 }}>
<img
src={data.sprites.front_shiny}
alt={data.species.name}
style={{ ...(isFetching ? { opacity: 0.3 } : {}) }}
/>
</div>
<div>
<label style={{ display: 'block' }}>Polling interval</label>
<select
value={pollingInterval}
onChange={({ target: { value } }) =>
setPollingInterval(Number(value))
}
>
{intervalOptions.map(({ label, value }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div>
<button onClick={refetch} disabled={isFetching}>
{isFetching ? 'Loading' : 'Manually refetch'}
</button>
</div>
</>
) : (
'No Data'
)}
</div>
);
};

View File

@ -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<PokemonName[]>(['bulbasaur']);
return (
<div className="App">
<div>
<button
onClick={() =>
setPokemon((prev) => [...prev, getRandomPokemonName()])
}
>
Add random pokemon
</button>
<button onClick={() => setPokemon((prev) => [...prev, 'bulbasaur'])}>
Add bulbasaur
</button>
</div>
{pokemon.map((name, index) => (
<Pokemon key={index} name={name} />
))}
</div>
);
}

View File

@ -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<Props> {
Shuffle Array
</Button>
</div>
<PokemonView />
</div>
<div style={styles.links}>
<a onClick={this.toggleExtension} style={styles.link}>

View File

@ -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 = () => (
<div

View File

@ -1,8 +1,9 @@
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { pokemonApi } from '../js/rtk-query/pokemonApi';
import {
createStore,
applyMiddleware,
compose,
StoreEnhancerStoreCreator,
@ -13,20 +14,17 @@ import { Route } from 'react-router';
import { createBrowserHistory } from 'history';
import { ConnectedRouter, routerMiddleware } from 'connected-react-router';
import { persistState } from '@redux-devtools/core';
import DemoApp from './DemoApp';
import DemoApp from './containers/DemoApp';
import createRootReducer from './reducers';
import getOptions from './getOptions';
import { ConnectedDevTools, getDevTools } from './DevTools';
import { ConnectedDevTools, getDevTools } from './containers/DevTools';
function getDebugSessionKey() {
const matches = /[?&]debug_session=([^&#]+)\b/.exec(window.location.href);
return matches && matches.length > 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(
<Provider store={store}>

View File

@ -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<typeof pokemonApi.reducer>;
}
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;

View File

@ -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];

View File

@ -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;

View File

@ -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",

View File

@ -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<A extends Action<unknown>>(
actions: { [actionId: number]: PerformAction<A> },
@ -21,15 +22,16 @@ function getTimestamps<A extends Action<unknown>>(
};
}
interface Props<A extends Action<unknown>> {
interface Props<S, A extends Action<unknown>> {
actions: { [actionId: number]: PerformAction<A> };
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<A extends Action<unknown>> {
onCommit: () => void;
onSweep: () => void;
onReorderAction: (actionId: number, beforeActionId: number) => void;
onActionFormChange: (formValues: Partial<ActionForm>) => void;
currentActionId: number;
lastActionId: number;
}
export default class ActionList<
S,
A extends Action<unknown>
> extends PureComponent<Props<A>> {
> extends PureComponent<Props<S, A>> {
node?: HTMLDivElement | null;
scrollDown?: boolean;
drake?: Drake;
UNSAFE_componentWillReceiveProps(nextProps: Props<A>) {
UNSAFE_componentWillReceiveProps(nextProps: Props<S, A>) {
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 (
<div
key="actionList"
@ -150,12 +146,14 @@ export default class ActionList<
onSearch={onSearch}
onCommit={onCommit}
onSweep={onSweep}
actionForm={actionForm}
onActionFormChange={onActionFormChange}
hideMainButtons={hideMainButtons}
hasSkippedActions={skippedActionIds.length > 0}
hasStagedActions={actionIds.length > 1}
/>
<div {...styling('actionListRows')} ref={this.getRef}>
{filteredActionIds.map((actionId) => (
{filteredActionIds.map((actionId: number) => (
<ActionListRow
key={actionId}
styling={styling}

View File

@ -2,12 +2,27 @@ import React, { FunctionComponent } from 'react';
import PropTypes from 'prop-types';
import { StylingFunction } from 'react-base16-styling';
import RightSlider from './RightSlider';
import { ActionForm } from './redux';
const getActiveButtons = (hasSkippedActions: boolean): ('Sweep' | 'Commit')[] =>
[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<ActionForm>) => void;
}
const ActionListHeader: FunctionComponent<Props> = ({
@ -26,41 +43,94 @@ const ActionListHeader: FunctionComponent<Props> = ({
onCommit,
onSweep,
hideMainButtons,
}) => (
<div {...styling('actionListHeader')}>
<input
{...styling('actionListHeaderSearch')}
onChange={(e) => onSearch(e.target.value)}
placeholder="filter..."
/>
{!hideMainButtons && (
<div {...styling('actionListHeaderWrapper')}>
<RightSlider shown={hasStagedActions} styling={styling}>
<div {...styling('actionListHeaderSelector')}>
{getActiveButtons(hasSkippedActions).map((btn) => (
<div
key={btn}
onClick={() =>
({
Commit: onCommit,
Sweep: onSweep,
}[btn]())
}
{...styling(
['selectorButton', 'selectorButtonSmall'],
false,
true
)}
>
{btn}
</div>
))}
</div>
</RightSlider>
actionForm,
onActionFormChange,
}) => {
const { isNoopFilterActive, isRtkQueryFilterActive, isInvertSearchActive } =
actionForm;
return (
<>
<div {...styling('actionListHeader')}>
<input
{...styling('actionListHeaderSearch')}
onChange={(e) => onSearch(e.target.value)}
placeholder="filter..."
/>
<div {...styling('toggleButtonWrapper')}>
<button
title={toggleButton.label.noop(isNoopFilterActive)}
aria-label={toggleButton.label.noop(isNoopFilterActive)}
aria-pressed={isNoopFilterActive}
onClick={() =>
onActionFormChange({ isNoopFilterActive: !isNoopFilterActive })
}
type="button"
{...styling('toggleButton')}
>
noop
</button>
<button
title={toggleButton.label.rtkq(isRtkQueryFilterActive)}
aria-label={toggleButton.label.rtkq(isRtkQueryFilterActive)}
aria-pressed={isRtkQueryFilterActive}
onClick={() =>
onActionFormChange({
isRtkQueryFilterActive: !isRtkQueryFilterActive,
})
}
type="button"
{...styling('toggleButton')}
>
RTKQ
</button>
<button
title={toggleButton.label.invertSearch(isInvertSearchActive)}
aria-label={toggleButton.label.invertSearch(isInvertSearchActive)}
aria-pressed={isInvertSearchActive}
onClick={() =>
onActionFormChange({
isInvertSearchActive: !isInvertSearchActive,
})
}
type="button"
{...styling('toggleButton')}
>
!
</button>
</div>
</div>
)}
</div>
);
{!hideMainButtons && (
<div {...styling(['actionListHeader', 'actionListHeaderSecondRow'])}>
<div {...styling('actionListHeaderWrapper')}>
<RightSlider shown={hasStagedActions} styling={styling}>
<div {...styling('actionListHeaderSelector')}>
{getActiveButtons(hasSkippedActions).map((btn) => (
<div
key={btn}
onClick={() =>
({
Commit: onCommit,
Sweep: onSweep,
}[btn]())
}
{...styling(
['selectorButton', 'selectorButtonSmall'],
false,
true
)}
>
{btn}
</div>
))}
</div>
</RightSlider>
</div>
</div>
)}
</>
);
};
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;

View File

@ -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<S, A extends Action<unknown>> extends PureComponent<
updateSizeTimeout?: number;
inspectorRef?: HTMLDivElement | null;
selectorsSource = computeSelectorSource(this.props);
componentDidMount() {
this.updateSizeMode();
this.updateSizeTimeout = window.setInterval(
@ -249,6 +256,10 @@ class DevtoolsInspector<S, A extends Action<unknown>> extends PureComponent<
this.props.dispatch(updateMonitorState(monitorState));
};
handleActionFormChange = (formValues: Partial<ActionForm>) => {
this.props.dispatch(changeActionFormValues(formValues));
};
updateSizeMode() {
const isWideLayout = this.inspectorRef!.offsetWidth > 500;
@ -286,6 +297,8 @@ class DevtoolsInspector<S, A extends Action<unknown>> extends PureComponent<
this.inspectorRef = node;
};
selectFilteredActions = makeSelectFilteredActions();
render() {
const {
stagedActionIds: actionIds,
@ -301,7 +314,7 @@ class DevtoolsInspector<S, A extends Action<unknown>> 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<S, A extends Action<unknown>> extends PureComponent<
this.state;
const { base16Theme, styling } = themeState;
this.selectorsSource = computeSelectorSource(
this.props,
this.selectorsSource
);
const filteredActionIds = this.selectFilteredActions(this.selectorsSource);
return (
<div
key="inspector"
@ -323,7 +342,6 @@ class DevtoolsInspector<S, A extends Action<unknown>> extends PureComponent<
actions,
actionIds,
isWideLayout,
searchValue,
selectedActionId,
startActionId,
skippedActionIds,
@ -332,7 +350,10 @@ class DevtoolsInspector<S, A extends Action<unknown>> 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<S, A extends Action<unknown>> 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<HTMLDivElement>,

View File

@ -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<DevtoolsInspectorState>;
}
export interface ChangeActionFormAction {
type: typeof ACTION_FORM_VALUE_CHANGE;
formValues: Partial<ActionForm>;
}
export function updateMonitorState(
monitorState: Partial<DevtoolsInspectorState>
): UpdateMonitorStateAction {
return { type: UPDATE_MONITOR_STATE, monitorState };
}
export type DevtoolsInspectorAction = UpdateMonitorStateAction;
export function changeActionFormValues(
formValues: Partial<ActionForm>
): 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<S, A extends Action<unknown>>(
@ -51,6 +91,6 @@ export function reducer<S, A extends Action<unknown>>(
action: DevtoolsInspectorAction
) {
return {
...reduceUpdateState(state, action),
...internalMonitorActionReducer(state, action),
};
}

View File

@ -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<typeof colorMap>;
@ -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',

View File

@ -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<S> = LiftedState<
S,
Action<unknown>,
unknown
>['computedStates'];
function isNoopAction<S>(
actionId: number,
computedStates: ComputedStates<S>
): boolean {
return (
actionId === 0 ||
computedStates[actionId]?.state === computedStates[actionId - 1]?.state
);
}
function filterStateChangingAction<S>(
actionIds: number[],
computedStates: ComputedStates<S>
): number[] {
return actionIds.filter(
(actionId) => !isNoopAction(actionId, computedStates)
);
}
function filterActionsBySearchValue<A extends Action<unknown>>(
searchValue: string | undefined,
actionIds: number[],
actions: Record<number, PerformAction<A>>
): 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<number, PerformAction<Action<unknown>>>,
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<S, A extends Action<unknown>> {
readonly actionIds: number[];
readonly actions: Record<number, PerformAction<A>>;
readonly computedStates: ComputedStates<S>;
readonly actionForm: ActionForm;
readonly rtkQueryRegex: RegExp | null;
}
function filterActions<S, A extends Action<unknown>>({
actionIds,
actions,
computedStates,
actionForm,
rtkQueryRegex,
}: FilterActionsPayload<S, A>): 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<S, A extends Action<unknown>> {
(selectorsSource: SelectorsSource<S, A>): number[];
}
/**
* Creates a selector that given `SelectorsSource` returns
* a list of filtered `actionsIds`.
* @returns {number[]}
*/
export function makeSelectFilteredActions<
S,
A extends Action<unknown>
>(): SelectFilteredActions<S, A> {
const selectRegex = makeSelectRtkQueryActionRegex();
return createShallowEqualSelector(
(selectorsSource: SelectorsSource<S, A>): FilterActionsPayload<S, A> => {
const actionForm = selectorsSource.monitorState.actionForm;
const { actionIds, actions, computedStates } = selectorsSource;
return {
actionIds,
actions,
computedStates,
actionForm,
rtkQueryRegex: selectRegex(selectorsSource),
};
},
filterActions
);
}

View File

@ -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<string, unknown> {
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<T>(val: T): T {
return val;
}

View File

@ -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('|')})`;
}

View File

@ -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<string, unknown>;
mutations: Record<string, unknown>;
config: Record<string, unknown>;
provided: Record<string, unknown>;
subscriptions: Record<string, unknown>;
}
const rtkqueryApiStateKeys: ReadonlyArray<keyof RtkQueryApiState> = [
'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<S, A extends Action<unknown>> {
(selectorsSource: SelectorsSource<S, A>): RegExp | null;
}
export function makeSelectRtkQueryActionRegex<
S,
A extends Action<unknown>
>(): SelectRTKQueryActionRegex<S, A> {
const selectApiReducerPaths = createSelector(
(source: SelectorsSource<S, A>) =>
source.computedStates[source.currentStateIndex]?.state,
getApiReducerPaths
);
const selectRtkQueryActionRegex = createShallowEqualSelector(
selectApiReducerPaths,
generateRtkQueryActionRegex
);
return selectRtkQueryActionRegex;
}

View File

@ -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<S> = LiftedState<
S,
Action<unknown>,
unknown
>['computedStates'];
export interface SelectorsSource<S, A extends Action<unknown>> {
readonly actionIds: number[];
readonly actions: Record<number, PerformAction<A>>;
readonly computedStates: ComputedStates<S>;
readonly monitorState: DevtoolsInspectorState;
readonly currentStateIndex: number;
}
export function computeSelectorSource<S, A extends Action<unknown>>(
props: DevtoolsInspectorProps<S, A>,
previous: SelectorsSource<S, A> | null = null
): SelectorsSource<S, A> {
const {
computedStates,
currentStateIndex,
monitorState,
stagedActionIds,
actionsById,
} = props;
const next: SelectorsSource<S, A> = {
currentStateIndex,
monitorState,
computedStates,
actions: actionsById,
actionIds: stagedActionIds,
};
if (previous && shallowEqual(next, previous)) {
return previous;
}
return next;
}

View File

@ -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<string, unknown>)[keysA[i]],
(objB as Record<string, unknown>)[keysA[i]]
)
) {
return false;
}
}
return true;
}

View File

@ -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"