From 2019cb64a8fc1791600978063db9b106f6996445 Mon Sep 17 00:00:00 2001 From: Aryan Deora Date: Sat, 29 Apr 2023 00:48:41 -0400 Subject: [PATCH 01/14] feat(devtools): Add framework agnostic devtools draft --- babel.config.js | 9 +- packages/query-devtools/.eslintrc.cjs | 17 + packages/query-devtools/package.json | 50 + packages/query-devtools/src/Context.ts | 29 + packages/query-devtools/src/Devtools.tsx | 1676 +++++++++++++++++ packages/query-devtools/src/Explorer.tsx | 348 ++++ packages/query-devtools/src/fonts.ts | 7 + packages/query-devtools/src/icons/index.tsx | 1065 +++++++++++ packages/query-devtools/src/index.tsx | 101 + packages/query-devtools/src/theme.ts | 321 ++++ packages/query-devtools/src/utils.tsx | 103 + packages/query-devtools/tsconfig.eslint.json | 7 + packages/query-devtools/tsconfig.json | 17 + packages/react-query-devtools/package.json | 3 +- .../src/CachePanel/ActiveQuery.tsx | 388 ---- .../src/CachePanel/CachePanel.tsx | 439 ----- .../CachePanel/Header/QueryStatusCount.tsx | 93 - .../src/CachePanel/QueryRow.tsx | 125 -- .../react-query-devtools/src/Explorer.tsx | 385 ---- packages/react-query-devtools/src/Logo.tsx | 32 - .../src/__tests__/Explorer.test.tsx | 150 -- .../src/__tests__/devtools.test.tsx | 1139 ----------- .../src/__tests__/utils.tsx | 85 - .../react-query-devtools/src/devtools.tsx | 354 +--- packages/react-query-devtools/src/index.ts | 12 +- packages/react-query-devtools/src/noop.ts | 7 - .../react-query-devtools/src/screenreader.tsx | 16 - .../src/styledComponents.ts | 126 -- packages/react-query-devtools/src/theme.tsx | 33 - packages/react-query-devtools/src/types.ts | 12 - .../src/useLocalStorage.ts | 52 - .../react-query-devtools/src/useMediaQuery.ts | 34 - .../src/useSubscribeToQueryCache.ts | 26 - packages/react-query-devtools/src/utils.ts | 319 ---- pnpm-lock.yaml | 751 +++++--- project.json | 3 +- rollup.config.ts | 36 +- tsconfig.base.json | 9 +- tsconfig.json | 1 + 39 files changed, 4285 insertions(+), 4095 deletions(-) create mode 100644 packages/query-devtools/.eslintrc.cjs create mode 100644 packages/query-devtools/package.json create mode 100644 packages/query-devtools/src/Context.ts create mode 100644 packages/query-devtools/src/Devtools.tsx create mode 100644 packages/query-devtools/src/Explorer.tsx create mode 100644 packages/query-devtools/src/fonts.ts create mode 100644 packages/query-devtools/src/icons/index.tsx create mode 100644 packages/query-devtools/src/index.tsx create mode 100644 packages/query-devtools/src/theme.ts create mode 100644 packages/query-devtools/src/utils.tsx create mode 100644 packages/query-devtools/tsconfig.eslint.json create mode 100644 packages/query-devtools/tsconfig.json delete mode 100644 packages/react-query-devtools/src/CachePanel/ActiveQuery.tsx delete mode 100644 packages/react-query-devtools/src/CachePanel/CachePanel.tsx delete mode 100644 packages/react-query-devtools/src/CachePanel/Header/QueryStatusCount.tsx delete mode 100644 packages/react-query-devtools/src/CachePanel/QueryRow.tsx delete mode 100644 packages/react-query-devtools/src/Explorer.tsx delete mode 100644 packages/react-query-devtools/src/Logo.tsx delete mode 100644 packages/react-query-devtools/src/__tests__/Explorer.test.tsx delete mode 100644 packages/react-query-devtools/src/__tests__/devtools.test.tsx delete mode 100644 packages/react-query-devtools/src/__tests__/utils.tsx delete mode 100644 packages/react-query-devtools/src/noop.ts delete mode 100644 packages/react-query-devtools/src/screenreader.tsx delete mode 100644 packages/react-query-devtools/src/styledComponents.ts delete mode 100644 packages/react-query-devtools/src/theme.tsx delete mode 100644 packages/react-query-devtools/src/types.ts delete mode 100644 packages/react-query-devtools/src/useLocalStorage.ts delete mode 100644 packages/react-query-devtools/src/useMediaQuery.ts delete mode 100644 packages/react-query-devtools/src/useSubscribeToQueryCache.ts delete mode 100644 packages/react-query-devtools/src/utils.ts 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/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..8b79170e6a --- /dev/null +++ b/packages/query-devtools/package.json @@ -0,0 +1,50 @@ +{ + "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" + } + }, + "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": { + "@tanstack/query-core": "workspace:*", + "@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": {}, + "peerDependenciesMeta": {} +} diff --git a/packages/query-devtools/src/Context.ts b/packages/query-devtools/src/Context.ts new file mode 100644 index 0000000000..983e84fb1a --- /dev/null +++ b/packages/query-devtools/src/Context.ts @@ -0,0 +1,29 @@ +import type { QueryClient, onlineManager } 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 QueryDevtoolsProps { + readonly client: QueryClient + queryFlavor: string + version: string + onlineManager: typeof onlineManager + + buttonPosition?: DevtoolsButtonPosition + position?: DevtoolsPosition + initialIsOpen?: boolean +} + +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..e0558b0a9c --- /dev/null +++ b/packages/query-devtools/src/Devtools.tsx @@ -0,0 +1,1676 @@ +import type { Accessor, Component, JSX, Setter } 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, +} 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 DevToolsErrorType { + /** + * The name of the error. + */ + name: string + /** + * How the error is initialized. + */ + initializer: (query: Query) => Error +} + +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 = 400 +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' || + 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 (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 (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(queriesContainerRef, ({ width, height }, el) => { + // if (el === queriesContainerRef) { + // setQueryContainerWidth(width); + // } + // }); + // }); + + 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!: HTMLDivElement + + const [mouseOver, setMouseOver] = createSignal(false) + + onMount(() => { + const mouseOverHandler = () => setMouseOver(true) + const mouseOutHandler = () => setMouseOver(false) + + tagRef.addEventListener('mouseenter', mouseOverHandler) + tagRef.addEventListener('mouseleave', mouseOutHandler) + + onCleanup(() => { + tagRef.removeEventListener('mouseenter', mouseOverHandler) + tagRef.removeEventListener('mouseleave', mouseOutHandler) + }) + }) + + const showLabel = createMemo(() => { + if (selectedQueryHash()) { + if (panelWidth() < firstBreakpoint && panelWidth() > secondBreakpoint) { + return false + } + } + if (panelWidth() < thirdBreakpoint) { + return false + } + + return true + }) + + return ( +
+ +
{props.label}
+
+ + + {props.label} + + 0 && props.color !== 'gray' + ? css` + background-color: ${tokens.colors[props.color][900]}; + color: ${tokens.colors[props.color][300]} !important; + ` + : css` + color: ${tokens.colors['gray'][400]} !important; + `, + )} + > + {props.count} + +
+ ) +} + +const QueryDetails = () => { + const styles = getStyles() + const queryClient = useQueryDevtoolsContext().client + + const [restoringLoading, setRestoringLoading] = createSignal(false) + + 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
+
+ + + + + +
+
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: 48px; + display: flex; + width: 48px; + 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]}; + overflow: hidden; + & * { + font-family: 'Inter', sans-serif; + color: ${colors.gray[300]}; + box-sizing: border-box; + } + `, + 'devtoolsBtn-position-bottom-right': css` + bottom: 16px; + right: 16px; + `, + 'devtoolsBtn-position-bottom-left': css` + bottom: 16px; + left: 16px; + `, + 'devtoolsBtn-position-top-left': css` + top: 16px; + left: 16px; + `, + 'devtoolsBtn-position-top-right': css` + top: 16px; + right: 16px; + `, + '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%; + `, + 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; + cursor: pointer; + 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; + &:hover { + background: ${colors.darkGray[400]}${alpha[80]}; + } + & 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}; + } + } + `, + 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/fonts.ts b/packages/query-devtools/src/fonts.ts new file mode 100644 index 0000000000..bd8d2431c8 --- /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..b88534cfa3 --- /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..f7d7edc059 --- /dev/null +++ b/packages/query-devtools/src/index.tsx @@ -0,0 +1,101 @@ +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, +} from './Context' +import type { Signal } from 'solid-js' +import { createSignal } from 'solid-js' + +export type { DevtoolsButtonPosition, DevtoolsPosition } +export interface TanstackQueryDevtoolsConfig extends QueryDevtoolsProps {} + +class TanstackQueryDevtools { + client: QueryClient + onlineManager: typeof TonlineManager + queryFlavor: string + version: string + isMounted = false + buttonPosition: Signal + position: Signal + initialIsOpen: Signal + dispose?: () => void + + constructor(config: TanstackQueryDevtoolsConfig) { + const { + client, + queryFlavor, + version, + onlineManager, + buttonPosition, + position, + initialIsOpen, + } = 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) + } + + setButtonPosition(position: DevtoolsButtonPosition) { + this.buttonPosition[1](position) + } + + setPosition(position: DevtoolsPosition) { + this.position[1](position) + } + + setInitialIsOpen(isOpen: boolean) { + this.initialIsOpen[1](isOpen) + } + + 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 + 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..57fe5efec7 --- /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/react-query-devtools/package.json b/packages/react-query-devtools/package.json index 631efa6829..d4407695bf 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 ( - - -