feat: complete inspector layout and add initial JSONTree setup

This commit is contained in:
FaberVitale 2021-06-12 17:16:08 +02:00
parent e9f397bd1e
commit 75aa663d64
17 changed files with 416 additions and 61 deletions

View File

@ -17,7 +17,8 @@
"react-redux": "^7.2.1", "react-redux": "^7.2.1",
"react-scripts": "4.0.2", "react-scripts": "4.0.2",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-devtools-themes": "^1.0.0" "redux-devtools-themes": "^1.0.0",
"react-json-tree": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "17.0.0", "@types/react": "17.0.0",

View File

@ -4,7 +4,10 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Web site created using create-snowpack-app" /> <meta
name="description"
content="Web site created using create-snowpack-app"
/>
<title>Snowpack App</title> <title>Snowpack App</title>
</head> </head>
<body> <body>

View File

@ -10,5 +10,5 @@ export default createDevTools(
changeMonitorKey="ctrl-m" changeMonitorKey="ctrl-m"
> >
<RtkQueryInspectorMonitor /> <RtkQueryInspectorMonitor />
</DockMonitor>, </DockMonitor>
); );

View File

@ -12,5 +12,5 @@ ReactDOM.render(
<App /> <App />
<DevTools /> <DevTools />
</Provider>, </Provider>,
rootElement, rootElement
); );

View File

@ -9,5 +9,5 @@ export const store = configureStore({
// adding the api middleware enables caching, invalidation, polling and other features of `rtk-query` // adding the api middleware enables caching, invalidation, polling and other features of `rtk-query`
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(pokemonApi.middleware), getDefaultMiddleware().concat(pokemonApi.middleware),
enhancers: [DevTools.instrument()] enhancers: [DevTools.instrument()],
}); });

View File

@ -1,14 +1,9 @@
{ {
"include": [ "include": ["./src/**/*"],
"./src/**/*"
],
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"lib": [ "lib": ["dom", "es2015"],
"dom",
"es2015"
],
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "es5", "target": "es5",
"allowJs": true, "allowJs": true,

View File

@ -9411,6 +9411,15 @@ react-is@^17.0.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-json-tree@^0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/react-json-tree/-/react-json-tree-0.15.0.tgz#16a5bbed761f711d1656de6c62818d40ddb09442"
integrity sha512-/bEFXZBfLFiep6ReuzatR8mz9G7sRmejElRDgcAuqY0Jsx7llouax2DM03rlQifrUJgmvTGmPA+olyWYyGagqA==
dependencies:
"@types/prop-types" "^15.7.3"
prop-types "^15.7.2"
react-base16-styling "^0.8.0"
react-redux@^7.2.1: react-redux@^7.2.1:
version "7.2.4" version "7.2.4"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225"

View File

@ -45,7 +45,8 @@
"@redux-devtools/dock-monitor": "^1.4.0", "@redux-devtools/dock-monitor": "^1.4.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"redux-devtools-themes": "^1.0.0", "redux-devtools-themes": "^1.0.0",
"devui": "^1.0.0-8" "devui": "^1.0.0-8",
"react-json-tree": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@redux-devtools/core": "^3.9.0", "@redux-devtools/core": "^3.9.0",

View File

@ -1,4 +1,4 @@
import React, { Component, createRef, CSSProperties, ReactNode } from 'react'; import React, { Component, createRef, ReactNode } from 'react';
import { AnyAction, Dispatch, Action } from 'redux'; import { AnyAction, Dispatch, Action } from 'redux';
import { LiftedAction, LiftedState } from '@redux-devtools/core'; import { LiftedAction, LiftedState } from '@redux-devtools/core';
import * as themes from 'redux-devtools-themes'; import * as themes from 'redux-devtools-themes';
@ -9,11 +9,7 @@ import { selectQueryKey } from './reducers';
import { QueryList } from './components/QueryList'; import { QueryList } from './components/QueryList';
import { StyleUtils } from './styles/createStylingFromTheme'; import { StyleUtils } from './styles/createStylingFromTheme';
import { QueryForm } from './components/QueryForm'; import { QueryForm } from './components/QueryForm';
import { QueryPreview } from './components/QueryPreview';
const wrapperStyle: CSSProperties = {
width: '100%',
height: '100%',
};
type SelectorsSource<S> = { type SelectorsSource<S> = {
currentState: S | null; currentState: S | null;
@ -29,26 +25,34 @@ export interface RtkQueryInspectorProps<S, A extends Action<unknown>>
styleUtils: StyleUtils; styleUtils: StyleUtils;
} }
type RtkQueryInspectorState<S> = { selectorsSource: SelectorsSource<S> }; type RtkQueryInspectorState<S> = {
selectorsSource: SelectorsSource<S>;
isWideLayout: boolean;
};
class RtkQueryInspector<S, A extends Action<unknown>> extends Component< class RtkQueryInspector<S, A extends Action<unknown>> extends Component<
RtkQueryInspectorProps<S, A>, RtkQueryInspectorProps<S, A>,
RtkQueryInspectorState<S> RtkQueryInspectorState<S>
> { > {
divRef = createRef<HTMLDivElement>(); inspectorRef = createRef<HTMLDivElement>();
isWideIntervalRef: number | NodeJS.Timeout | null = null;
constructor(props: RtkQueryInspectorProps<S, A>) { constructor(props: RtkQueryInspectorProps<S, A>) {
super(props); super(props);
this.state = { this.state = {
isWideLayout: true,
selectorsSource: computeSelectorSource(props, null), selectorsSource: computeSelectorSource(props, null),
}; };
} }
static wideLayout = 500;
static getDerivedStateFromProps( static getDerivedStateFromProps(
props: RtkQueryInspectorProps<unknown, Action<unknown>>, props: RtkQueryInspectorProps<unknown, Action<unknown>>,
state: RtkQueryInspectorState<unknown> state: RtkQueryInspectorState<unknown>
): null | RtkQueryInspectorState<unknown> { ): null | Partial<RtkQueryInspectorState<unknown>> {
const selectorsSource = computeSelectorSource<unknown, Action<unknown>>( const selectorsSource = computeSelectorSource<unknown, Action<unknown>>(
props, props,
state.selectorsSource state.selectorsSource
@ -65,12 +69,35 @@ class RtkQueryInspector<S, A extends Action<unknown>> extends Component<
selectors = createInspectorSelectors<S>(); selectors = createInspectorSelectors<S>();
handleSelectQuery = (queryInfo: QueryInfo) => { updateSizeMode = (): void => {
if (this.inspectorRef.current) {
const isWideLayout =
this.inspectorRef.current.offsetWidth > RtkQueryInspector.wideLayout;
if (isWideLayout !== this.state.isWideLayout) {
this.setState({ isWideLayout });
}
}
};
componentDidMount() {
this.updateSizeMode();
this.isWideIntervalRef = setInterval(this.updateSizeMode, 200);
}
componentWillUnmount() {
if (this.isWideIntervalRef) {
clearTimeout(this.isWideIntervalRef as any);
}
}
handleSelectQuery = (queryInfo: QueryInfo): void => {
this.props.dispatch(selectQueryKey(queryInfo) as AnyAction); this.props.dispatch(selectQueryKey(queryInfo) as AnyAction);
}; };
render(): ReactNode { render(): ReactNode {
const { selectorsSource } = this.state; const { selectorsSource, isWideLayout } = this.state;
const { const {
styleUtils: { styling }, styleUtils: { styling },
} = this.props; } = this.props;
@ -79,11 +106,27 @@ class RtkQueryInspector<S, A extends Action<unknown>> extends Component<
selectorsSource selectorsSource
); );
console.log('inspector', { apiStates, allSortedQueries, selectorsSource }); const currentQueryInfo = this.selectors.selectorCurrentQueryInfo(
selectorsSource
);
console.log('inspector', {
apiStates,
allSortedQueries,
selectorsSource,
currentQueryInfo,
});
return ( return (
<div style={wrapperStyle} ref={this.divRef}> <div
<div {...styling('querySectionWrapper')}> ref={this.inspectorRef}
data-wide-layout={+this.state.isWideLayout}
{...styling('inspector')}
>
<div
{...styling('querySectionWrapper')}
data-wide-layout={+this.state.isWideLayout}
>
<QueryForm <QueryForm
dispatch={this.props.dispatch} dispatch={this.props.dispatch}
queryComparator={selectorsSource.monitorState.queryComparator} queryComparator={selectorsSource.monitorState.queryComparator}
@ -97,6 +140,11 @@ class RtkQueryInspector<S, A extends Action<unknown>> extends Component<
selectedQueryKey={selectorsSource.monitorState.selectedQueryKey} selectedQueryKey={selectorsSource.monitorState.selectedQueryKey}
/> />
</div> </div>
<QueryPreview
selectedQueryInfo={currentQueryInfo}
styling={styling}
isWideLayout={isWideLayout}
/>
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { CSSProperties, PureComponent } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as themes from 'redux-devtools-themes'; import * as themes from 'redux-devtools-themes';
import { Action } from 'redux'; import { Action } from 'redux';
@ -15,18 +15,6 @@ import {
StyleUtils, StyleUtils,
StyleUtilsContext, StyleUtilsContext,
} from './styles/createStylingFromTheme'; } from './styles/createStylingFromTheme';
const styles: { container: CSSProperties } = {
container: {
fontFamily: 'monaco, Consolas, Lucida Console, monospace',
position: 'relative',
overflowY: 'hidden',
width: '100%',
height: '100%',
minWidth: 300,
},
};
interface DefaultProps<S> { interface DefaultProps<S> {
select: (state: unknown) => unknown; select: (state: unknown) => unknown;
theme: keyof typeof themes | Base16Theme; theme: keyof typeof themes | Base16Theme;
@ -85,20 +73,18 @@ class RtkQueryInspectorMonitor<
render() { render() {
const { const {
styleUtils: { base16Theme, styling }, styleUtils: { base16Theme },
} = this.state; } = this.state;
const RtkQueryInspectorAsAny = RtkQueryInspector as any; const RtkQueryInspectorAsAny = RtkQueryInspector as any;
return ( return (
<StyleUtilsContext.Provider value={this.state.styleUtils}> <StyleUtilsContext.Provider value={this.state.styleUtils}>
<div {...styling(['inspector'])}>
<RtkQueryInspectorAsAny <RtkQueryInspectorAsAny
{...this.props} {...this.props}
theme={base16Theme} theme={base16Theme}
styleUtils={this.state.styleUtils} styleUtils={this.state.styleUtils}
/> />
</div>
</StyleUtilsContext.Provider> </StyleUtilsContext.Provider>
); );
} }

View File

@ -0,0 +1,96 @@
import React, { ReactNode } from 'react';
import JSONTree from 'react-json-tree';
import { StylingFunction } from 'react-base16-styling';
import { DATA_TYPE_KEY } from '../monitor-config';
import {
getJsonTreeTheme,
StyleUtilsContext,
} from '../styles/createStylingFromTheme';
import { createTreeItemLabelRenderer, getItemString } from '../styles/tree';
import { QueryInfo } from '../types';
export interface QueryPreviewProps {
selectedQueryInfo: QueryInfo | null;
styling: StylingFunction;
isWideLayout: boolean;
}
export class QueryPreview extends React.PureComponent<QueryPreviewProps> {
readonly labelRenderer: ReturnType<typeof createTreeItemLabelRenderer>;
constructor(props: QueryPreviewProps) {
super(props);
this.labelRenderer = createTreeItemLabelRenderer(this.props.styling);
}
render(): ReactNode {
const { selectedQueryInfo, isWideLayout } = this.props;
if (!selectedQueryInfo) {
return (
<StyleUtilsContext.Consumer>
{({ styling }) => <div {...styling('queryPreview')} />}
</StyleUtilsContext.Consumer>
);
}
const {
query: {
endpointName,
fulfilledTimeStamp,
status,
startedTimeStamp,
data,
},
reducerPath,
} = selectedQueryInfo;
const startedAt = startedTimeStamp
? new Date(startedTimeStamp).toISOString()
: '-';
const latestFetch = fulfilledTimeStamp
? new Date(fulfilledTimeStamp).toISOString()
: '-';
return (
<StyleUtilsContext.Consumer>
{({ styling, base16Theme, invertTheme }) => {
return (
<div {...styling('queryPreview')}>
<React.Fragment>
<div {...styling('previewHeader')}></div>
<ul>
<li>{`reducerPath: ${reducerPath ?? '-'}`}</li>
<li>{`endpointName: ${endpointName ?? '-'}`}</li>
<li>{`status: ${status}`}</li>
<li>{`loaded at: ${latestFetch}`}</li>
<li>{`requested at: ${startedAt}`}</li>
</ul>
<div style={{ padding: '1em' }}>
<JSONTree
data={data}
labelRenderer={this.labelRenderer}
theme={getJsonTreeTheme(base16Theme)}
invertTheme={invertTheme}
getItemString={(type, data) =>
getItemString(
styling,
type,
data,
DATA_TYPE_KEY,
isWideLayout
)
}
hideRoot
/>
</div>
</React.Fragment>
</div>
);
}}
</StyleUtilsContext.Consumer>
);
}
}

View File

@ -0,0 +1 @@
export const DATA_TYPE_KEY = Symbol.for('__serializedType__');

View File

@ -49,6 +49,7 @@ export interface InspectorSelectors<S> {
ReturnType<typeof extractAllApiQueries> ReturnType<typeof extractAllApiQueries>
>; >;
readonly selectAllSortedQueries: InspectorSelector<S, QueryInfo[]>; readonly selectAllSortedQueries: InspectorSelector<S, QueryInfo[]>;
readonly selectorCurrentQueryInfo: InspectorSelector<S, QueryInfo | null>;
} }
export function createInspectorSelectors<S>(): InspectorSelectors<S> { export function createInspectorSelectors<S>(): InspectorSelectors<S> {
@ -85,10 +86,30 @@ export function createInspectorSelectors<S>(): InspectorSelectors<S> {
} }
); );
const selectorCurrentQueryInfo = createSelector(
selectAllQueries,
({ monitorState }: SelectorsSource<S>) => monitorState.selectedQueryKey,
(allQueries, selectedQueryKey) => {
if (!selectedQueryKey) {
return null;
}
const currentQueryInfo =
allQueries.find(
(query) =>
query.queryKey === selectedQueryKey.queryKey &&
selectedQueryKey.reducerPath === query.reducerPath
) || null;
return currentQueryInfo;
}
);
return { return {
selectQueryComparator, selectQueryComparator,
selectApiStates, selectApiStates,
selectAllQueries, selectAllQueries,
selectAllSortedQueries, selectAllSortedQueries,
selectorCurrentQueryInfo,
}; };
} }

View File

@ -1,5 +1,6 @@
import jss, { StyleSheet } from 'jss'; import jss, { StyleSheet } from 'jss';
import preset from 'jss-preset-default'; import preset from 'jss-preset-default';
import { StylingConfig } from 'react-base16-styling';
import { import {
createStyling, createStyling,
getBase16Theme, getBase16Theme,
@ -50,7 +51,7 @@ type ColorMap = {
const getSheetFromColorMap = (map: ColorMap) => ({ const getSheetFromColorMap = (map: ColorMap) => ({
inspector: { inspector: {
display: 'flex', display: 'flex',
'flex-direction': 'column', flexFlow: 'column nowrap',
width: '100%', width: '100%',
height: '100%', height: '100%',
'font-family': 'monaco, Consolas, "Lucida Console", monospace', 'font-family': 'monaco, Consolas, "Lucida Console", monospace',
@ -60,11 +61,30 @@ const getSheetFromColorMap = (map: ColorMap) => ({
'background-color': map.BACKGROUND_COLOR, 'background-color': map.BACKGROUND_COLOR,
color: map.TEXT_COLOR, color: map.TEXT_COLOR,
'&[data-wide-layout="1"]': {
flexFlow: 'row nowrap',
},
}, },
querySectionWrapper: { querySectionWrapper: {
display: 'flex', display: 'flex',
flex: '0 0 auto',
height: '50%',
width: '100%',
borderColor: map.TAB_BORDER_COLOR,
'&[data-wide-layout="0"]': {
borderBottomWidth: 1,
borderStyle: 'solid',
},
'&[data-wide-layout="1"]': {
height: '100%', height: '100%',
width: '40%',
borderRightWidth: 1,
borderStyle: 'solid',
},
flexFlow: 'column nowrap', flexFlow: 'column nowrap',
'& > :first-child': { '& > :first-child': {
flex: '0 0 auto', flex: '0 0 auto',
@ -230,6 +250,47 @@ const getSheetFromColorMap = (map: ColorMap) => ({
color: map.TEXT_PLACEHOLDER_COLOR, color: map.TEXT_PLACEHOLDER_COLOR,
}, },
}, },
queryPreview: {
flex: '1 1 50%',
display: 'flex',
'flex-direction': 'column',
'overflow-y': 'hidden',
'& pre': {
border: 'inherit',
'border-radius': '3px',
'line-height': 'inherit',
color: 'inherit',
},
'background-color': map.BACKGROUND_COLOR,
},
previewHeader: {
flex: '0 0 30px',
padding: '5px 10px',
'align-items': 'center',
'border-bottom-width': '1px',
'border-bottom-style': 'solid',
'background-color': map.HEADER_BACKGROUND_COLOR,
'border-bottom-color': map.HEADER_BORDER_COLOR,
},
treeItemPin: {
'font-size': '0.7em',
'padding-left': '5px',
cursor: 'pointer',
'&:hover': {
'text-decoration': 'underline',
},
color: map.PIN_COLOR,
},
treeItemKey: {
color: map.TEXT_PLACEHOLDER_COLOR,
},
}); });
let themeSheet: StyleSheet; let themeSheet: StyleSheet;
@ -256,6 +317,7 @@ export const createStylingFromTheme = createStyling(getDefaultThemeStyling, {
export interface StyleUtils { export interface StyleUtils {
base16Theme: Base16Theme; base16Theme: Base16Theme;
styling: StylingFunction; styling: StylingFunction;
invertTheme: boolean;
} }
export function createThemeState<S, A extends Action<unknown>>( export function createThemeState<S, A extends Action<unknown>>(
@ -267,10 +329,31 @@ export function createThemeState<S, A extends Action<unknown>>(
const theme = props.invertTheme ? invertTheme(props.theme) : props.theme; const theme = props.invertTheme ? invertTheme(props.theme) : props.theme;
const styling = createStylingFromTheme(theme); const styling = createStylingFromTheme(theme);
return { base16Theme, styling }; return { base16Theme, styling, invertTheme: !!props.invertTheme };
} }
const mockStyling = (...args: any[]) => ({ className: '', style: {} });
export const StyleUtilsContext = createContext<StyleUtils>({ export const StyleUtilsContext = createContext<StyleUtils>({
base16Theme: rtkInspectorTheme, base16Theme: rtkInspectorTheme,
styling: (...args: any[]) => ({ className: '', style: {} }), invertTheme: false,
styling: mockStyling,
}); });
export function getJsonTreeTheme(base16Theme: Base16Theme): StylingConfig {
return {
extend: base16Theme,
nestedNode: ({ style }, keyPath, nodeType, expanded) => ({
style: {
...style,
whiteSpace: expanded ? 'inherit' : 'nowrap',
},
}),
nestedNodeItemString: ({ style }, keyPath, nodeType, expanded) => ({
style: {
...style,
display: expanded ? 'none' : 'inline',
},
}),
};
}

View File

@ -0,0 +1,104 @@
import React, { ReactNode } from 'react';
import { StylingFunction } from 'react-base16-styling';
import { isCollection, isIndexed, isKeyed } from 'immutable';
import isIterable from '../utils/isIterable';
const IS_IMMUTABLE_KEY = '@@__IS_IMMUTABLE__@@';
function isImmutable(value: any) {
return isKeyed(value) || isIndexed(value) || isCollection(value);
}
function getShortTypeString(val: any, diff: boolean | undefined) {
if (diff && Array.isArray(val)) {
val = val[val.length === 2 ? 1 : 0];
}
if (isIterable(val) && !isImmutable(val)) {
return '(…)';
} else if (Array.isArray(val)) {
return val.length > 0 ? '[…]' : '[]';
} else if (val === null) {
return 'null';
} else if (val === undefined) {
return 'undef';
} else if (typeof val === 'object') {
return Object.keys(val).length > 0 ? '{…}' : '{}';
} else if (typeof val === 'function') {
return 'fn';
} else if (typeof val === 'string') {
return `"${val.substr(0, 10) + (val.length > 10 ? '…' : '')}"`;
} else if (typeof val === 'symbol') {
return 'symbol';
} else {
return val;
}
}
function getText(
type: string,
data: any,
isWideLayout: boolean,
isDiff: boolean | undefined
) {
if (type === 'Object') {
const keys = Object.keys(data);
if (!isWideLayout) return keys.length ? '{…}' : '{}';
const str = keys
.slice(0, 3)
.map(
(key) => `${key}: ${getShortTypeString(data[key], isDiff) as string}`
)
.concat(keys.length > 3 ? ['…'] : [])
.join(', ');
return `{ ${str} }`;
} else if (type === 'Array') {
if (!isWideLayout) return data.length ? '[…]' : '[]';
const str = data
.slice(0, 4)
.map((val: any) => getShortTypeString(val, isDiff))
.concat(data.length > 4 ? ['…'] : [])
.join(', ');
return `[${str as string}]`;
} else {
return type;
}
}
export function getItemString(
styling: StylingFunction,
type: string,
data: any,
dataTypeKey: string | symbol | undefined,
isWideLayout: boolean,
isDiff?: boolean
): ReactNode {
return (
<span {...styling('treeItemHint')}>
{data[IS_IMMUTABLE_KEY] ? 'Immutable' : ''}
{dataTypeKey && data[dataTypeKey]
? `${data[dataTypeKey] as string} `
: ''}
{getText(type, data, isWideLayout, isDiff)}
</span>
);
}
export function createTreeItemLabelRenderer(styling: StylingFunction) {
return function labelRenderer(
[key]: (string | number)[],
nodeType: string,
expanded: boolean
): ReactNode {
return (
<span>
<span {...styling('treeItemKey')}>{key}</span>
{!expanded && ': '}
</span>
);
};
}

View File

@ -0,0 +1,8 @@
export default function isIterable(obj: any) {
return (
obj !== null &&
typeof obj === 'object' &&
!Array.isArray(obj) &&
typeof obj[window.Symbol.iterator] === 'function'
);
}

View File

@ -5,6 +5,5 @@
"module": "ES2015", "module": "ES2015",
"strict": false "strict": false
}, },
"include": ["src"], "include": ["src"]
} }