Query actions and copy data to clipboard

- RTK query Refresh, Loading, Fulfill actions.
- Copy button to copy json data.
This commit is contained in:
Raosandeep007 2025-08-27 13:29:39 +05:30
parent 68f82b6994
commit 193141bf52
8 changed files with 407 additions and 13 deletions

View File

@ -0,0 +1,29 @@
import React, { HTMLAttributes } from 'react';
export type CopyIconProps = Omit<
HTMLAttributes<SVGElement>,
'xmlns' | 'children' | 'viewBox'
>;
/* eslint-disable max-len */
/**
* @see import { IoCopySharp } from "react-icons/io5";
*/
export function CopyIcon(props: CopyIconProps): React.JSX.Element {
return (
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 512 512"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M456 480H136a24 24 0 0 1-24-24V128a16 16 0 0 1 16-16h328a24 24 0 0 1 24 24v320a24 24 0 0 1-24 24z"></path>
<path d="M112 80h288V56a24 24 0 0 0-24-24H60a28 28 0 0 0-28 28v316a24 24 0 0 0 24 24h24V112a32 32 0 0 1 32-32z"></path>
</svg>
);
}
/* eslint-enable max-len */

View File

@ -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<AnyAction>;
resInfo?: RtkResourceInfo | null;
liftedDispatch?: Dispatch<AnyAction>; // Add lifted dispatch for DevTools
}
const keySep = ' - ';
@ -79,15 +83,24 @@ export class QueryPreviewActions extends PureComponent<QueryPreviewActionsProps>
};
render(): ReactNode {
const { isWideLayout, actionsOfQuery } = this.props;
const { isWideLayout, actionsOfQuery, dispatch, liftedDispatch, resInfo } =
this.props;
return (
<TreeView
rootProps={rootProps}
data={this.selectFormattedActions(actionsOfQuery)}
isWideLayout={isWideLayout}
shouldExpandNodeInitially={this.shouldExpandNodeInitially}
/>
<>
<RTKActionButtons
dispatch={dispatch}
liftedDispatch={liftedDispatch}
resInfo={resInfo}
actionsOfQuery={actionsOfQuery}
/>
<TreeView
rootProps={rootProps}
data={this.selectFormattedActions(actionsOfQuery)}
isWideLayout={isWideLayout}
shouldExpandNodeInitially={this.shouldExpandNodeInitially}
/>
</>
);
}
}

View File

@ -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<AnyAction>;
liftedDispatch?: Dispatch<AnyAction>;
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<RTKActionButtonsProps> = ({
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 (
<div css={{ padding: '8px 12px', fontSize: '12px', color: 'orange' }}>
No query selected
</div>
);
}
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 (
<Toolbar borderPosition="bottom" style={{ alignItems: 'center' }}>
<label
css={(theme) => ({
fontSize: '12px',
fontWeight: 'bold',
color: theme.TEXT_COLOR,
marginRight: '8px',
})}
>
RTK Query Actions:
</label>
<Button
tooltipPosition="top-right"
onClick={() => handleAction(ACTION_TYPES.REFRESH, actions.refresh)}
title="Force refetch"
>
Refresh
</Button>
<Button
tooltipPosition="top-right"
onClick={() =>
handleAction(ACTION_TYPES.TRIGGER_LOADING, actions.triggerLoading)
}
title="Simulate loading state"
>
Loading
</Button>
<Button
tooltipPosition="top-right"
onClick={() => handleAction(ACTION_TYPES.FULFILLED, actions.fulfilled)}
title="Simulate successful response"
>
Fulfill
</Button>
</Toolbar>
);
};

View File

@ -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<S = unknown> {
readonly isWideLayout: boolean;
readonly selectorsSource: SelectorsSource<S>;
readonly selectors: InspectorSelectors<S>;
readonly dispatch?: Dispatch<AnyAction>;
readonly liftedDispatch?: Dispatch<AnyAction>;
}
/**
@ -111,9 +114,12 @@ const MappedApiPreview = mapProps<QueryPreviewTabProps, QueryPreviewApiProps>(
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<

View File

@ -217,6 +217,8 @@ class RtkQueryInspector<S, A extends Action<string>> extends PureComponent<
onTabChange={this.handleTabChange}
isWideLayout={isWideLayout}
hasNoApis={hasNoApi}
dispatch={this.props.dispatch}
liftedDispatch={this.props.dispatch}
/>
</div>
);

View File

@ -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 (
<Toolbar
noBorder
style={{
display: 'inline',
backgroundColor: 'transparent',
}}
>
<Button
onClick={(e) => {
e.stopPropagation();
void copyToClipboard(data);
}}
>
<CopyIcon style={iconStyle} />
</Button>
</Toolbar>
);
};
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)}
<CopyButton data={data} />
</span>
);