mirror of
https://github.com/reduxjs/redux-devtools.git
synced 2024-11-21 17:16:42 +03:00
[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:
parent
6cdc18f2fa
commit
7d92a5e186
|
@ -9,3 +9,5 @@ node_modules
|
|||
__snapshots__
|
||||
dev
|
||||
.yarn/*
|
||||
**/.yarn/*
|
||||
**/demo/public/**
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
0
packages/redux-devtools-cli/bin/redux-devtools.js
Normal file → Executable file
8
packages/redux-devtools-rtk-query-monitor/.babelrc
Normal file
8
packages/redux-devtools-rtk-query-monitor/.babelrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"]
|
||||
}
|
2
packages/redux-devtools-rtk-query-monitor/.eslintignore
Normal file
2
packages/redux-devtools-rtk-query-monitor/.eslintignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
lib
|
||||
demo/
|
13
packages/redux-devtools-rtk-query-monitor/.eslintrc.js
Normal file
13
packages/redux-devtools-rtk-query-monitor/.eslintrc.js
Normal 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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
21
packages/redux-devtools-rtk-query-monitor/LICENSE.md
Normal file
21
packages/redux-devtools-rtk-query-monitor/LICENSE.md
Normal 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.
|
108
packages/redux-devtools-rtk-query-monitor/README.md
Normal file
108
packages/redux-devtools-rtk-query-monitor/README.md
Normal 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)
|
11
packages/redux-devtools-rtk-query-monitor/demo/.babelrc
Normal file
11
packages/redux-devtools-rtk-query-monitor/demo/.babelrc
Normal 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"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
extends: ['react-app'],
|
||||
};
|
4
packages/redux-devtools-rtk-query-monitor/demo/.gitignore
vendored
Normal file
4
packages/redux-devtools-rtk-query-monitor/demo/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
.snowpack
|
||||
build
|
||||
node_modules
|
||||
.yarn/*
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["webpack.config.ts"]
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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>
|
71
packages/redux-devtools-rtk-query-monitor/demo/src/App.tsx
Normal file
71
packages/redux-devtools-rtk-query-monitor/demo/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export const isExtensionEnabledKey = 'prefer-extension';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
41
packages/redux-devtools-rtk-query-monitor/demo/src/index.css
Normal file
41
packages/redux-devtools-rtk-query-monitor/demo/src/index.css
Normal 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;
|
||||
}
|
28
packages/redux-devtools-rtk-query-monitor/demo/src/index.tsx
Normal file
28
packages/redux-devtools-rtk-query-monitor/demo/src/index.tsx
Normal 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);
|
|
@ -0,0 +1,4 @@
|
|||
import { setupWorker } from 'msw';
|
||||
import { handlers } from './db';
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
|
@ -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;
|
|
@ -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];
|
5
packages/redux-devtools-rtk-query-monitor/demo/src/react-app-env.d.ts
vendored
Normal file
5
packages/redux-devtools-rtk-query-monitor/demo/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="react-scripts" />
|
||||
|
||||
declare module '@redux-devtools/app';
|
||||
|
||||
declare module 'remote-redux-devtools';
|
|
@ -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;
|
|
@ -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;
|
29
packages/redux-devtools-rtk-query-monitor/demo/src/store.ts
Normal file
29
packages/redux-devtools-rtk-query-monitor/demo/src/store.ts
Normal 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,
|
||||
});
|
11
packages/redux-devtools-rtk-query-monitor/demo/tsconfig.json
Normal file
11
packages/redux-devtools-rtk-query-monitor/demo/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.react.base.json",
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": "./src",
|
||||
"target": "esNext",
|
||||
"module": "es6",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["../src", "src"]
|
||||
}
|
17887
packages/redux-devtools-rtk-query-monitor/demo/yarn.lock
Normal file
17887
packages/redux-devtools-rtk-query-monitor/demo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/redux-devtools-rtk-query-monitor/monitor-demo.gif
Normal file
BIN
packages/redux-devtools-rtk-query-monitor/monitor-demo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.4 MiB |
88
packages/redux-devtools-rtk-query-monitor/package.json
Normal file
88
packages/redux-devtools-rtk-query-monitor/package.json
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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 */
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
}
|
2
packages/redux-devtools-rtk-query-monitor/src/index.ts
Normal file
2
packages/redux-devtools-rtk-query-monitor/src/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default } from './containers/RtkQueryMonitor';
|
||||
export type { ExternalProps } from './types';
|
|
@ -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';
|
65
packages/redux-devtools-rtk-query-monitor/src/reducers.ts
Normal file
65
packages/redux-devtools-rtk-query-monitor/src/reducers.ts
Normal 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;
|
283
packages/redux-devtools-rtk-query-monitor/src/selectors.ts
Normal file
283
packages/redux-devtools-rtk-query-monitor/src/selectors.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
106
packages/redux-devtools-rtk-query-monitor/src/styles/tree.tsx
Normal file
106
packages/redux-devtools-rtk-query-monitor/src/styles/tree.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
}
|
179
packages/redux-devtools-rtk-query-monitor/src/types.ts
Normal file
179
packages/redux-devtools-rtk-query-monitor/src/types.ts
Normal 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;
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
// https://stackoverflow.com/a/9310752
|
||||
export function escapeRegExpSpecialCharacter(text: string): string {
|
||||
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
||||
}
|
666
packages/redux-devtools-rtk-query-monitor/src/utils/rtk-query.ts
Normal file
666
packages/redux-devtools-rtk-query-monitor/src/utils/rtk-query.ts
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
16
packages/redux-devtools-rtk-query-monitor/src/utils/tabs.ts
Normal file
16
packages/redux-devtools-rtk-query-monitor/src/utils/tabs.ts
Normal 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;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.react.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./demo/src/build",
|
||||
"module": "ES2015",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
8
packages/redux-devtools-rtk-query-monitor/tsconfig.json
Normal file
8
packages/redux-devtools-rtk-query-monitor/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.react.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user