diff --git a/babel.config.js b/babel.config.js index 114137163b..26141cec6f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -34,11 +34,16 @@ module.exports = { ].filter(Boolean), overrides: [ { - exclude: ['./packages/solid-query/**', './packages/svelte-query/**', './packages/vue-query/**'], + exclude: [ + './packages/solid-query/**', + './packages/query-devtools/**', + './packages/svelte-query/**', + './packages/vue-query/**', + ], presets: ['@babel/react'], }, { - include: './packages/solid-query/**', + include: ['./packages/solid-query/**', './packages/query-devtools/**'], presets: ['babel-preset-solid'], }, ], diff --git a/docs/react/devtools.md b/docs/react/devtools.md index 1628de5892..dc220ac62e 100644 --- a/docs/react/devtools.md +++ b/docs/react/devtools.md @@ -54,53 +54,17 @@ function App() { - `initialIsOpen: Boolean` - Set this `true` if you want the dev tools to default to being open -- `panelProps: PropsObject` - - Use this to add props to the panel. For example, you can add `className`, `style` (merge and override default style), etc. -- `closeButtonProps: PropsObject` - - Use this to add props to the close button. For example, you can add `className`, `style` (merge and override default style), `onClick` (extend default handler), etc. -- `toggleButtonProps: PropsObject` - - Use this to add props to the toggle button. For example, you can add `className`, `style` (merge and override default style), `onClick` (extend default handler), etc. -- `position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"` +- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right"` - Defaults to `bottom-left` - The position of the React Query logo to open and close the devtools panel -- `panelPosition?: "top" | "bottom" | "left" | "right"` +- `position?: "top" | "bottom" | "left" | "right"` - Defaults to `bottom` - The position of the React Query devtools panel -- `queryClient?: QueryClient`, +- `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. - `errorTypes?: { name: string; initializer: (query: Query) => TError}` - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. -## Embedded Mode - -Embedded Mode will embed the devtools as a regular component in your application. You can style it however you'd like after that! - -```tsx -import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' - -function App() { - return ( - - {/* The rest of your application */} - - - ) -} -``` - -### Options - -Use these options to style the dev tools. - -- `style: StyleObject` - - The standard React style object used to style a component with inline styles -- `className: string` - - The standard React className property used to style a component with classes -- `showCloseButton?: boolean` - - Show a close button inside the devtools panel -- `closeButtonProps: PropsObject` - - Use this to add props to the close button. For example, you can add `className`, `style` (merge and override default style), `onClick` (extend default handler), etc. - ## Devtools in production Devtools are excluded in production builds. However, it might be desirable to lazy load the devtools in production: diff --git a/packages/query-devtools/.eslintrc.cjs b/packages/query-devtools/.eslintrc.cjs new file mode 100644 index 0000000000..a70eea2aee --- /dev/null +++ b/packages/query-devtools/.eslintrc.cjs @@ -0,0 +1,17 @@ +// @ts-check + +/** @type {import('eslint').Linter.Config} */ +const config = { + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.eslint.json', + sourceType: 'module', + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react-hooks/rules-of-hooks': 'off', + 'react/jsx-key': 'off', + }, +} + +module.exports = config diff --git a/packages/query-devtools/package.json b/packages/query-devtools/package.json new file mode 100644 index 0000000000..532609ea59 --- /dev/null +++ b/packages/query-devtools/package.json @@ -0,0 +1,52 @@ +{ + "name": "@tanstack/query-devtools", + "version": "5.0.0-alpha.23", + "description": "Developer tools to interact with and visualize the TanStack Query cache", + "author": "tannerlinsley", + "license": "MIT", + "repository": "tanstack/query", + "homepage": "https://tanstack.com/query", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "types": "./build/types/index.d.ts", + "main": "./build/umd/index.js", + "module": "./build/esm/index.js", + "exports": { + ".": { + "import": "./build/esm/index.js", + "require": "./build/umd/index.js", + "default": "./build/umd/index.js" + } + }, + "scripts": { + "clean": "rimraf ./build", + "test:eslint": "eslint --ext .ts,.tsx ./src", + "test:types": "tsc", + "test:lib": "vitest run --coverage", + "test:lib:dev": "pnpm run test:lib --watch", + "build:types": "tsc --build" + }, + "files": [ + "build", + "src" + ], + "devDependencies": { + "vite-plugin-solid": "^2.5.0" + }, + "dependencies": { + "@emotion/css": "^11.10.5", + "@solid-primitives/keyed": "^1.1.4", + "@solid-primitives/resize-observer": "^2.0.15", + "@solid-primitives/storage": "^1.3.9", + "@tanstack/match-sorter-utils": "^8.8.4", + "solid-js": "^1.6.10", + "solid-transition-group": "^0.2.2", + "superjson": "^1.12.1" + }, + "peerDependencies": { + "@tanstack/query-core": "workspace:*" + }, + "peerDependenciesMeta": {} +} diff --git a/packages/query-devtools/src/Context.ts b/packages/query-devtools/src/Context.ts new file mode 100644 index 0000000000..73e4af771e --- /dev/null +++ b/packages/query-devtools/src/Context.ts @@ -0,0 +1,41 @@ +import type { QueryClient, onlineManager, Query } from '@tanstack/query-core' +import { createContext, useContext } from 'solid-js' + +type XPosition = 'left' | 'right' +type YPosition = 'top' | 'bottom' +export type DevtoolsPosition = XPosition | YPosition +export type DevtoolsButtonPosition = `${YPosition}-${XPosition}` + +export interface DevToolsErrorType { + /** + * The name of the error. + */ + name: string + /** + * How the error is initialized. + */ + initializer: (query: Query) => Error +} + +export interface QueryDevtoolsProps { + readonly client: QueryClient + queryFlavor: string + version: string + onlineManager: typeof onlineManager + + buttonPosition?: DevtoolsButtonPosition + position?: DevtoolsPosition + initialIsOpen?: boolean + errorTypes?: DevToolsErrorType[] +} + +export const QueryDevtoolsContext = createContext({ + client: undefined as unknown as QueryClient, + onlineManager: undefined as unknown as typeof onlineManager, + queryFlavor: '', + version: '', +}) + +export function useQueryDevtoolsContext() { + return useContext(QueryDevtoolsContext) +} diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx new file mode 100644 index 0000000000..fdb27c7089 --- /dev/null +++ b/packages/query-devtools/src/Devtools.tsx @@ -0,0 +1,1890 @@ +import type { Accessor, Component, JSX, Setter } from 'solid-js' +import { For } from 'solid-js' +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + onMount, + Show, +} from 'solid-js' +import { rankItem } from '@tanstack/match-sorter-utils' +import { css, cx } from '@emotion/css' +import { tokens } from './theme' +import type { Query, QueryCache, QueryState } from '@tanstack/query-core' +import { + getQueryStatusLabel, + getQueryStatusColor, + displayValue, + getQueryStatusColorByLabel, + sortFns, + convertRemToPixels, +} from './utils' +import { + ArrowDown, + ArrowUp, + ChevronDown, + Offline, + Search, + Settings, + TanstackLogo, + Wifi, +} from './icons' +import Explorer from './Explorer' +import type { + QueryDevtoolsProps, + DevtoolsPosition, + DevtoolsButtonPosition, + DevToolsErrorType, +} from './Context' +import { QueryDevtoolsContext, useQueryDevtoolsContext } from './Context' +import { TransitionGroup } from 'solid-transition-group' +import { loadFonts } from './fonts' +import { Key } from '@solid-primitives/keyed' +import type { StorageObject, StorageSetter } from '@solid-primitives/storage' +import { createLocalStorage } from '@solid-primitives/storage' +import { createResizeObserver } from '@solid-primitives/resize-observer' + +interface DevtoolsPanelProps { + localStore: StorageObject + setLocalStore: StorageSetter +} + +interface QueryStatusProps { + label: string + color: 'green' | 'yellow' | 'gray' | 'blue' | 'purple' + count: number +} + +const firstBreakpoint = 1024 +const secondBreakpoint = 796 +const thirdBreakpoint = 700 + +const BUTTON_POSITION: DevtoolsButtonPosition = 'bottom-right' +const POSITION: DevtoolsPosition = 'bottom' +const INITIAL_IS_OPEN = false +const DEFAULT_HEIGHT = 500 +const DEFAULT_WIDTH = 500 +const DEFAULT_SORT_FN_NAME = Object.keys(sortFns)[0] +const DEFAULT_SORT_ORDER = 1 + +const [selectedQueryHash, setSelectedQueryHash] = createSignal( + null, +) +const [panelWidth, setPanelWidth] = createSignal(0) + +export const DevtoolsComponent: Component = (props) => { + return ( + + + + ) +} + +export const Devtools = () => { + loadFonts() + + const styles = getStyles() + + const [localStore, setLocalStore] = createLocalStorage({ + prefix: 'TanstackQueryDevtools', + }) + + const buttonPosition = createMemo(() => { + return useQueryDevtoolsContext().buttonPosition || BUTTON_POSITION + }) + + const isOpen = createMemo(() => { + return localStore.open === 'true' + ? true + : localStore.open === 'false' + ? false + : useQueryDevtoolsContext().initialIsOpen || INITIAL_IS_OPEN + }) + + const position = createMemo(() => { + return localStore.position || useQueryDevtoolsContext().position || POSITION + }) + + return ( +
+ + + + + + + +
+ + +
+
+
+
+ ) +} + +export const DevtoolsPanel: Component = (props) => { + const styles = getStyles() + const [isResizing, setIsResizing] = createSignal(false) + + const sort = createMemo(() => props.localStore.sort || DEFAULT_SORT_FN_NAME) + const sortOrder = createMemo( + () => Number(props.localStore.sortOrder) || DEFAULT_SORT_ORDER, + ) as () => 1 | -1 + + const [offline, setOffline] = createSignal(false) + const [settingsOpen, setSettingsOpen] = createSignal(false) + + const position = createMemo( + () => + (props.localStore.position || + useQueryDevtoolsContext().position || + POSITION) as DevtoolsPosition, + ) + + const sortFn = createMemo(() => sortFns[sort() as string]) + + const onlineManager = createMemo( + () => useQueryDevtoolsContext().onlineManager, + ) + + const cache = createMemo(() => { + return useQueryDevtoolsContext().client.getQueryCache() + }) + + const queryCount = createSubscribeToQueryCacheBatcher((queryCache) => { + return queryCache().getAll().length + }, false) + + const queries = createMemo( + on( + () => [queryCount(), props.localStore.filter, sort(), sortOrder()], + () => { + const curr = cache().getAll() + + const filtered = props.localStore.filter + ? curr.filter( + (item) => + rankItem(item.queryHash, props.localStore.filter || '').passed, + ) + : [...curr] + + const sorted = sortFn() + ? filtered.sort((a, b) => sortFn()!(a, b) * sortOrder()) + : filtered + return sorted + }, + ), + ) + + const handleDragStart: JSX.EventHandler = ( + event, + ) => { + const panelElement = event.currentTarget.parentElement + if (!panelElement) return + setIsResizing(true) + const { height, width } = panelElement.getBoundingClientRect() + const startX = event.clientX + const startY = event.clientY + let newSize = 0 + const minHeight = convertRemToPixels(3.5) + const minWidth = convertRemToPixels(12) + const runDrag = (moveEvent: MouseEvent) => { + moveEvent.preventDefault() + + if (position() === 'left' || position() === 'right') { + const valToAdd = + position() === 'right' + ? startX - moveEvent.clientX + : moveEvent.clientX - startX + newSize = Math.round(width + valToAdd) + if (newSize < minWidth) { + newSize = minWidth + } + props.setLocalStore('width', String(Math.round(newSize))) + + const newWidth = panelElement.getBoundingClientRect().width + // If the panel size didn't decrease, this means we have reached the minimum width + // of the panel so we restore the original width in local storage + // Restoring the width helps in smooth open/close transitions + if (Number(props.localStore.width) < newWidth) { + props.setLocalStore('width', String(newWidth)) + } + } else { + const valToAdd = + position() === 'bottom' + ? startY - moveEvent.clientY + : moveEvent.clientY - startY + newSize = Math.round(height + valToAdd) + // If the panel size is less than the minimum height, + // we set the size to the minimum height + if (newSize < minHeight) { + newSize = minHeight + setSelectedQueryHash(null) + } + props.setLocalStore('height', String(Math.round(newSize))) + } + } + + const unsub = () => { + if (isResizing()) { + setIsResizing(false) + } + document.removeEventListener('mousemove', runDrag, false) + document.removeEventListener('mouseUp', unsub, false) + } + + document.addEventListener('mousemove', runDrag, false) + document.addEventListener('mouseup', unsub, false) + } + + setupQueryCacheSubscription() + + let queriesContainerRef!: HTMLDivElement + let panelRef!: HTMLDivElement + + onMount(() => { + createResizeObserver(panelRef, ({ width }, el) => { + if (el === panelRef) { + setPanelWidth(width) + } + }) + }) + + const setDevtoolsPosition = (pos: DevtoolsPosition) => { + props.setLocalStore('position', pos) + setSettingsOpen(false) + } + + return ( + + ) +} + +export const QueryRow: Component<{ query: Query }> = (props) => { + const styles = getStyles() + + const queryState = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache().find({ + queryKey: props.query.queryKey, + })?.state, + ) + + const isDisabled = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .find({ + queryKey: props.query.queryKey, + }) + ?.isDisabled() ?? false, + ) + + const isStale = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .find({ + queryKey: props.query.queryKey, + }) + ?.isStale() ?? false, + ) + + const observers = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .find({ + queryKey: props.query.queryKey, + }) + ?.getObserversCount() ?? 0, + ) + + const color = createMemo(() => + getQueryStatusColor({ + queryState: queryState()!, + observerCount: observers(), + isStale: isStale(), + }), + ) + + return ( + + + + ) +} + +export const QueryStatusCount: Component = () => { + const stale = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .filter((q) => getQueryStatusLabel(q) === 'stale').length, + ) + + const fresh = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .filter((q) => getQueryStatusLabel(q) === 'fresh').length, + ) + + const fetching = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .filter((q) => getQueryStatusLabel(q) === 'fetching').length, + ) + + const paused = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .filter((q) => getQueryStatusLabel(q) === 'paused').length, + ) + + const inactive = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .filter((q) => getQueryStatusLabel(q) === 'inactive').length, + ) + + const styles = getStyles() + + return ( +
+ + + + + +
+ ) +} + +export const QueryStatus: Component = (props) => { + const styles = getStyles() + + let tagRef!: HTMLButtonElement + + const [mouseOver, setMouseOver] = createSignal(false) + const [focused, setFocused] = createSignal(false) + + const showLabel = createMemo(() => { + if (selectedQueryHash()) { + if (panelWidth() < firstBreakpoint && panelWidth() > secondBreakpoint) { + return false + } + } + if (panelWidth() < thirdBreakpoint) { + return false + } + + return true + }) + + return ( + + ) +} + +const QueryDetails = () => { + const styles = getStyles() + const queryClient = useQueryDevtoolsContext().client + + const [restoringLoading, setRestoringLoading] = createSignal(false) + + const errorTypes = createMemo(() => { + return useQueryDevtoolsContext().errorTypes || [] + }) + + const activeQuery = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .find((query) => query.queryHash === selectedQueryHash()), + false, + ) + + const activeQueryFresh = createSubscribeToQueryCacheBatcher((queryCache) => { + return queryCache() + .getAll() + .find((query) => query.queryHash === selectedQueryHash()) + }, false) + + const activeQueryState = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .find((query) => query.queryHash === selectedQueryHash())?.state, + false, + ) + + const activeQueryStateData = createSubscribeToQueryCacheBatcher( + (queryCache) => { + return queryCache() + .getAll() + .find((query) => query.queryHash === selectedQueryHash())?.state.data + }, + false, + ) + + const statusLabel = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + if (!query) return 'inactive' + return getQueryStatusLabel(query) + }) + + const queryStatus = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + if (!query) return 'pending' + return query.state.status + }) + + const observerCount = createSubscribeToQueryCacheBatcher( + (queryCache) => + queryCache() + .getAll() + .find((query) => query.queryHash === selectedQueryHash()) + ?.getObserversCount() ?? 0, + ) + + const color = createMemo(() => getQueryStatusColorByLabel(statusLabel())) + + const handleRefetch = () => { + const promise = activeQuery()?.fetch() + // eslint-disable-next-line @typescript-eslint/no-empty-function + promise?.catch(() => {}) + } + + const triggerError = (errorType?: DevToolsErrorType) => { + const error = + errorType?.initializer(activeQuery()!) ?? + new Error('Unknown error from devtools') + + const __previousQueryOptions = activeQuery()!.options + + activeQuery()!.setState({ + status: 'error', + error, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + fetchMeta: { + ...activeQuery()!.state.fetchMeta, + __previousQueryOptions, + } as any, + } as QueryState) + } + + const restoreQueryAfterLoadingOrError = () => { + activeQuery()?.fetch( + (activeQuery()?.state.fetchMeta as any).__previousQueryOptions, + { + // Make sure this fetch will cancel the previous one + cancelRefetch: true, + }, + ) + } + + createEffect(() => { + if (statusLabel() !== 'fetching') { + setRestoringLoading(false) + } + }) + + return ( + +
+
Query Details
+
+
+
+              {displayValue(activeQuery()!.queryKey, true)}
+            
+ + {statusLabel()} + +
+
+ Observers: + {observerCount()} +
+
+ Last Updated: + + {new Date(activeQueryState()!.dataUpdatedAt).toLocaleTimeString()} + +
+
+
Actions
+
+ + + + + + + + +
+ + Trigger Error + + +
+
+
+
Data Explorer
+
+ +
+
Query Explorer
+
+ +
+
+
+ ) +} + +const signalsMap = new Map<(q: Accessor) => any, Setter>() + +const setupQueryCacheSubscription = () => { + const queryCache = createMemo(() => { + const client = useQueryDevtoolsContext().client + return client.getQueryCache() + }) + + const unsub = queryCache().subscribe(() => { + for (const [callback, setter] of signalsMap.entries()) { + queueMicrotask(() => { + setter(callback(queryCache)) + }) + } + }) + + onCleanup(() => { + signalsMap.clear() + unsub() + }) + + return unsub +} + +const createSubscribeToQueryCacheBatcher = ( + callback: (queryCache: Accessor) => Exclude, + equalityCheck: boolean = true, +) => { + const queryCache = createMemo(() => { + const client = useQueryDevtoolsContext().client + return client.getQueryCache() + }) + + const [value, setValue] = createSignal( + callback(queryCache), + !equalityCheck ? { equals: false } : undefined, + ) + + createEffect(() => { + setValue(callback(queryCache)) + }) + + // @ts-ignore + signalsMap.set(callback, setValue) + + onCleanup(() => { + // @ts-ignore + signalsMap.delete(callback) + }) + + return value +} + +const getStyles = () => { + const { colors, font, size, alpha, shadow, border } = tokens + + return { + devtoolsBtn: css` + z-index: 100000; + position: fixed; + padding: 4px; + + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + box-shadow: ${shadow.md()}; + overflow: hidden; + + & div { + position: absolute; + top: -8px; + left: -8px; + right: -8px; + bottom: -8px; + border-radius: 9999px; + + & svg { + position: absolute; + width: 100%; + height: 100%; + } + filter: blur(6px) saturate(1.2) contrast(1.1); + } + + &:focus-within { + outline-offset: 2px; + outline: 3px solid ${colors.green[600]}; + } + + & button { + position: relative; + z-index: 1; + padding: 0; + border-radius: 9999px; + background-color: transparent; + border: none; + height: 40px; + display: flex; + width: 40px; + overflow: hidden; + cursor: pointer; + outline: none; + & svg { + position: absolute; + width: 100%; + height: 100%; + } + } + `, + panel: css` + position: fixed; + z-index: 9999; + display: flex; + gap: ${tokens.size[0.5]}; + & * { + font-family: 'Inter', sans-serif; + color: ${colors.gray[300]}; + box-sizing: border-box; + } + `, + 'devtoolsBtn-position-bottom-right': css` + bottom: 12px; + right: 12px; + `, + 'devtoolsBtn-position-bottom-left': css` + bottom: 12px; + left: 12px; + `, + 'devtoolsBtn-position-top-left': css` + top: 12px; + left: 12px; + `, + 'devtoolsBtn-position-top-right': css` + top: 12px; + right: 12px; + `, + 'panel-position-top': css` + top: 0; + right: 0; + left: 0; + max-height: 90%; + min-height: 3.5rem; + border-bottom: ${colors.darkGray[300]} 1px solid; + `, + 'panel-position-bottom': css` + bottom: 0; + right: 0; + left: 0; + max-height: 90%; + min-height: 3.5rem; + border-top: ${colors.darkGray[300]} 1px solid; + `, + 'panel-position-right': css` + bottom: 0; + right: 0; + top: 0; + border-left: ${colors.darkGray[300]} 1px solid; + max-width: 90%; + `, + 'panel-position-left': css` + bottom: 0; + left: 0; + top: 0; + border-right: ${colors.darkGray[300]} 1px solid; + max-width: 90%; + `, + closeBtn: css` + position: absolute; + cursor: pointer; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + outline: none; + &:hover { + background-color: ${colors.darkGray[500]}; + } + &:focus-visible { + outline: 2px solid ${colors.blue[600]}; + } + `, + 'closeBtn-position-top': css` + bottom: 0; + right: ${size[3]}; + transform: translate(0, 100%); + background-color: ${colors.darkGray[700]}; + border-right: ${colors.darkGray[300]} 1px solid; + border-left: ${colors.darkGray[300]} 1px solid; + border-top: none; + border-bottom: ${colors.darkGray[300]} 1px solid; + border-radius: 0px 0px ${border.radius.sm} ${border.radius.sm}; + padding: ${size[1.5]} ${size[2.5]} ${size[2]} ${size[2.5]}; + + &::after { + content: ' '; + position: absolute; + bottom: 100%; + left: -${size[2.5]}; + height: ${size[1.5]}; + width: calc(100% + ${size[5]}); + } + + & svg { + transform: rotate(180deg); + } + `, + 'closeBtn-position-bottom': css` + top: 0; + right: ${size[3]}; + transform: translate(0, -100%); + background-color: ${colors.darkGray[700]}; + border-right: ${colors.darkGray[300]} 1px solid; + border-left: ${colors.darkGray[300]} 1px solid; + border-top: ${colors.darkGray[300]} 1px solid; + border-bottom: none; + border-radius: ${border.radius.sm} ${border.radius.sm} 0px 0px; + padding: ${size[2]} ${size[2.5]} ${size[1.5]} ${size[2.5]}; + + &::after { + content: ' '; + position: absolute; + top: 100%; + left: -${size[2.5]}; + height: ${size[1.5]}; + width: calc(100% + ${size[5]}); + } + `, + 'closeBtn-position-right': css` + bottom: ${size[3]}; + left: 0; + transform: translate(-100%, 0); + background-color: ${colors.darkGray[700]}; + border-right: none; + border-left: ${colors.darkGray[300]} 1px solid; + border-top: ${colors.darkGray[300]} 1px solid; + border-bottom: ${colors.darkGray[300]} 1px solid; + border-radius: ${border.radius.sm} 0px 0px ${border.radius.sm}; + padding: ${size[2.5]} ${size[1]} ${size[2.5]} ${size[1.5]}; + + &::after { + content: ' '; + position: absolute; + left: 100%; + height: calc(100% + ${size[5]}); + width: ${size[1.5]}; + } + + & svg { + transform: rotate(-90deg); + } + `, + 'closeBtn-position-left': css` + bottom: ${size[3]}; + right: 0; + transform: translate(100%, 0); + background-color: ${colors.darkGray[700]}; + border-left: none; + border-right: ${colors.darkGray[300]} 1px solid; + border-top: ${colors.darkGray[300]} 1px solid; + border-bottom: ${colors.darkGray[300]} 1px solid; + border-radius: 0px ${border.radius.sm} ${border.radius.sm} 0px; + padding: ${size[2.5]} ${size[1.5]} ${size[2.5]} ${size[1]}; + + &::after { + content: ' '; + position: absolute; + right: 100%; + height: calc(100% + ${size[5]}); + width: ${size[1.5]}; + } + + & svg { + transform: rotate(90deg); + } + `, + queriesContainer: css` + flex: 1 1 700px; + background-color: ${colors.darkGray[700]}; + display: flex; + flex-direction: column; + `, + dragHandle: css` + position: absolute; + transition: background-color 0.125s ease; + &:hover { + background-color: ${colors.gray[400]}${alpha[90]}; + } + z-index: 4; + `, + 'dragHandle-position-top': css` + bottom: 0; + width: 100%; + height: ${tokens.size[1]}; + cursor: ns-resize; + `, + 'dragHandle-position-bottom': css` + top: 0; + width: 100%; + height: ${tokens.size[1]}; + cursor: ns-resize; + `, + 'dragHandle-position-right': css` + left: 0; + width: ${tokens.size[1]}; + height: 100%; + cursor: ew-resize; + `, + 'dragHandle-position-left': css` + right: 0; + width: ${tokens.size[1]}; + height: 100%; + cursor: ew-resize; + `, + row: css` + display: flex; + justify-content: space-between; + padding: ${tokens.size[2.5]} ${tokens.size[3]}; + gap: ${tokens.size[4]}; + border-bottom: ${colors.darkGray[500]} 1px solid; + align-items: center; + & > button { + padding: 0; + background: transparent; + border: none; + display: flex; + flex-direction: column; + } + `, + logo: css` + cursor: pointer; + &:hover { + opacity: 0.7; + } + &:focus-visible { + outline-offset: 4px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + `, + tanstackLogo: css` + font-size: ${font.size.lg}; + font-weight: ${font.weight.extrabold}; + line-height: ${font.lineHeight.sm}; + white-space: nowrap; + `, + queryFlavorLogo: css` + font-weight: ${font.weight.semibold}; + font-size: ${font.size.sm}; + background: linear-gradient(to right, #dd524b, #e9a03b); + background-clip: text; + line-height: ${font.lineHeight.xs}; + -webkit-text-fill-color: transparent; + white-space: nowrap; + `, + queryStatusContainer: css` + display: flex; + gap: ${tokens.size[2]}; + height: min-content; + `, + queryStatusTag: css` + display: flex; + gap: ${tokens.size[1.5]}; + background: ${colors.darkGray[500]}; + border-radius: ${tokens.border.radius.md}; + font-size: ${font.size.sm}; + padding: ${tokens.size[1]}; + padding-left: ${tokens.size[2.5]}; + align-items: center; + line-height: ${font.lineHeight.md}; + font-weight: ${font.weight.medium}; + border: none; + user-select: none; + position: relative; + &:focus-visible { + outline-offset: 2px; + outline: 2px solid ${colors.blue[800]}; + } + & span:nth-child(2) { + color: ${colors.gray[300]}${alpha[80]}; + } + `, + statusTooltip: css` + position: absolute; + z-index: 1; + background-color: ${colors.darkGray[500]}; + top: 100%; + left: 50%; + transform: translate(-50%, calc(${tokens.size[2]})); + padding: ${tokens.size[0.5]} ${tokens.size[3]}; + border-radius: ${tokens.border.radius.md}; + font-size: ${font.size.sm}; + border: 2px solid ${colors.gray[600]}; + color: ${tokens.colors['gray'][300]}; + + &::before { + top: 0px; + content: ' '; + display: block; + left: 50%; + transform: translate(-50%, -100%); + position: absolute; + border-color: transparent transparent ${colors.gray[600]} transparent; + border-style: solid; + border-width: 7px; + /* transform: rotate(180deg); */ + } + + &::after { + top: 0px; + content: ' '; + display: block; + left: 50%; + transform: translate(-50%, calc(-100% + 2.5px)); + position: absolute; + border-color: transparent transparent ${colors.darkGray[500]} + transparent; + border-style: solid; + border-width: 7px; + } + `, + selectedQueryRow: css` + background-color: ${colors.darkGray[500]}; + `, + queryStatusCount: css` + padding: 0 8px; + display: flex; + align-items: center; + justify-content: center; + color: ${colors.gray[400]}; + background-color: ${colors.darkGray[300]}; + border-radius: 3px; + font-variant-numeric: tabular-nums; + `, + filtersContainer: css` + display: flex; + gap: ${tokens.size[2.5]}; + & > button { + cursor: pointer; + padding: ${tokens.size[1.5]} ${tokens.size[2.5]}; + padding-right: ${tokens.size[1.5]}; + border-radius: ${tokens.border.radius.md}; + background-color: ${colors.darkGray[400]}; + font-size: ${font.size.sm}; + display: flex; + align-items: center; + line-height: ${font.lineHeight.sm}; + gap: ${tokens.size[1.5]}; + max-width: 160px; + border: 1px solid ${colors.darkGray[200]}; + &:focus-visible { + outline-offset: 2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + } + `, + filterInput: css` + padding: ${tokens.size[1.5]} ${tokens.size[2.5]}; + border-radius: ${tokens.border.radius.md}; + background-color: ${colors.darkGray[400]}; + display: flex; + box-sizing: content-box; + align-items: center; + gap: ${tokens.size[1.5]}; + max-width: 160px; + min-width: 100px; + border: 1px solid ${colors.darkGray[200]}; + height: min-content; + & > svg { + width: ${tokens.size[3.5]}; + height: ${tokens.size[3.5]}; + } + & input { + font-size: ${font.size.sm}; + width: 100%; + background-color: ${colors.darkGray[400]}; + border: none; + padding: 0; + line-height: ${font.lineHeight.sm}; + color: ${colors.gray[300]}; + &::placeholder { + color: ${colors.gray[300]}; + } + &:focus { + outline: none; + } + } + + &:focus-within { + outline-offset: 2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + `, + filterSelect: css` + padding: ${tokens.size[1.5]} ${tokens.size[2.5]}; + border-radius: ${tokens.border.radius.md}; + background-color: ${colors.darkGray[400]}; + display: flex; + align-items: center; + gap: ${tokens.size[1.5]}; + box-sizing: content-box; + max-width: 160px; + border: 1px solid ${colors.darkGray[200]}; + height: min-content; + & > svg { + width: ${tokens.size[3]}; + height: ${tokens.size[3]}; + } + & > select { + appearance: none; + min-width: 100px; + line-height: ${font.lineHeight.sm}; + font-size: ${font.size.sm}; + background-color: ${colors.darkGray[400]}; + border: none; + &:focus { + outline: none; + } + } + &:focus-within { + outline-offset: 2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + `, + actionsContainer: css` + display: flex; + gap: ${tokens.size[2.5]}; + `, + actionsBtn: css` + border-radius: ${tokens.border.radius.md}; + background-color: ${colors.darkGray[400]}; + width: 2.125rem; // 34px + height: 2.125rem; // 34px + justify-content: center; + display: flex; + align-items: center; + gap: ${tokens.size[1.5]}; + max-width: 160px; + border: 1px solid ${colors.darkGray[200]}; + cursor: pointer; + &:hover { + background-color: ${colors.darkGray[500]}; + } + & svg { + width: ${tokens.size[4]}; + height: ${tokens.size[4]}; + } + &:focus-visible { + outline-offset: 2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + `, + overflowQueryContainer: css` + flex: 1; + overflow-y: auto; + & > div { + display: flex; + flex-direction: column; + } + `, + queryRow: css` + display: flex; + align-items: center; + padding: 0; + background-color: inherit; + border: none; + cursor: pointer; + &:focus-visible { + outline-offset: -2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + &:hover .TSQDQueryHash { + background-color: ${colors.darkGray[600]}; + } + + & .TSQDObserverCount { + padding: 0 ${tokens.size[1]}; + user-select: none; + min-width: ${tokens.size[8]}; + align-self: stretch !important; + display: flex; + align-items: center; + justify-content: center; + font-size: ${font.size.sm}; + font-weight: ${font.weight.medium}; + border-bottom: 1px solid ${colors.darkGray[700]}; + } + & .TSQDQueryHash { + user-select: text; + font-size: ${font.size.sm}; + display: flex; + align-items: center; + min-height: ${tokens.size[8]}; + flex: 1; + padding: ${tokens.size[1]} ${tokens.size[2]}; + font-family: 'Menlo', 'Fira Code', monospace !important; + border-bottom: 1px solid ${colors.darkGray[400]}; + text-align: left; + text-overflow: clip; + word-break: break-word; + } + + & .TSQDQueryDisabled { + align-self: stretch; + align-self: stretch !important; + display: flex; + align-items: center; + padding: 0 ${tokens.size[3]}; + color: ${colors.gray[300]}; + background-color: ${colors.darkGray[600]}; + border-bottom: 1px solid ${colors.darkGray[400]}; + font-size: ${font.size.sm}; + } + `, + detailsContainer: css` + flex: 1 1 700px; + background-color: ${colors.darkGray[700]}; + display: flex; + flex-direction: column; + overflow-y: auto; + display: flex; + `, + detailsHeader: css` + position: sticky; + top: 0; + z-index: 2; + background-color: ${colors.darkGray[600]}; + padding: ${tokens.size[2]} ${tokens.size[2]}; + font-weight: ${font.weight.medium}; + font-size: ${font.size.sm}; + `, + detailsBody: css` + margin: ${tokens.size[2]} 0px ${tokens.size[3]} 0px; + & > div { + display: flex; + align-items: stretch; + padding: 0 ${tokens.size[2]}; + line-height: ${font.lineHeight.sm}; + justify-content: space-between; + & > span { + font-size: ${font.size.sm}; + } + & > span:nth-child(2) { + font-variant-numeric: tabular-nums; + } + } + + & > div:first-child { + margin-bottom: ${tokens.size[2]}; + } + + & code { + font-family: 'Menlo', 'Fira Code', monospace !important; + margin: 0; + font-size: ${font.size.sm}; + line-height: ${font.lineHeight.sm}; + } + `, + queryDetailsStatus: css` + border: 1px solid ${colors.darkGray[200]}; + border-radius: ${tokens.border.radius.md}; + font-weight: ${font.weight.medium}; + padding: ${tokens.size[1]} ${tokens.size[2.5]}; + `, + actionsBody: css` + flex-wrap: wrap; + margin: ${tokens.size[3]} 0px ${tokens.size[3]} 0px; + display: flex; + gap: ${tokens.size[2]}; + padding: 0px ${tokens.size[2]}; + & > button { + font-size: ${font.size.sm}; + padding: ${tokens.size[2]} ${tokens.size[2]}; + display: flex; + border-radius: ${tokens.border.radius.md}; + border: 1px solid ${colors.darkGray[400]}; + background-color: ${colors.darkGray[600]}; + align-items: center; + gap: ${tokens.size[2]}; + font-weight: ${font.weight.medium}; + line-height: ${font.lineHeight.sm}; + cursor: pointer; + &:focus-visible { + outline-offset: 2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + &:hover { + background-color: ${colors.darkGray[500]}; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + & > span { + width: ${size[2]}; + height: ${size[2]}; + border-radius: ${tokens.border.radius.full}; + } + } + `, + actionsSelect: css` + font-size: ${font.size.sm}; + padding: ${tokens.size[2]} ${tokens.size[2]}; + display: flex; + border-radius: ${tokens.border.radius.md}; + overflow: hidden; + border: 1px solid ${colors.darkGray[400]}; + background-color: ${colors.darkGray[600]}; + align-items: center; + gap: ${tokens.size[2]}; + font-weight: ${font.weight.medium}; + line-height: ${font.lineHeight.sm}; + color: ${tokens.colors.red[400]}; + cursor: pointer; + position: relative; + &:hover { + background-color: ${colors.darkGray[500]}; + } + & > span { + width: ${size[2]}; + height: ${size[2]}; + border-radius: ${tokens.border.radius.full}; + } + &:focus-within { + outline-offset: 2px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + & select { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + appearance: none; + background-color: transparent; + border: none; + color: transparent; + outline: none; + } + + & svg path { + stroke: ${tokens.colors.red[400]} !important; + } + `, + settingsMenu: css` + position: absolute; + top: calc(100% + ${tokens.size[2]}); + border-radius: ${tokens.border.radius.lg}; + border: 1px solid ${colors.gray[600]}; + right: 0; + min-width: ${tokens.size[44]}; + background-color: ${colors.darkGray[400]}; + font-size: ${font.size.sm}; + color: ${colors.gray[500]}; + z-index: 2; + `, + settingsMenuHeader: css` + padding: ${tokens.size[1.5]} ${tokens.size[2.5]}; + color: ${colors.gray[300]}; + font-weight: ${font.weight.medium}; + `, + settingsMenuSection: css` + border-top: 1px solid ${colors.gray[600]}; + display: flex; + flex-direction: column; + padding: ${tokens.size[1]} ${tokens.size[1]}; + + & > button { + cursor: pointer; + background-color: transparent; + border: none; + padding: ${tokens.size[2]} ${tokens.size[1.5]}; + font-size: ${font.size.sm}; + display: flex; + align-items: center; + justify-content: flex-start; + gap: ${tokens.size[2]}; + border-radius: ${tokens.border.radius.md}; + &:hover { + background-color: ${colors.darkGray[500]}; + } + + &:focus-visible { + outline-offset: 2px; + outline: 2px solid ${colors.blue[800]}; + } + } + + & button:nth-child(4) svg { + transform: rotate(-90deg); + } + + & button:nth-child(3) svg { + transform: rotate(90deg); + } + `, + } +} diff --git a/packages/query-devtools/src/Explorer.tsx b/packages/query-devtools/src/Explorer.tsx new file mode 100644 index 0000000000..1b4dd4c8f0 --- /dev/null +++ b/packages/query-devtools/src/Explorer.tsx @@ -0,0 +1,348 @@ +import { displayValue } from './utils' +import superjson from 'superjson' +import { css, cx } from '@emotion/css' +import { tokens } from './theme' +import { createMemo, createSignal, Index, Match, Show, Switch } from 'solid-js' +import { Key } from '@solid-primitives/keyed' +import { CopiedCopier, Copier, ErrorCopier } from './icons' + +/** + * Chunk elements in the array by size + * + * when the array cannot be chunked evenly by size, the last chunk will be + * filled with the remaining elements + * + * @example + * chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']] + */ +export function chunkArray( + array: T[], + size: number, +): T[][] { + if (size < 1) return [] + let i = 0 + const result: T[][] = [] + while (i < array.length) { + result.push(array.slice(i, i + size)) + i = i + size + } + return result +} + +const Expander = (props: { expanded: boolean }) => { + const styles = getStyles() + + return ( + + + + + + ) +} + +type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy' +const CopyButton = (props: { value: unknown }) => { + const styles = getStyles() + const [copyState, setCopyState] = createSignal('NoCopy') + + return ( + + ) +} + +type ExplorerProps = { + copyable?: boolean + label: string + value: unknown + defaultExpanded?: string[] +} + +function isIterable(x: any): x is Iterable { + return Symbol.iterator in x +} + +export default function Explorer(props: ExplorerProps) { + const styles = getStyles() + + const [expanded, setExpanded] = createSignal( + (props.defaultExpanded || []).includes(props.label), + ) + const toggleExpanded = () => setExpanded((old) => !old) + const [expandedPages, setExpandedPages] = createSignal([]) + + const subEntries = createMemo(() => { + if (Array.isArray(props.value)) { + return props.value.map((d, i) => ({ + label: i.toString(), + value: d, + })) + } else if ( + props.value !== null && + typeof props.value === 'object' && + isIterable(props.value) && + typeof props.value[Symbol.iterator] === 'function' + ) { + if (props.value instanceof Map) { + return Array.from(props.value, ([key, val]) => ({ + label: key, + value: val, + })) + } + return Array.from(props.value, (val, i) => ({ + label: i.toString(), + value: val, + })) + } else if (typeof props.value === 'object' && props.value !== null) { + return Object.entries(props.value).map(([key, val]) => ({ + label: key, + value: val, + })) + } + return [] + }) + + const type = createMemo(() => { + if (Array.isArray(props.value)) { + return 'array' + } else if ( + props.value !== null && + typeof props.value === 'object' && + isIterable(props.value) && + typeof props.value[Symbol.iterator] === 'function' + ) { + return 'Iterable' + } else if (typeof props.value === 'object' && props.value !== null) { + return 'object' + } + return typeof props.value + }) + + const subEntryPages = createMemo(() => chunkArray(subEntries(), 100)) + + return ( +
+ + + + + + + +
+ item.label}> + {(entry) => { + return ( + + ) + }} + +
+
+ 1}> +
+ + {(entries, index) => ( +
+
+ + +
+ entry.label}> + {(entry) => ( + + )} + +
+
+
+
+ )} +
+
+
+
+
+ + {props.label}:{' '} + {displayValue(props.value)} + +
+ ) +} + +const getStyles = () => { + const { colors, font, size, border } = tokens + + return { + entry: css` + & * { + font-size: ${font.size.sm}; + font-family: 'Menlo', 'Fira Code', monospace; + line-height: 1.7; + } + position: relative; + outline: none; + word-break: break-word; + `, + subEntry: css` + margin: 0 0 0 0.5em; + padding-left: 0.75em; + border-left: 2px solid ${colors.darkGray[400]}; + `, + expander: css` + & path { + stroke: ${colors.gray[400]}; + } + display: inline-flex; + align-items: center; + transition: all 0.1s ease; + `, + expanderButton: css` + cursor: pointer; + color: inherit; + font: inherit; + outline: inherit; + line-height: ${font.size.sm}; + background: transparent; + border: none; + padding: 0; + display: inline-flex; + align-items: center; + gap: ${size[1]}; + + &:focus-visible { + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + `, + info: css` + color: ${colors.gray[500]}; + font-size: ${font.size.xs}; + line-height: ${font.size.xs}; + margin-left: ${size[1]}; + `, + label: css` + color: ${colors.gray[300]}; + `, + value: css` + color: ${colors.purple[400]}; + `, + copyButton: css` + background-color: transparent; + border: none; + display: inline-flex; + padding: 0px; + align-items: center; + justify-content: center; + cursor: pointer; + width: ${size[3.5]}; + height: ${size[3.5]}; + position: relative; + top: 4px; + left: ${size[2]}; + z-index: 1; + + &:hover svg .copier { + stroke: ${colors.gray[500]} !important; + } + + &:focus-visible { + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + outline-offset: 2px; + } + `, + } +} diff --git a/packages/query-devtools/src/__tests__/devtools.test.tsx b/packages/query-devtools/src/__tests__/devtools.test.tsx new file mode 100644 index 0000000000..56317afee0 --- /dev/null +++ b/packages/query-devtools/src/__tests__/devtools.test.tsx @@ -0,0 +1,5 @@ +describe('ReactQueryDevtools', () => { + it('should be able to open and close devtools', async () => { + expect(1).toBe(1) + }) +}) diff --git a/packages/query-devtools/src/fonts.ts b/packages/query-devtools/src/fonts.ts new file mode 100644 index 0000000000..bbb3269796 --- /dev/null +++ b/packages/query-devtools/src/fonts.ts @@ -0,0 +1,7 @@ +export const loadFonts = () => { + const link = document.createElement('link') + link.href = + 'https://fonts.googleapis.com/css2?family=Fira+Code&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Roboto&display=swap' + link.rel = 'stylesheet' + document.head.appendChild(link) +} diff --git a/packages/query-devtools/src/icons/index.tsx b/packages/query-devtools/src/icons/index.tsx new file mode 100644 index 0000000000..e400f4b7d2 --- /dev/null +++ b/packages/query-devtools/src/icons/index.tsx @@ -0,0 +1,1065 @@ +import { tokens } from '../theme' + +export function Search() { + return ( + + + + ) +} + +export function ChevronDown() { + return ( + + + + ) +} + +export function ArrowUp() { + return ( + + + + ) +} + +export function ArrowDown() { + return ( + + + + ) +} + +export function Wifi() { + return ( + + + + + ) +} + +export function Offline() { + return ( + + + + + ) +} + +export function Settings() { + return ( + + + + + ) +} + +export function Copier() { + return ( + + + + ) +} + +export function CopiedCopier() { + return ( + + + + ) +} + +export function ErrorCopier() { + return ( + + + + ) +} + +export function TanstackLogo() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/query-devtools/src/index.tsx b/packages/query-devtools/src/index.tsx new file mode 100644 index 0000000000..9fdb8dbf4f --- /dev/null +++ b/packages/query-devtools/src/index.tsx @@ -0,0 +1,113 @@ +import type { + QueryClient, + onlineManager as TonlineManager, +} from '@tanstack/query-core' +import { DevtoolsComponent } from './Devtools' +import { render } from 'solid-js/web' +import type { + DevtoolsButtonPosition, + DevtoolsPosition, + QueryDevtoolsProps, + DevToolsErrorType, +} from './Context' +import type { Signal } from 'solid-js' +import { createSignal } from 'solid-js' + +export type { DevtoolsButtonPosition, DevtoolsPosition, DevToolsErrorType } +export interface TanstackQueryDevtoolsConfig extends QueryDevtoolsProps {} + +class TanstackQueryDevtools { + client: QueryClient + onlineManager: typeof TonlineManager + queryFlavor: string + version: string + isMounted = false + buttonPosition: Signal + position: Signal + initialIsOpen: Signal + errorTypes: Signal + dispose?: () => void + + constructor(config: TanstackQueryDevtoolsConfig) { + const { + client, + queryFlavor, + version, + onlineManager, + buttonPosition, + position, + initialIsOpen, + errorTypes, + } = config + this.client = client + this.queryFlavor = queryFlavor + this.version = version + this.onlineManager = onlineManager + this.buttonPosition = createSignal(buttonPosition) + this.position = createSignal(position) + this.initialIsOpen = createSignal(initialIsOpen) + this.errorTypes = createSignal(errorTypes) + } + + setButtonPosition(position: DevtoolsButtonPosition) { + this.buttonPosition[1](position) + } + + setPosition(position: DevtoolsPosition) { + this.position[1](position) + } + + setInitialIsOpen(isOpen: boolean) { + this.initialIsOpen[1](isOpen) + } + + setErrorTypes(errorTypes: DevToolsErrorType[]) { + this.errorTypes[1](errorTypes) + } + + mount(el: T) { + if (this.isMounted) { + throw new Error('Devtools is already mounted') + } + const dispose = render(() => { + const [btnPosition] = this.buttonPosition + const [pos] = this.position + const [isOpen] = this.initialIsOpen + const [errors] = this.errorTypes + return ( + + ) + }, el) + this.isMounted = true + this.dispose = dispose + } + + unmount() { + if (!this.isMounted) { + throw new Error('Devtools is not mounted') + } + this.dispose?.() + this.isMounted = false + } +} + +export { TanstackQueryDevtools } diff --git a/packages/query-devtools/src/theme.ts b/packages/query-devtools/src/theme.ts new file mode 100644 index 0000000000..565c02840c --- /dev/null +++ b/packages/query-devtools/src/theme.ts @@ -0,0 +1,321 @@ +const ShadowVariants = { + xs: '0 1px 2px 0 rgb(0 0 0 / 0.05)', + sm: '0 1px 3px 0 color, 0 1px 2px -1px color', + md: '0 4px 6px -1px color, 0 2px 4px -2px color', + lg: '0 10px 15px -3px color, 0 4px 6px -4px color', + xl: '0 20px 25px -5px color, 0 8px 10px -6px color', + '2xl': '0 25px 50px -12px color', + inner: 'inset 0 2px 4px 0 color', + none: 'none', +} + +type ShadowVariantType = keyof typeof ShadowVariants + +const getShadow = (variant: ShadowVariantType, color: string = ''): string => { + return ShadowVariants[variant].replace(/color/g, color) +} + +const Shadow = { + xs: (color: string = 'rgb(0 0 0 / 0.1)') => getShadow('xs', color), + sm: (color: string = 'rgb(0 0 0 / 0.1)') => getShadow('sm', color), + md: (color: string = 'rgb(0 0 0 / 0.1)') => getShadow('md', color), + lg: (color: string = 'rgb(0 0 0 / 0.1)') => getShadow('lg', color), + xl: (color: string = 'rgb(0 0 0 / 0.1)') => getShadow('xl', color), + '2xl': (color: string = 'rgb(0 0 0 / 0.25)') => getShadow('2xl', color), + inner: (color: string = 'rgb(0 0 0 / 0.05)') => getShadow('inner', color), + none: () => getShadow('none'), +} + +export const tokens = { + colors: { + inherit: 'inherit', + current: 'currentColor', + transparent: 'transparent', + black: '#000000', + white: '#ffffff', + neutral: { + 50: '#f9fafb', + 100: '#f2f4f7', + 200: '#eaecf0', + 300: '#d0d5dd', + 400: '#98a2b3', + 500: '#667085', + 600: '#475467', + 700: '#344054', + 800: '#1d2939', + 900: '#101828', + }, + darkGray: { + 50: '#525c7a', + 100: '#49536e', + 200: '#414962', + 300: '#394056', + 400: '#313749', + 500: '#292e3d', + 600: '#212530', + 700: '#191c24', + 800: '#111318', + 900: '#0b0d10', + }, + gray: { + 50: '#f9fafb', + 100: '#f2f4f7', + 200: '#eaecf0', + 300: '#d0d5dd', + 400: '#98a2b3', + 500: '#667085', + 600: '#475467', + 700: '#344054', + 800: '#1d2939', + 900: '#101828', + }, + blue: { + 25: '#F5FAFF', + 50: '#EFF8FF', + 100: '#D1E9FF', + 200: '#B2DDFF', + 300: '#84CAFF', + 400: '#53B1FD', + 500: '#2E90FA', + 600: '#1570EF', + 700: '#175CD3', + 800: '#1849A9', + 900: '#194185', + }, + green: { + 25: '#F6FEF9', + 50: '#ECFDF3', + 100: '#D1FADF', + 200: '#A6F4C5', + 300: '#6CE9A6', + 400: '#32D583', + 500: '#12B76A', + 600: '#039855', + 700: '#027A48', + 800: '#05603A', + 900: '#054F31', + }, + red: { + 25: '#FFFBFA', + 50: '#FEF3F2', + 100: '#FEE4E2', + 200: '#FECDCA', + 300: '#FDA29B', + 400: '#F97066', + 500: '#F04438', + 600: '#D92D20', + 700: '#B42318', + 800: '#912018', + 900: '#7A271A', + }, + yellow: { + 25: '#FFFCF5', + 50: '#FFFAEB', + 100: '#FEF0C7', + 200: '#FEDF89', + 300: '#FEC84B', + 400: '#FDB022', + 500: '#F79009', + 600: '#DC6803', + 700: '#B54708', + 800: '#93370D', + 900: '#7A2E0E', + }, + purple: { + 25: '#FAFAFF', + 50: '#F4F3FF', + 100: '#EBE9FE', + 200: '#D9D6FE', + 300: '#BDB4FE', + 400: '#9B8AFB', + 500: '#7A5AF8', + 600: '#6938EF', + 700: '#5925DC', + 800: '#4A1FB8', + 900: '#3E1C96', + }, + teal: { + 25: '#F6FEFC', + 50: '#F0FDF9', + 100: '#CCFBEF', + 200: '#99F6E0', + 300: '#5FE9D0', + 400: '#2ED3B7', + 500: '#15B79E', + 600: '#0E9384', + 700: '#107569', + 800: '#125D56', + 900: '#134E48', + }, + pink: { + 25: '#fdf2f8', + 50: '#fce7f3', + 100: '#fbcfe8', + 200: '#f9a8d4', + 300: '#f472b6', + 400: '#ec4899', + 500: '#db2777', + 600: '#be185d', + 700: '#9d174d', + 800: '#831843', + 900: '#500724', + }, + cyan: { + 25: '#ecfeff', + 50: '#cffafe', + 100: '#a5f3fc', + 200: '#67e8f9', + 300: '#22d3ee', + 400: '#06b6d4', + 500: '#0891b2', + 600: '#0e7490', + 700: '#155e75', + 800: '#164e63', + 900: '#083344', + }, + }, + alpha: { + 100: 'ff', + 90: 'e5', + 80: 'cc', + 70: 'b3', + 60: '99', + 50: '80', + 40: '66', + 30: '4d', + 20: '33', + 10: '1a', + 0: '00', + }, + font: { + size: { + '2xs': '0.625rem', + xs: '0.75rem', + sm: '0.875rem', + md: '1rem', + lg: '1.125rem', + xl: '1.25rem', + '2xl': '1.5rem', + '3xl': '1.875rem', + '4xl': '2.25rem', + '5xl': '3rem', + '6xl': '3.75rem', + '7xl': '4.5rem', + '8xl': '6rem', + '9xl': '8rem', + }, + lineHeight: { + xs: '1rem', + sm: '1.25rem', + md: '1.5rem', + lg: '1.75rem', + xl: '1.75rem', + '2xl': '2rem', + '3xl': '2.25rem', + '4xl': '2.5rem', + '5xl': '1', + '6xl': '1', + '7xl': '1', + '8xl': '1', + '9xl': '1', + }, + weight: { + thin: '100', + extralight: '200', + light: '300', + normal: '400', + medium: '500', + semibold: '600', + bold: '700', + extrabold: '800', + black: '900', + }, + }, + breakpoints: { + xs: '320px', + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + }, + border: { + radius: { + none: '0px', + xs: '0.125rem', + sm: '0.25rem', + md: '0.375rem', + lg: '0.5rem', + xl: '0.75rem', + '2xl': '1rem', + '3xl': '1.5rem', + full: '9999px', + }, + }, + size: { + 0: '0px', + 0.25: '0.0625rem', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 4.5: '1.125rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 96: '24rem', + }, + shadow: Shadow, + zIndices: { + hide: -1, + auto: 'auto', + base: 0, + docked: 10, + dropdown: 1000, + sticky: 1100, + banner: 1200, + overlay: 1300, + modal: 1400, + popover: 1500, + skipLink: 1600, + toast: 1700, + tooltip: 1800, + }, +} + +export type ThemeConfigType = typeof tokens +export type ThemeColorsAll = { + [key in keyof ThemeConfigType['colors']]: key +}[keyof ThemeConfigType['colors']] +export type AtomicThemeColors = + | 'white' + | 'black' + | 'transparent' + | 'current' + | 'inherit' +export type ThemeColors = Exclude diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx new file mode 100644 index 0000000000..2a2050c26f --- /dev/null +++ b/packages/query-devtools/src/utils.tsx @@ -0,0 +1,103 @@ +import type { Query } from '@tanstack/query-core' +import SuperJSON from 'superjson' + +export function getQueryStatusLabel(query: Query) { + return query.state.fetchStatus === 'fetching' + ? 'fetching' + : !query.getObserversCount() + ? 'inactive' + : query.state.fetchStatus === 'paused' + ? 'paused' + : query.isStale() + ? 'stale' + : 'fresh' +} + +export const queryStatusLabels = [ + 'fresh', + 'stale', + 'paused', + 'inactive', + 'fetching', +] as const +export type IQueryStatusLabel = (typeof queryStatusLabels)[number] + +export function getQueryStatusColor({ + queryState, + observerCount, + isStale, +}: { + queryState: Query['state'] + observerCount: number + isStale: boolean +}) { + return queryState.fetchStatus === 'fetching' + ? 'blue' + : !observerCount + ? 'gray' + : queryState.fetchStatus === 'paused' + ? 'purple' + : isStale + ? 'yellow' + : 'green' +} + +export function getQueryStatusColorByLabel(label: IQueryStatusLabel) { + return label === 'fresh' + ? 'green' + : label === 'stale' + ? 'yellow' + : label === 'paused' + ? 'purple' + : label === 'inactive' + ? 'gray' + : 'blue' +} + +/** + * Displays a string regardless the type of the data + * @param {unknown} value Value to be stringified + * @param {boolean} beautify Formats json to multiline + */ +export const displayValue = (value: unknown, beautify: boolean = false) => { + const { json } = SuperJSON.serialize(value) + + return JSON.stringify(json, null, beautify ? 2 : undefined) +} + +// Sorting functions +type SortFn = (a: Query, b: Query) => number + +const getStatusRank = (q: Query) => + q.state.fetchStatus !== 'idle' + ? 0 + : !q.getObserversCount() + ? 3 + : q.isStale() + ? 2 + : 1 + +const queryHashSort: SortFn = (a, b) => a.queryHash.localeCompare(b.queryHash) + +const dateSort: SortFn = (a, b) => + a.state.dataUpdatedAt < b.state.dataUpdatedAt ? 1 : -1 + +const statusAndDateSort: SortFn = (a, b) => { + if (getStatusRank(a) === getStatusRank(b)) { + return dateSort(a, b) + } + + return getStatusRank(a) > getStatusRank(b) ? 1 : -1 +} + +export const sortFns: Record = { + status: statusAndDateSort, + 'query hash': queryHashSort, + 'last updated': dateSort, +} + +export const convertRemToPixels = (rem: number) => { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize) +} + +export const convertPixelsToRem = (px: number) => px / convertRemToPixels(1) diff --git a/packages/query-devtools/tsconfig.eslint.json b/packages/query-devtools/tsconfig.eslint.json new file mode 100644 index 0000000000..a6f6c1aa09 --- /dev/null +++ b/packages/query-devtools/tsconfig.eslint.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx", "./.eslintrc.cjs"] +} diff --git a/packages/query-devtools/tsconfig.json b/packages/query-devtools/tsconfig.json new file mode 100644 index 0000000000..2dcf15db02 --- /dev/null +++ b/packages/query-devtools/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./build/source", + "declarationDir": "./build/types", + "tsBuildInfoFile": "./build/.tsbuildinfo", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "emitDeclarationOnly": false, + "types": ["vitest/globals"] + }, + "include": ["src"], + "exclude": ["node_modules", "build"], + "references": [{ "path": "../query-core" }] +} diff --git a/packages/query-devtools/vitest.config.ts b/packages/query-devtools/vitest.config.ts new file mode 100644 index 0000000000..ea92ce34f8 --- /dev/null +++ b/packages/query-devtools/vitest.config.ts @@ -0,0 +1,21 @@ +import { resolve } from 'path' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'solid-query', + watch: false, + setupFiles: [], + environment: 'jsdom', + globals: true, + dir: 'src/__tests__', + coverage: { provider: 'istanbul' }, + }, + resolve: { + alias: { + '@tanstack/query-core': resolve(__dirname, '..', 'query-core', 'src'), + }, + }, + plugins: [solid()], +}) diff --git a/packages/react-query-devtools/package.json b/packages/react-query-devtools/package.json index 40d6e6bb8d..c9eafb0842 100644 --- a/packages/react-query-devtools/package.json +++ b/packages/react-query-devtools/package.json @@ -54,8 +54,7 @@ "react-error-boundary": "^3.1.4" }, "dependencies": { - "@tanstack/match-sorter-utils": "^8.7.0", - "superjson": "^1.10.0" + "@tanstack/query-devtools": "workspace:*" }, "peerDependencies": { "@tanstack/react-query": "workspace:*", diff --git a/packages/react-query-devtools/src/CachePanel/ActiveQuery.tsx b/packages/react-query-devtools/src/CachePanel/ActiveQuery.tsx deleted file mode 100644 index 766a802599..0000000000 --- a/packages/react-query-devtools/src/CachePanel/ActiveQuery.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import React from 'react' -import type { QueryCache, QueryClient } from '@tanstack/react-query' - -import useSubscribeToQueryCache from '../useSubscribeToQueryCache' -import { Button, Code, Select, ActiveQueryPanel } from '../styledComponents' - -import { - getQueryStatusLabel, - getQueryStatusColor, - displayValue, -} from '../utils' -import Explorer from '../Explorer' -import type { DevToolsErrorType } from '../types' -import { defaultTheme as theme } from '../theme' - -// eslint-disable-next-line @typescript-eslint/no-empty-function -function noop() {} - -/** - * Panel for the query currently being inspected - * - * It displays query details (key, observers...), query actions, - * the data explorer and the query explorer - */ -const ActiveQuery = ({ - queryCache, - activeQueryHash, - queryClient, - errorTypes, -}: { - queryCache: QueryCache - activeQueryHash: string - queryClient: QueryClient - errorTypes: DevToolsErrorType[] -}) => { - const activeQuery = useSubscribeToQueryCache(queryCache, () => - queryCache.getAll().find((query) => query.queryHash === activeQueryHash), - ) - - const activeQueryState = useSubscribeToQueryCache( - queryCache, - () => - queryCache.getAll().find((query) => query.queryHash === activeQueryHash) - ?.state, - ) - - const isStale = - useSubscribeToQueryCache(queryCache, () => - queryCache - .getAll() - .find((query) => query.queryHash === activeQueryHash) - ?.isStale(), - ) ?? false - - const observerCount = - useSubscribeToQueryCache(queryCache, () => - queryCache - .getAll() - .find((query) => query.queryHash === activeQueryHash) - ?.getObserversCount(), - ) ?? 0 - - const handleRefetch = () => { - const promise = activeQuery?.fetch() - promise?.catch(noop) - } - - const currentErrorTypeName = React.useMemo(() => { - if (activeQuery && activeQueryState?.error) { - const errorType = errorTypes.find( - (type) => - type.initializer(activeQuery).toString() === - activeQueryState.error?.toString(), - ) - return errorType?.name - } - return undefined - }, [activeQuery, activeQueryState?.error, errorTypes]) - - if (!activeQuery || !activeQueryState) { - return null - } - - const triggerError = (errorType?: DevToolsErrorType) => { - const error = - errorType?.initializer(activeQuery) ?? - new Error('Unknown error from devtools') - - const __previousQueryOptions = activeQuery.options - - activeQuery.setState({ - status: 'error', - error, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - fetchMeta: { - ...activeQuery.state.fetchMeta, - __previousQueryOptions, - } as any, - }) - } - - const restoreQueryAfterLoadingOrError = () => { - activeQuery.fetch( - (activeQuery.state.fetchMeta as any)?.__previousQueryOptions, - { - // Make sure this fetch will cancel the previous one - cancelRefetch: true, - }, - ) - } - - return ( - -
- Query Details -
-
-
- -
-              {displayValue(activeQuery.queryKey, true)}
-            
-
- - {getQueryStatusLabel(activeQuery)} - -
-
- Observers: {observerCount} -
-
- Last Updated:{' '} - - {new Date(activeQueryState.dataUpdatedAt).toLocaleTimeString()} - -
-
-
- Actions -
-
- {' '} - {' '} - {' '} - {' '} - {' '} - {errorTypes.length === 0 || activeQuery.state.status === 'error' ? ( - - ) : ( - - )} -
-
- Data Explorer -
-
- -
-
- Query Explorer -
-
- -
-
- ) -} - -ActiveQuery.displayName = 'ActiveQuery' - -export default ActiveQuery diff --git a/packages/react-query-devtools/src/CachePanel/CachePanel.tsx b/packages/react-query-devtools/src/CachePanel/CachePanel.tsx deleted file mode 100644 index b3a2d86df3..0000000000 --- a/packages/react-query-devtools/src/CachePanel/CachePanel.tsx +++ /dev/null @@ -1,439 +0,0 @@ -import React from 'react' -import type { QueryClient } from '@tanstack/react-query' -import { useQueryClient, onlineManager } from '@tanstack/react-query' -import { rankItem } from '@tanstack/match-sorter-utils' - -import { Panel, Button, Input, Select } from '../styledComponents' -import useSubscribeToQueryCache from '../useSubscribeToQueryCache' -import QueryStatusCount from './Header/QueryStatusCount' -import QueryRow from './QueryRow' -import ActiveQuery from './ActiveQuery' -import type { Side } from '../utils' -import { sortFns, getResizeHandleStyle, defaultPanelSize } from '../utils' -import { ThemeProvider, defaultTheme as theme } from '../theme' -import type { DevToolsErrorType } from '../types' -import useLocalStorage from '../useLocalStorage' -import Logo from '../Logo' -import ScreenReader from '../screenreader' - -interface DevtoolsPanelOptions { - /** - * The standard React style object used to style a component with inline styles - */ - style?: React.CSSProperties - /** - * The standard React className property used to style a component with classes - */ - className?: string - /** - * A boolean variable indicating whether the panel is open or closed - */ - isOpen?: boolean - /** - * nonce for style element for CSP - */ - styleNonce?: string - /** - * A function that toggles the open and close state of the panel - */ - setIsOpen: (isOpen: boolean) => void - /** - * Handles the opening and closing the devtools panel - */ - onDragStart: (e: React.MouseEvent) => void - /** - * The position of the React Query devtools panel. - * Defaults to 'bottom'. - */ - position?: Side - /** - * Handles the panel position select change - */ - onPositionChange?: (side: Side) => void - /** - * Show a close button inside the panel - */ - showCloseButton?: boolean - /** - * Use this to add props to the close button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc. - */ - closeButtonProps?: React.ComponentPropsWithoutRef<'button'> - /** - * Custom instance of QueryClient - */ - queryClient?: QueryClient - /** - * Use this so you can define custom errors that can be shown in the devtools. - */ - errorTypes?: DevToolsErrorType[] -} - -export const ReactQueryDevtoolsPanel = React.forwardRef< - HTMLDivElement, - DevtoolsPanelOptions ->(function ReactQueryDevtoolsPanel(props, ref): React.ReactElement { - const { - isOpen = true, - styleNonce, - setIsOpen, - queryClient, - onDragStart, - onPositionChange, - showCloseButton, - position, - closeButtonProps = {}, - errorTypes = [], - ...panelProps - } = props - - const { onClick: onCloseClick, ...otherCloseButtonProps } = closeButtonProps - - const client = useQueryClient(queryClient) - const queryCache = client.getQueryCache() - - const [sort, setSort] = useLocalStorage( - 'reactQueryDevtoolsSortFn', - Object.keys(sortFns)[0], - ) - - const [filter, setFilter] = useLocalStorage('reactQueryDevtoolsFilter', '') - - const [baseSort, setBaseSort] = useLocalStorage( - 'reactQueryDevtoolsBaseSort', - 1, - ) - - const sortFn = React.useMemo(() => sortFns[sort as string], [sort]) - - const queriesCount = useSubscribeToQueryCache( - queryCache, - () => queryCache.getAll().length, - !isOpen, - ) - - const [activeQueryHash, setActiveQueryHash] = useLocalStorage( - 'reactQueryDevtoolsActiveQueryHash', - '', - ) - - const queries = React.useMemo(() => { - const unsortedQueries = queryCache.getAll() - - if (queriesCount === 0) { - return [] - } - - const filtered = filter - ? unsortedQueries.filter( - (item) => rankItem(item.queryHash, filter).passed, - ) - : [...unsortedQueries] - - const sorted = sortFn - ? filtered.sort((a, b) => sortFn(a, b) * (baseSort as number)) - : filtered - - return sorted - }, [baseSort, sortFn, filter, queriesCount, queryCache]) - - const [isMockOffline, setMockOffline] = React.useState(false) - - return ( - - -