From 193141bf52620bd64be68c2bb38af523fab56841 Mon Sep 17 00:00:00 2001 From: Raosandeep007 Date: Wed, 27 Aug 2025 13:29:39 +0530 Subject: [PATCH 1/3] Query actions and copy data to clipboard - RTK query Refresh, Loading, Fulfill actions. - Copy button to copy json data. --- .gitattributes | 2 +- .gitignore | 2 +- .../src/components/CopyIcon.tsx | 29 ++ .../src/components/QueryPreviewActions.tsx | 31 +- .../src/components/RTKQueryActions.tsx | 296 ++++++++++++++++++ .../src/containers/QueryPreview.tsx | 8 +- .../src/containers/RtkQueryInspector.tsx | 2 + .../src/styles/tree.tsx | 50 ++- 8 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 packages/redux-devtools-rtk-query-monitor/src/components/CopyIcon.tsx create mode 100644 packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx diff --git a/.gitattributes b/.gitattributes index 6313b56c..94f480de 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -* text=auto eol=lf +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index 34daf06a..dd6c3fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ coverage .idea .eslintcache !packages/redux-devtools-slider-monitor/examples/todomvc/dist/index.html -.nx +.nx \ No newline at end of file diff --git a/packages/redux-devtools-rtk-query-monitor/src/components/CopyIcon.tsx b/packages/redux-devtools-rtk-query-monitor/src/components/CopyIcon.tsx new file mode 100644 index 00000000..134507aa --- /dev/null +++ b/packages/redux-devtools-rtk-query-monitor/src/components/CopyIcon.tsx @@ -0,0 +1,29 @@ +import React, { HTMLAttributes } from 'react'; + +export type CopyIconProps = Omit< + HTMLAttributes, + 'xmlns' | 'children' | 'viewBox' +>; + +/* eslint-disable max-len */ +/** + * @see import { IoCopySharp } from "react-icons/io5"; + */ +export function CopyIcon(props: CopyIconProps): React.JSX.Element { + return ( + + + + + ); +} +/* eslint-enable max-len */ diff --git a/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx b/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx index 7696e6d4..65f2e60f 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx +++ b/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx @@ -1,15 +1,19 @@ import { createSelector, Selector } from '@reduxjs/toolkit'; import React, { ReactNode, PureComponent } from 'react'; -import { Action, AnyAction } from 'redux'; +import { Action, AnyAction, Dispatch } from 'redux'; import type { KeyPath, ShouldExpandNodeInitially } from 'react-json-tree'; -import { QueryPreviewTabs } from '../types'; +import { QueryPreviewTabs, RtkResourceInfo } from '../types'; import { renderTabPanelButtonId, renderTabPanelId } from '../utils/a11y'; import { emptyRecord, identity } from '../utils/object'; import { TreeView, TreeViewProps } from './TreeView'; +import { RTKActionButtons } from './RTKQueryActions'; export interface QueryPreviewActionsProps { isWideLayout: boolean; actionsOfQuery: AnyAction[]; + dispatch?: Dispatch; + resInfo?: RtkResourceInfo | null; + liftedDispatch?: Dispatch; // Add lifted dispatch for DevTools } const keySep = ' - '; @@ -79,15 +83,24 @@ export class QueryPreviewActions extends PureComponent }; render(): ReactNode { - const { isWideLayout, actionsOfQuery } = this.props; + const { isWideLayout, actionsOfQuery, dispatch, liftedDispatch, resInfo } = + this.props; return ( - + <> + + + ); } } diff --git a/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx b/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx new file mode 100644 index 00000000..8210fc48 --- /dev/null +++ b/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx @@ -0,0 +1,296 @@ +import { AnyAction, Dispatch } from 'redux'; +import React from 'react'; +import { Button, Toolbar } from '@redux-devtools/ui'; +import { RtkResourceInfo } from '../types'; + +interface RTKActionButtonsProps { + dispatch?: Dispatch; + liftedDispatch?: Dispatch; + resInfo?: RtkResourceInfo | null; + actionsOfQuery?: AnyAction[]; +} + +const ACTION_TYPES = { + REFRESH: 'REFRESH', + FULFILLED: 'FULFILLED', + TRIGGER_LOADING: 'TRIGGER_LOADING', +} as const; + +const createFulfilledMeta = ( + requestStatus: string, + requestId: string, + baseEndpoint: string, + parsedArgs: any, + queryKey: string, +) => ({ + arg: { + type: 'query', + subscribe: true, + forceRefetch: true, + subscriptionOptions: { + pollingInterval: 0, + skipPollingIfUnfocused: false, + }, + endpointName: baseEndpoint, + originalArgs: parsedArgs || {}, + queryCacheKey: queryKey, + }, + requestId, + requestStatus, + fulfilledTimeStamp: Date.now(), + baseQueryMeta: { + request: {}, + response: {}, + }, + RTK_autoBatch: true, +}); + +const createPendingMeta = ( + requestStatus: string, + requestId: string, + baseEndpoint: string, + parsedArgs: any, + queryKey: string, +) => ({ + arg: { + type: 'query', + subscribe: true, + forceRefetch: true, + subscriptionOptions: { + pollingInterval: 0, + skipPollingIfUnfocused: false, + }, + endpointName: baseEndpoint, + originalArgs: parsedArgs || {}, + queryCacheKey: queryKey, + }, + requestId, + requestStatus, + baseQueryMeta: { + request: {}, + response: {}, + }, + RTK_autoBatch: true, +}); + +export const createRtkQueryActions = ( + reducerPath: string, + queryKey: string, + endpointName?: string, + currentData?: any, + originalArgs?: any, +) => { + const baseEndpoint = endpointName || queryKey.split('(')[0]; + let parsedArgs = originalArgs; + if (!parsedArgs && queryKey.includes('(')) { + const argsString = queryKey.split('(')[1]?.split(')')[0]; + if ( + argsString === undefined || + argsString === '' || + argsString === 'undefined' + ) { + parsedArgs = undefined; + } else { + try { + parsedArgs = JSON.parse(argsString); + } catch (e) { + parsedArgs = undefined; + } + } + } + const actualPayload = currentData || { + message: `No data available for ${baseEndpoint}`, + timestamp: Date.now(), + endpointName: baseEndpoint, + queryKey: queryKey, + }; + + return { + refresh: { + type: `${reducerPath}/executeQuery/pending`, + payload: undefined, + meta: createPendingMeta( + 'pending', + `refresh-${Date.now()}`, + baseEndpoint, + parsedArgs, + queryKey, + ), + }, + fulfilled: { + type: `${reducerPath}/executeQuery/fulfilled`, + payload: actualPayload, + meta: createFulfilledMeta( + 'fulfilled', + `fulfilled-${Date.now()}`, + baseEndpoint, + parsedArgs, + queryKey, + ), + }, + triggerLoading: { + type: `${reducerPath}/executeQuery/pending`, + payload: undefined, + meta: createPendingMeta( + 'pending', + `loading-${Date.now()}`, + baseEndpoint, + parsedArgs, + queryKey, + ), + }, + }; +}; + +export const RTKActionButtons: React.FC = ({ + dispatch, + liftedDispatch, + resInfo, + actionsOfQuery, +}) => { + // Extract current data from query actions if available + let currentData: any; + if (actionsOfQuery && actionsOfQuery.length > 0) { + // Look for the most recent fulfilled action to get current data + const fulfilledActions = actionsOfQuery.filter( + (action) => action.type?.includes('/fulfilled') && action.payload, + ); + if (fulfilledActions.length > 0) { + currentData = fulfilledActions[fulfilledActions.length - 1].payload; + console.log('📊 Found current data from actions:', currentData); + } + } + + if (!resInfo) { + return ( +
+ No query selected +
+ ); + } + let originalArgs; + let lastPendingAction; + + if (actionsOfQuery && actionsOfQuery.length > 0) { + const queryAction = actionsOfQuery.find( + (action) => + action.type?.includes('/executeQuery/') || + action.type?.includes('/pending') || + action.type?.includes('/fulfilled'), + ); + if (queryAction?.meta?.arg?.originalArgs) { + originalArgs = queryAction.meta.arg.originalArgs; + } + const pendingActions = actionsOfQuery.filter( + (action) => action.type?.includes('/pending') && action.meta?.requestId, + ); + if (pendingActions.length > 0) { + lastPendingAction = pendingActions[pendingActions.length - 1]; + } + } + + const actions = createRtkQueryActions( + resInfo.reducerPath, + resInfo.queryKey, + resInfo.queryKey.split('(')[0], + currentData, + originalArgs, + ); + + if (lastPendingAction) { + actions.fulfilled.meta.requestId = lastPendingAction.meta.requestId; + + actions.fulfilled.meta.arg.queryCacheKey = + lastPendingAction.meta.arg.queryCacheKey; + + actions.triggerLoading.meta.requestId = lastPendingAction.meta.requestId; + + actions.triggerLoading.meta.arg.queryCacheKey = + lastPendingAction.meta.arg.queryCacheKey; + + actions.refresh.meta.requestId = lastPendingAction.meta.requestId; + + actions.refresh.meta.arg.queryCacheKey = + lastPendingAction.meta.arg.queryCacheKey; + } + + const handleAction = ( + actionType: keyof typeof ACTION_TYPES, + action: AnyAction, + ) => { + if (liftedDispatch) { + try { + const performAction = { + type: 'PERFORM_ACTION' as const, + action, + timestamp: Date.now(), + }; + liftedDispatch(performAction); + + if (actionType === ACTION_TYPES.REFRESH && currentData) { + setTimeout(() => { + const resolveAction = { + type: 'PERFORM_ACTION' as const, + action: actions.fulfilled, + timestamp: Date.now(), + }; + liftedDispatch(resolveAction); + }, 1000); + } + } catch (e) { + console.error('Error in liftedDispatch:', e); + } + } + + if (dispatch) { + try { + dispatch(action); + if (actionType === ACTION_TYPES.REFRESH && currentData) { + setTimeout(() => { + dispatch(actions.fulfilled); + }, 1000); + } + } catch (e) { + console.error('Error in dispatch:', e); + } + } + }; + + return ( + + + + + + + ); +}; diff --git a/packages/redux-devtools-rtk-query-monitor/src/containers/QueryPreview.tsx b/packages/redux-devtools-rtk-query-monitor/src/containers/QueryPreview.tsx index ee40af6b..65da27e0 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/containers/QueryPreview.tsx +++ b/packages/redux-devtools-rtk-query-monitor/src/containers/QueryPreview.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react'; import type { Interpolation, Theme } from '@emotion/react'; +import { Dispatch, AnyAction } from 'redux'; import { QueryPreviewTabs, RtkResourceInfo, @@ -61,6 +62,8 @@ export interface QueryPreviewProps { readonly isWideLayout: boolean; readonly selectorsSource: SelectorsSource; readonly selectors: InspectorSelectors; + readonly dispatch?: Dispatch; + readonly liftedDispatch?: Dispatch; } /** @@ -111,9 +114,12 @@ const MappedApiPreview = mapProps( const MappedQueryPreviewActions = mapProps< QueryPreviewTabProps, QueryPreviewActionsProps ->(({ isWideLayout, selectorsSource, selectors }) => ({ +>(({ isWideLayout, selectorsSource, selectors, resInfo, ...props }) => ({ isWideLayout, actionsOfQuery: selectors.selectActionsOfCurrentQuery(selectorsSource), + dispatch: (props as any).dispatch, + liftedDispatch: (props as any).liftedDispatch, + resInfo, }))(QueryPreviewActions); const tabs: ReadonlyArray< diff --git a/packages/redux-devtools-rtk-query-monitor/src/containers/RtkQueryInspector.tsx b/packages/redux-devtools-rtk-query-monitor/src/containers/RtkQueryInspector.tsx index da231add..e6845761 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/containers/RtkQueryInspector.tsx +++ b/packages/redux-devtools-rtk-query-monitor/src/containers/RtkQueryInspector.tsx @@ -217,6 +217,8 @@ class RtkQueryInspector> extends PureComponent< onTabChange={this.handleTabChange} isWideLayout={isWideLayout} hasNoApis={hasNoApi} + dispatch={this.props.dispatch} + liftedDispatch={this.props.dispatch} /> ); diff --git a/packages/redux-devtools-rtk-query-monitor/src/styles/tree.tsx b/packages/redux-devtools-rtk-query-monitor/src/styles/tree.tsx index ac4ac512..53c746f1 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/styles/tree.tsx +++ b/packages/redux-devtools-rtk-query-monitor/src/styles/tree.tsx @@ -1,11 +1,58 @@ -import React from 'react'; +import React, { CSSProperties } from 'react'; import type { GetItemString, LabelRenderer } from 'react-json-tree'; import { isCollection, isIndexed, isKeyed } from 'immutable'; import isIterable from '../utils/isIterable'; import { DATA_TYPE_KEY } from '../monitor-config'; +import { Button, Toolbar } from '@redux-devtools/ui'; +import { CopyIcon } from '../components/CopyIcon'; const IS_IMMUTABLE_KEY = '@@__IS_IMMUTABLE__@@'; +const copyToClipboard = async (data: any) => { + try { + const text = + typeof data === 'string' ? data : JSON.stringify(data, null, 2); + await navigator.clipboard.writeText(text); + console.log('Copied to clipboard'); + } catch (err) { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = + typeof data === 'string' ? data : JSON.stringify(data, null, 2); + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + console.log('Copied to clipboard'); + } +}; + +const CopyButton: React.FC<{ data: any }> = ({ data }) => { + const iconStyle: CSSProperties = { + width: '14px', + height: '14px', + }; + + return ( + + + + ); +}; + function isImmutable(value: unknown) { return isKeyed(value) || isIndexed(value) || isCollection(value); } @@ -79,6 +126,7 @@ export const getItemString: GetItemString = (type: string, data: any) => ( ? `${data[DATA_TYPE_KEY] as string} ` : ''} {getText(type, data, false, undefined)} + ); From faf719c1fd05cf5ee65cc5b097d35c9b2273a24d Mon Sep 17 00:00:00 2001 From: Sandeep Yadav <92537906+Raosandeep007@users.noreply.github.com> Date: Wed, 27 Aug 2025 21:48:19 +0530 Subject: [PATCH 2/3] make updating states simple and code clean up --- .../src/components/RTKQueryActions.tsx | 270 ++++-------------- 1 file changed, 54 insertions(+), 216 deletions(-) diff --git a/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx b/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx index 8210fc48..fcae3b8a 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx +++ b/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx @@ -1,223 +1,45 @@ -import { AnyAction, Dispatch } from 'redux'; -import React from 'react'; import { Button, Toolbar } from '@redux-devtools/ui'; -import { RtkResourceInfo } from '../types'; +import React from 'react'; +import { AnyAction, Dispatch } from 'redux'; interface RTKActionButtonsProps { dispatch?: Dispatch; liftedDispatch?: Dispatch; - resInfo?: RtkResourceInfo | null; - actionsOfQuery?: AnyAction[]; + data: Record; } const ACTION_TYPES = { REFRESH: 'REFRESH', FULFILLED: 'FULFILLED', - TRIGGER_LOADING: 'TRIGGER_LOADING', + TRIGGER_FETCHING: 'TRIGGER_FETCHING', } as const; -const createFulfilledMeta = ( - requestStatus: string, - requestId: string, - baseEndpoint: string, - parsedArgs: any, - queryKey: string, -) => ({ - arg: { - type: 'query', - subscribe: true, - forceRefetch: true, - subscriptionOptions: { - pollingInterval: 0, - skipPollingIfUnfocused: false, - }, - endpointName: baseEndpoint, - originalArgs: parsedArgs || {}, - queryCacheKey: queryKey, - }, - requestId, - requestStatus, - fulfilledTimeStamp: Date.now(), - baseQueryMeta: { - request: {}, - response: {}, - }, - RTK_autoBatch: true, -}); - -const createPendingMeta = ( - requestStatus: string, - requestId: string, - baseEndpoint: string, - parsedArgs: any, - queryKey: string, -) => ({ - arg: { - type: 'query', - subscribe: true, - forceRefetch: true, - subscriptionOptions: { - pollingInterval: 0, - skipPollingIfUnfocused: false, - }, - endpointName: baseEndpoint, - originalArgs: parsedArgs || {}, - queryCacheKey: queryKey, - }, - requestId, - requestStatus, - baseQueryMeta: { - request: {}, - response: {}, - }, - RTK_autoBatch: true, -}); - -export const createRtkQueryActions = ( - reducerPath: string, - queryKey: string, - endpointName?: string, - currentData?: any, - originalArgs?: any, -) => { - const baseEndpoint = endpointName || queryKey.split('(')[0]; - let parsedArgs = originalArgs; - if (!parsedArgs && queryKey.includes('(')) { - const argsString = queryKey.split('(')[1]?.split(')')[0]; - if ( - argsString === undefined || - argsString === '' || - argsString === 'undefined' - ) { - parsedArgs = undefined; - } else { - try { - parsedArgs = JSON.parse(argsString); - } catch (e) { - parsedArgs = undefined; - } - } - } - const actualPayload = currentData || { - message: `No data available for ${baseEndpoint}`, - timestamp: Date.now(), - endpointName: baseEndpoint, - queryKey: queryKey, - }; - - return { - refresh: { - type: `${reducerPath}/executeQuery/pending`, - payload: undefined, - meta: createPendingMeta( - 'pending', - `refresh-${Date.now()}`, - baseEndpoint, - parsedArgs, - queryKey, - ), - }, - fulfilled: { - type: `${reducerPath}/executeQuery/fulfilled`, - payload: actualPayload, - meta: createFulfilledMeta( - 'fulfilled', - `fulfilled-${Date.now()}`, - baseEndpoint, - parsedArgs, - queryKey, - ), - }, - triggerLoading: { - type: `${reducerPath}/executeQuery/pending`, - payload: undefined, - meta: createPendingMeta( - 'pending', - `loading-${Date.now()}`, - baseEndpoint, - parsedArgs, - queryKey, - ), - }, - }; -}; +const REFRESH_TIMEOUT_MS = 1000; export const RTKActionButtons: React.FC = ({ dispatch, liftedDispatch, - resInfo, - actionsOfQuery, + data, }) => { - // Extract current data from query actions if available - let currentData: any; - if (actionsOfQuery && actionsOfQuery.length > 0) { - // Look for the most recent fulfilled action to get current data - const fulfilledActions = actionsOfQuery.filter( - (action) => action.type?.includes('/fulfilled') && action.payload, - ); - if (fulfilledActions.length > 0) { - currentData = fulfilledActions[fulfilledActions.length - 1].payload; - console.log('📊 Found current data from actions:', currentData); - } - } - - if (!resInfo) { - return ( -
- No query selected -
- ); - } - let originalArgs; - let lastPendingAction; - - if (actionsOfQuery && actionsOfQuery.length > 0) { - const queryAction = actionsOfQuery.find( - (action) => - action.type?.includes('/executeQuery/') || - action.type?.includes('/pending') || - action.type?.includes('/fulfilled'), - ); - if (queryAction?.meta?.arg?.originalArgs) { - originalArgs = queryAction.meta.arg.originalArgs; - } - const pendingActions = actionsOfQuery.filter( - (action) => action.type?.includes('/pending') && action.meta?.requestId, - ); - if (pendingActions.length > 0) { - lastPendingAction = pendingActions[pendingActions.length - 1]; - } - } - - const actions = createRtkQueryActions( - resInfo.reducerPath, - resInfo.queryKey, - resInfo.queryKey.split('(')[0], - currentData, - originalArgs, + const [isLoading, setIsLoading] = React.useState(false); + const pending = Object.values(data).filter( + (action) => action.meta?.requestStatus === 'pending', ); - if (lastPendingAction) { - actions.fulfilled.meta.requestId = lastPendingAction.meta.requestId; + const fulfilled = Object.values(data).filter( + (action) => action.meta?.requestStatus === 'fulfilled', + ); - actions.fulfilled.meta.arg.queryCacheKey = - lastPendingAction.meta.arg.queryCacheKey; - - actions.triggerLoading.meta.requestId = lastPendingAction.meta.requestId; - - actions.triggerLoading.meta.arg.queryCacheKey = - lastPendingAction.meta.arg.queryCacheKey; - - actions.refresh.meta.requestId = lastPendingAction.meta.requestId; - - actions.refresh.meta.arg.queryCacheKey = - lastPendingAction.meta.arg.queryCacheKey; - } + // Get the most recent actions + const recentPending = pending[pending.length - 1]; + const recentFulfilled = fulfilled[fulfilled.length - 1]; const handleAction = ( actionType: keyof typeof ACTION_TYPES, action: AnyAction, + fulfilledAction?: AnyAction, ) => { + setIsLoading(true); if (liftedDispatch) { try { const performAction = { @@ -227,37 +49,46 @@ export const RTKActionButtons: React.FC = ({ }; liftedDispatch(performAction); - if (actionType === ACTION_TYPES.REFRESH && currentData) { + // For refresh actions, automatically dispatch fulfilled action after delay + if (actionType === ACTION_TYPES.REFRESH && fulfilledAction) { setTimeout(() => { const resolveAction = { type: 'PERFORM_ACTION' as const, - action: actions.fulfilled, + action: fulfilledAction, timestamp: Date.now(), }; liftedDispatch(resolveAction); - }, 1000); + setIsLoading(false); + }, REFRESH_TIMEOUT_MS); } - } catch (e) { - console.error('Error in liftedDispatch:', e); + } catch (error) { + console.error('Error in liftedDispatch:', error); } } if (dispatch) { try { dispatch(action); - if (actionType === ACTION_TYPES.REFRESH && currentData) { + + // For refresh actions, automatically dispatch fulfilled action after delay + if (actionType === ACTION_TYPES.REFRESH && fulfilledAction) { setTimeout(() => { - dispatch(actions.fulfilled); - }, 1000); + dispatch(fulfilledAction); + setIsLoading(false); + }, REFRESH_TIMEOUT_MS); } - } catch (e) { - console.error('Error in dispatch:', e); + } catch (error) { + console.error('Error in dispatch:', error); } } }; return ( - + + + + From ab0381934a903db4652b1dc21357d2a1a54b62f7 Mon Sep 17 00:00:00 2001 From: Sandeep Yadav <92537906+Raosandeep007@users.noreply.github.com> Date: Wed, 27 Aug 2025 21:48:55 +0530 Subject: [PATCH 3/3] pass data to RTKActionButtons --- .../src/components/QueryPreviewActions.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx b/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx index 65f2e60f..c27658bf 100644 --- a/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx +++ b/packages/redux-devtools-rtk-query-monitor/src/components/QueryPreviewActions.tsx @@ -91,8 +91,7 @@ export class QueryPreviewActions extends PureComponent