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..c27658bf 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,23 @@ 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..fcae3b8a --- /dev/null +++ b/packages/redux-devtools-rtk-query-monitor/src/components/RTKQueryActions.tsx @@ -0,0 +1,134 @@ +import { Button, Toolbar } from '@redux-devtools/ui'; +import React from 'react'; +import { AnyAction, Dispatch } from 'redux'; + +interface RTKActionButtonsProps { + dispatch?: Dispatch; + liftedDispatch?: Dispatch; + data: Record; +} + +const ACTION_TYPES = { + REFRESH: 'REFRESH', + FULFILLED: 'FULFILLED', + TRIGGER_FETCHING: 'TRIGGER_FETCHING', +} as const; + +const REFRESH_TIMEOUT_MS = 1000; + +export const RTKActionButtons: React.FC = ({ + dispatch, + liftedDispatch, + data, +}) => { + const [isLoading, setIsLoading] = React.useState(false); + const pending = Object.values(data).filter( + (action) => action.meta?.requestStatus === 'pending', + ); + + const fulfilled = Object.values(data).filter( + (action) => action.meta?.requestStatus === 'fulfilled', + ); + + // 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 = { + type: 'PERFORM_ACTION' as const, + action, + timestamp: Date.now(), + }; + liftedDispatch(performAction); + + // For refresh actions, automatically dispatch fulfilled action after delay + if (actionType === ACTION_TYPES.REFRESH && fulfilledAction) { + setTimeout(() => { + const resolveAction = { + type: 'PERFORM_ACTION' as const, + action: fulfilledAction, + timestamp: Date.now(), + }; + liftedDispatch(resolveAction); + setIsLoading(false); + }, REFRESH_TIMEOUT_MS); + } + } catch (error) { + console.error('Error in liftedDispatch:', error); + } + } + + if (dispatch) { + try { + dispatch(action); + + // For refresh actions, automatically dispatch fulfilled action after delay + if (actionType === ACTION_TYPES.REFRESH && fulfilledAction) { + setTimeout(() => { + dispatch(fulfilledAction); + setIsLoading(false); + }, REFRESH_TIMEOUT_MS); + } + } catch (error) { + console.error('Error in dispatch:', error); + } + } + }; + + 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)} + );