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)} + );