[feat][monitor] Add rkt-query-inspector-monitor - feat/rtk query monitor (#750)

* chore: copy rtk-query example from toolkit

* feat(rtk-query): complete initial setup of rtk-query

* feat: complete inspector layout and add initial JSONTree setup

* fix: unintentional removal of tsconfig

* feat(search): add search logic and refactor monitor state shape

* fix: inverted monitor theme inside  devtoop-app

Othetr changes:

* simplify monitor integration

* fix: rtk monitor reducer not working in  app

* refactor(rtk-query): simplify theme config

* feat(rtk-query-monitor): add query preview tabs

* fix: wip

* refactor(examples): add rtk-query-polling to workspace

Other changes:

* docs(rtk-query-polling): add README.md

* chore(rtk-query-inspector): add demo to monorepo

Other changes:

chore: increase isWideScreen polling interval to 300

refactor: add subscription as root node in QueryPreviewSubscriptions

* feat(rtk-query): add multiple filter options

* chore(rtk-queery): rename demo build script and add SKIP_PREFLIGHT_CHECK=true

* feat(rtk-query): display status flags in QueryPreviewInfo

* chore(rtk-query): update typescript versions in rkt-inspector-monitor & its demo

* docs(rtk-query): add proper README

Other changes:

* fix examples/rtk-query-poilling

* docs(rtk-query): improve rtk-query-inspector-monitor demo gif

* docs(rtk-query): clean up demo

* fix(rtk-query): clear button not updating redux state

* docs(rtk-query): add link to rtk-query-inspector-monitor demo site

* chore(rtk-query): run prettier after prettier upgrade (55e2284)

* docs(rtk.query): clean up readme add features, todo and development section

* docs(rtk-query): fix link href

* chore(rtk-query): clean up rtk-query-imspector-monitor-demo and add post example

* feat(rtk-query): add counters on tags & subs tab buttons

* fix(rtk-query): layering issue between queryPreview tabList and select

Other changes:

* clean up demo styles

* run prettier

* fix: revert accidental changes packages/redux-devtools-serialize/tsconfig.json

* chore: change QueryComparators.fulfilledTimeStamp label

* feat(rtk-query): display api stats

* refactor: remove rtk-query-polling example from monorepo

* chore: regenerate lock file and add @types/react as monorepo devDep

* chore: display apiState

Other changes:

* fix close button overflow

* minor responsive style tweaks

* display reducerPath in query tab

* fix(rtk-query): resolve CI errors

- fix(prettier:check): unformatted file
- fix(lint:all): fix accidentallly removed .eslintignore

* chore(rtk-query): rename package to '@redux-devtools/rtk-query-monitor'

* fix(rtk-query): lint:all error

https://github.com/reduxjs/redux-devtools/runs/2869729048?check_suite_focus=true

* feat(rtk-query): add fallback message if there are no rtk-query apis

* refactor(rtk-query): move Inspector & Monitor to containers clean up typings

Other changes:

* chore: improved type coverage

* chore: do not lint packages/redux-devtools-rtk-query-monitor/demo folder

* refactor: put sort order buttons inside a component

* chore: hopefully resolve mockServiceWorker formatting issues

* fix(rtk-query): incorrect link color

Other changes:

* fix: add missing anchor property value noopener

* refactor(rtk-query): simplify sort order control

* feat(rtk-query): add timings to apiStats sections

* feat(rtk-query): add slowest and fastest in timings section

* feat(rtk-query): improve formatting of timings and display average loading time

* fix(rtk-query): rtk-query imports

* refactor(rtk-query): reduce selector computations

Other changes:

* simplify TreeView props

* feat(rtk-query): add actions tab

* refactor(rtk-query): add custom logic for TreeView shouldExpandNode

Other changes:

* feat: added duration in QueryPreviewInfo tab

* refactor: TreeView component

* chore(rtk-query): improve demo visibility on small devices

* feat(rtk-query): do not display tree node preview

Other changes:

* improve visibility of demo devTools on small devices

* tweak QueryPreviewInfo labels

* chore(rtk-query): improve responsiveness

* refactor(rtk-query): move preview to containers remove unnecessary computation

* feat(rtk-query): display median of timings

Other changes:

* improved shouldExpandNode logic of QueryPreviewActions

* tweaked mean logic

* refactor(rtk-query-monitor): conform demo setup to repo standards

* chore(rtk-query-monitor): add option to select active devtools

* chore(rtk-query-monitor): remove redux-devtools/examples/rtk-query-polling

* refactor(rtk-query): improve UI of api tab

* feat(rtk-query): add regex search

* feat(rtk-query): display mutations in queryList

* refactor(rtk-query): track all fulfilled requests using actions

Other changes:

* refactor(rtk-query): rename tally properties

* chore(rtk-query): update @redux-devtools/rtk-query-monitor dependencies

* fix(rtk-query): demo build failing caused by a typing error
This commit is contained in:
Fabrizio Vitale 2021-08-26 21:33:06 +02:00 committed by GitHub
parent 6cdc18f2fa
commit 7d92a5e186
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 24880 additions and 36 deletions

View File

@ -9,3 +9,5 @@ node_modules
__snapshots__
dev
.yarn/*
**/.yarn/*
**/demo/public/**

View File

@ -50,6 +50,9 @@
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"
},
"resolutions": {
"@types/react": "16.14.8"
},
"scripts": {
"lerna": "lerna",
"build:all": "lerna run build",

View File

@ -43,7 +43,9 @@
"@redux-devtools/inspector-monitor-test-tab": "^0.7.2",
"@redux-devtools/inspector-monitor-trace-tab": "^0.2.2",
"@redux-devtools/log-monitor": "^2.3.0",
"@redux-devtools/rtk-query-monitor": "^1.0.0",
"@redux-devtools/slider-monitor": "^2.0.0-8",
"@reduxjs/toolkit": "^1.6.0",
"d3-state-visualizer": "^1.4.0",
"devui": "^1.0.0-9",
"javascript-stringify": "^2.1.0",

View File

@ -2,11 +2,13 @@ import React from 'react';
import LogMonitor from '@redux-devtools/log-monitor';
import ChartMonitorWrapper from '../containers/monitors/ChartMonitorWrapper';
import InspectorWrapper from '../containers/monitors/InspectorWrapper';
import RtkQueryMonitor from '@redux-devtools/rtk-query-monitor';
export const monitors = [
{ value: 'InspectorMonitor', name: 'Inspector' },
{ value: 'LogMonitor', name: 'Log monitor' },
{ value: 'ChartMonitor', name: 'Chart' },
{ value: 'RtkQueryMonitor', name: 'RTK Query' },
];
export default function getMonitor({ monitor }: { monitor: string }) {
@ -17,6 +19,8 @@ export default function getMonitor({ monitor }: { monitor: string }) {
);
case 'ChartMonitor':
return <ChartMonitorWrapper />;
case 'RtkQueryMonitor':
return <RtkQueryMonitor />;
default:
return <InspectorWrapper />;
}

0
packages/redux-devtools-cli/bin/redux-devtools.js Normal file → Executable file
View File

View File

@ -0,0 +1,8 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": ["@babel/plugin-proposal-class-properties"]
}

View File

@ -0,0 +1,2 @@
lib
demo/

View File

@ -0,0 +1,13 @@
module.exports = {
extends: '../../.eslintrc',
overrides: [
{
files: ['*.ts', '*.tsx'],
extends: '../../eslintrc.ts.react.base.json',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
},
],
};

View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 Fabrizio Vitale
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,108 @@
# Redux DevTools RTK Query inspector monitor
A monitor that displays [RTK query](https://redux-toolkit.js.org/rtk-query/overview) queries and mutations for [Redux DevTools](https://github.com/gaearon/redux-devtools).
Created by [FaberVitale](https://github.com/FaberVitale), inspired by [react-query devtools](https://github.com/tannerlinsley/react-query/tree/master/devtools).
## Demo
- [link](https://rtk-query-monitor-demo.netlify.app/)
- [demo source](https://github.com/FaberVitale/redux-devtools/tree/feat/rtk-query-monitor/packages/redux-devtools-rtk-query-monitor/demo)
## Preview
![RTK Query inspector monitor demo](./monitor-demo.gif)
## Installation
### npm
```bash
npm i @redux-devtools/rtk-query-monitor --save
```
### yarn
```bash
yarn add @redux-devtools/rtk-query-monitor
```
## Usage
You can use `RtkQueryMonitor` as the only monitor in your app:
##### `containers/DevTools.js`
```ts
import React from 'react';
import { createDevTools } from '@redux-devtools/core';
import RtkQueryrMonitor from '@redux-devtools/rtk-query-monitor';
export default createDevTools(<RtkQueryrMonitor />);
```
Then you can render `<DevTools>` to any place inside app or even into a separate popup window.
Alternatively, you can use it together with [`DockMonitor`](https://github.com/reduxjs/redux-devtools/tree/master/packages/redux-devtools-dock-monitor) to make it dockable.
See also
- [`DockMonitor` README](https://github.com/reduxjs/redux-devtools/tree/master/packages/redux-devtools-dock-monitor)
- [Read how to start using Redux DevTools.](https://github.com/reduxjs/redux-devtools)
- [Redux Devtools walkthrough](https://github.com/reduxjs/redux-devtools/tree/master/docs/Walkthrough.md)
## Features
- sorts queries in ascending or descending order by:
- fulfilledTimeStamp
- query key
- query status
- endpoint
- api reducerPath
- filters queries by:
- fulfilledTimeStamp
- query key
- query status
- endpoint
- api reducerPath
- displays
- status flags
- query state
- tags
- subscriptions
- api state
- api stats
## TODO
- [ ] display mutations
- [ ] filter by tags types
- [ ] download query.data
- [ ] upload query.data(?)
- [ ] refetch query button(?)
- ...suggestions are welcome
## Redux DevTools props
| Name | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | Either a string referring to one of the themes provided by [redux-devtools-themes](https://github.com/gaearon/redux-devtools-themes) (feel free to contribute!) or a custom object of the same format. Optional. By default, set to [`'nicinabox'`](https://github.com/gaearon/redux-devtools-themes/blob/master/src/nicinabox.js). |
| `invertTheme` | Boolean value that will invert the colors of the selected theme. Optional. By default, set to `false` |
<br/>
### Development
#### Start Demo
```bash
yarn lerna run start --stream --scope @redux-devtools/rtk-query-monitor
```
<br/>
## License
[MIT](./LICENSE.md)

View File

@ -0,0 +1,11 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-runtime"
]
}

View File

@ -0,0 +1,3 @@
module.exports = {
extends: ['react-app'],
};

View File

@ -0,0 +1,4 @@
.snowpack
build
node_modules
.yarn/*

View File

@ -0,0 +1,7 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"resolveJsonModule": true
},
"include": ["webpack.config.ts"]
}

View File

@ -0,0 +1,88 @@
import * as path from 'path';
import * as webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import * as pkg from '../../package.json';
const CopyWebpackPlugin = require('copy-webpack-plugin');
const isProduction = process.env.NODE_ENV === 'production';
const demoSrc = path.join(__dirname, '../src');
const libSrc = path.join(__dirname, '../../src');
const publicDir = path.join(__dirname, '../public');
module.exports = {
mode: process.env.NODE_ENV || 'development',
entry: isProduction
? ['./demo/src/index']
: [
'webpack-dev-server/client?http://localhost:3000',
'webpack/hot/only-dev-server',
'./demo/src/index',
],
output: {
path: path.join(__dirname, '../dist'),
filename: isProduction ? '[name].[contenthash:8].js' : 'js/bundle.js',
},
module: {
rules: [
{
test: /\.(js|ts)x?$/,
loader: 'babel-loader',
exclude: /node_modules/,
include: [demoSrc, libSrc],
},
{
test: /\.css?$/,
loaders: ['style-loader', 'css-loader'],
include: demoSrc,
},
],
},
resolve: {
modules: ['node_modules', demoSrc],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
optimization: {
minimize: isProduction,
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
inject: true,
template: 'demo/public/index.html',
package: pkg,
}),
new CopyWebpackPlugin({
patterns: [
{
from: 'demo/public/assets/*.js',
to: ({ absoluteFilename }: any) => {
return `./${path.basename(absoluteFilename)}`;
},
globOptions: {
ignore: ['*.DS_Store'],
},
},
],
}),
new ForkTsCheckerWebpackPlugin({
typescript: {
configFile: 'demo/tsconfig.json',
},
}),
].concat(isProduction ? [] : [new webpack.HotModuleReplacementPlugin()]),
devServer: isProduction
? {}
: {
quiet: false,
port: 3000,
hot: true,
stats: {
chunkModules: false,
colors: true,
},
historyApiFallback: true,
},
devtool: isProduction ? 'source-map' : 'cheap-module-source-map',
};

View File

@ -0,0 +1,322 @@
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
/* eslint-disable */
/* tslint:disable */
const INTEGRITY_CHECKSUM = '82ef9b96d8393b6da34527d1d6e19187'
const bypassHeaderName = 'x-msw-bypass'
const activeClientIds = new Set()
self.addEventListener('install', function () {
return self.skipWaiting()
})
self.addEventListener('activate', async function (event) {
return self.clients.claim()
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll()
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
// Resolve the "master" client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMasterClient(event) {
const client = await self.clients.get(event.clientId)
if (client.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll()
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function handleRequest(event, requestId) {
const client = await resolveMasterClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: serializeHeaders(clonedResponse.headers),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
async function getResponse(event, client, requestId) {
const { request } = event
const requestClone = request.clone()
const getOriginalResponse = () => fetch(requestClone)
// Bypass mocking when the request client is not active.
if (!client) {
return getOriginalResponse()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return await getOriginalResponse()
}
// Bypass requests with the explicit bypass header
if (requestClone.headers.get(bypassHeaderName) === 'true') {
const cleanRequestHeaders = serializeHeaders(requestClone.headers)
// Remove the bypass header to comply with the CORS preflight check.
delete cleanRequestHeaders[bypassHeaderName]
const originalRequest = new Request(requestClone, {
headers: new Headers(cleanRequestHeaders),
})
return fetch(originalRequest)
}
// Send the request to the client-side MSW.
const reqHeaders = serializeHeaders(request.headers)
const body = await request.text()
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: reqHeaders,
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body,
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_SUCCESS': {
return delayPromise(
() => respondWithMock(clientMessage),
clientMessage.payload.delay,
)
}
case 'MOCK_NOT_FOUND': {
return getOriginalResponse()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload
const networkError = new Error(message)
networkError.name = name
// Rejecting a request Promise emulates a network error.
throw networkError
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body)
console.error(
`\
[MSW] Request handler function for "%s %s" has thrown the following exception:
${parsedBody.errorType}: ${parsedBody.message}
(see more detailed error stack trace in the mocked response body)
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error.
If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url,
)
return respondWithMock(clientMessage)
}
}
return getOriginalResponse()
}
self.addEventListener('fetch', function (event) {
const { request } = event
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = uuidv4()
return event.respondWith(
handleRequest(event, requestId).catch((error) => {
console.error(
'[MSW] Failed to mock a "%s" request to "%s": %s',
request.method,
request.url,
error,
)
}),
)
})
function serializeHeaders(headers) {
const reqHeaders = {}
headers.forEach((value, name) => {
reqHeaders[name] = reqHeaders[name]
? [].concat(reqHeaders[name]).concat(value)
: value
})
return reqHeaders
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(JSON.stringify(message), [channel.port2])
})
}
function delayPromise(cb, duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), duration)
})
}
function respondWithMock(clientMessage) {
return new Response(clientMessage.payload.body, {
...clientMessage.payload,
headers: clientMessage.payload.headers,
})
}
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="@redux-devtools/rtk-query-monitor demo site"
/>
<title>RTK Query monitor demo</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/dist/index.js"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,71 @@
import PokemonView from 'features/pokemon/PokemonView';
import PostsView from 'features/posts/PostsView';
import { Box, Flex, Heading } from '@chakra-ui/react';
import { Link, UnorderedList, ListItem } from '@chakra-ui/react';
import { Code } from '@chakra-ui/react';
import * as React from 'react';
import { DevToolsSelector } from 'features/DevTools/DevToolsSelector';
export function App() {
return (
<main className="rtk-query-demo-app">
<Heading as="h1" p="2">
RTK Query inspector monitor demo
</Heading>
<PokemonView />
<PostsView />
<DevToolsSelector />
<Flex p="2" as="section" flexWrap="nowrap" flexDirection="column">
<Heading as="h2">Dock controls</Heading>
<Box as="pre" p="2" paddingX="4">
<Code>
{`toggleVisibilityKey="ctrl-h"\nchangePositionKey="ctrl-q"`}
</Code>
</Box>
<Box as="p" p="2" paddingX="4">
Drag its border to resize
</Box>
</Flex>
<Flex p="2" as="footer">
<UnorderedList p="2">
<ListItem>
<Link
className="link"
isExternal
href="https://github.com/FaberVitale/redux-devtools/tree/feat/rtk-query-monitor/packages/redux-devtools-rtk-query-monitor/demo"
>
demo source
</Link>
</ListItem>
<ListItem>
<Link
className="link"
isExternal
href="https://github.com/FaberVitale/redux-devtools/tree/feat/rtk-query-monitor/packages/redux-devtools-rtk-query-monitor"
>
@redux-devtools/rtk-query-monitor source
</Link>
</ListItem>
<ListItem>
<Link
className="link"
isExternal
href="https://github.com/reduxjs/redux-toolkit/tree/master/examples/query/react/polling"
>
polling example
</Link>
</ListItem>
<ListItem>
<Link
className="link"
isExternal
href="https://github.com/reduxjs/redux-toolkit/tree/master/examples/query/react/mutations"
>
mutations example
</Link>
</ListItem>
</UnorderedList>
</Flex>
</main>
);
}

View File

@ -0,0 +1,18 @@
import * as React from 'react';
import { createDevTools } from '@redux-devtools/core';
import DockMonitor from '@redux-devtools/dock-monitor';
import RtkQueryMonitor from '../../../../src';
const largeScreenQuery = window.matchMedia('(min-width: 1024px)');
export default createDevTools(
<DockMonitor
toggleVisibilityKey="ctrl-h"
changePositionKey="ctrl-q"
changeMonitorKey="ctrl-m"
fluid
defaultSize={largeScreenQuery.matches ? 0.44 : 0.55}
>
<RtkQueryMonitor />
</DockMonitor>
);

View File

@ -0,0 +1,40 @@
import * as React from 'react';
import { ButtonGroup, Button } from '@chakra-ui/react';
import { isExtensionEnabled, setIsExtensionEnabled } from './helpers';
import { Box, Heading } from '@chakra-ui/react';
export function DevToolsSelector() {
const handleClick = (evt: React.MouseEvent<HTMLButtonElement>) => {
setIsExtensionEnabled(evt.currentTarget.dataset.extension === '1');
window.location.reload();
};
const extensionEnabled = isExtensionEnabled();
return (
<Box as="section" p="2">
<Heading as="h2">Set active devTools</Heading>
<ButtonGroup variant="outline" spacing="4" p="4">
<Button
aria-selected={!extensionEnabled}
colorScheme="blue"
selected={!extensionEnabled}
data-extension="0"
variant={!extensionEnabled ? 'solid' : 'outline'}
onClick={handleClick}
>
Dock
</Button>
<Button
aria-selected={extensionEnabled}
data-extension="1"
colorScheme="blue"
variant={extensionEnabled ? 'solid' : 'outline'}
onClick={handleClick}
>
Extension
</Button>
</ButtonGroup>
</Box>
);
}

View File

@ -0,0 +1 @@
export const isExtensionEnabledKey = 'prefer-extension';

View File

@ -0,0 +1,22 @@
import { isExtensionEnabledKey } from './config';
export function isExtensionEnabled(): boolean {
let extensionEnabled = false;
try {
extensionEnabled =
window.sessionStorage.getItem(isExtensionEnabledKey) === '1';
} catch (err) {
console.error(err);
}
return extensionEnabled;
}
export function setIsExtensionEnabled(active: boolean): void {
try {
window.sessionStorage.setItem(isExtensionEnabledKey, active ? '1' : '0');
} catch (err) {
console.error(err);
}
}

View File

@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { Button, Select } from '@chakra-ui/react';
import { useGetPokemonByNameQuery } from '../../services/pokemon';
import type { PokemonName } from '../../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 function Pokemon({ name }: { name: PokemonName }) {
const [pollingInterval, setPollingInterval] = useState(60000);
const { data, error, isLoading, isFetching, refetch } =
useGetPokemonByNameQuery(name, {
pollingInterval,
});
return (
<div
className="pokemon"
style={{
...(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
colorScheme="blue"
variant="outline"
onClick={refetch}
disabled={isFetching}
>
{isFetching ? 'Loading' : 'Manually refetch'}
</Button>
</div>
</>
) : (
'No Data'
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
import * as React from 'react';
import { Pokemon } from './Pokemon';
import { PokemonName, POKEMON_NAMES } from '../../pokemon.data';
import { Flex, Heading, Button } from '@chakra-ui/react';
const getRandomPokemonName = () =>
POKEMON_NAMES[Math.floor(Math.random() * POKEMON_NAMES.length)];
export default function PokemonView() {
const [pokemon, setPokemon] = React.useState<PokemonName[]>(['bulbasaur']);
return (
<Flex p="2" as="section" flexWrap="nowrap" flexDirection="column">
<Heading as="h2">Pokemon polling demo</Heading>
<Flex p="2" gridGap="0.5em" flexDirection="row" flexWrap="wrap">
<Button
colorScheme="blue"
variant="outline"
onClick={() =>
setPokemon((prev) => [...prev, getRandomPokemonName()])
}
>
Add random pokemon
</Button>
<Button
colorScheme="blue"
variant="outline"
onClick={() => setPokemon((prev) => [...prev, 'bulbasaur'])}
>
Add bulbasaur
</Button>
</Flex>
<div className="pokemon-list">
{pokemon.map((name, index) => (
<Pokemon key={index} name={name} />
))}
</div>
</Flex>
);
}

View File

@ -0,0 +1,155 @@
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import {
useDeletePostMutation,
useGetPostQuery,
useUpdatePostMutation,
} from 'services/posts';
import {
Box,
Button,
Center,
CloseButton,
Flex,
Heading,
Input,
Spacer,
Stack,
useToast,
} from '@chakra-ui/react';
const EditablePostName = ({
name: initialName,
onUpdate,
onCancel,
isLoading = false,
}: {
name: string;
onUpdate: (name: string) => void;
onCancel: () => void;
isLoading?: boolean;
}) => {
const [name, setName] = useState(initialName);
const handleChange = ({
target: { value },
}: React.ChangeEvent<HTMLInputElement>) => setName(value);
const handleUpdate = () => onUpdate(name);
const handleCancel = () => onCancel();
return (
<Flex>
<Box flex={10}>
<Input
type="text"
onChange={handleChange}
value={name}
disabled={isLoading}
/>
</Box>
<Spacer />
<Box>
<Stack spacing={4} direction="row" align="center">
<Button onClick={handleUpdate} isLoading={isLoading}>
Update
</Button>
<CloseButton bg="red" onClick={handleCancel} disabled={isLoading} />
</Stack>
</Box>
</Flex>
);
};
const PostJsonDetail = ({ id }: { id: string }) => {
const { data: post } = useGetPostQuery(id);
return (
<Box mt={5} bg="#eee">
<pre>{JSON.stringify(post, null, 2)}</pre>
</Box>
);
};
export const PostDetail = () => {
const { id } = useParams<{ id: any }>();
const history = useHistory();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const { data: post, isLoading } = useGetPostQuery(id);
const [updatePost, { isLoading: isUpdating }] = useUpdatePostMutation();
const [deletePost, { isLoading: isDeleting }] = useDeletePostMutation();
if (isLoading) {
return <div>Loading...</div>;
}
if (!post) {
return (
<Center h="200px">
<Heading size="md">
Post {id} is missing! Try reloading or selecting another post...
</Heading>
</Center>
);
}
return (
<Box p={4}>
{isEditing ? (
<EditablePostName
name={post.name}
onUpdate={async (name) => {
try {
await updatePost({ id, name }).unwrap();
} catch {
toast({
title: 'An error occurred',
description: "We couldn't save your changes, try again!",
status: 'error',
duration: 2000,
isClosable: true,
});
} finally {
setIsEditing(false);
}
}}
onCancel={() => setIsEditing(false)}
isLoading={isUpdating}
/>
) : (
<Flex>
<Box>
<Heading size="md">{post.name}</Heading>
</Box>
<Spacer />
<Box>
<Stack spacing={4} direction="row" align="center">
<Button
onClick={() => setIsEditing(true)}
disabled={isDeleting || isUpdating}
>
{isUpdating ? 'Updating...' : 'Edit'}
</Button>
<Button
onClick={() =>
deletePost(id).then(() => history.push('/posts'))
}
disabled={isDeleting}
colorScheme="red"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</Stack>
</Box>
</Flex>
)}
<PostJsonDetail id={post.id} />
</Box>
);
};

View File

@ -0,0 +1,160 @@
import {
Box,
Button,
Center,
Divider,
Flex,
FormControl,
FormLabel,
Heading,
Input,
List,
ListIcon,
ListItem,
Spacer,
Stat,
StatLabel,
StatNumber,
useToast,
} from '@chakra-ui/react';
import { Route, Switch, useHistory } from 'react-router-dom';
import { MdBook } from 'react-icons/md';
import React, { useState } from 'react';
import { Post, useAddPostMutation, useGetPostsQuery } from 'services/posts';
import { PostDetail } from './PostDetail';
const AddPost = () => {
const initialValue = { name: '' };
const [post, setPost] = useState<Pick<Post, 'name'>>(initialValue);
const [addPost, { isLoading }] = useAddPostMutation();
const toast = useToast();
const handleChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
setPost((prev) => ({
...prev,
[target.name]: target.value,
}));
};
const handleAddPost = async () => {
try {
await addPost(post).unwrap();
setPost(initialValue);
} catch {
toast({
title: 'An error occurred',
description: "We couldn't save your post, try again!",
status: 'error',
duration: 2000,
isClosable: true,
});
}
};
return (
<Flex p={'5px 0'} flexDirection="row" flexWrap="wrap" maxWidth={'85%'}>
<Box flex={'5 0 auto'} padding="0 5px 0 0">
<FormControl
flexDirection="column"
isInvalid={Boolean(post.name.length < 3 && post.name)}
>
<FormLabel htmlFor="name">Post name</FormLabel>
<Input
id="name"
name="name"
placeholder="Enter post name"
value={post.name}
onChange={handleChange}
/>
</FormControl>
</Box>
<Box>
<Button
mt={8}
colorScheme="purple"
isLoading={isLoading}
onClick={handleAddPost}
>
Add Post
</Button>
</Box>
</Flex>
);
};
const PostList = () => {
const { data: posts, isLoading } = useGetPostsQuery();
const history = useHistory();
if (isLoading) {
return <div>Loading</div>;
}
if (!posts) {
return <div>No posts :(</div>;
}
return (
<List spacing={3}>
{posts.map(({ id, name }) => (
<ListItem key={id} onClick={() => history.push(`/posts/${id}`)}>
<ListIcon as={MdBook} color="green.500" /> {name}
</ListItem>
))}
</List>
);
};
export const PostsCountStat = () => {
const { data: posts } = useGetPostsQuery();
if (!posts) return null;
return (
<Stat>
<StatLabel>Active Posts</StatLabel>
<StatNumber>{posts?.length}</StatNumber>
</Stat>
);
};
export const PostsManager = () => {
return (
<Box>
<Flex bg="#011627" p={4} color="white">
<Box>
<Heading size="xl">Manage Posts</Heading>
</Box>
<Spacer />
<Box>
<PostsCountStat />
</Box>
</Flex>
<Divider />
<AddPost />
<Divider />
<Flex wrap="wrap">
<Box flex={1} borderRight="1px solid #eee">
<Box p={4} borderBottom="1px solid #eee">
<Heading size="sm">Posts</Heading>
</Box>
<Box p={4}>
<PostList />
</Box>
</Box>
<Box flex={2}>
<Switch>
<Route path="/posts/:id" component={PostDetail} />
<Route>
<Center h="200px">
<Heading size="md">Select a post to edit!</Heading>
</Center>
</Route>
</Switch>
</Box>
</Flex>
</Box>
);
};
export default PostsManager;

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { Switch, Route } from 'react-router-dom';
import { PostsManager } from 'features/posts/PostsManager';
import { Box, Heading } from '@chakra-ui/react';
function PostsView() {
return (
<Box as="section" p="2">
<Heading as="h2">Posts Demo</Heading>
<Switch>
<Route path="/" component={PostsManager} />
</Switch>
</Box>
);
}
export default PostsView;

View File

@ -0,0 +1,41 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
section {
display: block;
max-width: 65%;
}
.pokemon-list {
display: flex;
flex-flow: row wrap;
overflow-x: hidden;
}
.pokemon {
padding: 0.2em;
text-align: center;
}
pre code {
white-space: pre-wrap;
}
article {
padding: 0 0 0.5em;
}
.link.link {
color: #805ad5;
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { ChakraProvider } from '@chakra-ui/react';
import './index.css';
import { store } from './store';
import DevTools from './features/DevTools/DevTools';
import { BrowserRouter } from 'react-router-dom';
import { App } from 'App';
import { worker } from './mocks/browser';
function renderApp() {
const rootElement = document.getElementById('root');
ReactDOM.render(
<Provider store={store}>
<ChakraProvider>
<BrowserRouter>
<App />
<DevTools />
</BrowserRouter>
</ChakraProvider>
</Provider>,
rootElement
);
}
worker.start({ quiet: true }).then(renderApp, renderApp);

View File

@ -0,0 +1,4 @@
import { setupWorker } from 'msw';
import { handlers } from './db';
export const worker = setupWorker(...handlers);

View File

@ -0,0 +1,59 @@
import { factory, primaryKey } from '@mswjs/data';
import { nanoid } from '@reduxjs/toolkit';
import { rest } from 'msw';
import { Post } from '../services/posts';
const db = factory({
post: {
id: primaryKey(String),
name: String,
},
});
[
'A sample post',
'A post about RTK Query',
'How to randomly throw errors, a novella',
].forEach((name) => {
db.post.create({ id: nanoid(), name });
});
export const handlers = [
rest.post('/posts', async (req, res, ctx) => {
const { name } = req.body as Partial<Post>;
if (Math.random() < 0.3) {
return res(
ctx.json({ error: 'Oh no, there was an error, try again.' }),
ctx.status(500),
ctx.delay(300)
);
}
const post = db.post.create({
id: nanoid(),
name,
});
return res(ctx.json(post), ctx.delay(300));
}),
rest.put('/posts/:id', (req, res, ctx) => {
const { name } = req.body as Partial<Post>;
if (Math.random() < 0.3) {
return res(
ctx.json({ error: 'Oh no, there was an error, try again.' }),
ctx.status(500),
ctx.delay(300)
);
}
const post = db.post.update({
where: { id: { equals: req.params.id } },
data: { name },
});
return res(ctx.json(post), ctx.delay(300));
}),
...db.post.toHandlers('rest'),
] as const;

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,5 @@
/// <reference types="react-scripts" />
declare module '@redux-devtools/app';
declare module 'remote-redux-devtools';

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

@ -0,0 +1,63 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export interface Post {
id: string;
name: string;
}
type PostsResponse = Post[];
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
tagTypes: ['Post'],
endpoints: (build) => ({
getPosts: build.query<PostsResponse, void>({
query: () => 'posts',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Post' as const, id })),
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
addPost: build.mutation<Post, Partial<Post>>({
query: (body) => ({
url: `posts`,
method: 'POST',
body,
}),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
getPost: build.query<Post, string>({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
query: ({ id, ...patch }) => ({
url: `posts/${id}`,
method: 'PUT',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: build.mutation<{ success: boolean; id: number }, number>({
query(id) {
return {
url: `posts/${id}`,
method: 'DELETE',
};
},
invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
});
export const {
useGetPostQuery,
useGetPostsQuery,
useAddPostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;

View File

@ -0,0 +1,29 @@
import {
configureStore,
Middleware,
combineReducers,
EnhancedStore,
} from '@reduxjs/toolkit';
import { pokemonApi } from './services/pokemon';
import { postsApi } from 'services/posts';
import DevTools from './features/DevTools/DevTools';
import { isExtensionEnabled } from 'features/DevTools/helpers';
const devTools = isExtensionEnabled();
const reducer = combineReducers({
[pokemonApi.reducerPath]: pokemonApi.reducer,
[postsApi.reducerPath]: postsApi.reducer,
});
export const store: EnhancedStore<ReturnType<typeof reducer>> = configureStore({
reducer,
devTools,
// adding the api middleware enables caching, invalidation, polling and other features of `rtk-query`
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat([
pokemonApi.middleware,
postsApi.middleware,
]) as Middleware[],
enhancers: (devTools ? [] : [DevTools.instrument()]) as any,
});

View File

@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.react.base.json",
"compilerOptions": {
"resolveJsonModule": true,
"baseUrl": "./src",
"target": "esNext",
"module": "es6",
"moduleResolution": "node"
},
"include": ["../src", "src"]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

View File

@ -0,0 +1,88 @@
{
"name": "@redux-devtools/rtk-query-monitor",
"version": "1.0.0",
"description": "rtk-query monitor for Redux DevTools",
"keywords": [
"redux",
"devtools",
"flux",
"react",
"redux-toolkit",
"rtk-query"
],
"homepage": "https://github.com/FaberVitale/redux-devtools/tree/feat/rtk-query-monitor/packages/redux-devtools-rtk-query-monitor",
"bugs": {
"url": "https://github.com/reduxjs/redux-devtools/issues"
},
"license": "MIT",
"author": {
"name": "FaberVitale",
"url": "https://github.com/FaberVitale"
},
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/reduxjs/redux-devtools.git"
},
"scripts": {
"start": "webpack-dev-server --config demo/config/webpack.config.ts",
"build": "npm run build:types && npm run build:js",
"stats": "webpack --profile --json > stats.json",
"build:demo": "cross-env NODE_ENV=production webpack -p --config demo/config/webpack.config.ts",
"build:types": "tsc --emitDeclarationOnly",
"build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline",
"clean": "rimraf lib",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"type-check": "tsc --noEmit",
"type-check:watch": "npm run type-check -- --watch",
"preversion": "npm run type-check && npm run lint",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@redux-devtools/dock-monitor": "^1.4.0",
"@types/prop-types": "^15.7.4",
"@types/redux-devtools-themes": "^1.0.0",
"devui": "^1.0.0-9",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
"react-json-tree": "^0.15.0",
"redux-devtools-themes": "^1.0.0"
},
"devDependencies": {
"@chakra-ui/react": "^1.6.5",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@mswjs/data": "^0.3.0",
"@redux-devtools/core": "^3.9.0",
"@redux-devtools/dock-monitor": "^1.4.0",
"@reduxjs/toolkit": "^1.6.0",
"@types/react": "^17.0.2",
"@types/react-dom": "17.0.0",
"@types/react-redux": "7.1.9",
"@types/react-router-dom": "5.1.6",
"cross-env": "^7.0.3",
"framer-motion": "^4",
"msw": "0.28.2",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-redux": "^7.2.1",
"react-router-dom": "^5.2.0",
"redux": "^4.0.5"
},
"peerDependencies": {
"@redux-devtools/core": "^3.7.0",
"@reduxjs/toolkit": "^1.6.0",
"@types/react": "^16.3.0 || ^17.0.0",
"react": "^16.3.0 || ^17.0.0",
"redux": "^3.4.0 || ^4.0.0"
},
"msw": {
"workerDirectory": "demo/public"
}
}

View File

@ -0,0 +1,27 @@
import React, { HTMLAttributes } from 'react';
export type ArrowUpIconProps = Omit<
HTMLAttributes<SVGElement>,
'xmlns' | 'children' | 'viewBox'
>;
/* eslint-disable max-len */
/**
* @see https://icons.getbootstrap.com/icons/arrow-up/
*/
export function ArrowUpIcon(props: ArrowUpIconProps): JSX.Element {
return (
<svg
fill="currentColor"
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"
/>
</svg>
);
}
/* eslint-enable max-len */

View File

@ -0,0 +1,24 @@
import React from 'react';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
export function NoRtkQueryApi(): JSX.Element {
return (
<StyleUtilsContext.Consumer>
{({ styling }) => (
<div {...styling('noApiFound')}>
No rtk-query api found.
<br />
Make sure to follow{' '}
<a
href="https://redux-toolkit.js.org/rtk-query/overview#basic-usage"
target="_blank"
rel="noreferrer noopener"
>
the instructions
</a>
.
</div>
)}
</StyleUtilsContext.Consumer>
);
}

View File

@ -0,0 +1,205 @@
import React, { ReactNode, FormEvent, MouseEvent, ChangeEvent } from 'react';
import { QueryFormValues } from '../types';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
import { Select } from 'devui';
import { SelectOption } from '../types';
import debounce from 'lodash.debounce';
import { sortQueryOptions, QueryComparators } from '../utils/comparators';
import { QueryFilters, filterQueryOptions } from '../utils/filters';
import { SortOrderButton } from './SortOrderButton';
import { RegexIcon } from './RegexIcon';
export interface QueryFormProps {
values: QueryFormValues;
searchQueryRegex: RegExp | null;
onFormValuesChange: (values: Partial<QueryFormValues>) => void;
}
interface QueryFormState {
searchValue: string;
}
const selectId = 'rtk-query-comp-select';
const searchId = 'rtk-query-search-query';
const filterSelectId = 'rtk-query-search-query-select';
const searchPlaceholder = 'filter query by...';
const labels = {
regexToggle: {
info: 'Use regular expression search',
error: 'Invalid regular expression provided',
},
};
export class QueryForm extends React.PureComponent<
QueryFormProps,
QueryFormState
> {
constructor(props: QueryFormProps) {
super(props);
this.state = {
searchValue: props.values.searchValue,
};
}
inputSearchRef = React.createRef<HTMLInputElement>();
handleSubmit = (evt: FormEvent<HTMLFormElement>): void => {
evt.preventDefault();
};
handleButtonGroupClick = (isAsc: boolean): void => {
this.props.onFormValuesChange({ isAscendingQueryComparatorOrder: isAsc });
};
handleSelectComparatorChange = (
option: SelectOption<QueryComparators> | undefined | null
): void => {
if (typeof option?.value === 'string') {
this.props.onFormValuesChange({ queryComparator: option.value });
}
};
handleSelectFilterChange = (
option: SelectOption<QueryFilters> | undefined | null
): void => {
if (typeof option?.value === 'string') {
this.props.onFormValuesChange({ queryFilter: option.value });
}
};
handleRegexSearchClick = (): void => {
this.props.onFormValuesChange({
isRegexSearch: !this.props.values.isRegexSearch,
});
};
restoreCaretPosition = (start: number | null, end: number | null): void => {
window.requestAnimationFrame(() => {
if (this.inputSearchRef.current) {
this.inputSearchRef.current.selectionStart = start;
this.inputSearchRef.current.selectionEnd = end;
}
});
};
invalidateSearchValueFromProps = debounce(() => {
this.props.onFormValuesChange({
searchValue: this.state.searchValue,
});
}, 150);
handleSearchChange = (evt: ChangeEvent<HTMLInputElement>): void => {
const searchValue = evt.target.value.trim();
this.setState({ searchValue });
this.invalidateSearchValueFromProps();
};
handleClearSearchClick = (evt: MouseEvent<HTMLButtonElement>): void => {
evt.preventDefault();
if (this.state.searchValue) {
this.setState({ searchValue: '' });
this.invalidateSearchValueFromProps();
}
};
render(): ReactNode {
const {
searchQueryRegex,
values: {
isAscendingQueryComparatorOrder: isAsc,
queryComparator,
searchValue,
queryFilter,
isRegexSearch,
},
} = this.props;
const isRegexInvalid =
isRegexSearch && searchValue.length > 0 && searchQueryRegex == null;
const regexToggleType = isRegexInvalid ? 'error' : 'info';
const regexToggleLabel = labels.regexToggle[regexToggleType];
return (
<StyleUtilsContext.Consumer>
{({ styling, base16Theme }) => {
return (
<form
action="#"
onSubmit={this.handleSubmit}
{...styling('queryForm')}
>
<div {...styling('queryListHeader')}>
<label htmlFor={searchId} {...styling('srOnly')}>
filter query
</label>
<div {...styling('querySearch')}>
<input
ref={this.inputSearchRef}
type="search"
value={this.state.searchValue}
onChange={this.handleSearchChange}
placeholder={searchPlaceholder}
/>
<button
type="reset"
aria-label="clear search"
data-invisible={
+(this.state.searchValue.length === 0) || undefined
}
onClick={this.handleClearSearchClick}
{...styling('closeButton')}
/>
<button
type="button"
aria-label={regexToggleLabel}
title={regexToggleLabel}
data-type={regexToggleType}
aria-pressed={isRegexSearch}
onClick={this.handleRegexSearchClick}
{...styling('toggleButton')}
>
<RegexIcon />
</button>
</div>
<label htmlFor={selectId} {...styling('srOnly')}>
filter by
</label>
<Select<SelectOption<QueryFilters>>
id={filterSelectId}
isSearchable={false}
options={filterQueryOptions}
theme={base16Theme as any}
value={filterQueryOptions.find(
(opt) => opt?.value === queryFilter
)}
onChange={this.handleSelectFilterChange}
/>
</div>
<div {...styling('sortBySection')}>
<label htmlFor={selectId}>Sort by</label>
<Select<SelectOption<QueryComparators>>
id={selectId}
isSearchable={false}
theme={base16Theme as any}
value={sortQueryOptions.find(
(opt) => opt?.value === queryComparator
)}
options={sortQueryOptions}
onChange={this.handleSelectComparatorChange}
/>
<SortOrderButton
id={'rtk-query-sort-order-button'}
isAsc={isAsc}
onChange={this.handleButtonGroupClick}
/>
</div>
</form>
);
}}
</StyleUtilsContext.Consumer>
);
}
}

View File

@ -0,0 +1,69 @@
import React, { PureComponent, ReactNode } from 'react';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
import { RtkResourceInfo, RtkQueryMonitorState } from '../types';
import { isQuerySelected } from '../utils/rtk-query';
export interface QueryListProps {
resInfos: RtkResourceInfo[];
selectedQueryKey: RtkQueryMonitorState['selectedQueryKey'];
onSelectQuery: (query: RtkResourceInfo) => void;
}
export class QueryList extends PureComponent<QueryListProps> {
static isItemSelected(
selectedQueryKey: QueryListProps['selectedQueryKey'],
queryInfo: RtkResourceInfo
): boolean {
return (
!!selectedQueryKey &&
selectedQueryKey.queryKey === queryInfo.queryKey &&
selectedQueryKey.reducerPath === queryInfo.reducerPath
);
}
static formatQuery(resInfo: RtkResourceInfo): string {
const key =
resInfo.type === 'query'
? resInfo.queryKey
: `${resInfo.state.endpointName ?? ''} ${resInfo.queryKey}`;
return key;
}
render(): ReactNode {
const { resInfos, selectedQueryKey, onSelectQuery } = this.props;
return (
<StyleUtilsContext.Consumer>
{({ styling }) => (
<ul {...styling('queryList')}>
{resInfos.map((resInfo) => {
const isSelected = isQuerySelected(selectedQueryKey, resInfo);
return (
<li
key={resInfo.queryKey}
onClick={() => onSelectQuery(resInfo)}
{...styling(
['queryListItem', isSelected && 'queryListItemSelected'],
isSelected
)}
>
<p {...styling('queryListItemKey')}>
{QueryList.formatQuery(resInfo)}
</p>
<div {...styling('queryStatusWrapper')}>
<strong {...styling(['queryStatus', 'queryType'])}>
{resInfo.type === 'query' ? 'Q' : 'M'}
</strong>
<p {...styling('queryStatus')}>{resInfo.state.status}</p>
</div>
</li>
);
})}
</ul>
)}
</StyleUtilsContext.Consumer>
);
}
}

View File

@ -0,0 +1,80 @@
import { createSelector } from '@reduxjs/toolkit';
import React, { ReactNode, PureComponent } from 'react';
import { Action, AnyAction } from 'redux';
import { emptyRecord, identity } from '../utils/object';
import { TreeView } from './TreeView';
export interface QueryPreviewActionsProps {
isWideLayout: boolean;
actionsOfQuery: AnyAction[];
}
const keySep = ' - ';
export class QueryPreviewActions extends PureComponent<QueryPreviewActionsProps> {
selectFormattedActions = createSelector<
AnyAction[],
AnyAction[],
Record<string, AnyAction>
>(identity, (actions) => {
const output: Record<string, AnyAction> = {};
if (actions.length === 0) {
return emptyRecord;
}
for (let i = 0, len = actions.length; i < len; i++) {
const action = actions[i];
const key = `${i}${keySep}${(action as Action<string>)?.type ?? ''}`;
output[key] = action;
}
return output;
});
isLastActionNode = (keyPath: (string | number)[], layer: number): boolean => {
if (layer >= 1) {
const len = this.props.actionsOfQuery.length;
const actionKey = keyPath[keyPath.length - 1];
if (typeof actionKey === 'string') {
const index = Number(actionKey.split(keySep)[0]);
return len > 0 && len - index < 2;
}
}
return false;
};
shouldExpandNode = (
keyPath: (string | number)[],
value: unknown,
layer: number
): boolean => {
if (layer === 1) {
return this.isLastActionNode(keyPath, layer);
}
if (layer === 2) {
return (
this.isLastActionNode(keyPath, layer) &&
(keyPath[0] === 'meta' || keyPath[0] === 'error')
);
}
return layer <= 1;
};
render(): ReactNode {
const { isWideLayout, actionsOfQuery } = this.props;
return (
<TreeView
data={this.selectFormattedActions(actionsOfQuery)}
isWideLayout={isWideLayout}
shouldExpandNode={this.shouldExpandNode}
/>
);
}
}

View File

@ -0,0 +1,72 @@
import React, { ReactNode, PureComponent } from 'react';
import { ApiStats, RtkQueryApiState } from '../types';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
import { TreeView } from './TreeView';
export interface QueryPreviewApiProps {
apiStats: ApiStats | null;
apiState: RtkQueryApiState | null;
isWideLayout: boolean;
}
export class QueryPreviewApi extends PureComponent<QueryPreviewApiProps> {
shouldExpandApiStateNode = (
keyPath: (string | number)[],
value: unknown,
layer: number
): boolean => {
const lastKey = keyPath[keyPath.length - 1];
return layer <= 1 && lastKey !== 'config';
};
render(): ReactNode {
const { apiStats, isWideLayout, apiState } = this.props;
if (!apiState) {
return null;
}
const hasMutations = Object.keys(apiState.mutations).length > 0;
const hasQueries = Object.keys(apiState.queries).length > 0;
return (
<StyleUtilsContext.Consumer>
{({ styling }) => (
<article {...styling('tabContent')}>
<h2>{apiState.config.reducerPath}</h2>
<TreeView
before={<h3>State</h3>}
data={apiState}
shouldExpandNode={this.shouldExpandApiStateNode}
isWideLayout={isWideLayout}
/>
{apiStats && (
<>
<TreeView
before={<h3>Tally</h3>}
data={apiStats.tally}
isWideLayout={isWideLayout}
/>
{hasQueries && (
<TreeView
before={<h3>Queries Timings</h3>}
data={apiStats.timings.queries}
isWideLayout={isWideLayout}
/>
)}
{hasMutations && (
<TreeView
before={<h3>Mutations Timings</h3>}
data={apiStats.timings.mutations}
isWideLayout={isWideLayout}
/>
)}
</>
)}
</article>
)}
</StyleUtilsContext.Consumer>
);
}
}

View File

@ -0,0 +1,57 @@
import React, { ReactNode } from 'react';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
import { QueryPreviewTabs, TabOption } from '../types';
import { emptyArray } from '../utils/object';
export interface QueryPreviewHeaderProps {
tabs: ReadonlyArray<
TabOption<QueryPreviewTabs, unknown, 'query' | 'mutation'>
>;
onTabChange: (tab: QueryPreviewTabs) => void;
selectedTab: QueryPreviewTabs;
renderTabLabel?: (tab: QueryPreviewHeaderProps['tabs'][number]) => ReactNode;
}
export class QueryPreviewHeader extends React.Component<QueryPreviewHeaderProps> {
handleTabClick = (tab: QueryPreviewHeaderProps['tabs'][number]): void => {
if (this.props.selectedTab !== tab.value) {
this.props.onTabChange(tab.value);
}
};
render(): ReactNode {
const { tabs, selectedTab, renderTabLabel } = this.props;
return (
<StyleUtilsContext.Consumer>
{({ styling }) => (
<div {...styling('previewHeader')}>
<div {...styling('tabSelector')}>
{tabs.map((tab) => (
<div
onClick={() => this.handleTabClick(tab)}
key={tab.value}
{...styling(
[
'selectorButton',
tab.value === selectedTab && 'selectorButtonSelected',
],
tab.value === selectedTab
)}
>
<span>
{renderTabLabel ? renderTabLabel(tab) : tab.label}
</span>
</div>
))}
</div>
</div>
)}
</StyleUtilsContext.Consumer>
);
}
static defaultProps = {
tabs: emptyArray,
};
}

View File

@ -0,0 +1,103 @@
import { createSelector, Selector } from '@reduxjs/toolkit';
import { QueryStatus } from '@reduxjs/toolkit/dist/query';
import React, { ReactNode, PureComponent } from 'react';
import { RtkResourceInfo, RTKStatusFlags } from '../types';
import { formatMs } from '../utils/formatters';
import { identity } from '../utils/object';
import { getQueryStatusFlags } from '../utils/rtk-query';
import { TreeView } from './TreeView';
type QueryTimings = {
startedAt: string;
loadedAt: string;
duration: string;
};
type FormattedQuery = {
key: string;
reducerPath: string;
timings: QueryTimings;
statusFlags: RTKStatusFlags;
} & (
| { mutation: RtkResourceInfo['state'] }
| { query: RtkResourceInfo['state'] }
);
export interface QueryPreviewInfoProps {
resInfo: RtkResourceInfo;
isWideLayout: boolean;
}
export class QueryPreviewInfo extends PureComponent<QueryPreviewInfoProps> {
shouldExpandNode = (
keyPath: (string | number)[],
value: unknown,
layer: number
): boolean => {
const lastKey = keyPath[keyPath.length - 1];
return layer <= 1 && lastKey !== 'query' && lastKey !== 'mutation';
};
selectFormattedQuery: Selector<RtkResourceInfo, FormattedQuery> =
createSelector(identity, (resInfo: RtkResourceInfo): FormattedQuery => {
const { state, queryKey, reducerPath } = resInfo;
const startedAt = state.startedTimeStamp
? new Date(state.startedTimeStamp).toISOString()
: '-';
const loadedAt = state.fulfilledTimeStamp
? new Date(state.fulfilledTimeStamp).toISOString()
: '-';
const statusFlags = getQueryStatusFlags(state);
const timings = {
startedAt,
loadedAt,
duration: '-',
};
if (
state.fulfilledTimeStamp &&
state.startedTimeStamp &&
state.status !== QueryStatus.pending &&
state.startedTimeStamp <= state.fulfilledTimeStamp
) {
timings.duration = formatMs(
state.fulfilledTimeStamp - state.startedTimeStamp
);
}
if (resInfo.type === 'query') {
return {
key: queryKey,
reducerPath,
query: resInfo.state,
statusFlags,
timings,
};
}
return {
key: queryKey,
reducerPath,
mutation: resInfo.state,
statusFlags,
timings,
};
});
render(): ReactNode {
const { resInfo, isWideLayout } = this.props;
const formattedQuery = this.selectFormattedQuery(resInfo);
return (
<TreeView
data={formattedQuery}
isWideLayout={isWideLayout}
shouldExpandNode={this.shouldExpandNode}
/>
);
}
}

View File

@ -0,0 +1,18 @@
import React, { ReactNode, PureComponent } from 'react';
import { RtkQueryApiState } from '../types';
import { TreeView } from './TreeView';
export interface QueryPreviewSubscriptionsProps {
subscriptions: RtkQueryApiState['subscriptions'][keyof RtkQueryApiState['subscriptions']];
isWideLayout: boolean;
}
export class QueryPreviewSubscriptions extends PureComponent<QueryPreviewSubscriptionsProps> {
render(): ReactNode {
const { subscriptions } = this.props;
return (
<TreeView data={subscriptions} isWideLayout={this.props.isWideLayout} />
);
}
}

View File

@ -0,0 +1,31 @@
import React, { ReactNode, PureComponent } from 'react';
import { RtkQueryTag } from '../types';
import { TreeView } from './TreeView';
interface QueryPreviewTagsState {
data: { tags: RtkQueryTag[] };
}
export interface QueryPreviewTagsProps {
tags: RtkQueryTag[];
isWideLayout: boolean;
}
export class QueryPreviewTags extends PureComponent<
QueryPreviewTagsProps,
QueryPreviewTagsState
> {
constructor(props: QueryPreviewTagsProps) {
super(props);
this.state = {
data: { tags: props.tags },
};
}
render(): ReactNode {
const { isWideLayout, tags } = this.props;
return <TreeView data={tags} isWideLayout={isWideLayout} />;
}
}

View File

@ -0,0 +1,23 @@
import * as React from 'react';
export type RegexIconProps = Omit<
React.HTMLAttributes<SVGElement>,
'viewBox' | 'children'
>;
// `OOjs_UI_icon_regular-expression.svg` (MIT License)
// from https://commons.wikimedia.org/wiki/File:OOjs_UI_icon_regular-expression.svg
export function RegexIcon(
props: React.HTMLAttributes<SVGElement>
): JSX.Element {
return (
<svg fill="currentColor" {...props} viewBox="0 0 24 24">
<g>
<path d="M3 12.045c0-.99.15-1.915.45-2.777A6.886 6.886 0 0 1 4.764 7H6.23a7.923 7.923 0 0 0-1.25 2.374 8.563 8.563 0 0 0 .007 5.314c.29.85.7 1.622 1.23 2.312h-1.45a6.53 6.53 0 0 1-1.314-2.223 8.126 8.126 0 0 1-.45-2.732" />
<path id="dot" d="M10 16a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
<path d="M14.25 7.013l-.24 2.156 2.187-.61.193 1.47-1.992.14 1.307 1.74-1.33.71-.914-1.833-.8 1.822-1.38-.698 1.296-1.74-1.98-.152.23-1.464 2.14.61-.24-2.158h1.534" />
<path d="M21 12.045c0 .982-.152 1.896-.457 2.744A6.51 6.51 0 0 1 19.236 17h-1.453a8.017 8.017 0 0 0 1.225-2.31c.29-.855.434-1.74.434-2.66 0-.91-.14-1.797-.422-2.66a7.913 7.913 0 0 0-1.248-2.374h1.465a6.764 6.764 0 0 1 1.313 2.28c.3.86.45 1.782.45 2.764" />
</g>
</svg>
);
}

View File

@ -0,0 +1,46 @@
import React, { CSSProperties } from 'react';
import { ArrowUpIcon } from './ArrowUpIcon';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
export interface SortOrderButtonProps {
readonly isAsc?: boolean;
readonly onChange: (isAsc: boolean) => void;
id?: string;
}
export function SortOrderButton({
isAsc,
onChange,
id,
}: SortOrderButtonProps): JSX.Element {
const handleButtonClick = (): void => {
if (!isAsc) {
onChange(true);
} else onChange(false);
};
const buttonLabel = isAsc ? 'asc' : 'desc';
const arrowStyles: CSSProperties = {
width: '1em',
height: '1em',
transform: !isAsc ? 'scaleY(-1)' : undefined,
};
return (
<StyleUtilsContext.Consumer>
{({ styling }) => (
<button
type="button"
id={id}
onClick={handleButtonClick}
aria-pressed={isAsc}
{...styling(['sortButton'])}
>
<ArrowUpIcon style={arrowStyles} />
{buttonLabel}
</button>
)}
</StyleUtilsContext.Consumer>
);
}

View File

@ -0,0 +1,100 @@
import { createSelector } from '@reduxjs/toolkit';
import React, { ComponentProps, ReactNode } from 'react';
import JSONTree from 'react-json-tree';
import { Base16Theme, 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 { identity } from '../utils/object';
export interface TreeViewProps
extends Partial<
Pick<
ComponentProps<typeof JSONTree>,
'keyPath' | 'shouldExpandNode' | 'hideRoot'
>
> {
data: unknown;
isWideLayout: boolean;
before?: ReactNode;
after?: ReactNode;
children?: ReactNode;
}
export class TreeView extends React.PureComponent<TreeViewProps> {
static defaultProps = {
hideRoot: true,
shouldExpandNode: (
keyPath: (string | number)[],
value: unknown,
layer: number
): boolean => {
return layer < 2;
},
};
readonly selectLabelRenderer = createSelector<
StylingFunction,
StylingFunction,
ReturnType<typeof createTreeItemLabelRenderer>
>(identity, createTreeItemLabelRenderer);
readonly selectGetItemString = createSelector<
StylingFunction,
StylingFunction,
(type: string, data: unknown) => ReactNode
>(
identity,
(styling) => (type, data) =>
getItemString(styling, type, data, DATA_TYPE_KEY, false)
);
readonly selectTheme = createSelector<
Base16Theme,
Base16Theme,
ReturnType<typeof getJsonTreeTheme>
>(identity, getJsonTreeTheme);
constructor(props: TreeViewProps) {
super(props);
}
render(): ReactNode {
const {
data,
before,
after,
children,
keyPath,
shouldExpandNode,
hideRoot,
} = this.props;
return (
<StyleUtilsContext.Consumer>
{({ styling, invertTheme, base16Theme }) => {
return (
<div {...styling('treeWrapper')}>
{before}
<JSONTree
keyPath={keyPath}
shouldExpandNode={shouldExpandNode}
data={data}
labelRenderer={this.selectLabelRenderer(styling)}
theme={this.selectTheme(base16Theme)}
invertTheme={invertTheme}
getItemString={this.selectGetItemString(styling)}
hideRoot={hideRoot}
/>
{after}
{children}
</div>
);
}}
</StyleUtilsContext.Consumer>
);
}
}

View File

@ -0,0 +1,12 @@
import * as React from 'react';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
export type UListProps = React.HTMLAttributes<HTMLUListElement>;
export function UList(props: UListProps): JSX.Element {
return (
<StyleUtilsContext.Consumer>
{({ styling }) => <ul {...props} {...styling('uList')} />}
</StyleUtilsContext.Consumer>
);
}

View File

@ -0,0 +1,246 @@
import React, { ReactNode } from 'react';
import { StyleUtilsContext } from '../styles/createStylingFromTheme';
import { createTreeItemLabelRenderer } from '../styles/tree';
import {
QueryPreviewTabs,
RtkResourceInfo,
SelectorsSource,
TabOption,
} from '../types';
import { QueryPreviewHeader } from '../components/QueryPreviewHeader';
import {
QueryPreviewInfo,
QueryPreviewInfoProps,
} from '../components/QueryPreviewInfo';
import {
QueryPreviewApi,
QueryPreviewApiProps,
} from '../components/QueryPreviewApi';
import {
QueryPreviewSubscriptions,
QueryPreviewSubscriptionsProps,
} from '../components/QueryPreviewSubscriptions';
import {
QueryPreviewTags,
QueryPreviewTagsProps,
} from '../components/QueryPreviewTags';
import { NoRtkQueryApi } from '../components/NoRtkQueryApi';
import { InspectorSelectors } from '../selectors';
import { StylingFunction } from 'react-base16-styling';
import { mapProps } from './mapProps';
import {
QueryPreviewActions,
QueryPreviewActionsProps,
} from '../components/QueryPreviewActions';
import { isTabVisible } from '../utils/tabs';
export interface QueryPreviewProps<S = unknown> {
readonly selectedTab: QueryPreviewTabs;
readonly hasNoApis: boolean;
readonly onTabChange: (tab: QueryPreviewTabs) => void;
readonly resInfo: RtkResourceInfo | null;
readonly styling: StylingFunction;
readonly isWideLayout: boolean;
readonly selectorsSource: SelectorsSource<S>;
readonly selectors: InspectorSelectors<S>;
}
/**
* Tab content is not rendered if there's no selected query.
*/
type QueryPreviewTabProps = Omit<QueryPreviewProps<unknown>, 'resInfo'> & {
resInfo: RtkResourceInfo;
};
const MappedQueryPreviewTags = mapProps<
QueryPreviewTabProps,
QueryPreviewTagsProps
>(({ selectors, selectorsSource, isWideLayout, resInfo }) => ({
resInfo,
tags: selectors.selectCurrentQueryTags(selectorsSource),
isWideLayout,
}))(QueryPreviewTags);
const MappedQueryPreviewInfo = mapProps<
QueryPreviewTabProps,
QueryPreviewInfoProps
>(({ resInfo, isWideLayout }) => ({ resInfo, isWideLayout }))(QueryPreviewInfo);
const MappedQuerySubscriptipns = mapProps<
QueryPreviewTabProps,
QueryPreviewSubscriptionsProps
>(({ selectors, selectorsSource, isWideLayout }) => ({
isWideLayout,
subscriptions: selectors.selectSubscriptionsOfCurrentQuery(selectorsSource),
}))(QueryPreviewSubscriptions);
const MappedApiPreview = mapProps<QueryPreviewTabProps, QueryPreviewApiProps>(
({ isWideLayout, selectors, selectorsSource }) => ({
isWideLayout,
apiState: selectors.selectApiOfCurrentQuery(selectorsSource),
apiStats: selectors.selectApiStatsOfCurrentQuery(selectorsSource),
})
)(QueryPreviewApi);
const MappedQueryPreviewActions = mapProps<
QueryPreviewTabProps,
QueryPreviewActionsProps
>(({ isWideLayout, selectorsSource, selectors }) => ({
isWideLayout,
actionsOfQuery: selectors.selectActionsOfCurrentQuery(selectorsSource),
}))(QueryPreviewActions);
const tabs: ReadonlyArray<
TabOption<QueryPreviewTabs, QueryPreviewTabProps, RtkResourceInfo['type']>
> = [
{
label: 'query',
value: QueryPreviewTabs.queryinfo,
component: MappedQueryPreviewInfo,
visible: {
query: true,
mutation: true,
default: true,
},
},
{
label: 'actions',
value: QueryPreviewTabs.actions,
component: MappedQueryPreviewActions,
visible: {
query: true,
mutation: true,
default: true,
},
},
{
label: 'tags',
value: QueryPreviewTabs.queryTags,
component: MappedQueryPreviewTags,
visible: {
query: true,
mutation: false,
default: true,
},
},
{
label: 'subs',
value: QueryPreviewTabs.querySubscriptions,
component: MappedQuerySubscriptipns,
visible: {
query: true,
mutation: false,
default: true,
},
},
{
label: 'api',
value: QueryPreviewTabs.apiConfig,
component: MappedApiPreview,
},
];
export class QueryPreview<S> extends React.PureComponent<QueryPreviewProps<S>> {
readonly labelRenderer: ReturnType<typeof createTreeItemLabelRenderer>;
constructor(props: QueryPreviewProps<S>) {
super(props);
this.labelRenderer = createTreeItemLabelRenderer(this.props.styling);
}
renderLabelWithCounter = (
label: React.ReactText,
counter: number
): string => {
let counterAsString = counter.toFixed(0);
if (counterAsString.length > 3) {
counterAsString = counterAsString.slice(0, 2) + '...';
}
return `${label}(${counterAsString})`;
};
renderTabLabel = (
tab: TabOption<QueryPreviewTabs, unknown, 'query' | 'mutation'>
): ReactNode => {
const { selectors, selectorsSource, resInfo } = this.props;
const tabCount = selectors.selectTabCounters(selectorsSource)[tab.value];
let tabLabel = tab.label;
if (tabLabel === 'query' && resInfo?.type === 'mutation') {
tabLabel = resInfo.type;
}
if (tabCount > 0) {
return this.renderLabelWithCounter(tabLabel, tabCount);
}
return tabLabel;
};
render(): ReactNode {
const { resInfo, selectedTab, onTabChange, hasNoApis } = this.props;
const { component: TabComponent } =
tabs.find((tab) => tab.value === selectedTab) || tabs[0];
if (!resInfo) {
return (
<StyleUtilsContext.Consumer>
{({ styling }) => (
<div {...styling('queryPreview')}>
<QueryPreviewHeader
selectedTab={selectedTab}
onTabChange={onTabChange}
tabs={
tabs.filter((tab) =>
isTabVisible(tab, 'default')
) as ReadonlyArray<
TabOption<
QueryPreviewTabs,
unknown,
RtkResourceInfo['type']
>
>
}
renderTabLabel={this.renderTabLabel}
/>
{hasNoApis && <NoRtkQueryApi />}
</div>
)}
</StyleUtilsContext.Consumer>
);
}
return (
<StyleUtilsContext.Consumer>
{({ styling }) => {
return (
<div {...styling('queryPreview')}>
<QueryPreviewHeader
selectedTab={selectedTab}
onTabChange={onTabChange}
tabs={
tabs.filter((tab) =>
isTabVisible(tab, resInfo.type)
) as ReadonlyArray<
TabOption<
QueryPreviewTabs,
unknown,
RtkResourceInfo['type']
>
>
}
renderTabLabel={this.renderTabLabel}
/>
<TabComponent {...(this.props as QueryPreviewTabProps)} />
</div>
);
}}
</StyleUtilsContext.Consumer>
);
}
}

View File

@ -0,0 +1,166 @@
import React, { PureComponent, createRef, ReactNode } from 'react';
import type { AnyAction, Dispatch, Action } from '@reduxjs/toolkit';
import type { LiftedAction, LiftedState } from '@redux-devtools/core';
import {
QueryFormValues,
QueryPreviewTabs,
RtkQueryMonitorState,
StyleUtils,
SelectorsSource,
RtkResourceInfo,
} from '../types';
import { createInspectorSelectors, computeSelectorSource } from '../selectors';
import {
changeQueryFormValues,
selectedPreviewTab,
selectQueryKey,
} from '../reducers';
import { QueryList } from '../components/QueryList';
import { QueryForm } from '../components/QueryForm';
import { QueryPreview } from './QueryPreview';
type ForwardedMonitorProps<S, A extends Action<unknown>> = Pick<
LiftedState<S, A, RtkQueryMonitorState>,
'monitorState' | 'currentStateIndex' | 'computedStates' | 'actionsById'
>;
export interface RtkQueryInspectorProps<S, A extends Action<unknown>>
extends ForwardedMonitorProps<S, A> {
dispatch: Dispatch<LiftedAction<S, A, RtkQueryMonitorState>>;
styleUtils: StyleUtils;
}
type RtkQueryInspectorState<S> = {
selectorsSource: SelectorsSource<S>;
isWideLayout: boolean;
};
class RtkQueryInspector<S, A extends Action<unknown>> extends PureComponent<
RtkQueryInspectorProps<S, A>,
RtkQueryInspectorState<S>
> {
inspectorRef = createRef<HTMLDivElement>();
isWideIntervalRef: ReturnType<typeof setInterval> | null = null;
constructor(props: RtkQueryInspectorProps<S, A>) {
super(props);
this.state = {
isWideLayout: true,
selectorsSource: computeSelectorSource(props, null),
};
}
static wideLayout = 600;
static getDerivedStateFromProps(
props: RtkQueryInspectorProps<unknown, Action<unknown>>,
state: RtkQueryInspectorState<unknown>
): null | Partial<RtkQueryInspectorState<unknown>> {
const selectorsSource = computeSelectorSource<unknown, Action<unknown>>(
props,
state.selectorsSource
);
if (selectorsSource !== state.selectorsSource) {
return {
selectorsSource,
};
}
return null;
}
selectors = createInspectorSelectors<S>();
updateSizeMode = (): void => {
if (this.inspectorRef.current) {
const isWideLayout =
this.inspectorRef.current.offsetWidth >= RtkQueryInspector.wideLayout;
if (isWideLayout !== this.state.isWideLayout) {
this.setState({ isWideLayout });
}
}
};
componentDidMount(): void {
this.updateSizeMode();
this.isWideIntervalRef = setInterval(this.updateSizeMode, 300);
}
componentWillUnmount(): void {
if (this.isWideIntervalRef) {
clearTimeout(this.isWideIntervalRef);
}
}
handleQueryFormValuesChange = (values: Partial<QueryFormValues>): void => {
this.props.dispatch(changeQueryFormValues(values) as AnyAction);
};
handleSelectQuery = (queryInfo: RtkResourceInfo): void => {
this.props.dispatch(selectQueryKey(queryInfo) as AnyAction);
};
handleTabChange = (tab: QueryPreviewTabs): void => {
this.props.dispatch(selectedPreviewTab(tab) as AnyAction);
};
render(): ReactNode {
const { selectorsSource, isWideLayout } = this.state;
const {
styleUtils: { styling },
} = this.props;
const allVisibleRtkResourceInfos =
this.selectors.selectAllVisbileQueries(selectorsSource);
const currentResInfo =
this.selectors.selectCurrentQueryInfo(selectorsSource);
const apiStates = this.selectors.selectApiStates(selectorsSource);
const hasNoApi = apiStates == null;
const searchQueryRegex =
this.selectors.selectSearchQueryRegex(selectorsSource);
return (
<div
ref={this.inspectorRef}
data-wide-layout={+this.state.isWideLayout}
{...styling('inspector')}
>
<div
{...styling('querySectionWrapper')}
data-wide-layout={+this.state.isWideLayout}
>
<QueryForm
searchQueryRegex={searchQueryRegex}
values={selectorsSource.monitorState.queryForm.values}
onFormValuesChange={this.handleQueryFormValuesChange}
/>
<QueryList
onSelectQuery={this.handleSelectQuery}
resInfos={allVisibleRtkResourceInfos}
selectedQueryKey={selectorsSource.monitorState.selectedQueryKey}
/>
</div>
<QueryPreview<S>
selectorsSource={this.state.selectorsSource}
selectors={this.selectors}
resInfo={currentResInfo}
selectedTab={selectorsSource.monitorState.selectedPreviewTab}
onTabChange={this.handleTabChange}
styling={styling}
isWideLayout={isWideLayout}
hasNoApis={hasNoApi}
/>
</div>
);
}
}
export default RtkQueryInspector;

View File

@ -0,0 +1,90 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Action, AnyAction } from 'redux';
import RtkQueryInspector from './RtkQueryInspector';
import { reducer } from '../reducers';
import {
ExternalProps,
RtkQueryMonitorProps,
RtkQueryMonitorState,
StyleUtils,
} from '../types';
import {
createThemeState,
StyleUtilsContext,
} from '../styles/createStylingFromTheme';
interface DefaultProps {
theme: string;
invertTheme: boolean;
}
export interface RtkQueryComponentState {
readonly styleUtils: StyleUtils;
}
class RtkQueryMonitor<S, A extends Action<unknown>> extends Component<
RtkQueryMonitorProps<S, A>,
RtkQueryComponentState
> {
static update = reducer;
static propTypes = {
dispatch: PropTypes.func,
computedStates: PropTypes.array,
currentStateIndex: PropTypes.number,
actionsById: PropTypes.object,
stagedActionIds: PropTypes.array,
skippedActionIds: PropTypes.array,
monitorState: PropTypes.object,
theme: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
invertTheme: PropTypes.bool,
};
static defaultProps: DefaultProps = {
theme: 'nicinabox',
invertTheme: false,
};
constructor(props: RtkQueryMonitorProps<S, A>) {
super(props);
this.state = {
styleUtils: createThemeState<S, A>(props),
};
}
render() {
const {
currentStateIndex,
computedStates,
monitorState,
dispatch,
actionsById,
} = this.props;
return (
<StyleUtilsContext.Provider value={this.state.styleUtils}>
<RtkQueryInspector<S, AnyAction>
computedStates={computedStates}
currentStateIndex={currentStateIndex}
monitorState={monitorState}
dispatch={dispatch}
styleUtils={this.state.styleUtils}
actionsById={actionsById}
/>
</StyleUtilsContext.Provider>
);
}
}
export default RtkQueryMonitor as unknown as React.ComponentType<
ExternalProps<unknown, Action<unknown>>
> & {
update(
monitorProps: ExternalProps<unknown, Action<unknown>>,
state: RtkQueryMonitorState | undefined,
action: Action
): RtkQueryMonitorState;
defaultProps: DefaultProps;
};

View File

@ -0,0 +1,29 @@
import React, { ComponentType, ReactNode, Component } from 'react';
interface Mapper<In, Out> {
(inProps: In): Out;
}
interface MapPropsOutput<In, Out> {
(comp: ComponentType<Out>): ComponentType<In>;
}
export function mapProps<In, Out>(
mapper: Mapper<In, Out>
): MapPropsOutput<In, Out> {
return function mapPropsHoc(Comp) {
class MapPropsHoc extends Component<In> {
render(): ReactNode {
const mappedProps = mapper(this.props);
return <Comp {...mappedProps} />;
}
static displayName = `mapProps(${
Comp.displayName || Comp.name || 'Component'
})`;
}
return MapPropsHoc;
};
}

View File

@ -0,0 +1,2 @@
export { default } from './containers/RtkQueryMonitor';
export type { ExternalProps } from './types';

View File

@ -0,0 +1,6 @@
export const DATA_TYPE_KEY = Symbol.for('__serializedType__');
/**
* @see https://github.com/reduxjs/redux-toolkit/blob/b718e01d323d3ab4b913e5d88c9b90aa790bb975/src/query/core/buildSlice.ts#L259
*/
export const missingTagId = '__internal_without_id';

View File

@ -0,0 +1,65 @@
import { Action, AnyAction } from 'redux';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import {
QueryInfo,
RtkQueryMonitorState,
QueryFormValues,
RtkQueryMonitorProps,
QueryPreviewTabs,
} from './types';
import { QueryComparators } from './utils/comparators';
import { QueryFilters } from './utils/filters';
const initialState: RtkQueryMonitorState = {
queryForm: {
values: {
queryComparator: QueryComparators.fulfilledTimeStamp,
isAscendingQueryComparatorOrder: false,
searchValue: '',
isRegexSearch: false,
queryFilter: QueryFilters.queryKey,
},
},
selectedPreviewTab: QueryPreviewTabs.queryinfo,
selectedQueryKey: null,
};
const monitorSlice = createSlice({
/**
* `@@` prefix is mandatory.
* @see lifedAction @ `packages/redux-devtools-app/src/actions/index.ts`
*/
name: '@@rtk-query-monitor',
initialState,
reducers: {
changeQueryFormValues(
state,
action: PayloadAction<Partial<QueryFormValues>>
) {
state.queryForm.values = { ...state.queryForm.values, ...action.payload };
},
selectQueryKey(
state,
action: PayloadAction<Pick<QueryInfo, 'reducerPath' | 'queryKey'>>
) {
state.selectedQueryKey = {
queryKey: action.payload.queryKey,
reducerPath: action.payload.reducerPath,
};
},
selectedPreviewTab(state, action: PayloadAction<QueryPreviewTabs>) {
state.selectedPreviewTab = action.payload;
},
},
});
export function reducer<S, A extends Action<unknown>>(
props: RtkQueryMonitorProps<S, A>,
state: RtkQueryMonitorState | undefined,
action: AnyAction
): RtkQueryMonitorState {
return monitorSlice.reducer(state, action);
}
export const { selectQueryKey, changeQueryFormValues, selectedPreviewTab } =
monitorSlice.actions;

View File

@ -0,0 +1,283 @@
import { Action, createSelector, Selector } from '@reduxjs/toolkit';
import { RtkQueryInspectorProps } from './containers/RtkQueryInspector';
import {
ApiStats,
QueryInfo,
RtkQueryApiState,
RtkQueryTag,
SelectorsSource,
RtkQueryProvided,
QueryPreviewTabs,
RtkResourceInfo,
} from './types';
import { Comparator, queryComparators } from './utils/comparators';
import { FilterList, queryListFilters } from './utils/filters';
import { emptyRecord } from './utils/object';
import { escapeRegExpSpecialCharacter } from './utils/regexp';
import {
getApiStatesOf,
extractAllApiQueries,
flipComparator,
getQueryTagsOf,
generateApiStatsOfCurrentQuery,
getActionsOfCurrentQuery,
extractAllApiMutations,
} from './utils/rtk-query';
type InspectorSelector<S, Output> = Selector<SelectorsSource<S>, Output>;
export function computeSelectorSource<S, A extends Action<unknown>>(
props: RtkQueryInspectorProps<S, A>,
previous: SelectorsSource<S> | null = null
): SelectorsSource<S> {
const { computedStates, currentStateIndex, monitorState, actionsById } =
props;
const userState =
computedStates.length > 0 ? computedStates[currentStateIndex].state : null;
if (
!previous ||
previous.userState !== userState ||
previous.monitorState !== monitorState ||
previous.actionsById !== actionsById
) {
return {
userState,
monitorState,
currentStateIndex,
actionsById,
};
}
return previous;
}
export interface InspectorSelectors<S> {
readonly selectQueryComparator: InspectorSelector<S, Comparator<QueryInfo>>;
readonly selectApiStates: InspectorSelector<
S,
ReturnType<typeof getApiStatesOf>
>;
readonly selectAllQueries: InspectorSelector<
S,
ReturnType<typeof extractAllApiQueries>
>;
readonly selectAllVisbileQueries: InspectorSelector<S, RtkResourceInfo[]>;
readonly selectCurrentQueryInfo: InspectorSelector<S, RtkResourceInfo | null>;
readonly selectSearchQueryRegex: InspectorSelector<S, RegExp | null>;
readonly selectCurrentQueryTags: InspectorSelector<S, RtkQueryTag[]>;
readonly selectApiStatsOfCurrentQuery: InspectorSelector<S, ApiStats | null>;
readonly selectApiOfCurrentQuery: InspectorSelector<
S,
RtkQueryApiState | null
>;
readonly selectTabCounters: InspectorSelector<
S,
Record<QueryPreviewTabs, number>
>;
readonly selectSubscriptionsOfCurrentQuery: InspectorSelector<
S,
RtkQueryApiState['subscriptions'][string]
>;
readonly selectActionsOfCurrentQuery: InspectorSelector<
S,
ReturnType<typeof getActionsOfCurrentQuery>
>;
}
export function createInspectorSelectors<S>(): InspectorSelectors<S> {
const selectQueryComparator = ({
monitorState,
}: SelectorsSource<S>): Comparator<RtkResourceInfo> => {
return queryComparators[monitorState.queryForm.values.queryComparator];
};
const selectQueryListFilter = ({
monitorState,
}: SelectorsSource<S>): FilterList<RtkResourceInfo> => {
return queryListFilters[monitorState.queryForm.values.queryFilter];
};
const selectActionsById = ({ actionsById }: SelectorsSource<S>) =>
actionsById;
const selectApiStates = createSelector(
({ userState }: SelectorsSource<S>) => userState,
getApiStatesOf
);
const selectAllQueries = createSelector(
selectApiStates,
extractAllApiQueries
);
const selectAllMutations = createSelector(
selectApiStates,
extractAllApiMutations
);
const selectSearchQueryRegex = createSelector(
({ monitorState }: SelectorsSource<S>) =>
monitorState.queryForm.values.searchValue,
({ monitorState }: SelectorsSource<S>) =>
monitorState.queryForm.values.isRegexSearch,
(searchValue, isRegexSearch) => {
if (searchValue) {
try {
const regexPattern = isRegexSearch
? searchValue
: escapeRegExpSpecialCharacter(searchValue);
return new RegExp(regexPattern, 'i');
} catch (err) {
// We notify that the search regex provided is not valid
}
}
return null;
}
);
const selectComparatorOrder = ({ monitorState }: SelectorsSource<S>) =>
monitorState.queryForm.values.isAscendingQueryComparatorOrder;
const selectAllVisbileQueries = createSelector(
[
selectQueryComparator,
selectQueryListFilter,
selectAllQueries,
selectAllMutations,
selectComparatorOrder,
selectSearchQueryRegex,
],
(
comparator,
queryListFilter,
queryList,
mutationsList,
isAscending,
searchRegex
) => {
const filteredList = queryListFilter(
searchRegex,
(queryList as RtkResourceInfo[]).concat(mutationsList)
);
const computedComparator = isAscending
? comparator
: flipComparator(comparator);
return filteredList.slice().sort(computedComparator);
}
);
const selectCurrentQueryInfo = createSelector(
selectAllQueries,
selectAllMutations,
({ monitorState }: SelectorsSource<S>) => monitorState.selectedQueryKey,
(allQueries, allMutations, selectedQueryKey) => {
if (!selectedQueryKey) {
return null;
}
let currentQueryInfo: null | RtkResourceInfo =
allQueries.find(
(query) =>
query.queryKey === selectedQueryKey.queryKey &&
selectedQueryKey.reducerPath === query.reducerPath
) || null;
if (!currentQueryInfo) {
currentQueryInfo =
allMutations.find(
(mutation) =>
mutation.queryKey === selectedQueryKey.queryKey &&
selectedQueryKey.reducerPath === mutation.reducerPath
) || null;
}
return currentQueryInfo;
}
);
const selectApiOfCurrentQuery: InspectorSelector<S, null | RtkQueryApiState> =
(selectorsSource: SelectorsSource<S>) => {
const apiStates = selectApiStates(selectorsSource);
const currentQueryInfo = selectCurrentQueryInfo(selectorsSource);
if (!apiStates || !currentQueryInfo) {
return null;
}
return apiStates[currentQueryInfo.reducerPath] ?? null;
};
const selectProvidedOfCurrentQuery: InspectorSelector<
S,
null | RtkQueryProvided
> = (selectorsSource: SelectorsSource<S>) => {
return selectApiOfCurrentQuery(selectorsSource)?.provided ?? null;
};
const selectSubscriptionsOfCurrentQuery = createSelector(
[selectApiOfCurrentQuery, selectCurrentQueryInfo],
(apiState, queryInfo) => {
if (!queryInfo || !apiState) {
return emptyRecord;
}
return apiState.subscriptions[queryInfo.queryKey];
}
);
const selectCurrentQueryTags = createSelector(
[selectCurrentQueryInfo, selectProvidedOfCurrentQuery],
getQueryTagsOf
);
const selectApiStatsOfCurrentQuery = createSelector(
selectApiOfCurrentQuery,
(selectorsSource) => selectorsSource.actionsById,
(selectorsSource) => selectorsSource.currentStateIndex,
generateApiStatsOfCurrentQuery
);
const selectActionsOfCurrentQuery = createSelector(
selectCurrentQueryInfo,
selectActionsById,
getActionsOfCurrentQuery
);
const selectTabCounters = createSelector(
[
selectSubscriptionsOfCurrentQuery,
selectActionsOfCurrentQuery,
selectCurrentQueryTags,
],
(subscriptions, actions, tags) => {
return {
[QueryPreviewTabs.queryTags]: tags.length,
[QueryPreviewTabs.querySubscriptions]: Object.keys(subscriptions ?? {})
.length,
[QueryPreviewTabs.apiConfig]: 0,
[QueryPreviewTabs.queryinfo]: 0,
[QueryPreviewTabs.actions]: actions.length,
};
}
);
return {
selectQueryComparator,
selectApiStates,
selectAllQueries,
selectAllVisbileQueries,
selectSearchQueryRegex,
selectCurrentQueryInfo,
selectCurrentQueryTags,
selectApiStatsOfCurrentQuery,
selectSubscriptionsOfCurrentQuery,
selectApiOfCurrentQuery,
selectTabCounters,
selectActionsOfCurrentQuery,
};
}

View File

@ -0,0 +1,559 @@
import jss, { StyleSheet } from 'jss';
import preset from 'jss-preset-default';
import {
createStyling,
getBase16Theme,
invertTheme,
StylingConfig,
} from 'react-base16-styling';
import rgba from 'hex-rgba';
import * as reduxThemes from 'redux-devtools-themes';
import { Action } from 'redux';
import { RtkQueryMonitorProps, StyleUtils } from '../types';
import { createContext } from 'react';
jss.setup(preset());
export const colorMap = (theme: reduxThemes.Base16Theme) =>
({
TEXT_COLOR: theme.base06,
TEXT_PLACEHOLDER_COLOR: rgba(theme.base06, 60),
BACKGROUND_COLOR: theme.base00,
SELECTED_BACKGROUND_COLOR: rgba(theme.base03, 20),
SKIPPED_BACKGROUND_COLOR: rgba(theme.base03, 10),
HEADER_BACKGROUND_COLOR: rgba(theme.base03, 30),
HEADER_BORDER_COLOR: rgba(theme.base03, 20),
BORDER_COLOR: rgba(theme.base03, 50),
LIST_BORDER_COLOR: rgba(theme.base03, 50),
ACTION_TIME_BACK_COLOR: rgba(theme.base03, 20),
ACTION_TIME_COLOR: theme.base04,
PIN_COLOR: theme.base04,
ITEM_HINT_COLOR: rgba(theme.base0F, 90),
TAB_BACK_SELECTED_COLOR: rgba(theme.base03, 20),
TAB_BACK_COLOR: rgba(theme.base00, 70),
TAB_BACK_HOVER_COLOR: rgba(theme.base03, 40),
TAB_BORDER_COLOR: rgba(theme.base03, 50),
DIFF_ADD_COLOR: rgba(theme.base0B, 40),
DIFF_REMOVE_COLOR: rgba(theme.base08, 40),
DIFF_ARROW_COLOR: theme.base0E,
LINK_COLOR: rgba(theme.base0E, 90),
LINK_HOVER_COLOR: theme.base0E,
ERROR_COLOR: theme.base08,
ULIST_DISC_COLOR: theme.base0D,
ULIST_COLOR: rgba(theme.base06, 60),
ULIST_STRONG_COLOR: theme.base0B,
TAB_CONTENT_COLOR: rgba(theme.base06, 60),
TOGGLE_BUTTON_BACKGROUND: rgba(theme.base00, 70),
TOGGLE_BUTTON_SELECTED_BACKGROUND: theme.base04,
TOGGLE_BUTTON_ERROR: rgba(theme.base08, 40),
} as const);
type Color = keyof ReturnType<typeof colorMap>;
type ColorMap = {
[color in Color]: string;
};
const getSheetFromColorMap = (map: ColorMap) => {
const appearanceNone = {
'-webkit-appearance': 'none',
};
return {
inspector: {
display: 'flex',
flexFlow: 'column nowrap',
overflow: 'hidden',
width: '100%',
height: '100%',
'font-family': 'monaco, Consolas, "Lucida Console", monospace',
'font-size': '12px',
'font-smoothing': 'antialiased',
'line-height': '1.5em',
'background-color': map.BACKGROUND_COLOR,
color: map.TEXT_COLOR,
'&[data-wide-layout="1"]': {
flexFlow: 'row nowrap',
},
},
querySectionWrapper: {
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%',
width: '44%',
borderRightWidth: 1,
borderStyle: 'solid',
},
flexFlow: 'column nowrap',
'& > :first-child': {
flex: '0 0 auto',
'border-bottom-width': '1px',
'border-bottom-style': 'solid',
'border-color': map.LIST_BORDER_COLOR,
},
'& > :nth-child(n + 2)': {
flex: '1 1 auto',
overflowX: 'hidden',
overflowY: 'auto',
maxHeight: 'calc(100% - 70px)',
},
},
queryList: {
listStyle: 'none',
margin: '0',
padding: '0',
},
queryListItem: {
'border-bottom-width': '1px',
'border-bottom-style': 'solid',
display: 'flex',
'justify-content': 'space-between',
padding: '5px 10px',
cursor: 'pointer',
'user-select': 'none',
'&:last-child': {
'border-bottom-width': 0,
},
overflow: 'hidden',
maxHeight: 47,
'border-bottom-color': map.BORDER_COLOR,
},
queryListItemKey: {
display: '-webkit-box',
boxOrient: 'vertical',
'-webkit-line-clamp': 2,
whiteSpace: 'normal',
overflow: 'hidden',
width: '100%',
maxWidth: 'calc(100% - 70px)',
wordBreak: 'break-all',
margin: 0,
},
queryListHeader: {
display: 'flex',
padding: 4,
flex: '0 0 auto',
'align-items': 'center',
'border-bottom-width': '1px',
'border-bottom-style': 'solid',
'border-color': map.LIST_BORDER_COLOR,
},
queryStatusWrapper: {
display: 'flex',
width: 'auto',
justifyContent: 'center',
alignItems: 'center',
margin: 0,
flex: '0 0 auto',
overflow: 'hidden',
},
queryType: {
marginRight: 4,
},
queryStatus: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
height: 22,
padding: '0 6px',
'border-radius': '3px',
'font-size': '0.7em',
'line-height': '1em',
'flex-shrink': 0,
fontWeight: 700,
'background-color': map.ACTION_TIME_BACK_COLOR,
color: map.ACTION_TIME_COLOR,
},
queryListItemSelected: {
'background-color': map.SELECTED_BACKGROUND_COLOR,
},
tabSelector: {
display: 'flex',
width: '100%',
justifyContent: 'flex-end',
overflow: 'hidden',
'& > *': {
flex: '0 1 auto',
},
},
srOnly: {
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
border: 0,
},
selectorButton: {
cursor: 'pointer',
position: 'relative',
height: '33px',
padding: '0 8px',
display: 'inline-flex',
alignItems: 'center',
color: map.TEXT_COLOR,
'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',
},
'background-color': map.TAB_BACK_COLOR,
'&:hover': {
'background-color': map.TAB_BACK_HOVER_COLOR,
},
'border-color': map.TAB_BORDER_COLOR,
'& > *': {
display: '-webkit-box',
boxOrient: 'vertical',
'-webkit-line-clamp': 1,
overflow: 'hidden',
wordBreak: 'break-all',
'-webkit-box-pack': 'end',
paddingBottom: 0,
},
},
selectorButtonSmall: {
padding: '0px 8px',
'font-size': '0.8em',
},
selectorButtonSelected: {
'background-color': map.TAB_BACK_SELECTED_COLOR,
},
sortButton: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexFlow: 'row nowrap',
cursor: 'pointer',
position: 'relative',
padding: '0 8px',
color: map.TEXT_COLOR,
borderStyle: 'solid',
borderWidth: '1px',
borderRadius: '3px',
backgroundColor: map.TAB_BACK_COLOR,
borderColor: map.TAB_BORDER_COLOR,
height: 30,
fontSize: 12,
width: 64,
'&:active': {
backgroundColor: map.TAB_BACK_SELECTED_COLOR,
},
},
toggleButton: {
width: '24px',
height: '24px',
display: 'inline-block',
flex: '0 0 auto',
color: map.TEXT_PLACEHOLDER_COLOR,
cursor: 'pointer',
padding: 0,
fontSize: '0.7em',
letterSpacing: '-0.7px',
outline: 'none',
boxShadow: 'none',
fontWeight: '700',
border: 'none',
'&:hover': {
color: map.TEXT_COLOR,
},
backgroundColor: 'transparent',
'&[aria-pressed="true"]': {
color: map.BACKGROUND_COLOR,
backgroundColor: map.TEXT_COLOR,
},
'&[data-type="error"]': {
color: map.TEXT_COLOR,
backgroundColor: map.TOGGLE_BUTTON_ERROR,
},
},
queryForm: {
display: 'flex',
flexFlow: 'column nowrap',
},
sortBySection: {
display: 'flex',
padding: '0.4em',
'& label': {
display: 'flex',
flex: '0 0 auto',
whiteSpace: 'noWrap',
alignItems: 'center',
paddingRight: '0.4em',
},
'& > :last-child': {
flex: '0 0 auto',
marginLeft: '0.4em',
},
},
querySearch: {
maxWidth: '65%',
'background-color': map.BACKGROUND_COLOR,
display: 'flex',
alignItems: 'center',
flexFlow: 'row nowrap',
flex: '1 1 auto',
paddingRight: 6,
'& input': {
outline: 'none',
border: 'none',
width: '100%',
flex: '1 1 auto',
padding: '5px 10px',
'font-size': '1em',
position: 'relative',
fontFamily: 'monaco, Consolas, "Lucida Console", monospace',
'background-color': map.BACKGROUND_COLOR,
color: map.TEXT_COLOR,
'&::-webkit-input-placeholder': {
color: map.TEXT_PLACEHOLDER_COLOR,
},
'&::-moz-placeholder': {
color: map.TEXT_PLACEHOLDER_COLOR,
},
'&::-webkit-search-cancel-button': appearanceNone,
},
},
closeButton: {
...appearanceNone,
border: 'none',
outline: 'none',
boxShadow: 'none',
display: 'block',
flex: '0 0 auto',
cursor: 'pointer',
background: 'transparent',
position: 'relative',
fontSize: 'inherit',
'&[data-invisible="1"]': {
visibility: 'hidden !important',
},
'&::after': {
content: '"\u00d7"',
display: 'block',
padding: 4,
fontSize: '1.2em',
color: map.TEXT_PLACEHOLDER_COLOR,
background: 'transparent',
},
'&:hover::after': {
color: map.TEXT_COLOR,
},
},
noApiFound: {
width: '100%',
textAlign: 'center',
color: map.TEXT_COLOR,
padding: '1.4em',
'& a': {
fontSize: 'inherit',
color: map.TEXT_COLOR,
textDecoration: 'underline',
},
},
searchSelectLabel: {
display: 'inline-block',
padding: 4,
borderLeft: '1px solid currentColor',
},
queryPreview: {
flex: '1 1 50%',
overflowX: 'hidden',
oveflowY: 'auto',
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 4px',
'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,
},
treeWrapper: {
overflowX: 'auto',
overflowY: 'auto',
padding: '0.5em 1em',
},
tabContent: {
display: 'block',
overflowY: 'auto',
padding: '0.5em 0',
color: map.TAB_CONTENT_COLOR,
'& h2': {
color: map.ULIST_STRONG_COLOR,
padding: '0.5em 1em',
fontWeight: 700,
},
'& h3': {
color: map.ULIST_STRONG_COLOR,
},
},
uList: {
listStyle: 'none',
padding: '0 0 0 1em',
color: map.ULIST_COLOR,
'& > li': {
listStyle: 'none',
},
'& > li::before': {
content: '"\\2022"',
display: 'inline-block',
paddingRight: '0.5em',
color: map.ULIST_DISC_COLOR,
fontSize: '0.8em',
},
'& strong': {
color: map.ULIST_STRONG_COLOR,
},
},
};
};
let themeSheet: StyleSheet;
const getDefaultThemeStyling = (theme: reduxThemes.Base16Theme) => {
if (themeSheet) {
themeSheet.detach();
}
themeSheet = jss
.createStyleSheet(getSheetFromColorMap(colorMap(theme)))
.attach();
return themeSheet.classes;
};
export const createStylingFromTheme = createStyling(getDefaultThemeStyling, {
defaultBase16: reduxThemes.nicinabox,
base16Themes: { ...reduxThemes },
});
export function createThemeState<S, A extends Action<unknown>>(
props: RtkQueryMonitorProps<S, A>
): StyleUtils {
const base16Theme =
getBase16Theme(props.theme, { ...reduxThemes }) ?? reduxThemes.nicinabox;
const theme = props.invertTheme ? invertTheme(props.theme) : props.theme;
const styling = createStylingFromTheme(theme);
return { base16Theme, styling, invertTheme: !!props.invertTheme };
}
const mockStyling = () => ({ className: '', style: {} });
export const StyleUtilsContext = createContext<StyleUtils>({
base16Theme: reduxThemes.nicinabox,
invertTheme: false,
styling: mockStyling,
});
export function getJsonTreeTheme(
base16Theme: reduxThemes.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,106 @@
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: unknown) {
return isKeyed(value) || isIndexed(value) || isCollection(value);
}
function getShortTypeString(val: unknown, 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 as Record<string, unknown>).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,
previewContent: boolean,
isDiff: boolean | undefined
) {
if (type === 'Object') {
const keys = Object.keys(data);
if (!previewContent) 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 (!previewContent) 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,
previewContent: boolean,
isDiff?: boolean
): ReactNode {
return (
<span {...styling('treeItemHint')}>
{data[IS_IMMUTABLE_KEY] ? 'Immutable' : ''}
{dataTypeKey && data[dataTypeKey]
? `${data[dataTypeKey] as string} `
: ''}
{getText(type, data, previewContent, 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,179 @@
import type { LiftedAction, LiftedState } from '@redux-devtools/instrument';
import type { createApi, QueryStatus } from '@reduxjs/toolkit/query';
import type { Action, AnyAction, Dispatch } from '@reduxjs/toolkit';
import type { ComponentType } from 'react';
import type { Base16Theme, StylingFunction } from 'react-base16-styling';
import type * as themes from 'redux-devtools-themes';
import type { QueryComparators } from './utils/comparators';
import type { QueryFilters } from './utils/filters';
export enum QueryPreviewTabs {
queryinfo,
apiConfig,
querySubscriptions,
queryTags,
actions,
}
export interface QueryFormValues {
queryComparator: QueryComparators;
isAscendingQueryComparatorOrder: boolean;
searchValue: string;
isRegexSearch: boolean;
queryFilter: QueryFilters;
}
export interface RtkQueryMonitorState {
readonly queryForm: {
values: QueryFormValues;
};
readonly selectedQueryKey: Pick<QueryInfo, 'reducerPath' | 'queryKey'> | null;
readonly selectedPreviewTab: QueryPreviewTabs;
}
export interface RtkQueryMonitorProps<S, A extends Action<unknown>>
extends LiftedState<S, A, RtkQueryMonitorState> {
dispatch: Dispatch<Action | LiftedAction<S, A, RtkQueryMonitorState>>;
theme: keyof typeof themes | Base16Theme;
invertTheme?: boolean;
}
export type RtkQueryApiState = ReturnType<
ReturnType<typeof createApi>['reducer']
>;
export type RtkQueryState = NonNullable<
RtkQueryApiState['queries'][keyof RtkQueryApiState]
>;
export type RtkMutationState = NonNullable<
RtkQueryApiState['mutations'][keyof RtkQueryApiState]
>;
export type RtkQueryApiConfig = RtkQueryApiState['config'];
export type RtkQueryProvided = RtkQueryApiState['provided'];
export interface ExternalProps<S, A extends Action<unknown>> {
dispatch: Dispatch<Action | LiftedAction<S, A, RtkQueryMonitorState>>;
theme: keyof typeof themes | Base16Theme;
hideMainButtons?: boolean;
invertTheme: boolean;
}
export interface QueryInfo {
type: 'query';
state: RtkQueryState;
queryKey: string;
reducerPath: string;
}
export interface MutationInfo {
type: 'mutation';
state: RtkMutationState;
queryKey: string;
reducerPath: string;
}
export type RtkResourceInfo = QueryInfo | MutationInfo;
export interface ApiInfo {
reducerPath: string;
apiState: RtkQueryApiState;
}
export interface SelectOption<
T = string,
VisConfig extends string = 'default'
> {
label: string;
value: T;
visible?: Record<VisConfig | 'default', boolean> | boolean;
}
export interface SelectorsSource<S> {
userState: S | null;
monitorState: RtkQueryMonitorState;
currentStateIndex: number;
actionsById: LiftedState<unknown, AnyAction, unknown>['actionsById'];
}
export interface StyleUtils {
readonly base16Theme: Base16Theme;
readonly styling: StylingFunction;
readonly invertTheme: boolean;
}
export type RTKQuerySubscribers = NonNullable<
RtkQueryApiState['subscriptions'][keyof RtkQueryApiState['subscriptions']]
>;
export interface RtkQueryTag {
type: string;
id?: number | string;
}
interface Tally {
count: number;
}
export type QueryTally = {
[key in QueryStatus]?: number;
} &
Tally;
export interface RtkRequestTiming {
requestId: string;
queryKey: string;
endpointName: string;
startedAt: string;
completedAt: string;
duration: string;
}
export interface QueryTimings {
readonly oldest: RtkRequestTiming | null;
readonly latest: RtkRequestTiming | null;
readonly slowest: RtkRequestTiming | null;
readonly fastest: RtkRequestTiming | null;
readonly average: string;
readonly median: string;
}
export interface ApiTimings {
readonly queries: QueryTimings;
readonly mutations: QueryTimings;
}
export interface ApiStats {
readonly timings: ApiTimings;
readonly tally: Readonly<{
subscriptions: number;
cachedQueries: QueryTally;
tagTypes: number;
cachedMutations: QueryTally;
}>;
}
export interface TabOption<S, P, V extends string = 'default'>
extends SelectOption<S, V> {
component: ComponentType<P>;
}
/**
* It is Omit<RequestStatusFlags, 'status'> & { isFetching: boolean; }
*/
export interface RTKStatusFlags {
readonly isUninitialized: boolean;
readonly isFetching: boolean;
readonly isSuccess: boolean;
readonly isError: boolean;
}
export type RtkRequest = {
status: QueryStatus;
queryKey: string;
requestId: string;
endpointName: string;
startedTimeStamp?: number;
fulfilledTimeStamp?: number;
};

View File

@ -0,0 +1,96 @@
import { QueryStatus } from '@reduxjs/toolkit/query';
import { RtkResourceInfo, SelectOption } from '../types';
export interface Comparator<T> {
(a: T, b: T): number;
}
export enum QueryComparators {
fulfilledTimeStamp = 'timestamp',
queryKey = 'key',
status = 'status',
endpointName = 'endpointName',
apiReducerPath = 'apiReducerPath',
}
export const sortQueryOptions: SelectOption<QueryComparators>[] = [
{ label: 'last updated', value: QueryComparators.fulfilledTimeStamp },
{ label: 'query key', value: QueryComparators.queryKey },
{ label: 'status', value: QueryComparators.status },
{ label: 'endpoint', value: QueryComparators.endpointName },
{ label: 'reducerPath', value: QueryComparators.apiReducerPath },
];
function sortQueryByFulfilled(
thisQueryInfo: RtkResourceInfo,
thatQueryInfo: RtkResourceInfo
): number {
const thisFulfilled = thisQueryInfo.state.fulfilledTimeStamp ?? -1;
const thatFulfilled = thatQueryInfo.state.fulfilledTimeStamp ?? -1;
return thisFulfilled - thatFulfilled;
}
const mapStatusToFactor = {
[QueryStatus.uninitialized]: 1,
[QueryStatus.pending]: 2,
[QueryStatus.rejected]: 3,
[QueryStatus.fulfilled]: 4,
};
function sortQueryByStatus(
thisQueryInfo: RtkResourceInfo,
thatQueryInfo: RtkResourceInfo
): number {
const thisTerm = mapStatusToFactor[thisQueryInfo.state.status] || -1;
const thatTerm = mapStatusToFactor[thatQueryInfo.state.status] || -1;
return thisTerm - thatTerm;
}
export function compareJSONPrimitive<
T extends string | number | boolean | null
>(a: T, b: T): number {
if (a === b) {
return 0;
}
return a > b ? 1 : -1;
}
function sortByQueryKey(
thisQueryInfo: RtkResourceInfo,
thatQueryInfo: RtkResourceInfo
): number {
return compareJSONPrimitive(thisQueryInfo.queryKey, thatQueryInfo.queryKey);
}
function sortQueryByEndpointName(
thisQueryInfo: RtkResourceInfo,
thatQueryInfo: RtkResourceInfo
): number {
const thisEndpointName = thisQueryInfo.state.endpointName ?? '';
const thatEndpointName = thatQueryInfo.state.endpointName ?? '';
return compareJSONPrimitive(thisEndpointName, thatEndpointName);
}
function sortByApiReducerPath(
thisQueryInfo: RtkResourceInfo,
thatQueryInfo: RtkResourceInfo
): number {
return compareJSONPrimitive(
thisQueryInfo.reducerPath,
thatQueryInfo.reducerPath
);
}
export const queryComparators: Readonly<
Record<QueryComparators, Comparator<RtkResourceInfo>>
> = {
[QueryComparators.fulfilledTimeStamp]: sortQueryByFulfilled,
[QueryComparators.status]: sortQueryByStatus,
[QueryComparators.endpointName]: sortQueryByEndpointName,
[QueryComparators.queryKey]: sortByQueryKey,
[QueryComparators.apiReducerPath]: sortByApiReducerPath,
};

View File

@ -0,0 +1,78 @@
import { RtkResourceInfo, SelectOption } from '../types';
export interface FilterList<T> {
(regex: RegExp | null, list: T[]): T[];
}
export enum QueryFilters {
queryKey = 'query key',
reducerPath = 'reducerPath',
endpointName = 'endpoint',
status = 'status',
}
function filterByQueryKey(
regex: RegExp | null,
list: RtkResourceInfo[]
): RtkResourceInfo[] {
if (!regex) {
return list;
}
return list.filter((RtkResourceInfo) => regex.test(RtkResourceInfo.queryKey));
}
function filterByReducerPath(
regex: RegExp | null,
list: RtkResourceInfo[]
): RtkResourceInfo[] {
if (!regex) {
return list;
}
return list.filter((RtkResourceInfo) =>
regex.test(RtkResourceInfo.reducerPath)
);
}
function filterByEndpointName(
regex: RegExp | null,
list: RtkResourceInfo[]
): RtkResourceInfo[] {
if (!regex) {
return list;
}
return list.filter((RtkResourceInfo) =>
regex.test(RtkResourceInfo.state.endpointName ?? 'undefined')
);
}
function filterByStatus(
regex: RegExp | null,
list: RtkResourceInfo[]
): RtkResourceInfo[] {
if (!regex) {
return list;
}
return list.filter((RtkResourceInfo) =>
regex.test(RtkResourceInfo.state.status)
);
}
export const filterQueryOptions: SelectOption<QueryFilters>[] = [
{ label: 'query key', value: QueryFilters.queryKey },
{ label: 'reducerPath', value: QueryFilters.reducerPath },
{ label: 'status', value: QueryFilters.status },
{ label: 'endpoint', value: QueryFilters.endpointName },
];
export const queryListFilters: Readonly<
Record<QueryFilters, FilterList<RtkResourceInfo>>
> = {
[QueryFilters.queryKey]: filterByQueryKey,
[QueryFilters.endpointName]: filterByEndpointName,
[QueryFilters.reducerPath]: filterByReducerPath,
[QueryFilters.status]: filterByStatus,
};

View File

@ -0,0 +1,31 @@
export function formatMs(milliseconds: number): string {
if (!Number.isFinite(milliseconds)) {
return 'NaN';
}
const absInput = Math.abs(Math.round(milliseconds));
let millis = (absInput % 1000).toString();
if (millis.length < 3) {
if (millis.length === 2) {
millis = '0' + millis;
} else {
millis = '00' + millis;
}
}
const seconds = Math.floor(absInput / 1_000) % 60;
const minutes = Math.floor(absInput / 60_000);
let output = `${seconds}.${millis}s`;
if (minutes > 0) {
output = `${minutes}m${output}`;
}
if (milliseconds < 0) {
output = `-${output}`;
}
return output;
}

View File

@ -0,0 +1,10 @@
export default function isIterable(obj: unknown): boolean {
return (
obj !== null &&
typeof obj === 'object' &&
!Array.isArray(obj) &&
typeof (obj as Record<string | typeof Symbol.iterator, unknown>)[
window.Symbol.iterator
] === 'function'
);
}

View File

@ -0,0 +1,9 @@
import { freeze } from '@reduxjs/toolkit';
export const emptyArray = freeze([]);
export const emptyRecord = freeze({});
export function identity<T>(val: T): T {
return val;
}

View File

@ -0,0 +1,4 @@
// https://stackoverflow.com/a/9310752
export function escapeRegExpSpecialCharacter(text: string): string {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
}

View File

@ -0,0 +1,666 @@
import { Action, AnyAction, isAllOf, isPlainObject } from '@reduxjs/toolkit';
import { QueryStatus } from '@reduxjs/toolkit/query';
import {
QueryInfo,
RtkQueryMonitorState,
RtkQueryApiState,
RTKQuerySubscribers,
RtkQueryTag,
RTKStatusFlags,
RtkQueryState,
MutationInfo,
ApiStats,
QueryTally,
RtkQueryProvided,
ApiTimings,
QueryTimings,
SelectorsSource,
RtkMutationState,
RtkResourceInfo,
RtkRequest,
RtkRequestTiming,
} from '../types';
import { missingTagId } from '../monitor-config';
import { Comparator, compareJSONPrimitive } from './comparators';
import { emptyArray } from './object';
import { formatMs } from './formatters';
import * as statistics from './statistics';
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 as Record<string, unknown>)[rtkqueryApiStateKeys[i]])
) {
return false;
}
}
return true;
}
/**
* Indexes api states by their `reducerPath`.
*
* Returns `null` if there are no api slice or `reduxStoreState`
* is not an object.
*
* @param reduxStoreState
* @returns
*/
export function getApiStatesOf(
reduxStoreState: unknown
): null | Readonly<Record<string, RtkQueryApiState>> {
if (!isPlainObject(reduxStoreState)) {
return null;
}
const output: null | Record<string, RtkQueryApiState> = {};
const keys = Object.keys(reduxStoreState);
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
const value = (reduxStoreState as Record<string, unknown>)[key];
if (isApiSlice(value)) {
output[key] = value;
}
}
if (Object.keys(output).length === 0) {
return null;
}
return output;
}
export function extractAllApiQueries(
apiStatesByReducerPath: null | Readonly<Record<string, RtkQueryApiState>>
): ReadonlyArray<QueryInfo> {
if (!apiStatesByReducerPath) {
return emptyArray;
}
const reducerPaths = Object.keys(apiStatesByReducerPath);
const output: QueryInfo[] = [];
for (let i = 0, len = reducerPaths.length; i < len; i++) {
const reducerPath = reducerPaths[i];
const api = apiStatesByReducerPath[reducerPath];
const queryKeys = Object.keys(api.queries);
for (let j = 0, qKeysLen = queryKeys.length; j < qKeysLen; j++) {
const queryKey = queryKeys[j];
const state = api.queries[queryKey];
if (state) {
output.push({
type: 'query',
reducerPath,
queryKey,
state,
});
}
}
}
return output;
}
export function extractAllApiMutations(
apiStatesByReducerPath: null | Readonly<Record<string, RtkQueryApiState>>
): ReadonlyArray<MutationInfo> {
if (!apiStatesByReducerPath) {
return emptyArray;
}
const reducerPaths = Object.keys(apiStatesByReducerPath);
const output: MutationInfo[] = [];
for (let i = 0, len = reducerPaths.length; i < len; i++) {
const reducerPath = reducerPaths[i];
const api = apiStatesByReducerPath[reducerPath];
const mutationKeys = Object.keys(api.mutations);
for (let j = 0, mKeysLen = mutationKeys.length; j < mKeysLen; j++) {
const queryKey = mutationKeys[j];
const state = api.mutations[queryKey];
if (state) {
output.push({
type: 'mutation',
reducerPath,
queryKey,
state,
});
}
}
}
return output;
}
function computeQueryTallyOf(
queryState: RtkQueryApiState['queries'] | RtkQueryApiState['mutations']
): QueryTally {
const queries = Object.values(queryState);
const output: QueryTally = {
count: 0,
};
for (let i = 0, len = queries.length; i < len; i++) {
const query = queries[i];
if (query) {
output.count++;
if (!output[query.status]) {
output[query.status] = 1;
} else {
(output[query.status] as number)++;
}
}
}
return output;
}
function tallySubscriptions(
subsState: RtkQueryApiState['subscriptions']
): number {
const subsOfQueries = Object.values(subsState);
let output = 0;
for (let i = 0, len = subsOfQueries.length; i < len; i++) {
const subsOfQuery = subsOfQueries[i];
if (subsOfQuery) {
output += Object.keys(subsOfQuery).length;
}
}
return output;
}
function computeRtkQueryRequests(
type: 'queries' | 'mutations',
api: RtkQueryApiState,
sortedActions: AnyAction[],
currentStateIndex: SelectorsSource<unknown>['currentStateIndex']
): Readonly<Record<string, RtkRequest>> {
const requestById: Record<string, RtkRequest> = {};
const matcher =
type === 'queries'
? matchesExecuteQuery(api.config.reducerPath)
: matchesExecuteMutation(api.config.reducerPath);
for (
let i = 0, len = sortedActions.length;
i < len && i <= currentStateIndex;
i++
) {
const action = sortedActions[i];
if (matcher(action)) {
let requestRecord: RtkRequest | undefined =
requestById[action.meta.requestId];
if (!requestRecord) {
const queryCacheKey: string | undefined = (
action.meta as Record<string, any>
)?.arg?.queryCacheKey;
const queryKey =
typeof queryCacheKey === 'string'
? queryCacheKey
: action.meta.requestId;
const endpointName: string =
(action.meta as any)?.arg?.endpointName ?? '-';
requestById[action.meta.requestId] = requestRecord = {
queryKey,
requestId: action.meta.requestId,
endpointName,
status: action.meta.requestStatus,
};
}
requestRecord.status = action.meta.requestStatus;
if (
action.meta.requestStatus === QueryStatus.pending &&
typeof (action.meta as any).startedTimeStamp === 'number'
) {
requestRecord.startedTimeStamp = (action.meta as any).startedTimeStamp;
}
if (
action.meta.requestStatus === QueryStatus.fulfilled &&
typeof (action.meta as any).fulfilledTimeStamp === 'number'
) {
requestRecord.fulfilledTimeStamp = (
action.meta as any
).fulfilledTimeStamp;
}
}
}
const requestIds = Object.keys(requestById);
// Patch queries that have pending actions that are committed
for (let i = 0, len = requestIds.length; i < len; i++) {
const requestId = requestIds[i];
const request = requestById[requestId];
if (
typeof request.startedTimeStamp === 'undefined' &&
typeof request.fulfilledTimeStamp === 'number'
) {
const startedTimeStampFromCache =
api[type][request.queryKey]?.startedTimeStamp;
if (typeof startedTimeStampFromCache === 'number') {
request.startedTimeStamp = startedTimeStampFromCache;
}
}
}
// Add queries that have pending and fulfilled actions committed
const queryCacheEntries = Object.entries(api[type] ?? {});
for (let i = 0, len = queryCacheEntries.length; i < len; i++) {
const [queryCacheKey, queryCache] = queryCacheEntries[i];
const requestId: string =
type === 'queries'
? (queryCache as typeof api['queries'][string])?.requestId ?? ''
: queryCacheKey;
if (
queryCache &&
!Object.prototype.hasOwnProperty.call(requestById, requestId)
) {
const startedTimeStamp = queryCache?.startedTimeStamp;
const fulfilledTimeStamp = queryCache?.fulfilledTimeStamp;
if (
typeof startedTimeStamp === 'number' &&
typeof fulfilledTimeStamp === 'number'
) {
requestById[requestId] = {
queryKey: queryCacheKey,
requestId,
endpointName: queryCache.endpointName ?? '',
startedTimeStamp,
fulfilledTimeStamp,
status: queryCache.status,
};
}
}
}
return requestById;
}
function formatRtkRequest(
rtkRequest: RtkRequest | null
): RtkRequestTiming | null {
if (!rtkRequest) {
return null;
}
const fulfilledTimeStamp = rtkRequest.fulfilledTimeStamp;
const startedTimeStamp = rtkRequest.startedTimeStamp;
const output: RtkRequestTiming = {
queryKey: rtkRequest.queryKey,
requestId: rtkRequest.requestId,
endpointName: rtkRequest.endpointName,
startedAt: '-',
completedAt: '-',
duration: '-',
};
if (
typeof fulfilledTimeStamp === 'number' &&
typeof startedTimeStamp === 'number'
) {
output.startedAt = new Date(startedTimeStamp).toISOString();
output.completedAt = new Date(fulfilledTimeStamp).toISOString();
output.duration = formatMs(fulfilledTimeStamp - startedTimeStamp);
}
return output;
}
function computeQueryApiTimings(
requestById: Readonly<Record<string, RtkRequest>>
): QueryTimings {
const requests = Object.values(requestById);
let latestRequest: RtkRequest | null = null;
let oldestRequest: null | RtkRequest = null;
let slowestRequest: RtkRequest | null = null;
let fastestRequest: RtkRequest | null = null;
let slowestDuration = 0;
let fastestDuration = Number.MAX_SAFE_INTEGER;
const pendingDurations: number[] = [];
for (let i = 0, len = requests.length; i < len; i++) {
const request = requests[i];
const { fulfilledTimeStamp, startedTimeStamp } = request;
if (typeof fulfilledTimeStamp === 'number') {
const latestFulfilledTimeStamp = latestRequest?.fulfilledTimeStamp || 0;
const oldestFulfilledTimeStamp =
oldestRequest?.fulfilledTimeStamp || Number.MAX_SAFE_INTEGER;
if (fulfilledTimeStamp > latestFulfilledTimeStamp) {
latestRequest = request;
}
if (fulfilledTimeStamp < oldestFulfilledTimeStamp) {
oldestRequest = request;
}
if (
typeof startedTimeStamp === 'number' &&
startedTimeStamp <= fulfilledTimeStamp
) {
const pendingDuration = fulfilledTimeStamp - startedTimeStamp;
pendingDurations.push(pendingDuration);
if (pendingDuration > slowestDuration) {
slowestDuration = pendingDuration;
slowestRequest = request;
}
if (pendingDuration < fastestDuration) {
fastestDuration = pendingDuration;
fastestRequest = request;
}
}
}
}
const average =
pendingDurations.length > 0
? formatMs(statistics.mean(pendingDurations))
: '-';
const median =
pendingDurations.length > 0
? formatMs(statistics.median(pendingDurations))
: '-';
return {
latest: formatRtkRequest(latestRequest),
oldest: formatRtkRequest(oldestRequest),
slowest: formatRtkRequest(slowestRequest),
fastest: formatRtkRequest(fastestRequest),
average,
median,
};
}
function computeApiTimings(
api: RtkQueryApiState,
actionsById: SelectorsSource<unknown>['actionsById'],
currentStateIndex: SelectorsSource<unknown>['currentStateIndex']
): ApiTimings {
const sortedActions = Object.entries(actionsById)
.sort((thisAction, thatAction) =>
compareJSONPrimitive(Number(thisAction[0]), Number(thatAction[0]))
)
.map((entry) => entry[1].action);
const queryRequestsById = computeRtkQueryRequests(
'queries',
api,
sortedActions,
currentStateIndex
);
const mutationRequestsById = computeRtkQueryRequests(
'mutations',
api,
sortedActions,
currentStateIndex
);
return {
queries: computeQueryApiTimings(queryRequestsById),
mutations: computeQueryApiTimings(mutationRequestsById),
};
}
export function generateApiStatsOfCurrentQuery(
api: RtkQueryApiState | null,
actionsById: SelectorsSource<unknown>['actionsById'],
currentStateIndex: SelectorsSource<unknown>['currentStateIndex']
): ApiStats | null {
if (!api) {
return null;
}
return {
timings: computeApiTimings(api, actionsById, currentStateIndex),
tally: {
cachedQueries: computeQueryTallyOf(api.queries),
cachedMutations: computeQueryTallyOf(api.mutations),
tagTypes: Object.keys(api.provided).length,
subscriptions: tallySubscriptions(api.subscriptions),
},
};
}
export function flipComparator<T>(comparator: Comparator<T>): Comparator<T> {
return function flipped(a: T, b: T) {
return comparator(b, a);
};
}
export function isQuerySelected(
selectedQueryKey: RtkQueryMonitorState['selectedQueryKey'],
queryInfo: RtkResourceInfo
): boolean {
return (
!!selectedQueryKey &&
selectedQueryKey.queryKey === queryInfo.queryKey &&
selectedQueryKey.reducerPath === queryInfo.reducerPath
);
}
export function getApiStateOf(
queryInfo: RtkResourceInfo | null,
apiStates: ReturnType<typeof getApiStatesOf>
): RtkQueryApiState | null {
if (!apiStates || !queryInfo) {
return null;
}
return apiStates[queryInfo.reducerPath] ?? null;
}
export function getQuerySubscriptionsOf(
queryInfo: QueryInfo | null,
apiStates: ReturnType<typeof getApiStatesOf>
): RTKQuerySubscribers | null {
if (!apiStates || !queryInfo) {
return null;
}
return (
apiStates[queryInfo.reducerPath]?.subscriptions?.[queryInfo.queryKey] ??
null
);
}
export function getProvidedOf(
queryInfo: QueryInfo | null,
apiStates: ReturnType<typeof getApiStatesOf>
): RtkQueryApiState['provided'] | null {
if (!apiStates || !queryInfo) {
return null;
}
return apiStates[queryInfo.reducerPath]?.provided ?? null;
}
export function getQueryTagsOf(
resInfo: RtkResourceInfo | null,
provided: RtkQueryProvided | null
): RtkQueryTag[] {
if (!resInfo || resInfo.type === 'mutation' || !provided) {
return emptyArray;
}
const tagTypes = Object.keys(provided);
if (tagTypes.length < 1) {
return emptyArray;
}
const output: RtkQueryTag[] = [];
for (const [type, tagIds] of Object.entries(provided)) {
if (tagIds) {
for (const [id, queryKeys] of Object.entries(tagIds)) {
if ((queryKeys as unknown[]).includes(resInfo.queryKey)) {
const tag: RtkQueryTag = { type };
if (id !== missingTagId) {
tag.id = id;
}
output.push(tag);
}
}
}
}
return output;
}
/**
* Computes query status flags.
* @param status
* @see https://redux-toolkit.js.org/rtk-query/usage/queries#frequently-used-query-hook-return-values
* @see https://github.com/reduxjs/redux-toolkit/blob/b718e01d323d3ab4b913e5d88c9b90aa790bb975/src/query/core/apiState.ts#L63
*/
export function getQueryStatusFlags({
status,
data,
}: RtkQueryState | RtkMutationState): RTKStatusFlags {
return {
isUninitialized: status === QueryStatus.uninitialized,
isFetching: status === QueryStatus.pending,
isSuccess: status === QueryStatus.fulfilled && !!data,
isError: status === QueryStatus.rejected,
};
}
/**
* endpoint matcher
* @param endpointName
* @see https://github.com/reduxjs/redux-toolkit/blob/b718e01d323d3ab4b913e5d88c9b90aa790bb975/src/query/core/buildThunks.ts#L415
*/
export function matchesEndpoint(endpointName: unknown) {
return (action: any): action is Action =>
endpointName != null && action?.meta?.arg?.endpointName === endpointName;
}
function matchesQueryKey(queryKey: string) {
return (action: any): action is Action =>
action?.meta?.arg?.queryCacheKey === queryKey;
}
function macthesRequestId(requestId: string) {
return (action: any): action is Action =>
action?.meta?.requestId === requestId;
}
function matchesReducerPath(reducerPath: string) {
return (action: any): action is Action<string> =>
typeof action?.type === 'string' && action.type.startsWith(reducerPath);
}
function matchesExecuteQuery(reducerPath: string) {
return (
action: any
): action is Action<string> & {
meta: { requestId: string; requestStatus: QueryStatus };
} => {
return (
typeof action?.type === 'string' &&
action.type.startsWith(`${reducerPath}/executeQuery`) &&
typeof action.meta?.requestId === 'string' &&
typeof action.meta?.requestStatus === 'string'
);
};
}
function matchesExecuteMutation(reducerPath: string) {
return (
action: any
): action is Action<string> & {
meta: { requestId: string; requestStatus: QueryStatus };
} =>
typeof action?.type === 'string' &&
action.type.startsWith(`${reducerPath}/executeMutation`) &&
typeof action.meta?.requestId === 'string' &&
typeof action.meta?.requestStatus === 'string';
}
export function getActionsOfCurrentQuery(
currentQuery: RtkResourceInfo | null,
actionById: SelectorsSource<unknown>['actionsById']
): Action[] {
if (!currentQuery) {
return emptyArray;
}
let matcher: ReturnType<typeof macthesRequestId>;
if (currentQuery.type === 'mutation') {
matcher = isAllOf(
matchesReducerPath(currentQuery.reducerPath),
macthesRequestId(currentQuery.queryKey)
);
} else {
matcher = isAllOf(
matchesReducerPath(currentQuery.reducerPath),
matchesQueryKey(currentQuery.queryKey)
);
}
const output: AnyAction[] = [];
for (const [, liftedAction] of Object.entries(actionById)) {
if (matcher(liftedAction?.action)) {
output.push(liftedAction.action);
}
}
return output.length === 0 ? emptyArray : output;
}

View File

@ -0,0 +1,68 @@
/**
* An implementation of `Kahan-Babuska algorithm`
* that reduces numerical floating point errors.
* @param {number[]} nums
* @returns {number}
* @see https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.582.288&rep=rep1&type=pdf
*/
function sum(nums: number[]): number {
if (nums.length === 0) {
return 0;
}
let t;
let correction = 0;
let output = nums[0];
for (let i = 1, len = nums.length; i < len; i++) {
t = output + nums[i];
if (Math.abs(output) >= Math.abs(nums[i])) {
correction += output - t + nums[i];
} else {
correction += nums[i] - t + output;
}
output = t;
}
return output + correction;
}
/**
* Returns mean, also known as average, of numerical sequences.
* @param nums
* @returns
*/
export function mean(nums: number[]): number {
if (nums.length === 0) {
return NaN;
}
return +(sum(nums) / nums.length).toFixed(6);
}
/**
* Returns median value of a numeric sequence.
* @param nums
* @returns
*/
export function median(nums: number[]): number {
const len = nums.length;
if (len === 0) {
return NaN;
}
if (len === 1) {
return nums[0];
}
const sorted = nums.slice().sort();
if (len % 2 === 1) {
return sorted[(len + 1) / 2 - 1];
}
return +(0.5 * (sorted[len / 2 - 1] + sorted[len / 2])).toFixed(6);
}

View File

@ -0,0 +1,16 @@
import { TabOption } from '../types';
export function isTabVisible<St, Props, Vis extends string>(
tab: TabOption<St, Props, Vis>,
visKey: Vis | 'default'
): boolean {
if (typeof tab.visible === 'boolean') {
return tab.visible;
}
if (typeof tab.visible === 'object' && tab.visible) {
return !!tab.visible[visKey];
}
return true;
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.react.base.json",
"compilerOptions": {
"outDir": "./demo/src/build",
"module": "ES2015",
"strict": false
},
"include": ["src"]
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.react.base.json",
"compilerOptions": {
"outDir": "lib",
"resolveJsonModule": true
},
"include": ["src"]
}

1709
yarn.lock

File diff suppressed because it is too large Load Diff