snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
157
packages/query-devtools/CHANGELOG.md
Normal file
157
packages/query-devtools/CHANGELOG.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# @tanstack/query-devtools
|
||||
|
||||
## 5.102.8
|
||||
|
||||
## 5.102.7
|
||||
|
||||
## 5.102.6
|
||||
|
||||
## 5.102.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11300](https://github.com/TanStack/query/pull/11300) [`ef1eff1`](https://github.com/TanStack/query/commit/ef1eff1b62a657d6ff6f2af819c1cb8b73ad2af5) - Remove an unintended `solid-js` import from the generated declaration files.
|
||||
|
||||
## 5.102.4
|
||||
|
||||
## 5.102.3
|
||||
|
||||
## 5.102.2
|
||||
|
||||
## 5.102.1
|
||||
|
||||
## 5.102.0
|
||||
|
||||
## 5.101.4
|
||||
|
||||
## 5.101.3
|
||||
|
||||
## 5.101.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#10813](https://github.com/TanStack/query/pull/10813) [`f5bf180`](https://github.com/TanStack/query/commit/f5bf180d933d8b8d9d9e7b845e55b26a3a413b07) - fix(query-devtools/PiPContext): reset 'pip_open' in 'localStore' from 'closePipWindow' so the auto-open createEffect does not reopen the window after a programmatic close
|
||||
|
||||
- [#10812](https://github.com/TanStack/query/pull/10812) [`25cdd97`](https://github.com/TanStack/query/commit/25cdd975fed4703d2ca5b600ca5ccd2b600b3dd8) - fix(query-devtools/utils): make 'last updated' sort return 0 for queries with equal 'dataUpdatedAt' to follow the standard comparator contract
|
||||
|
||||
- [#10815](https://github.com/TanStack/query/pull/10815) [`ecd89c8`](https://github.com/TanStack/query/commit/ecd89c8faf7acc226f00633ea3a761d3ab842c1d) - fix(query-devtools/utils): scope the 'setupStyleSheet' dedup check to the target so a 'shadowDOMTarget' still receives its own '#\_goober' style tag when 'document.head' already has one
|
||||
|
||||
- [#10811](https://github.com/TanStack/query/pull/10811) [`01c7634`](https://github.com/TanStack/query/commit/01c763444e3cf3dfa9744f13911aa1533cac3c29) - fix(query-devtools/Devtools): correct the Theme sub-trigger className from 'position' to 'theme'
|
||||
|
||||
- [#10736](https://github.com/TanStack/query/pull/10736) [`49012db`](https://github.com/TanStack/query/commit/49012dbd5192dfe483d3b108b72ffaa7f2849e0f) - `setupStyleSheet` now sets `window.__nonce__` when a `styleNonce` is provided.
|
||||
|
||||
The devtools use [goober](https://goober.js.org/) for CSS-in-JS, which reads `window.__nonce__` every time it creates or accesses its style element. Without this, goober overwrote the nonce with `undefined`, causing CSP violations even when `styleNonce` was correctly passed to `<ReactQueryDevtools>`.
|
||||
|
||||
## 5.101.1
|
||||
|
||||
## 5.101.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#10772](https://github.com/TanStack/query/pull/10772) [`3042860`](https://github.com/TanStack/query/commit/3042860e3c8731c94ca4dec0e277e415d0484fce) - Avoid crashing devtools query rows when a cached query state is temporarily unavailable.
|
||||
|
||||
- [#10750](https://github.com/TanStack/query/pull/10750) [`e631dc3`](https://github.com/TanStack/query/commit/e631dc3fa17bff71f413246b7a770a730016d346) - Resolve devtools query rows from their stable query hash so mutated object query keys do not break row rendering.
|
||||
|
||||
## 5.100.14
|
||||
|
||||
## 5.100.13
|
||||
|
||||
## 5.100.12
|
||||
|
||||
## 5.100.11
|
||||
|
||||
## 5.100.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(query-devtools): remove experimentalDts to prevent solid-js type leak ([#10694](https://github.com/TanStack/query/pull/10694))
|
||||
|
||||
## 5.100.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update the devtools panel `setOnClose` callback type to return `void`. ([#10607](https://github.com/TanStack/query/pull/10607))
|
||||
|
||||
## 5.100.8
|
||||
|
||||
## 5.100.7
|
||||
|
||||
## 5.100.6
|
||||
|
||||
## 5.100.5
|
||||
|
||||
## 5.100.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(devtools): change onClose callback type from () => unknown to () => void ([#10118](https://github.com/TanStack/query/pull/10118))
|
||||
|
||||
## 5.100.3
|
||||
|
||||
## 5.100.2
|
||||
|
||||
## 5.100.1
|
||||
|
||||
## 5.100.0
|
||||
|
||||
## 5.99.2
|
||||
|
||||
## 5.99.1
|
||||
|
||||
## 5.99.0
|
||||
|
||||
## 5.98.0
|
||||
|
||||
## 5.97.0
|
||||
|
||||
## 5.96.2
|
||||
|
||||
## 5.96.1
|
||||
|
||||
## 5.96.0
|
||||
|
||||
## 5.95.2
|
||||
|
||||
## 5.95.1
|
||||
|
||||
## 5.95.0
|
||||
|
||||
## 5.94.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312))
|
||||
|
||||
## 5.94.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064))
|
||||
|
||||
## 5.93.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Set data to undefined on Trigger Error ([#10072](https://github.com/TanStack/query/pull/10072))
|
||||
|
||||
## 5.92.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- improve a11y around screenreader + resizing ([#9961](https://github.com/TanStack/query/pull/9961))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(devtools): handle PiP open failures by resetting persisted state ([#9983](https://github.com/TanStack/query/pull/9983))
|
||||
|
||||
## 5.91.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- improves accessibility of devtools ([#9806](https://github.com/TanStack/query/pull/9806))
|
||||
|
||||
## 5.91.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat(devtools): allow passing a theme via prop ([#9887](https://github.com/TanStack/query/pull/9887))
|
||||
5
packages/query-devtools/eslint.config.js
Normal file
5
packages/query-devtools/eslint.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import rootConfig from './root.eslint.config.js'
|
||||
|
||||
export default [...rootConfig]
|
||||
82
packages/query-devtools/package.json
Normal file
82
packages/query-devtools/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@tanstack/query-devtools",
|
||||
"version": "5.102.8",
|
||||
"description": "Developer tools to interact with and visualize the TanStack Query cache",
|
||||
"author": "tannerlinsley",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/TanStack/query.git",
|
||||
"directory": "packages/query-devtools"
|
||||
},
|
||||
"homepage": "https://tanstack.com/query",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "premove ./build ./coverage ./dist-ts ./.cache/test-types",
|
||||
"compile": "tsc --project tsconfig.json",
|
||||
"test:eslint": "eslint --concurrency=auto ./src",
|
||||
"test:types": "npm-run-all --serial test:types:*",
|
||||
"test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts56 --tsBuildInfoFile .cache/test-types/ts56.tsbuildinfo",
|
||||
"test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts57 --tsBuildInfoFile .cache/test-types/ts57.tsbuildinfo",
|
||||
"test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts58 --tsBuildInfoFile .cache/test-types/ts58.tsbuildinfo",
|
||||
"test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts59 --tsBuildInfoFile .cache/test-types/ts59.tsbuildinfo",
|
||||
"test:types:tscurrent": "tsc --project tsconfig.json --outDir .cache/test-types/tscurrent --tsBuildInfoFile .cache/test-types/tscurrent.tsbuildinfo",
|
||||
"test:types:ts70": "node ../../node_modules/typescript70/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts70 --tsBuildInfoFile .cache/test-types/ts70.tsbuildinfo",
|
||||
"test:lib": "vitest",
|
||||
"test:lib:dev": "pnpm run test:lib --watch",
|
||||
"test:build": "publint --strict && attw --pack",
|
||||
"build": "tsdown --tsconfig tsconfig.prod.json",
|
||||
"build:dev": "tsdown --watch"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./build/index.cjs",
|
||||
"module": "./build/index.js",
|
||||
"types": "./build/index.d.ts",
|
||||
"browser": {},
|
||||
"exports": {
|
||||
"@tanstack/custom-condition": "./src/index.ts",
|
||||
"solid": {
|
||||
"development": "./build/index.js",
|
||||
"import": "./build/index.js"
|
||||
},
|
||||
"development": {
|
||||
"import": {
|
||||
"types": "./build/index.d.ts",
|
||||
"default": "./build/dev.js"
|
||||
},
|
||||
"require": "./build/dev.cjs"
|
||||
},
|
||||
"import": {
|
||||
"types": "./build/index.d.ts",
|
||||
"default": "./build/index.js"
|
||||
},
|
||||
"require": "./build/index.cjs"
|
||||
},
|
||||
"files": [
|
||||
"build",
|
||||
"src",
|
||||
"!src/__tests__"
|
||||
],
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.4",
|
||||
"@solid-primitives/keyed": "^1.2.2",
|
||||
"@solid-primitives/resize-observer": "^2.0.26",
|
||||
"@solid-primitives/storage": "^1.3.11",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/query-core": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"goober": "^2.1.16",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-transition-group": "^0.2.3",
|
||||
"superjson": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@solidjs/testing-library": "^0.8.10",
|
||||
"npm-run-all2": "^5.0.0",
|
||||
"unplugin-solid": "^2.0.0",
|
||||
"vite-plugin-solid": "^2.11.6"
|
||||
}
|
||||
}
|
||||
1
packages/query-devtools/root.eslint.config.js
Symbolic link
1
packages/query-devtools/root.eslint.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../eslint.config.js
|
||||
3772
packages/query-devtools/src/Devtools.tsx
Normal file
3772
packages/query-devtools/src/Devtools.tsx
Normal file
File diff suppressed because it is too large
Load Diff
36
packages/query-devtools/src/DevtoolsComponent.tsx
Normal file
36
packages/query-devtools/src/DevtoolsComponent.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createLocalStorage } from '@solid-primitives/storage'
|
||||
import { createMemo } from 'solid-js'
|
||||
import { Devtools } from './Devtools'
|
||||
import { getPreferredColorScheme } from './utils'
|
||||
import { THEME_PREFERENCE } from './constants'
|
||||
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
|
||||
import type { Theme } from './contexts'
|
||||
import type { DevtoolsComponentType } from './Devtools'
|
||||
|
||||
const DevtoolsComponent: DevtoolsComponentType = (props) => {
|
||||
const [localStore, setLocalStore] = createLocalStorage({
|
||||
prefix: 'TanstackQueryDevtools',
|
||||
})
|
||||
|
||||
const colorScheme = getPreferredColorScheme()
|
||||
|
||||
const theme = createMemo(() => {
|
||||
const preference = (props.theme ||
|
||||
localStore.theme_preference ||
|
||||
THEME_PREFERENCE) as Theme
|
||||
if (preference !== 'system') return preference
|
||||
return colorScheme()
|
||||
})
|
||||
|
||||
return (
|
||||
<QueryDevtoolsContext.Provider value={props}>
|
||||
<PiPProvider localStore={localStore} setLocalStore={setLocalStore}>
|
||||
<ThemeContext.Provider value={theme}>
|
||||
<Devtools localStore={localStore} setLocalStore={setLocalStore} />
|
||||
</ThemeContext.Provider>
|
||||
</PiPProvider>
|
||||
</QueryDevtoolsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DevtoolsComponent
|
||||
47
packages/query-devtools/src/DevtoolsPanelComponent.tsx
Normal file
47
packages/query-devtools/src/DevtoolsPanelComponent.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createLocalStorage } from '@solid-primitives/storage'
|
||||
import { createMemo } from 'solid-js'
|
||||
import { ContentView, ParentPanel } from './Devtools'
|
||||
import { getPreferredColorScheme } from './utils'
|
||||
import { THEME_PREFERENCE } from './constants'
|
||||
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
|
||||
import type { Theme } from './contexts'
|
||||
import type { DevtoolsComponentType } from './Devtools'
|
||||
|
||||
const DevtoolsPanelComponent: DevtoolsComponentType = (props) => {
|
||||
const [localStore, setLocalStore] = createLocalStorage({
|
||||
prefix: 'TanstackQueryDevtools',
|
||||
})
|
||||
|
||||
const colorScheme = getPreferredColorScheme()
|
||||
|
||||
const theme = createMemo(() => {
|
||||
const preference = (props.theme ||
|
||||
localStore.theme_preference ||
|
||||
THEME_PREFERENCE) as Theme
|
||||
if (preference !== 'system') return preference
|
||||
return colorScheme()
|
||||
})
|
||||
|
||||
return (
|
||||
<QueryDevtoolsContext.Provider value={props}>
|
||||
<PiPProvider
|
||||
disabled
|
||||
localStore={localStore}
|
||||
setLocalStore={setLocalStore}
|
||||
>
|
||||
<ThemeContext.Provider value={theme}>
|
||||
<ParentPanel>
|
||||
<ContentView
|
||||
localStore={localStore}
|
||||
setLocalStore={setLocalStore}
|
||||
onClose={props.onClose}
|
||||
showPanelViewOnly
|
||||
/>
|
||||
</ParentPanel>
|
||||
</ThemeContext.Provider>
|
||||
</PiPProvider>
|
||||
</QueryDevtoolsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DevtoolsPanelComponent
|
||||
674
packages/query-devtools/src/Explorer.tsx
Normal file
674
packages/query-devtools/src/Explorer.tsx
Normal file
@@ -0,0 +1,674 @@
|
||||
import { serialize, stringify } from 'superjson'
|
||||
import { clsx as cx } from 'clsx'
|
||||
import {
|
||||
Index,
|
||||
Match,
|
||||
Show,
|
||||
Switch,
|
||||
createMemo,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
} from 'solid-js'
|
||||
import { Key } from '@solid-primitives/keyed'
|
||||
import * as goober from 'goober'
|
||||
import { tokens } from './theme'
|
||||
import {
|
||||
deleteNestedDataByPath,
|
||||
displayValue,
|
||||
updateNestedDataByPath,
|
||||
} from './utils'
|
||||
import {
|
||||
Check,
|
||||
CopiedCopier,
|
||||
Copier,
|
||||
ErrorCopier,
|
||||
List,
|
||||
Pencil,
|
||||
Trash,
|
||||
} from './icons'
|
||||
import { useQueryDevtoolsContext, useTheme } from './contexts'
|
||||
import type { Query } from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* 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']]
|
||||
*/
|
||||
function chunkArray<T extends { label: string; value: unknown }>(
|
||||
array: Array<T>,
|
||||
size: number,
|
||||
): Array<Array<T>> {
|
||||
if (size < 1) return []
|
||||
let i = 0
|
||||
const result: Array<Array<T>> = []
|
||||
while (i < array.length) {
|
||||
result.push(array.slice(i, i + size))
|
||||
i = i + size
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const Expander = (props: { expanded: boolean }) => {
|
||||
const theme = useTheme()
|
||||
const css = useQueryDevtoolsContext().shadowDOMTarget
|
||||
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
|
||||
: goober.css
|
||||
const styles = createMemo(() => {
|
||||
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
class={cx(
|
||||
styles().expander,
|
||||
css`
|
||||
transform: rotate(${props.expanded ? 90 : 0}deg);
|
||||
`,
|
||||
props.expanded &&
|
||||
css`
|
||||
& svg {
|
||||
top: -1px;
|
||||
}
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 12L10 8L6 4"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy'
|
||||
const CopyButton = (props: { value: unknown }) => {
|
||||
const theme = useTheme()
|
||||
const css = useQueryDevtoolsContext().shadowDOMTarget
|
||||
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
|
||||
: goober.css
|
||||
const styles = createMemo(() => {
|
||||
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
|
||||
})
|
||||
const [copyState, setCopyState] = createSignal<CopyState>('NoCopy')
|
||||
|
||||
return (
|
||||
<button
|
||||
class={styles().actionButton}
|
||||
title="Copy object to clipboard"
|
||||
aria-label={`${
|
||||
copyState() === 'NoCopy'
|
||||
? 'Copy object to clipboard'
|
||||
: copyState() === 'SuccessCopy'
|
||||
? 'Object copied to clipboard'
|
||||
: 'Error copying object to clipboard'
|
||||
}`}
|
||||
onClick={
|
||||
copyState() === 'NoCopy'
|
||||
? () => {
|
||||
navigator.clipboard.writeText(stringify(props.value)).then(
|
||||
() => {
|
||||
setCopyState('SuccessCopy')
|
||||
setTimeout(() => {
|
||||
setCopyState('NoCopy')
|
||||
}, 1500)
|
||||
},
|
||||
(err) => {
|
||||
console.error('Failed to copy: ', err)
|
||||
setCopyState('ErrorCopy')
|
||||
setTimeout(() => {
|
||||
setCopyState('NoCopy')
|
||||
}, 1500)
|
||||
},
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={copyState() === 'NoCopy'}>
|
||||
<Copier />
|
||||
</Match>
|
||||
<Match when={copyState() === 'SuccessCopy'}>
|
||||
<CopiedCopier theme={theme()} />
|
||||
</Match>
|
||||
<Match when={copyState() === 'ErrorCopy'}>
|
||||
<ErrorCopier />
|
||||
</Match>
|
||||
</Switch>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const ClearArrayButton = (props: {
|
||||
dataPath: Array<string>
|
||||
activeQuery: Query
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const css = useQueryDevtoolsContext().shadowDOMTarget
|
||||
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
|
||||
: goober.css
|
||||
const styles = createMemo(() => {
|
||||
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
|
||||
})
|
||||
const queryClient = useQueryDevtoolsContext().client
|
||||
|
||||
return (
|
||||
<button
|
||||
class={styles().actionButton}
|
||||
title={'Remove all items'}
|
||||
aria-label={'Remove all items'}
|
||||
onClick={() => {
|
||||
const oldData = props.activeQuery.state.data
|
||||
const newData = updateNestedDataByPath(oldData, props.dataPath, [])
|
||||
queryClient.setQueryData(props.activeQuery.queryKey, newData)
|
||||
}}
|
||||
>
|
||||
<List />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteItemButton = (props: {
|
||||
dataPath: Array<string>
|
||||
activeQuery: Query
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const css = useQueryDevtoolsContext().shadowDOMTarget
|
||||
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
|
||||
: goober.css
|
||||
const styles = createMemo(() => {
|
||||
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
|
||||
})
|
||||
const queryClient = useQueryDevtoolsContext().client
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cx(styles().actionButton)}
|
||||
title={'Delete item'}
|
||||
aria-label={'Delete item'}
|
||||
onClick={() => {
|
||||
const oldData = props.activeQuery.state.data
|
||||
const newData = deleteNestedDataByPath(oldData, props.dataPath)
|
||||
queryClient.setQueryData(props.activeQuery.queryKey, newData)
|
||||
}}
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleValueButton = (props: {
|
||||
dataPath: Array<string>
|
||||
activeQuery: Query
|
||||
value: boolean
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const css = useQueryDevtoolsContext().shadowDOMTarget
|
||||
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
|
||||
: goober.css
|
||||
const styles = createMemo(() => {
|
||||
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
|
||||
})
|
||||
const queryClient = useQueryDevtoolsContext().client
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cx(
|
||||
styles().actionButton,
|
||||
css`
|
||||
width: ${tokens.size[3.5]};
|
||||
height: ${tokens.size[3.5]};
|
||||
`,
|
||||
)}
|
||||
title={'Toggle value'}
|
||||
aria-label={'Toggle value'}
|
||||
onClick={() => {
|
||||
const oldData = props.activeQuery.state.data
|
||||
const newData = updateNestedDataByPath(
|
||||
oldData,
|
||||
props.dataPath,
|
||||
!props.value,
|
||||
)
|
||||
queryClient.setQueryData(props.activeQuery.queryKey, newData)
|
||||
}}
|
||||
>
|
||||
<Check theme={theme()} checked={props.value} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type ExplorerProps = {
|
||||
editable?: boolean
|
||||
label: string
|
||||
value: unknown
|
||||
defaultExpanded?: Array<string>
|
||||
dataPath?: Array<string>
|
||||
activeQuery?: Query
|
||||
itemsDeletable?: boolean
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
function isIterable(x: any): x is Iterable<unknown> {
|
||||
return Symbol.iterator in x
|
||||
}
|
||||
|
||||
export default function Explorer(props: ExplorerProps) {
|
||||
const theme = useTheme()
|
||||
const css = useQueryDevtoolsContext().shadowDOMTarget
|
||||
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
|
||||
: goober.css
|
||||
const styles = createMemo(() => {
|
||||
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
|
||||
})
|
||||
const queryClient = useQueryDevtoolsContext().client
|
||||
|
||||
const [expanded, setExpanded] = createSignal(
|
||||
(props.defaultExpanded || []).includes(props.label),
|
||||
)
|
||||
const toggleExpanded = () => setExpanded((old) => !old)
|
||||
const [expandedPages, setExpandedPages] = createSignal<Array<number>>([])
|
||||
|
||||
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<string>(() => {
|
||||
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))
|
||||
|
||||
const currentDataPath = props.dataPath ?? []
|
||||
|
||||
const inputId = createUniqueId()
|
||||
|
||||
return (
|
||||
<div class={styles().entry}>
|
||||
<Show when={subEntryPages().length}>
|
||||
<div class={styles().expanderButtonContainer}>
|
||||
<button
|
||||
class={styles().expanderButton}
|
||||
onClick={() => toggleExpanded()}
|
||||
aria-expanded={expanded() ? 'true' : 'false'}
|
||||
>
|
||||
<Expander expanded={expanded()} /> <span>{props.label}</span>{' '}
|
||||
<span class={styles().info}>
|
||||
{String(type()).toLowerCase() === 'iterable' ? '(Iterable) ' : ''}
|
||||
{subEntries().length} {subEntries().length > 1 ? `items` : `item`}
|
||||
</span>
|
||||
</button>
|
||||
<Show when={props.editable}>
|
||||
<div class={styles().actions}>
|
||||
<CopyButton value={props.value} />
|
||||
|
||||
<Show
|
||||
when={props.itemsDeletable && props.activeQuery !== undefined}
|
||||
>
|
||||
<DeleteItemButton
|
||||
activeQuery={props.activeQuery!}
|
||||
dataPath={currentDataPath}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={type() === 'array' && props.activeQuery !== undefined}
|
||||
>
|
||||
<ClearArrayButton
|
||||
activeQuery={props.activeQuery!}
|
||||
dataPath={currentDataPath}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!!props.onEdit && !serialize(props.value).meta}>
|
||||
<button
|
||||
class={styles().actionButton}
|
||||
title={'Bulk Edit Data'}
|
||||
aria-label={'Bulk Edit Data'}
|
||||
onClick={() => {
|
||||
props.onEdit?.()
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<Show when={subEntryPages().length === 1}>
|
||||
<div class={styles().subEntry}>
|
||||
<Key each={subEntries()} by={(item) => item.label}>
|
||||
{(entry) => {
|
||||
return (
|
||||
<Explorer
|
||||
defaultExpanded={props.defaultExpanded}
|
||||
label={entry().label}
|
||||
value={entry().value}
|
||||
editable={props.editable}
|
||||
dataPath={[...currentDataPath, entry().label]}
|
||||
activeQuery={props.activeQuery}
|
||||
itemsDeletable={
|
||||
type() === 'array' ||
|
||||
type() === 'Iterable' ||
|
||||
type() === 'object'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Key>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={subEntryPages().length > 1}>
|
||||
<div class={styles().subEntry}>
|
||||
<Index each={subEntryPages()}>
|
||||
{(entries, index) => (
|
||||
<div>
|
||||
<div class={styles().entry}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedPages((old) =>
|
||||
old.includes(index)
|
||||
? old.filter((d) => d !== index)
|
||||
: [...old, index],
|
||||
)
|
||||
}
|
||||
class={styles().expanderButton}
|
||||
>
|
||||
<Expander expanded={expandedPages().includes(index)} />{' '}
|
||||
[{index * 100}...
|
||||
{index * 100 + 100 - 1}]
|
||||
</button>
|
||||
<Show when={expandedPages().includes(index)}>
|
||||
<div class={styles().subEntry}>
|
||||
<Key each={entries()} by={(entry) => entry.label}>
|
||||
{(entry) => (
|
||||
<Explorer
|
||||
defaultExpanded={props.defaultExpanded}
|
||||
label={entry().label}
|
||||
value={entry().value}
|
||||
editable={props.editable}
|
||||
dataPath={[...currentDataPath, entry().label]}
|
||||
activeQuery={props.activeQuery}
|
||||
/>
|
||||
)}
|
||||
</Key>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={subEntryPages().length === 0}>
|
||||
<div class={styles().row}>
|
||||
<label for={inputId} class={styles().label}>
|
||||
{props.label}:
|
||||
</label>
|
||||
<Show
|
||||
when={
|
||||
props.editable &&
|
||||
props.activeQuery !== undefined &&
|
||||
(type() === 'string' ||
|
||||
type() === 'number' ||
|
||||
type() === 'boolean')
|
||||
}
|
||||
fallback={
|
||||
<span class={styles().value}>{displayValue(props.value)}</span>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
props.editable &&
|
||||
props.activeQuery !== undefined &&
|
||||
(type() === 'string' || type() === 'number')
|
||||
}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type={type() === 'number' ? 'number' : 'text'}
|
||||
class={cx(styles().value, styles().editableInput)}
|
||||
value={props.value as string | number}
|
||||
onChange={(changeEvent) => {
|
||||
const oldData = props.activeQuery!.state.data
|
||||
|
||||
const newData = updateNestedDataByPath(
|
||||
oldData,
|
||||
currentDataPath,
|
||||
type() === 'number'
|
||||
? changeEvent.target.valueAsNumber
|
||||
: changeEvent.target.value,
|
||||
)
|
||||
|
||||
queryClient.setQueryData(props.activeQuery!.queryKey, newData)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={type() === 'boolean'}>
|
||||
<span
|
||||
class={cx(
|
||||
styles().value,
|
||||
styles().actions,
|
||||
styles().editableInput,
|
||||
)}
|
||||
>
|
||||
<ToggleValueButton
|
||||
activeQuery={props.activeQuery!}
|
||||
dataPath={currentDataPath}
|
||||
value={props.value as boolean}
|
||||
/>
|
||||
{displayValue(props.value)}
|
||||
</span>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.editable &&
|
||||
props.itemsDeletable &&
|
||||
props.activeQuery !== undefined
|
||||
}
|
||||
>
|
||||
<DeleteItemButton
|
||||
activeQuery={props.activeQuery!}
|
||||
dataPath={currentDataPath}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stylesFactory = (
|
||||
theme: 'light' | 'dark',
|
||||
css: (typeof goober)['css'],
|
||||
) => {
|
||||
const { colors, font, size, border } = tokens
|
||||
const t = (light: string, dark: string) => (theme === 'light' ? light : dark)
|
||||
return {
|
||||
entry: css`
|
||||
& * {
|
||||
font-size: ${font.size.xs};
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
position: relative;
|
||||
outline: none;
|
||||
word-break: break-word;
|
||||
`,
|
||||
subEntry: css`
|
||||
margin: 0 0 0 0.5em;
|
||||
padding-left: 0.75em;
|
||||
border-left: 2px solid ${t(colors.gray[300], colors.darkGray[400])};
|
||||
/* outline: 1px solid ${colors.teal[400]}; */
|
||||
`,
|
||||
expander: css`
|
||||
& path {
|
||||
stroke: ${colors.gray[400]};
|
||||
}
|
||||
& svg {
|
||||
width: ${size[3]};
|
||||
height: ${size[3]};
|
||||
}
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: all 0.1s ease;
|
||||
/* outline: 1px solid ${colors.blue[400]}; */
|
||||
`,
|
||||
expanderButtonContainer: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: ${size[4]};
|
||||
min-height: ${size[4]};
|
||||
gap: ${size[2]};
|
||||
`,
|
||||
expanderButton: css`
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: inherit;
|
||||
height: ${size[5]};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${size[1]};
|
||||
position: relative;
|
||||
/* outline: 1px solid ${colors.green[400]}; */
|
||||
|
||||
&:focus-visible {
|
||||
border-radius: ${border.radius.xs};
|
||||
outline: 2px solid ${colors.blue[800]};
|
||||
}
|
||||
|
||||
& svg {
|
||||
position: relative;
|
||||
left: 1px;
|
||||
}
|
||||
`,
|
||||
info: css`
|
||||
color: ${t(colors.gray[500], colors.gray[500])};
|
||||
font-size: ${font.size.xs};
|
||||
margin-left: ${size[1]};
|
||||
/* outline: 1px solid ${colors.yellow[400]}; */
|
||||
`,
|
||||
label: css`
|
||||
color: ${t(colors.gray[700], colors.gray[300])};
|
||||
white-space: nowrap;
|
||||
`,
|
||||
value: css`
|
||||
color: ${t(colors.purple[600], colors.purple[400])};
|
||||
flex-grow: 1;
|
||||
`,
|
||||
actions: css`
|
||||
display: inline-flex;
|
||||
gap: ${size[2]};
|
||||
align-items: center;
|
||||
`,
|
||||
row: css`
|
||||
display: inline-flex;
|
||||
gap: ${size[2]};
|
||||
width: 100%;
|
||||
margin: ${size[0.25]} 0px;
|
||||
line-height: ${size[4.5]};
|
||||
align-items: center;
|
||||
`,
|
||||
editableInput: css`
|
||||
border: none;
|
||||
padding: ${size[0.5]} ${size[1]} ${size[0.5]} ${size[1.5]};
|
||||
flex-grow: 1;
|
||||
border-radius: ${border.radius.xs};
|
||||
background-color: ${t(colors.gray[200], colors.darkGray[500])};
|
||||
|
||||
&:hover {
|
||||
background-color: ${t(colors.gray[300], colors.darkGray[600])};
|
||||
}
|
||||
`,
|
||||
actionButton: css`
|
||||
background-color: transparent;
|
||||
color: ${t(colors.gray[500], colors.gray[500])};
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
width: ${size[3]};
|
||||
height: ${size[3]};
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&:hover svg {
|
||||
color: ${t(colors.gray[600], colors.gray[400])};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-radius: ${border.radius.xs};
|
||||
outline: 2px solid ${colors.blue[800]};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
const lightStyles = (css: (typeof goober)['css']) => stylesFactory('light', css)
|
||||
const darkStyles = (css: (typeof goober)['css']) => stylesFactory('dark', css)
|
||||
160
packages/query-devtools/src/TanstackQueryDevtools.tsx
Normal file
160
packages/query-devtools/src/TanstackQueryDevtools.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { render } from 'solid-js/web'
|
||||
import { createSignal, lazy } from 'solid-js'
|
||||
import { setupStyleSheet } from './utils'
|
||||
import type {
|
||||
QueryClient,
|
||||
onlineManager as TOnlineManager,
|
||||
} from '@tanstack/query-core'
|
||||
import type { DevtoolsComponentType } from './Devtools'
|
||||
import type {
|
||||
DevtoolsButtonPosition,
|
||||
DevtoolsErrorType,
|
||||
DevtoolsPosition,
|
||||
QueryDevtoolsProps,
|
||||
Theme,
|
||||
} from './contexts/types'
|
||||
import type { Signal } from 'solid-js'
|
||||
|
||||
export interface TanstackQueryDevtoolsConfig extends QueryDevtoolsProps {
|
||||
styleNonce?: string
|
||||
shadowDOMTarget?: ShadowRoot
|
||||
}
|
||||
|
||||
class TanstackQueryDevtools {
|
||||
#client: Signal<QueryClient>
|
||||
#onlineManager: typeof TOnlineManager
|
||||
#queryFlavor: string
|
||||
#version: string
|
||||
#isMounted = false
|
||||
#styleNonce?: string
|
||||
#shadowDOMTarget?: ShadowRoot
|
||||
#buttonPosition: Signal<DevtoolsButtonPosition | undefined>
|
||||
#position: Signal<DevtoolsPosition | undefined>
|
||||
#initialIsOpen: Signal<boolean | undefined>
|
||||
#errorTypes: Signal<Array<DevtoolsErrorType> | undefined>
|
||||
#hideDisabledQueries: Signal<boolean | undefined>
|
||||
#Component: DevtoolsComponentType | undefined
|
||||
#theme: Signal<Theme | undefined>
|
||||
#dispose?: () => void
|
||||
|
||||
constructor(config: TanstackQueryDevtoolsConfig) {
|
||||
const {
|
||||
client,
|
||||
queryFlavor,
|
||||
version,
|
||||
onlineManager,
|
||||
buttonPosition,
|
||||
position,
|
||||
initialIsOpen,
|
||||
errorTypes,
|
||||
styleNonce,
|
||||
shadowDOMTarget,
|
||||
hideDisabledQueries,
|
||||
theme,
|
||||
} = config
|
||||
this.#client = createSignal(client)
|
||||
this.#queryFlavor = queryFlavor
|
||||
this.#version = version
|
||||
this.#onlineManager = onlineManager
|
||||
this.#styleNonce = styleNonce
|
||||
this.#shadowDOMTarget = shadowDOMTarget
|
||||
this.#buttonPosition = createSignal(buttonPosition)
|
||||
this.#position = createSignal(position)
|
||||
this.#initialIsOpen = createSignal(initialIsOpen)
|
||||
this.#errorTypes = createSignal(errorTypes)
|
||||
this.#hideDisabledQueries = createSignal(hideDisabledQueries)
|
||||
this.#theme = createSignal(theme)
|
||||
}
|
||||
|
||||
setButtonPosition(position: DevtoolsButtonPosition) {
|
||||
this.#buttonPosition[1](position)
|
||||
}
|
||||
|
||||
setPosition(position: DevtoolsPosition) {
|
||||
this.#position[1](position)
|
||||
}
|
||||
|
||||
setInitialIsOpen(isOpen: boolean) {
|
||||
this.#initialIsOpen[1](isOpen)
|
||||
}
|
||||
|
||||
setErrorTypes(errorTypes: Array<DevtoolsErrorType>) {
|
||||
this.#errorTypes[1](errorTypes)
|
||||
}
|
||||
|
||||
setClient(client: QueryClient) {
|
||||
this.#client[1](client)
|
||||
}
|
||||
|
||||
setTheme(theme?: Theme) {
|
||||
this.#theme[1](theme)
|
||||
}
|
||||
|
||||
mount<T extends HTMLElement>(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
|
||||
const [hideDisabledQueries] = this.#hideDisabledQueries
|
||||
const [queryClient] = this.#client
|
||||
const [theme] = this.#theme
|
||||
let Devtools: DevtoolsComponentType
|
||||
|
||||
if (this.#Component) {
|
||||
Devtools = this.#Component
|
||||
} else {
|
||||
Devtools = lazy(() => import('./DevtoolsComponent'))
|
||||
this.#Component = Devtools
|
||||
}
|
||||
|
||||
setupStyleSheet(this.#styleNonce, this.#shadowDOMTarget)
|
||||
return (
|
||||
<Devtools
|
||||
queryFlavor={this.#queryFlavor}
|
||||
version={this.#version}
|
||||
onlineManager={this.#onlineManager}
|
||||
shadowDOMTarget={this.#shadowDOMTarget}
|
||||
{...{
|
||||
get client() {
|
||||
return queryClient()
|
||||
},
|
||||
get buttonPosition() {
|
||||
return btnPosition()
|
||||
},
|
||||
get position() {
|
||||
return pos()
|
||||
},
|
||||
get initialIsOpen() {
|
||||
return isOpen()
|
||||
},
|
||||
get errorTypes() {
|
||||
return errors()
|
||||
},
|
||||
get hideDisabledQueries() {
|
||||
return hideDisabledQueries()
|
||||
},
|
||||
get theme() {
|
||||
return theme()
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}, 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 }
|
||||
172
packages/query-devtools/src/TanstackQueryDevtoolsPanel.tsx
Normal file
172
packages/query-devtools/src/TanstackQueryDevtoolsPanel.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { render } from 'solid-js/web'
|
||||
import { createSignal, lazy } from 'solid-js'
|
||||
import { setupStyleSheet } from './utils'
|
||||
import type {
|
||||
QueryClient,
|
||||
onlineManager as TOnlineManager,
|
||||
} from '@tanstack/query-core'
|
||||
import type { DevtoolsComponentType } from './Devtools'
|
||||
import type {
|
||||
DevtoolsButtonPosition,
|
||||
DevtoolsErrorType,
|
||||
DevtoolsPosition,
|
||||
QueryDevtoolsProps,
|
||||
Theme,
|
||||
} from './contexts/types'
|
||||
import type { Signal } from 'solid-js'
|
||||
|
||||
export interface TanstackQueryDevtoolsPanelConfig extends QueryDevtoolsProps {
|
||||
styleNonce?: string
|
||||
shadowDOMTarget?: ShadowRoot
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
class TanstackQueryDevtoolsPanel {
|
||||
#client: Signal<QueryClient>
|
||||
#onlineManager: typeof TOnlineManager
|
||||
#queryFlavor: string
|
||||
#version: string
|
||||
#isMounted = false
|
||||
#styleNonce?: string
|
||||
#shadowDOMTarget?: ShadowRoot
|
||||
#buttonPosition: Signal<DevtoolsButtonPosition | undefined>
|
||||
#position: Signal<DevtoolsPosition | undefined>
|
||||
#initialIsOpen: Signal<boolean | undefined>
|
||||
#errorTypes: Signal<Array<DevtoolsErrorType> | undefined>
|
||||
#hideDisabledQueries: Signal<boolean | undefined>
|
||||
#onClose: Signal<(() => void) | undefined>
|
||||
#Component: DevtoolsComponentType | undefined
|
||||
#theme: Signal<Theme | undefined>
|
||||
#dispose?: () => void
|
||||
|
||||
constructor(config: TanstackQueryDevtoolsPanelConfig) {
|
||||
const {
|
||||
client,
|
||||
queryFlavor,
|
||||
version,
|
||||
onlineManager,
|
||||
buttonPosition,
|
||||
position,
|
||||
initialIsOpen,
|
||||
errorTypes,
|
||||
styleNonce,
|
||||
shadowDOMTarget,
|
||||
onClose,
|
||||
hideDisabledQueries,
|
||||
theme,
|
||||
} = config
|
||||
this.#client = createSignal(client)
|
||||
this.#queryFlavor = queryFlavor
|
||||
this.#version = version
|
||||
this.#onlineManager = onlineManager
|
||||
this.#styleNonce = styleNonce
|
||||
this.#shadowDOMTarget = shadowDOMTarget
|
||||
this.#buttonPosition = createSignal(buttonPosition)
|
||||
this.#position = createSignal(position)
|
||||
this.#initialIsOpen = createSignal(initialIsOpen)
|
||||
this.#errorTypes = createSignal(errorTypes)
|
||||
this.#hideDisabledQueries = createSignal(hideDisabledQueries)
|
||||
this.#onClose = createSignal(onClose)
|
||||
this.#theme = createSignal(theme)
|
||||
}
|
||||
|
||||
setButtonPosition(position: DevtoolsButtonPosition) {
|
||||
this.#buttonPosition[1](position)
|
||||
}
|
||||
|
||||
setPosition(position: DevtoolsPosition) {
|
||||
this.#position[1](position)
|
||||
}
|
||||
|
||||
setInitialIsOpen(isOpen: boolean) {
|
||||
this.#initialIsOpen[1](isOpen)
|
||||
}
|
||||
|
||||
setErrorTypes(errorTypes: Array<DevtoolsErrorType>) {
|
||||
this.#errorTypes[1](errorTypes)
|
||||
}
|
||||
|
||||
setClient(client: QueryClient) {
|
||||
this.#client[1](client)
|
||||
}
|
||||
|
||||
setOnClose(onClose: () => void) {
|
||||
this.#onClose[1](() => onClose)
|
||||
}
|
||||
|
||||
setTheme(theme?: Theme) {
|
||||
this.#theme[1](theme)
|
||||
}
|
||||
|
||||
mount<T extends HTMLElement>(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
|
||||
const [hideDisabledQueries] = this.#hideDisabledQueries
|
||||
const [queryClient] = this.#client
|
||||
const [onClose] = this.#onClose
|
||||
const [theme] = this.#theme
|
||||
let Devtools: DevtoolsComponentType
|
||||
|
||||
if (this.#Component) {
|
||||
Devtools = this.#Component
|
||||
} else {
|
||||
Devtools = lazy(() => import('./DevtoolsPanelComponent'))
|
||||
this.#Component = Devtools
|
||||
}
|
||||
|
||||
setupStyleSheet(this.#styleNonce, this.#shadowDOMTarget)
|
||||
return (
|
||||
<Devtools
|
||||
queryFlavor={this.#queryFlavor}
|
||||
version={this.#version}
|
||||
onlineManager={this.#onlineManager}
|
||||
shadowDOMTarget={this.#shadowDOMTarget}
|
||||
{...{
|
||||
get client() {
|
||||
return queryClient()
|
||||
},
|
||||
get buttonPosition() {
|
||||
return btnPosition()
|
||||
},
|
||||
get position() {
|
||||
return pos()
|
||||
},
|
||||
get initialIsOpen() {
|
||||
return isOpen()
|
||||
},
|
||||
get errorTypes() {
|
||||
return errors()
|
||||
},
|
||||
get hideDisabledQueries() {
|
||||
return hideDisabledQueries()
|
||||
},
|
||||
get onClose() {
|
||||
return onClose()
|
||||
},
|
||||
get theme() {
|
||||
return theme()
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}, el)
|
||||
this.#isMounted = true
|
||||
this.#dispose = dispose
|
||||
}
|
||||
|
||||
unmount() {
|
||||
if (!this.#isMounted) {
|
||||
throw new Error('Devtools is not mounted')
|
||||
}
|
||||
this.#dispose?.()
|
||||
this.#isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
export { TanstackQueryDevtoolsPanel }
|
||||
1506
packages/query-devtools/src/__tests__/Devtools.test.tsx
Normal file
1506
packages/query-devtools/src/__tests__/Devtools.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import DevtoolsComponent from '../DevtoolsComponent'
|
||||
|
||||
// `solid-transition-group` internally imports from
|
||||
// `@solid-primitives/transition-group`, whose `exports` field points at
|
||||
// `src/index.ts` (not published) under a `@solid-primitives/source` condition
|
||||
// that Vite can't fall through, so we stub it with a transparent pass-through.
|
||||
vi.mock('solid-transition-group', () => ({
|
||||
TransitionGroup: (props: { children: unknown }) => props.children,
|
||||
}))
|
||||
|
||||
describe('DevtoolsComponent', () => {
|
||||
const storage: { [key: string]: string } = {}
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(storage, key)
|
||||
? storage[key]
|
||||
: null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete storage[key]
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
},
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
},
|
||||
)
|
||||
queryClient = new QueryClient()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should render without throwing when only required props are provided', () => {
|
||||
expect(() =>
|
||||
render(() => (
|
||||
<DevtoolsComponent
|
||||
client={queryClient}
|
||||
queryFlavor="TanStack Query"
|
||||
version="5"
|
||||
onlineManager={onlineManager}
|
||||
/>
|
||||
)),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without throwing when all props are provided', () => {
|
||||
expect(() =>
|
||||
render(() => (
|
||||
<DevtoolsComponent
|
||||
client={queryClient}
|
||||
queryFlavor="TanStack Query"
|
||||
version="5"
|
||||
onlineManager={onlineManager}
|
||||
buttonPosition="top-left"
|
||||
position="left"
|
||||
initialIsOpen={true}
|
||||
errorTypes={[
|
||||
{ name: 'NetworkError', initializer: () => new Error('Network') },
|
||||
]}
|
||||
hideDisabledQueries={true}
|
||||
theme="dark"
|
||||
/>
|
||||
)),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import DevtoolsPanelComponent from '../DevtoolsPanelComponent'
|
||||
|
||||
// `solid-transition-group` internally imports from
|
||||
// `@solid-primitives/transition-group`, whose `exports` field points at
|
||||
// `src/index.ts` (not published) under a `@solid-primitives/source` condition
|
||||
// that Vite can't fall through, so we stub it with a transparent pass-through.
|
||||
vi.mock('solid-transition-group', () => ({
|
||||
TransitionGroup: (props: { children: unknown }) => props.children,
|
||||
}))
|
||||
|
||||
// `goober` compiles every `css\`...\`` template literal at mount time
|
||||
// (template parsing + class hashing + style serialization), which
|
||||
// dominates mount cost and produces no value for label/role-based
|
||||
// assertions, so we replace it with a no-op factory.
|
||||
vi.mock('goober', () => {
|
||||
let counter = 0
|
||||
const css = Object.assign(() => `tsqd-${++counter}`, {
|
||||
bind: () => css,
|
||||
})
|
||||
return { css, glob: () => {}, setup: () => {} }
|
||||
})
|
||||
|
||||
describe('DevtoolsPanelComponent', () => {
|
||||
const storage: { [key: string]: string } = {}
|
||||
let queryClient: QueryClient
|
||||
let previousRootFontSize = ''
|
||||
|
||||
beforeEach(() => {
|
||||
previousRootFontSize = document.documentElement.style.fontSize
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(storage, key)
|
||||
? storage[key]
|
||||
: null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete storage[key]
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
},
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
},
|
||||
)
|
||||
queryClient = new QueryClient()
|
||||
document.documentElement.style.fontSize = '16px'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
queryClient.clear()
|
||||
document.documentElement.style.fontSize = previousRootFontSize
|
||||
})
|
||||
|
||||
it('should render the panel without throwing when only required props are provided', () => {
|
||||
expect(() =>
|
||||
render(() => (
|
||||
<DevtoolsPanelComponent
|
||||
client={queryClient}
|
||||
queryFlavor="TanStack Query"
|
||||
version="5"
|
||||
onlineManager={onlineManager}
|
||||
/>
|
||||
)),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render the panel without throwing when all props are provided', () => {
|
||||
expect(() =>
|
||||
render(() => (
|
||||
<DevtoolsPanelComponent
|
||||
client={queryClient}
|
||||
queryFlavor="TanStack Query"
|
||||
version="5"
|
||||
onlineManager={onlineManager}
|
||||
errorTypes={[
|
||||
{ name: 'NetworkError', initializer: () => new Error('Network') },
|
||||
]}
|
||||
hideDisabledQueries={true}
|
||||
theme="dark"
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not render the open devtools button in panel-only mode', () => {
|
||||
const rendered = render(() => (
|
||||
<DevtoolsPanelComponent
|
||||
client={queryClient}
|
||||
queryFlavor="TanStack Query"
|
||||
version="5"
|
||||
onlineManager={onlineManager}
|
||||
/>
|
||||
))
|
||||
|
||||
expect(
|
||||
rendered.queryByLabelText('Open Tanstack query devtools'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call "onClose" when the close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
const rendered = render(() => (
|
||||
<DevtoolsPanelComponent
|
||||
client={queryClient}
|
||||
queryFlavor="TanStack Query"
|
||||
version="5"
|
||||
onlineManager={onlineManager}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))
|
||||
|
||||
rendered.getByLabelText('Close Tanstack query devtools').click()
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
633
packages/query-devtools/src/__tests__/Explorer.test.tsx
Normal file
633
packages/query-devtools/src/__tests__/Explorer.test.tsx
Normal file
@@ -0,0 +1,633 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, within } from '@solidjs/testing-library'
|
||||
import { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import Explorer from '../Explorer'
|
||||
import { QueryDevtoolsContext, ThemeContext } from '../contexts'
|
||||
import type { Query } from '@tanstack/query-core'
|
||||
|
||||
// `goober` compiles every `css\`...\`` template literal at mount time;
|
||||
// replace it with a no-op factory so label/role-based assertions stay fast.
|
||||
vi.mock('goober', () => {
|
||||
let counter = 0
|
||||
const css = Object.assign(() => `tsqd-${++counter}`, {
|
||||
bind: () => css,
|
||||
})
|
||||
return { css, glob: () => {}, setup: () => {} }
|
||||
})
|
||||
|
||||
describe('Explorer', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryClient = new QueryClient()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderExplorer(
|
||||
props: Parameters<typeof Explorer>[0],
|
||||
options: { theme?: 'dark' | 'light' } = {},
|
||||
) {
|
||||
const theme = options.theme ?? 'dark'
|
||||
return render(() => (
|
||||
<QueryDevtoolsContext.Provider
|
||||
value={{
|
||||
client: queryClient,
|
||||
queryFlavor: 'TanStack Query',
|
||||
version: '5',
|
||||
onlineManager,
|
||||
}}
|
||||
>
|
||||
<ThemeContext.Provider value={() => theme}>
|
||||
<Explorer {...props} />
|
||||
</ThemeContext.Provider>
|
||||
</QueryDevtoolsContext.Provider>
|
||||
))
|
||||
}
|
||||
|
||||
describe('primitive values', () => {
|
||||
it('should render a "label: value" row for a string value', () => {
|
||||
const rendered = renderExplorer({ label: 'name', value: 'Anna' })
|
||||
|
||||
expect(rendered.getByText('name:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('"Anna"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a "label: value" row for a number value', () => {
|
||||
const rendered = renderExplorer({ label: 'count', value: 42 })
|
||||
|
||||
expect(rendered.getByText('count:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a "label: value" row for a boolean value', () => {
|
||||
const rendered = renderExplorer({ label: 'active', value: true })
|
||||
|
||||
expect(rendered.getByText('active:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a "label: value" row for a "null" value', () => {
|
||||
const rendered = renderExplorer({ label: 'missing', value: null })
|
||||
|
||||
expect(rendered.getByText('missing:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('null')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays and objects', () => {
|
||||
it('should render an empty object as a primitive row (no expander)', () => {
|
||||
const rendered = renderExplorer({ label: 'data', value: {} })
|
||||
|
||||
expect(rendered.getByText('data:')).toBeInTheDocument()
|
||||
expect(rendered.queryByRole('button', { expanded: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('should render an array with an expander showing the item count', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'list',
|
||||
value: ['a', 'b', 'c'],
|
||||
})
|
||||
|
||||
const expander = rendered.getByRole('button', { expanded: false })
|
||||
expect(expander).toBeInTheDocument()
|
||||
expect(expander.textContent).toContain('list')
|
||||
expect(expander.textContent).toContain('3 items')
|
||||
})
|
||||
|
||||
it('should render children under their index labels when the array expander is clicked', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'list',
|
||||
value: ['a', 'b'],
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(rendered.getByText('0:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('1:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('"a"')).toBeInTheDocument()
|
||||
expect(rendered.getByText('"b"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render object entries under their keys when expanded', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'user',
|
||||
value: { name: 'Anna', age: 30 },
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(rendered.getByText('name:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('"Anna"')).toBeInTheDocument()
|
||||
expect(rendered.getByText('age:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('30')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Map and iterable values', () => {
|
||||
it('should preserve "Map" keys as labels when expanded', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'm',
|
||||
value: new Map([
|
||||
['first', 1],
|
||||
['second', 2],
|
||||
]),
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(rendered.getByText('first:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('second:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark an iterable value with an "(Iterable)" prefix on the expander', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 's',
|
||||
value: new Set(['x', 'y']),
|
||||
})
|
||||
|
||||
expect(
|
||||
rendered.getByRole('button', { expanded: false }).textContent,
|
||||
).toContain('(Iterable)')
|
||||
})
|
||||
|
||||
it('should render iterable children under their numeric index when expanded', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 's',
|
||||
value: new Set(['x', 'y']),
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(rendered.getByText('0:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('1:')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"defaultExpanded"', () => {
|
||||
it('should render children eagerly when the label is in "defaultExpanded"', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'list',
|
||||
value: ['a'],
|
||||
defaultExpanded: ['list'],
|
||||
})
|
||||
|
||||
expect(
|
||||
rendered.getByRole('button', { expanded: true }),
|
||||
).toBeInTheDocument()
|
||||
expect(rendered.getByText('0:')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('action menu', () => {
|
||||
it('should copy the serialized value to the clipboard when the copy button is clicked', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'data',
|
||||
value: { name: 'Anna' },
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
const [arg] = writeText.mock.calls[0]!
|
||||
expect(JSON.parse(arg as string)).toMatchObject({
|
||||
json: { name: 'Anna' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch the copy button to an error state when clipboard write fails', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'data',
|
||||
value: { name: 'Anna' },
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(
|
||||
rendered.getByLabelText('Error copying object to clipboard'),
|
||||
).toBeInTheDocument()
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to copy: ',
|
||||
new Error('denied'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should reset the copy button to the idle state 1500ms after a successful copy', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'data',
|
||||
value: { name: 'Anna' },
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(
|
||||
rendered.getByLabelText('Object copied to clipboard'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(
|
||||
rendered.getByLabelText('Copy object to clipboard'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset the copy button to the idle state 1500ms after a failed copy', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'data',
|
||||
value: { name: 'Anna' },
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(
|
||||
rendered.getByLabelText('Error copying object to clipboard'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(
|
||||
rendered.getByLabelText('Copy object to clipboard'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear array items via "setQueryData" when the clear-array button is clicked', () => {
|
||||
queryClient.setQueryData(['data'], ['a', 'b', 'c'])
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'list',
|
||||
value: ['a', 'b', 'c'],
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Remove all items'))
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual([])
|
||||
})
|
||||
|
||||
it('should delete the entry at the current "dataPath" when the delete button is clicked', () => {
|
||||
queryClient.setQueryData(['data'], ['a', 'b', 'c'])
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'list',
|
||||
value: ['a', 'b', 'c'],
|
||||
editable: true,
|
||||
itemsDeletable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
dataPath: ['1'],
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Delete item'))
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('should toggle a boolean value via "setQueryData" when the toggle button is clicked', () => {
|
||||
queryClient.setQueryData(['data'], { flag: true })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'flag',
|
||||
value: true,
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
dataPath: ['flag'],
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Toggle value'))
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual({ flag: false })
|
||||
})
|
||||
|
||||
it('should not render action buttons when "editable" is false', () => {
|
||||
queryClient.setQueryData(['data'], ['a'])
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'list',
|
||||
value: ['a'],
|
||||
editable: false,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
expect(rendered.queryByLabelText('Copy object to clipboard')).toBeNull()
|
||||
expect(rendered.queryByLabelText('Remove all items')).toBeNull()
|
||||
})
|
||||
|
||||
it('should not render "ClearArrayButton" when value is not an array', () => {
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'user',
|
||||
value: { name: 'Anna' },
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
expect(rendered.queryByLabelText('Remove all items')).toBeNull()
|
||||
expect(
|
||||
rendered.getByLabelText('Copy object to clipboard'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('should group entries into 100-item pages when the array has more than 100 entries', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'big',
|
||||
value: Array.from({ length: 101 }, (_, i) => i),
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(rendered.getByText('[0...99]')).toBeInTheDocument()
|
||||
expect(rendered.getByText('[100...199]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the items of a page hidden until the page header is clicked', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'big',
|
||||
value: Array.from({ length: 101 }, (_, i) => `item-${i}`),
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(rendered.queryByText('0:')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reveal the items of a page when the page header is clicked', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'big',
|
||||
value: Array.from({ length: 101 }, (_, i) => `item-${i}`),
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
fireEvent.click(rendered.getByText('[0...99]'))
|
||||
|
||||
expect(rendered.getByText('0:')).toBeInTheDocument()
|
||||
expect(rendered.getByText('"item-0"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should independently toggle two pages when their headers are clicked', () => {
|
||||
const rendered = renderExplorer({
|
||||
label: 'big',
|
||||
value: Array.from({ length: 200 }, (_, i) => `item-${i}`),
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { expanded: false }))
|
||||
fireEvent.click(rendered.getByText('[0...99]'))
|
||||
fireEvent.click(rendered.getByText('[100...199]'))
|
||||
|
||||
expect(rendered.getByText('"item-0"')).toBeInTheDocument()
|
||||
expect(rendered.getByText('"item-100"')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('[0...99]'))
|
||||
|
||||
expect(rendered.queryByText('"item-0"')).toBeNull()
|
||||
expect(rendered.getByText('"item-100"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render action buttons for items inside a paginated page', () => {
|
||||
const value: Array<Array<number>> = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => [i],
|
||||
)
|
||||
queryClient.setQueryData(['data'], value)
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'Data',
|
||||
value,
|
||||
defaultExpanded: ['Data'],
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByText('[0...99]'))
|
||||
|
||||
expect(
|
||||
rendered.getAllByLabelText('Remove all items').length,
|
||||
).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inline edit', () => {
|
||||
it('should write the new string value via "setQueryData" when a text input is changed', () => {
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'name',
|
||||
value: 'Anna',
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
dataPath: ['name'],
|
||||
})
|
||||
|
||||
const input = rendered.getByLabelText('name:')
|
||||
expect(input).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Bob' } })
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual({ name: 'Bob' })
|
||||
})
|
||||
|
||||
it('should write the new number value via "setQueryData" when a number input is changed', () => {
|
||||
queryClient.setQueryData(['data'], { count: 1 })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'count',
|
||||
value: 1,
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
dataPath: ['count'],
|
||||
})
|
||||
|
||||
const input = rendered.getByLabelText('count:')
|
||||
expect(input).toHaveAttribute('type', 'number')
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: '42', valueAsNumber: 42 },
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual({ count: 42 })
|
||||
})
|
||||
|
||||
it('should render "ToggleValueButton" inline for a boolean primitive row', () => {
|
||||
queryClient.setQueryData(['data'], { flag: false })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'flag',
|
||||
value: false,
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
dataPath: ['flag'],
|
||||
})
|
||||
|
||||
expect(rendered.getByLabelText('Toggle value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "DeleteItemButton" inline when a primitive row has "itemsDeletable"', () => {
|
||||
queryClient.setQueryData(['data'], { name: 'Anna' })
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'name',
|
||||
value: 'Anna',
|
||||
editable: true,
|
||||
itemsDeletable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
dataPath: ['name'],
|
||||
})
|
||||
|
||||
expect(rendered.getByLabelText('Delete item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should delete fields from the active query when their inline delete buttons are clicked', () => {
|
||||
const value = { name: 'Anna', age: 30 }
|
||||
queryClient.setQueryData(['data'], value)
|
||||
|
||||
const rendered = renderExplorer({
|
||||
label: 'Data',
|
||||
value,
|
||||
defaultExpanded: ['Data'],
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
})
|
||||
|
||||
const ageRow = rendered.getByText('age:').parentElement!
|
||||
fireEvent.click(within(ageRow).getByLabelText('Delete item'))
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual({ name: 'Anna' })
|
||||
|
||||
const nameRow = rendered.getByText('name:').parentElement!
|
||||
fireEvent.click(within(nameRow).getByLabelText('Delete item'))
|
||||
|
||||
expect(queryClient.getQueryData(['data'])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme', () => {
|
||||
it('should render without throwing under the "light" theme', () => {
|
||||
const value = { items: ['a'], flag: true }
|
||||
queryClient.setQueryData(['data'], value)
|
||||
|
||||
expect(() =>
|
||||
renderExplorer(
|
||||
{
|
||||
label: 'Data',
|
||||
value,
|
||||
defaultExpanded: ['Data'],
|
||||
editable: true,
|
||||
activeQuery: queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query,
|
||||
},
|
||||
{ theme: 'light' },
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"shadowDOMTarget"', () => {
|
||||
it('should render without throwing when a "shadowDOMTarget" is provided', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const shadowRoot = host.attachShadow({ mode: 'open' })
|
||||
const value = { items: ['a'], flag: true }
|
||||
queryClient.setQueryData(['data'], value)
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
render(() => (
|
||||
<QueryDevtoolsContext.Provider
|
||||
value={{
|
||||
client: queryClient,
|
||||
queryFlavor: 'TanStack Query',
|
||||
version: '5',
|
||||
onlineManager,
|
||||
shadowDOMTarget: shadowRoot,
|
||||
}}
|
||||
>
|
||||
<ThemeContext.Provider value={() => 'dark'}>
|
||||
<Explorer
|
||||
label="Data"
|
||||
value={value}
|
||||
defaultExpanded={['Data']}
|
||||
editable={true}
|
||||
activeQuery={
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ['data'] }) as Query
|
||||
}
|
||||
/>
|
||||
</ThemeContext.Provider>
|
||||
</QueryDevtoolsContext.Provider>
|
||||
)),
|
||||
).not.toThrow()
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import { TanstackQueryDevtools } from '..'
|
||||
|
||||
describe('TanstackQueryDevtools', () => {
|
||||
let devtools: TanstackQueryDevtools
|
||||
|
||||
beforeEach(() => {
|
||||
devtools = new TanstackQueryDevtools({
|
||||
client: new QueryClient(),
|
||||
queryFlavor: 'TanStack Query',
|
||||
version: '5',
|
||||
onlineManager,
|
||||
})
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('should mount devtools to the provided element', () => {
|
||||
const el = document.createElement('div')
|
||||
|
||||
expect(() => devtools.mount(el)).not.toThrow()
|
||||
|
||||
devtools.unmount()
|
||||
})
|
||||
|
||||
it('should throw if mount is called twice without unmount', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
|
||||
expect(() => devtools.mount(el)).toThrow('Devtools is already mounted')
|
||||
|
||||
devtools.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount', () => {
|
||||
it('should unmount devtools and allow remounting', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
|
||||
expect(() => devtools.unmount()).not.toThrow()
|
||||
expect(() => devtools.mount(el)).not.toThrow()
|
||||
|
||||
devtools.unmount()
|
||||
})
|
||||
|
||||
it('should throw if unmount is called before mount', () => {
|
||||
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
|
||||
})
|
||||
|
||||
it('should throw if unmount is called twice', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
devtools.unmount()
|
||||
|
||||
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setters', () => {
|
||||
describe('before mount', () => {
|
||||
it('should not throw when "setButtonPosition" is called', () => {
|
||||
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setPosition" is called', () => {
|
||||
expect(() => devtools.setPosition('left')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setInitialIsOpen" is called', () => {
|
||||
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setErrorTypes" is called', () => {
|
||||
expect(() =>
|
||||
devtools.setErrorTypes([
|
||||
{ name: 'NetworkError', initializer: () => new Error('Network') },
|
||||
]),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setClient" is called', () => {
|
||||
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setTheme" is called', () => {
|
||||
expect(() => devtools.setTheme('dark')).not.toThrow()
|
||||
expect(() => devtools.setTheme('light')).not.toThrow()
|
||||
expect(() => devtools.setTheme('system')).not.toThrow()
|
||||
expect(() => devtools.setTheme(undefined)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('after mount', () => {
|
||||
it('should not throw when setters are called on a mounted instance', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
|
||||
try {
|
||||
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
|
||||
expect(() => devtools.setPosition('left')).not.toThrow()
|
||||
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
|
||||
expect(() =>
|
||||
devtools.setErrorTypes([
|
||||
{ name: 'NetworkError', initializer: () => new Error('Network') },
|
||||
]),
|
||||
).not.toThrow()
|
||||
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
|
||||
expect(() => devtools.setTheme('dark')).not.toThrow()
|
||||
} finally {
|
||||
devtools.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import { TanstackQueryDevtoolsPanel } from '..'
|
||||
|
||||
describe('TanstackQueryDevtoolsPanel', () => {
|
||||
let devtools: TanstackQueryDevtoolsPanel
|
||||
|
||||
beforeEach(() => {
|
||||
devtools = new TanstackQueryDevtoolsPanel({
|
||||
client: new QueryClient(),
|
||||
queryFlavor: 'TanStack Query',
|
||||
version: '5',
|
||||
onlineManager,
|
||||
})
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('should mount devtools to the provided element', () => {
|
||||
const el = document.createElement('div')
|
||||
|
||||
expect(() => devtools.mount(el)).not.toThrow()
|
||||
|
||||
devtools.unmount()
|
||||
})
|
||||
|
||||
it('should throw if mount is called twice without unmount', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
|
||||
expect(() => devtools.mount(el)).toThrow('Devtools is already mounted')
|
||||
|
||||
devtools.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount', () => {
|
||||
it('should unmount devtools and allow remounting', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
|
||||
expect(() => devtools.unmount()).not.toThrow()
|
||||
expect(() => devtools.mount(el)).not.toThrow()
|
||||
|
||||
devtools.unmount()
|
||||
})
|
||||
|
||||
it('should throw if unmount is called before mount', () => {
|
||||
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
|
||||
})
|
||||
|
||||
it('should throw if unmount is called twice', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
devtools.unmount()
|
||||
|
||||
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setters', () => {
|
||||
describe('before mount', () => {
|
||||
it('should not throw when "setButtonPosition" is called', () => {
|
||||
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setPosition" is called', () => {
|
||||
expect(() => devtools.setPosition('left')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setInitialIsOpen" is called', () => {
|
||||
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setErrorTypes" is called', () => {
|
||||
expect(() =>
|
||||
devtools.setErrorTypes([
|
||||
{ name: 'NetworkError', initializer: () => new Error('Network') },
|
||||
]),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setClient" is called', () => {
|
||||
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setOnClose" is called', () => {
|
||||
expect(() => devtools.setOnClose(() => {})).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when "setTheme" is called', () => {
|
||||
expect(() => devtools.setTheme('dark')).not.toThrow()
|
||||
expect(() => devtools.setTheme('light')).not.toThrow()
|
||||
expect(() => devtools.setTheme('system')).not.toThrow()
|
||||
expect(() => devtools.setTheme(undefined)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('after mount', () => {
|
||||
it('should not throw when setters are called on a mounted instance', () => {
|
||||
const el = document.createElement('div')
|
||||
devtools.mount(el)
|
||||
|
||||
try {
|
||||
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
|
||||
expect(() => devtools.setPosition('left')).not.toThrow()
|
||||
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
|
||||
expect(() =>
|
||||
devtools.setErrorTypes([
|
||||
{ name: 'NetworkError', initializer: () => new Error('Network') },
|
||||
]),
|
||||
).not.toThrow()
|
||||
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
|
||||
expect(() => devtools.setOnClose(() => {})).not.toThrow()
|
||||
expect(() => devtools.setTheme('dark')).not.toThrow()
|
||||
} finally {
|
||||
devtools.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,472 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import { createEffect } from 'solid-js'
|
||||
import { createLocalStorage } from '@solid-primitives/storage'
|
||||
import { PiPProvider, usePiPWindow } from '../../contexts'
|
||||
|
||||
type FakePipWindowOverrides = {
|
||||
document?: Document
|
||||
innerWidth?: number
|
||||
innerHeight?: number
|
||||
}
|
||||
|
||||
function stubPipWindow(overrides: FakePipWindowOverrides = {}) {
|
||||
const pipDocument =
|
||||
overrides.document ?? document.implementation.createHTMLDocument('PiP')
|
||||
const fakeWindow = {
|
||||
document: pipDocument,
|
||||
innerWidth: overrides.innerWidth ?? 800,
|
||||
innerHeight: overrides.innerHeight ?? 600,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}
|
||||
const open = vi.fn(() => fakeWindow)
|
||||
vi.stubGlobal('open', open)
|
||||
return { pipDocument, fakeWindow, open }
|
||||
}
|
||||
|
||||
describe('PiPContext', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
function renderAndAct(
|
||||
action: (pip: ReturnType<typeof usePiPWindow>) => void,
|
||||
options: {
|
||||
disabled?: boolean
|
||||
initialStorage?: Record<string, string>
|
||||
} = {},
|
||||
) {
|
||||
Object.entries(options.initialStorage ?? {}).forEach(([key, value]) => {
|
||||
localStorage.setItem(key, value)
|
||||
})
|
||||
render(() => {
|
||||
const [localStore, setLocalStore] = createLocalStorage({
|
||||
prefix: 'TanstackQueryDevtools',
|
||||
})
|
||||
return (
|
||||
<PiPProvider
|
||||
localStore={localStore}
|
||||
setLocalStore={setLocalStore}
|
||||
disabled={options.disabled}
|
||||
>
|
||||
<PiPActor run={action} />
|
||||
</PiPProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function PiPActor(props: {
|
||||
run: (pip: ReturnType<typeof usePiPWindow>) => void
|
||||
}) {
|
||||
const pip = usePiPWindow()
|
||||
let hasRun = false
|
||||
createEffect(() => {
|
||||
pip()
|
||||
if (!hasRun) {
|
||||
hasRun = true
|
||||
props.run(pip)
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
describe('usePiPWindow', () => {
|
||||
it('should throw when used outside a "PiPProvider"', () => {
|
||||
function PiPProbe() {
|
||||
usePiPWindow()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(() => <PiPProbe />)).toThrow(
|
||||
'usePiPWindow must be used within a PiPProvider',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('"requestPipWindow"', () => {
|
||||
it('should call "window.open" with the expected target and features', () => {
|
||||
const { open } = stubPipWindow()
|
||||
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
'',
|
||||
'TSQD-Devtools-Panel',
|
||||
'width=640,height=480,popup',
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the "pipWindow" signal to the opened window', () => {
|
||||
const { fakeWindow } = stubPipWindow()
|
||||
let observed: Window | null = null
|
||||
|
||||
renderAndAct((pip) => {
|
||||
pip().requestPipWindow(640, 480)
|
||||
observed = pip().pipWindow
|
||||
})
|
||||
|
||||
expect(observed).toBe(fakeWindow)
|
||||
})
|
||||
|
||||
it('should persist "pip_open" as "true" to "localStore" after a successful open', () => {
|
||||
stubPipWindow()
|
||||
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the PiP document title to "TanStack Query Devtools"', () => {
|
||||
const { pipDocument } = stubPipWindow()
|
||||
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(pipDocument.title).toBe('TanStack Query Devtools')
|
||||
})
|
||||
|
||||
it('should reset the PiP document body margin to "0"', () => {
|
||||
const { pipDocument } = stubPipWindow()
|
||||
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(pipDocument.body.style.margin).toMatch(/^0(px)?$/)
|
||||
})
|
||||
|
||||
it('should clear any existing nodes in the PiP document "head"', () => {
|
||||
const { pipDocument } = stubPipWindow()
|
||||
pipDocument.head.appendChild(pipDocument.createElement('meta'))
|
||||
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(pipDocument.head.querySelector('meta')).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear any existing nodes in the PiP document "body"', () => {
|
||||
const { pipDocument } = stubPipWindow()
|
||||
const leftover = pipDocument.createElement('div')
|
||||
leftover.id = 'leftover'
|
||||
pipDocument.body.appendChild(leftover)
|
||||
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(pipDocument.body.querySelector('#leftover')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styleSheet propagation', () => {
|
||||
type FakeCssRule = { readonly cssText: string }
|
||||
type FakeStyleSheet = {
|
||||
readonly cssRules?: ArrayLike<FakeCssRule>
|
||||
readonly href?: string | null
|
||||
readonly type?: string
|
||||
readonly media?: { toString: () => string }
|
||||
readonly ownerNode?: Element | null
|
||||
}
|
||||
|
||||
function makeCssRules(...cssTexts: Array<string>): ArrayLike<FakeCssRule> {
|
||||
return cssTexts.map((cssText) => ({ cssText }))
|
||||
}
|
||||
|
||||
function stubParentStyleSheet(sheet: FakeStyleSheet) {
|
||||
return vi
|
||||
.spyOn(document, 'styleSheets', 'get')
|
||||
.mockReturnValue([sheet] as unknown as StyleSheetList)
|
||||
}
|
||||
|
||||
it('should copy parent stylesheets as "<style>" with the same id into the PiP document head', () => {
|
||||
const sourceStyle = document.createElement('style')
|
||||
sourceStyle.id = 'tsqd-source'
|
||||
const sheetSpy = stubParentStyleSheet({
|
||||
cssRules: makeCssRules('.tsqd { color: red; }'),
|
||||
ownerNode: sourceStyle,
|
||||
})
|
||||
const { pipDocument } = stubPipWindow()
|
||||
|
||||
try {
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
const copied = pipDocument.head.querySelector('style#tsqd-source')
|
||||
expect(copied).not.toBeNull()
|
||||
expect(copied?.textContent).toBe('.tsqd { color: red; }')
|
||||
} finally {
|
||||
sheetSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('should fall back to a "<link>" for cross-origin stylesheets whose "cssRules" throw', () => {
|
||||
const sheetSpy = stubParentStyleSheet({
|
||||
get cssRules(): CSSRuleList {
|
||||
throw new DOMException('blocked', 'SecurityError')
|
||||
},
|
||||
href: 'https://example.com/external.css',
|
||||
type: 'text/css',
|
||||
media: { toString: () => 'all' },
|
||||
})
|
||||
const { pipDocument } = stubPipWindow()
|
||||
|
||||
try {
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
const link = pipDocument.head.querySelector<HTMLLinkElement>(
|
||||
'link[href="https://example.com/external.css"]',
|
||||
)
|
||||
expect(link).not.toBeNull()
|
||||
expect(link?.rel).toBe('stylesheet')
|
||||
} finally {
|
||||
sheetSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('should skip the "<link>" fallback when the cross-origin stylesheet has no "href"', () => {
|
||||
const sheetSpy = stubParentStyleSheet({
|
||||
get cssRules(): CSSRuleList {
|
||||
throw new DOMException('blocked', 'SecurityError')
|
||||
},
|
||||
href: null,
|
||||
type: 'text/css',
|
||||
media: { toString: () => 'all' },
|
||||
})
|
||||
const { pipDocument } = stubPipWindow()
|
||||
|
||||
try {
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480))
|
||||
|
||||
expect(pipDocument.head.querySelector('link')).toBeNull()
|
||||
expect(pipDocument.head.querySelector('style')).toBeNull()
|
||||
} finally {
|
||||
sheetSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('"pagehide" lifecycle', () => {
|
||||
it('should reset the "pipWindow" signal and "pip_open" when the "pagehide" event fires on the opened window', () => {
|
||||
const { fakeWindow } = stubPipWindow()
|
||||
const observed: Array<Window | null> = []
|
||||
|
||||
renderAndAct(
|
||||
(pip) => {
|
||||
pip().requestPipWindow(640, 480)
|
||||
observed.push(pip().pipWindow)
|
||||
const pagehideHandler = fakeWindow.addEventListener.mock.calls.find(
|
||||
([event]) => event === 'pagehide',
|
||||
)?.[1]
|
||||
pagehideHandler?.()
|
||||
observed.push(pip().pipWindow)
|
||||
},
|
||||
{ disabled: true },
|
||||
)
|
||||
|
||||
expect(observed).toEqual([fakeWindow, null])
|
||||
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
|
||||
'false',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('"closePipWindow"', () => {
|
||||
it('should be a noop when no window is open', () => {
|
||||
const { fakeWindow } = stubPipWindow()
|
||||
|
||||
renderAndAct((pip) => {
|
||||
pip().closePipWindow()
|
||||
})
|
||||
|
||||
expect(fakeWindow.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call "close" on the opened window and reset the "pipWindow" signal', () => {
|
||||
const { fakeWindow } = stubPipWindow()
|
||||
const observed: Array<Window | null> = []
|
||||
|
||||
renderAndAct(
|
||||
(pip) => {
|
||||
pip().requestPipWindow(640, 480)
|
||||
observed.push(pip().pipWindow)
|
||||
pip().closePipWindow()
|
||||
observed.push(pip().pipWindow)
|
||||
},
|
||||
{ disabled: true },
|
||||
)
|
||||
|
||||
expect(fakeWindow.close).toHaveBeenCalledTimes(1)
|
||||
expect(observed).toEqual([fakeWindow, null])
|
||||
})
|
||||
|
||||
it('should call "close" only once when "closePipWindow" runs twice in a row', () => {
|
||||
const { fakeWindow } = stubPipWindow()
|
||||
|
||||
renderAndAct(
|
||||
(pip) => {
|
||||
pip().requestPipWindow(640, 480)
|
||||
pip().closePipWindow()
|
||||
pip().closePipWindow()
|
||||
},
|
||||
{ disabled: true },
|
||||
)
|
||||
|
||||
expect(fakeWindow.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reset "pip_open" in "localStore" so the auto-open createEffect does not reopen the window', () => {
|
||||
stubPipWindow()
|
||||
|
||||
renderAndAct(
|
||||
(pip) => {
|
||||
pip().requestPipWindow(640, 480)
|
||||
pip().closePipWindow()
|
||||
},
|
||||
{ disabled: true },
|
||||
)
|
||||
|
||||
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
|
||||
'false',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('"pip_open" auto-open createEffect', () => {
|
||||
it('should auto-open a PiP window when "pip_open" is "true" on mount', () => {
|
||||
const { open } = stubPipWindow()
|
||||
|
||||
renderAndAct(() => {}, {
|
||||
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
|
||||
})
|
||||
|
||||
expect(open).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not auto-open a PiP window when "disabled" is true', () => {
|
||||
const { open } = stubPipWindow()
|
||||
|
||||
renderAndAct(() => {}, {
|
||||
disabled: true,
|
||||
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
|
||||
})
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset "pip_open"/"open" and log when "window.open" returns null on auto-open', () => {
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn(() => null),
|
||||
)
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
renderAndAct(() => {}, {
|
||||
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
|
||||
})
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to open popup. Please allow popups for this site to view the devtools in picture-in-picture mode.',
|
||||
)
|
||||
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(localStorage.getItem('TanstackQueryDevtools.open')).toBe('false')
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('should re-throw non-"PipOpenError" errors from "window.open" on auto-open', () => {
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn(() => {
|
||||
throw new Error('unexpected')
|
||||
}),
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
renderAndAct(() => {}, {
|
||||
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
|
||||
}),
|
||||
).toThrow('unexpected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('"#_goober" MutationObserver', () => {
|
||||
function stubMutationObserver() {
|
||||
let observerCallback: MutationCallback | undefined
|
||||
const observeSpy = vi.fn()
|
||||
const disconnectSpy = vi.fn()
|
||||
class FakeMutationObserver {
|
||||
observe = observeSpy
|
||||
disconnect = disconnectSpy
|
||||
takeRecords = () => []
|
||||
constructor(cb: MutationCallback) {
|
||||
observerCallback = cb
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('MutationObserver', FakeMutationObserver)
|
||||
return {
|
||||
observeSpy,
|
||||
disconnectSpy,
|
||||
fire: () => observerCallback?.([], {} as MutationObserver),
|
||||
}
|
||||
}
|
||||
|
||||
it('should observe the parent "#_goober" style for childList and subtree mutations', () => {
|
||||
const gooberStyle = document.createElement('style')
|
||||
gooberStyle.id = '_goober'
|
||||
gooberStyle.textContent = '.initial { color: red; }'
|
||||
document.head.appendChild(gooberStyle)
|
||||
const { observeSpy } = stubMutationObserver()
|
||||
stubPipWindow()
|
||||
|
||||
try {
|
||||
renderAndAct((pip) => pip().requestPipWindow(640, 480), {
|
||||
disabled: true,
|
||||
})
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(gooberStyle, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterDataOldValue: true,
|
||||
})
|
||||
} finally {
|
||||
gooberStyle.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('should copy parent "#_goober" "textContent" into the PiP mirror when the observer fires', () => {
|
||||
const gooberStyle = document.createElement('style')
|
||||
gooberStyle.id = '_goober'
|
||||
gooberStyle.textContent = '.initial { color: red; }'
|
||||
document.head.appendChild(gooberStyle)
|
||||
const { fire } = stubMutationObserver()
|
||||
const { pipDocument } = stubPipWindow()
|
||||
|
||||
try {
|
||||
const pipGooberStyle = pipDocument.createElement('style')
|
||||
pipGooberStyle.id = '_goober'
|
||||
|
||||
renderAndAct(
|
||||
(pip) => {
|
||||
pip().requestPipWindow(640, 480)
|
||||
// `requestPipWindow` clears the PiP document head, so install
|
||||
// the goober mirror style after that point.
|
||||
pipDocument.head.appendChild(pipGooberStyle)
|
||||
},
|
||||
{ disabled: true },
|
||||
)
|
||||
|
||||
gooberStyle.textContent = '.next { color: blue; }'
|
||||
fire()
|
||||
|
||||
expect(pipGooberStyle.textContent).toBe('.next { color: blue; }')
|
||||
} finally {
|
||||
gooberStyle.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import { createEffect, createSignal } from 'solid-js'
|
||||
import { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import { QueryDevtoolsContext, useQueryDevtoolsContext } from '../../contexts'
|
||||
|
||||
describe('QueryDevtoolsContext', () => {
|
||||
it('should reflect updates when the "Provider" value exposes a reactive getter', () => {
|
||||
const initialClient = new QueryClient()
|
||||
const nextClient = new QueryClient()
|
||||
const [client, setClient] = createSignal(initialClient)
|
||||
const observed: Array<QueryClient> = []
|
||||
function ContextProbe() {
|
||||
const ctx = useQueryDevtoolsContext()
|
||||
createEffect(() => {
|
||||
observed.push(ctx.client)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
render(() => (
|
||||
<QueryDevtoolsContext.Provider
|
||||
value={{
|
||||
queryFlavor: 'TanStack Query',
|
||||
version: '5',
|
||||
onlineManager,
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ContextProbe />
|
||||
</QueryDevtoolsContext.Provider>
|
||||
))
|
||||
|
||||
expect(observed).toEqual([initialClient])
|
||||
|
||||
setClient(nextClient)
|
||||
expect(observed).toEqual([initialClient, nextClient])
|
||||
|
||||
initialClient.clear()
|
||||
nextClient.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import { createEffect, createSignal } from 'solid-js'
|
||||
import { ThemeContext, useTheme } from '../../contexts'
|
||||
|
||||
type Theme = ReturnType<ReturnType<typeof useTheme>>
|
||||
|
||||
describe('ThemeContext', () => {
|
||||
describe('default value', () => {
|
||||
it('should resolve to "dark" when no "ThemeContext.Provider" wraps the consumer', () => {
|
||||
let resolvedTheme: Theme | undefined
|
||||
function ThemeProbe() {
|
||||
const theme = useTheme()
|
||||
resolvedTheme = theme()
|
||||
return null
|
||||
}
|
||||
|
||||
render(() => <ThemeProbe />)
|
||||
|
||||
expect(resolvedTheme).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a "ThemeContext.Provider"', () => {
|
||||
it('should reflect updates when the "Provider" value is a reactive "Accessor"', () => {
|
||||
const [theme, setTheme] = createSignal<Theme>('dark')
|
||||
const observed: Array<Theme> = []
|
||||
function ThemeProbe() {
|
||||
const resolved = useTheme()
|
||||
createEffect(() => {
|
||||
observed.push(resolved())
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
render(() => (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
<ThemeProbe />
|
||||
</ThemeContext.Provider>
|
||||
))
|
||||
|
||||
expect(observed).toEqual(['dark'])
|
||||
|
||||
setTheme('light')
|
||||
expect(observed).toEqual(['dark', 'light'])
|
||||
})
|
||||
})
|
||||
})
|
||||
1659
packages/query-devtools/src/__tests__/utils.test.ts
Normal file
1659
packages/query-devtools/src/__tests__/utils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
17
packages/query-devtools/src/constants.ts
Normal file
17
packages/query-devtools/src/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { mutationSortFns, sortFns } from './utils'
|
||||
import type { DevtoolsButtonPosition, DevtoolsPosition } from './contexts'
|
||||
|
||||
export const firstBreakpoint = 1024
|
||||
export const secondBreakpoint = 796
|
||||
export const thirdBreakpoint = 700
|
||||
|
||||
export const BUTTON_POSITION: DevtoolsButtonPosition = 'bottom-right'
|
||||
export const POSITION: DevtoolsPosition = 'bottom'
|
||||
export const THEME_PREFERENCE = 'system'
|
||||
export const INITIAL_IS_OPEN = false
|
||||
export const DEFAULT_HEIGHT = 500
|
||||
export const PIP_DEFAULT_HEIGHT = 500
|
||||
export const DEFAULT_WIDTH = 500
|
||||
export const DEFAULT_SORT_FN_NAME = Object.keys(sortFns)[0]
|
||||
export const DEFAULT_SORT_ORDER = 1
|
||||
export const DEFAULT_MUTATION_SORT_FN_NAME = Object.keys(mutationSortFns)[0]
|
||||
204
packages/query-devtools/src/contexts/PiPContext.tsx
Normal file
204
packages/query-devtools/src/contexts/PiPContext.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
} from 'solid-js'
|
||||
import { clearDelegatedEvents, delegateEvents } from 'solid-js/web'
|
||||
import { PIP_DEFAULT_HEIGHT } from '../constants'
|
||||
import { useQueryDevtoolsContext } from './QueryDevtoolsContext'
|
||||
import type { Accessor, JSX } from 'solid-js'
|
||||
import type { StorageObject, StorageSetter } from '@solid-primitives/storage'
|
||||
|
||||
interface PiPProviderProps {
|
||||
children: JSX.Element
|
||||
localStore: StorageObject<string>
|
||||
setLocalStore: StorageSetter<string, unknown>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type PiPContextType = {
|
||||
pipWindow: Window | null
|
||||
requestPipWindow: (width: number, height: number) => void
|
||||
closePipWindow: () => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
class PipOpenError extends Error {}
|
||||
|
||||
const PiPContext = createContext<Accessor<PiPContextType> | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
export const PiPProvider = (props: PiPProviderProps) => {
|
||||
// Expose pipWindow that is currently active
|
||||
const [pipWindow, setPipWindow] = createSignal<Window | null>(null)
|
||||
|
||||
// Close pipWindow programmatically
|
||||
const closePipWindow = () => {
|
||||
const w = pipWindow()
|
||||
if (w != null) {
|
||||
w.close()
|
||||
props.setLocalStore('pip_open', 'false')
|
||||
setPipWindow(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Open new pipWindow
|
||||
const requestPipWindow = (width: number, height: number) => {
|
||||
// We don't want to allow multiple requests.
|
||||
if (pipWindow() != null) {
|
||||
return
|
||||
}
|
||||
|
||||
const pip = window.open(
|
||||
'',
|
||||
'TSQD-Devtools-Panel',
|
||||
`width=${width},height=${height},popup`,
|
||||
)
|
||||
|
||||
if (!pip) {
|
||||
throw new PipOpenError(
|
||||
'Failed to open popup. Please allow popups for this site to view the devtools in picture-in-picture mode.',
|
||||
)
|
||||
}
|
||||
|
||||
// Remove existing styles
|
||||
pip.document.head.innerHTML = ''
|
||||
// Remove existing body
|
||||
pip.document.body.innerHTML = ''
|
||||
// Clear Delegated Events
|
||||
clearDelegatedEvents(pip.document)
|
||||
|
||||
pip.document.title = 'TanStack Query Devtools'
|
||||
pip.document.body.style.margin = '0'
|
||||
|
||||
// Detect when window is closed by user
|
||||
pip.addEventListener('pagehide', () => {
|
||||
props.setLocalStore('pip_open', 'false')
|
||||
setPipWindow(null)
|
||||
})
|
||||
|
||||
// It is important to copy all parent window styles. Otherwise, there would be no CSS available at all
|
||||
// https://developer.chrome.com/docs/web-platform/document-picture-in-picture/#copy-style-sheets-to-the-picture-in-picture-window
|
||||
;[
|
||||
...(useQueryDevtoolsContext().shadowDOMTarget || document).styleSheets,
|
||||
].forEach((styleSheet) => {
|
||||
try {
|
||||
const cssRules = [...styleSheet.cssRules]
|
||||
.map((rule) => rule.cssText)
|
||||
.join('')
|
||||
const style = document.createElement('style')
|
||||
const style_node = styleSheet.ownerNode
|
||||
let style_id = ''
|
||||
|
||||
if (style_node && 'id' in style_node) {
|
||||
style_id = style_node.id
|
||||
}
|
||||
|
||||
if (style_id) {
|
||||
style.setAttribute('id', style_id)
|
||||
}
|
||||
style.textContent = cssRules
|
||||
pip.document.head.appendChild(style)
|
||||
} catch (e) {
|
||||
const link = document.createElement('link')
|
||||
if (styleSheet.href == null) {
|
||||
return
|
||||
}
|
||||
|
||||
link.rel = 'stylesheet'
|
||||
link.type = styleSheet.type
|
||||
link.media = styleSheet.media.toString()
|
||||
link.href = styleSheet.href
|
||||
pip.document.head.appendChild(link)
|
||||
}
|
||||
})
|
||||
delegateEvents(
|
||||
[
|
||||
'focusin',
|
||||
'focusout',
|
||||
'pointermove',
|
||||
'keydown',
|
||||
'pointerdown',
|
||||
'pointerup',
|
||||
'click',
|
||||
'mousedown',
|
||||
'input',
|
||||
],
|
||||
pip.document,
|
||||
)
|
||||
props.setLocalStore('pip_open', 'true')
|
||||
setPipWindow(pip)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pip_open = (props.localStore.pip_open ?? 'false') as 'true' | 'false'
|
||||
if (pip_open === 'true' && !props.disabled) {
|
||||
try {
|
||||
requestPipWindow(
|
||||
Number(window.innerWidth),
|
||||
Number(props.localStore.height || PIP_DEFAULT_HEIGHT),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof PipOpenError) {
|
||||
console.error(error.message)
|
||||
props.setLocalStore('pip_open', 'false')
|
||||
props.setLocalStore('open', 'false')
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Setup mutation observer for goober styles with id `_goober
|
||||
const gooberStyles = (
|
||||
useQueryDevtoolsContext().shadowDOMTarget || document
|
||||
).querySelector('#_goober')
|
||||
const w = pipWindow()
|
||||
if (gooberStyles && w) {
|
||||
const observer = new MutationObserver(() => {
|
||||
const pip_style = (
|
||||
useQueryDevtoolsContext().shadowDOMTarget || w.document
|
||||
).querySelector('#_goober')
|
||||
if (pip_style) {
|
||||
pip_style.textContent = gooberStyles.textContent
|
||||
}
|
||||
})
|
||||
observer.observe(gooberStyles, {
|
||||
childList: true, // observe direct children
|
||||
subtree: true, // and lower descendants too
|
||||
characterDataOldValue: true, // pass old data to callback
|
||||
})
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const value = createMemo(() => ({
|
||||
pipWindow: pipWindow(),
|
||||
requestPipWindow,
|
||||
closePipWindow,
|
||||
disabled: props.disabled ?? false,
|
||||
}))
|
||||
|
||||
return (
|
||||
<PiPContext.Provider value={value}>{props.children}</PiPContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const usePiPWindow = () => {
|
||||
const context = createMemo(() => {
|
||||
const ctx = useContext(PiPContext)
|
||||
if (!ctx) {
|
||||
throw new Error('usePiPWindow must be used within a PiPProvider')
|
||||
}
|
||||
return ctx()
|
||||
})
|
||||
return context
|
||||
}
|
||||
23
packages/query-devtools/src/contexts/QueryDevtoolsContext.ts
Normal file
23
packages/query-devtools/src/contexts/QueryDevtoolsContext.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from 'solid-js'
|
||||
import type { QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
import type { QueryDevtoolsProps } from './types'
|
||||
|
||||
export type {
|
||||
DevtoolsButtonPosition,
|
||||
DevtoolsErrorType,
|
||||
DevtoolsPosition,
|
||||
QueryDevtoolsProps,
|
||||
Theme,
|
||||
} from './types'
|
||||
|
||||
export const QueryDevtoolsContext = createContext<QueryDevtoolsProps>({
|
||||
client: undefined as unknown as QueryClient,
|
||||
onlineManager: undefined as unknown as typeof onlineManager,
|
||||
queryFlavor: '',
|
||||
version: '',
|
||||
shadowDOMTarget: undefined,
|
||||
})
|
||||
|
||||
export function useQueryDevtoolsContext() {
|
||||
return useContext(QueryDevtoolsContext)
|
||||
}
|
||||
10
packages/query-devtools/src/contexts/ThemeContext.ts
Normal file
10
packages/query-devtools/src/contexts/ThemeContext.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createContext, useContext } from 'solid-js'
|
||||
import type { Accessor } from 'solid-js'
|
||||
|
||||
export const ThemeContext = createContext<Accessor<'light' | 'dark'>>(
|
||||
() => 'dark' as const,
|
||||
)
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
3
packages/query-devtools/src/contexts/index.ts
Normal file
3
packages/query-devtools/src/contexts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './PiPContext'
|
||||
export * from './QueryDevtoolsContext'
|
||||
export * from './ThemeContext'
|
||||
35
packages/query-devtools/src/contexts/types.ts
Normal file
35
packages/query-devtools/src/contexts/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Query, QueryClient, onlineManager } from '@tanstack/query-core'
|
||||
|
||||
type XPosition = 'left' | 'right'
|
||||
type YPosition = 'top' | 'bottom'
|
||||
|
||||
export type DevtoolsPosition = XPosition | YPosition
|
||||
export type DevtoolsButtonPosition = `${YPosition}-${XPosition}` | 'relative'
|
||||
export type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
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?: Array<DevtoolsErrorType>
|
||||
shadowDOMTarget?: ShadowRoot
|
||||
onClose?: () => void
|
||||
hideDisabledQueries?: boolean
|
||||
theme?: Theme
|
||||
}
|
||||
1385
packages/query-devtools/src/icons/index.tsx
Normal file
1385
packages/query-devtools/src/icons/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/query-devtools/src/index.ts
Normal file
14
packages/query-devtools/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
DevtoolsButtonPosition,
|
||||
DevtoolsErrorType,
|
||||
DevtoolsPosition,
|
||||
Theme,
|
||||
} from './contexts/types'
|
||||
export {
|
||||
TanstackQueryDevtools,
|
||||
type TanstackQueryDevtoolsConfig,
|
||||
} from './TanstackQueryDevtools'
|
||||
export {
|
||||
TanstackQueryDevtoolsPanel,
|
||||
type TanstackQueryDevtoolsPanelConfig,
|
||||
} from './TanstackQueryDevtoolsPanel'
|
||||
299
packages/query-devtools/src/theme.ts
Normal file
299
packages/query-devtools/src/theme.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
950: '#450a0a',
|
||||
},
|
||||
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': 'calc(var(--tsqd-font-size) * 0.625)',
|
||||
xs: 'calc(var(--tsqd-font-size) * 0.75)',
|
||||
sm: 'calc(var(--tsqd-font-size) * 0.875)',
|
||||
md: 'var(--tsqd-font-size)',
|
||||
lg: 'calc(var(--tsqd-font-size) * 1.125)',
|
||||
xl: 'calc(var(--tsqd-font-size) * 1.25)',
|
||||
'2xl': 'calc(var(--tsqd-font-size) * 1.5)',
|
||||
'3xl': 'calc(var(--tsqd-font-size) * 1.875)',
|
||||
'4xl': 'calc(var(--tsqd-font-size) * 2.25)',
|
||||
'5xl': 'calc(var(--tsqd-font-size) * 3)',
|
||||
'6xl': 'calc(var(--tsqd-font-size) * 3.75)',
|
||||
'7xl': 'calc(var(--tsqd-font-size) * 4.5)',
|
||||
'8xl': 'calc(var(--tsqd-font-size) * 6)',
|
||||
'9xl': 'calc(var(--tsqd-font-size) * 8)',
|
||||
},
|
||||
lineHeight: {
|
||||
xs: 'calc(var(--tsqd-font-size) * 1)',
|
||||
sm: 'calc(var(--tsqd-font-size) * 1.25)',
|
||||
md: 'calc(var(--tsqd-font-size) * 1.5)',
|
||||
lg: 'calc(var(--tsqd-font-size) * 1.75)',
|
||||
xl: 'calc(var(--tsqd-font-size) * 2)',
|
||||
'2xl': 'calc(var(--tsqd-font-size) * 2.25)',
|
||||
'3xl': 'calc(var(--tsqd-font-size) * 2.5)',
|
||||
'4xl': 'calc(var(--tsqd-font-size) * 2.75)',
|
||||
'5xl': 'calc(var(--tsqd-font-size) * 3)',
|
||||
'6xl': 'calc(var(--tsqd-font-size) * 3.25)',
|
||||
'7xl': 'calc(var(--tsqd-font-size) * 3.5)',
|
||||
'8xl': 'calc(var(--tsqd-font-size) * 3.75)',
|
||||
'9xl': 'calc(var(--tsqd-font-size) * 4)',
|
||||
},
|
||||
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: 'calc(var(--tsqd-font-size) * 0.125)',
|
||||
sm: 'calc(var(--tsqd-font-size) * 0.25)',
|
||||
md: 'calc(var(--tsqd-font-size) * 0.375)',
|
||||
lg: 'calc(var(--tsqd-font-size) * 0.5)',
|
||||
xl: 'calc(var(--tsqd-font-size) * 0.75)',
|
||||
'2xl': 'calc(var(--tsqd-font-size) * 1)',
|
||||
'3xl': 'calc(var(--tsqd-font-size) * 1.5)',
|
||||
full: '9999px',
|
||||
},
|
||||
},
|
||||
size: {
|
||||
0: '0px',
|
||||
0.25: 'calc(var(--tsqd-font-size) * 0.0625)',
|
||||
0.5: 'calc(var(--tsqd-font-size) * 0.125)',
|
||||
1: 'calc(var(--tsqd-font-size) * 0.25)',
|
||||
1.5: 'calc(var(--tsqd-font-size) * 0.375)',
|
||||
2: 'calc(var(--tsqd-font-size) * 0.5)',
|
||||
2.5: 'calc(var(--tsqd-font-size) * 0.625)',
|
||||
3: 'calc(var(--tsqd-font-size) * 0.75)',
|
||||
3.5: 'calc(var(--tsqd-font-size) * 0.875)',
|
||||
4: 'calc(var(--tsqd-font-size) * 1)',
|
||||
4.5: 'calc(var(--tsqd-font-size) * 1.125)',
|
||||
5: 'calc(var(--tsqd-font-size) * 1.25)',
|
||||
5.5: 'calc(var(--tsqd-font-size) * 1.375)',
|
||||
6: 'calc(var(--tsqd-font-size) * 1.5)',
|
||||
6.5: 'calc(var(--tsqd-font-size) * 1.625)',
|
||||
7: 'calc(var(--tsqd-font-size) * 1.75)',
|
||||
8: 'calc(var(--tsqd-font-size) * 2)',
|
||||
9: 'calc(var(--tsqd-font-size) * 2.25)',
|
||||
10: 'calc(var(--tsqd-font-size) * 2.5)',
|
||||
11: 'calc(var(--tsqd-font-size) * 2.75)',
|
||||
12: 'calc(var(--tsqd-font-size) * 3)',
|
||||
14: 'calc(var(--tsqd-font-size) * 3.5)',
|
||||
16: 'calc(var(--tsqd-font-size) * 4)',
|
||||
20: 'calc(var(--tsqd-font-size) * 5)',
|
||||
24: 'calc(var(--tsqd-font-size) * 6)',
|
||||
28: 'calc(var(--tsqd-font-size) * 7)',
|
||||
32: 'calc(var(--tsqd-font-size) * 8)',
|
||||
36: 'calc(var(--tsqd-font-size) * 9)',
|
||||
40: 'calc(var(--tsqd-font-size) * 10)',
|
||||
44: 'calc(var(--tsqd-font-size) * 11)',
|
||||
48: 'calc(var(--tsqd-font-size) * 12)',
|
||||
52: 'calc(var(--tsqd-font-size) * 13)',
|
||||
56: 'calc(var(--tsqd-font-size) * 14)',
|
||||
60: 'calc(var(--tsqd-font-size) * 15)',
|
||||
64: 'calc(var(--tsqd-font-size) * 16)',
|
||||
72: 'calc(var(--tsqd-font-size) * 18)',
|
||||
80: 'calc(var(--tsqd-font-size) * 20)',
|
||||
96: 'calc(var(--tsqd-font-size) * 24)',
|
||||
},
|
||||
shadow: {
|
||||
xs: (_: string = 'rgb(0 0 0 / 0.1)') =>
|
||||
`0 1px 2px 0 rgb(0 0 0 / 0.05)` as const,
|
||||
sm: (color: string = 'rgb(0 0 0 / 0.1)') =>
|
||||
`0 1px 3px 0 ${color}, 0 1px 2px -1px ${color}` as const,
|
||||
md: (color: string = 'rgb(0 0 0 / 0.1)') =>
|
||||
`0 4px 6px -1px ${color}, 0 2px 4px -2px ${color}` as const,
|
||||
lg: (color: string = 'rgb(0 0 0 / 0.1)') =>
|
||||
`0 10px 15px -3px ${color}, 0 4px 6px -4px ${color}` as const,
|
||||
xl: (color: string = 'rgb(0 0 0 / 0.1)') =>
|
||||
`0 20px 25px -5px ${color}, 0 8px 10px -6px ${color}` as const,
|
||||
'2xl': (color: string = 'rgb(0 0 0 / 0.25)') =>
|
||||
`0 25px 50px -12px ${color}` as const,
|
||||
inner: (color: string = 'rgb(0 0 0 / 0.05)') =>
|
||||
`inset 0 2px 4px 0 ${color}` as const,
|
||||
none: () => `none` as const,
|
||||
},
|
||||
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,
|
||||
},
|
||||
} as const
|
||||
324
packages/query-devtools/src/utils.tsx
Normal file
324
packages/query-devtools/src/utils.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { serialize } from 'superjson'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import type { Mutation, Query } from '@tanstack/query-core'
|
||||
import type { DevtoolsPosition } from './contexts'
|
||||
|
||||
export function getQueryStatusLabel(query: Query) {
|
||||
return query.state.fetchStatus === 'fetching'
|
||||
? 'fetching'
|
||||
: !query.getObserversCount()
|
||||
? 'inactive'
|
||||
: query.state.fetchStatus === 'paused'
|
||||
? 'paused'
|
||||
: query.isStale()
|
||||
? 'stale'
|
||||
: 'fresh'
|
||||
}
|
||||
|
||||
type QueryStatusLabel = 'fresh' | 'stale' | 'paused' | 'inactive' | 'fetching'
|
||||
|
||||
export function getSidedProp<T extends string>(
|
||||
prop: T,
|
||||
side: DevtoolsPosition,
|
||||
) {
|
||||
return `${prop}${
|
||||
side.charAt(0).toUpperCase() + side.slice(1)
|
||||
}` as `${T}${Capitalize<DevtoolsPosition>}`
|
||||
}
|
||||
|
||||
export function getQueryStatusColor({
|
||||
queryState,
|
||||
observerCount,
|
||||
isStale,
|
||||
}: {
|
||||
queryState: Query['state'] | undefined
|
||||
observerCount: number
|
||||
isStale: boolean
|
||||
}) {
|
||||
return queryState?.fetchStatus === 'fetching'
|
||||
? 'blue'
|
||||
: !observerCount
|
||||
? 'gray'
|
||||
: queryState?.fetchStatus === 'paused'
|
||||
? 'purple'
|
||||
: isStale
|
||||
? 'yellow'
|
||||
: 'green'
|
||||
}
|
||||
|
||||
export function getMutationStatusColor({
|
||||
status,
|
||||
isPaused,
|
||||
}: {
|
||||
status: Mutation['state']['status']
|
||||
isPaused: boolean
|
||||
}) {
|
||||
return isPaused
|
||||
? 'purple'
|
||||
: status === 'error'
|
||||
? 'red'
|
||||
: status === 'pending'
|
||||
? 'yellow'
|
||||
: status === 'success'
|
||||
? 'green'
|
||||
: 'gray'
|
||||
}
|
||||
|
||||
export function getQueryStatusColorByLabel(label: QueryStatusLabel) {
|
||||
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 } = 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) => {
|
||||
const diff = b.state.dataUpdatedAt - a.state.dataUpdatedAt
|
||||
return diff < 0 ? -1 : diff > 0 ? 1 : 0
|
||||
}
|
||||
|
||||
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<string, SortFn> = {
|
||||
status: statusAndDateSort,
|
||||
'query hash': queryHashSort,
|
||||
'last updated': dateSort,
|
||||
}
|
||||
|
||||
type MutationSortFn = (a: Mutation, b: Mutation) => number
|
||||
|
||||
const getMutationStatusRank = (m: Mutation) =>
|
||||
m.state.isPaused
|
||||
? 0
|
||||
: m.state.status === 'error'
|
||||
? 2
|
||||
: m.state.status === 'pending'
|
||||
? 1
|
||||
: 3
|
||||
|
||||
const mutationDateSort: MutationSortFn = (a, b) =>
|
||||
a.state.submittedAt < b.state.submittedAt ? 1 : -1
|
||||
|
||||
const mutationStatusSort: MutationSortFn = (a, b) => {
|
||||
if (getMutationStatusRank(a) === getMutationStatusRank(b)) {
|
||||
return mutationDateSort(a, b)
|
||||
}
|
||||
|
||||
return getMutationStatusRank(a) > getMutationStatusRank(b) ? 1 : -1
|
||||
}
|
||||
|
||||
export const mutationSortFns: Record<string, MutationSortFn> = {
|
||||
status: mutationStatusSort,
|
||||
'last updated': mutationDateSort,
|
||||
}
|
||||
|
||||
export const convertRemToPixels = (rem: number) => {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
}
|
||||
|
||||
export const getPreferredColorScheme = () => {
|
||||
const [colorScheme, setColorScheme] = createSignal<'light' | 'dark'>('dark')
|
||||
|
||||
onMount(() => {
|
||||
const query = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
setColorScheme(query.matches ? 'dark' : 'light')
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
setColorScheme(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
query.addEventListener('change', listener)
|
||||
onCleanup(() => query.removeEventListener('change', listener))
|
||||
})
|
||||
|
||||
return colorScheme
|
||||
}
|
||||
|
||||
/**
|
||||
* updates nested data by path
|
||||
*
|
||||
* @param {unknown} oldData Data to be updated
|
||||
* @param {Array<string>} updatePath Path to the data to be updated
|
||||
* @param {unknown} value New value
|
||||
*/
|
||||
export const updateNestedDataByPath = (
|
||||
oldData: unknown,
|
||||
updatePath: Array<string>,
|
||||
value: unknown,
|
||||
): any => {
|
||||
if (updatePath.length === 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (oldData instanceof Map) {
|
||||
const newData = new Map(oldData)
|
||||
|
||||
if (updatePath.length === 1) {
|
||||
newData.set(updatePath[0], value)
|
||||
return newData
|
||||
}
|
||||
|
||||
const [head, ...tail] = updatePath
|
||||
newData.set(head, updateNestedDataByPath(newData.get(head), tail, value))
|
||||
return newData
|
||||
}
|
||||
|
||||
if (oldData instanceof Set) {
|
||||
const setAsArray = updateNestedDataByPath(
|
||||
Array.from(oldData),
|
||||
updatePath,
|
||||
value,
|
||||
)
|
||||
|
||||
return new Set(setAsArray)
|
||||
}
|
||||
|
||||
if (Array.isArray(oldData)) {
|
||||
const newData = [...oldData]
|
||||
|
||||
if (updatePath.length === 1) {
|
||||
// @ts-expect-error
|
||||
newData[updatePath[0]] = value
|
||||
return newData
|
||||
}
|
||||
|
||||
const [head, ...tail] = updatePath
|
||||
// @ts-expect-error
|
||||
newData[head] = updateNestedDataByPath(newData[head], tail, value)
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
if (oldData instanceof Object) {
|
||||
const newData = { ...oldData }
|
||||
|
||||
if (updatePath.length === 1) {
|
||||
// @ts-expect-error
|
||||
newData[updatePath[0]] = value
|
||||
return newData
|
||||
}
|
||||
|
||||
const [head, ...tail] = updatePath
|
||||
// @ts-expect-error
|
||||
newData[head] = updateNestedDataByPath(newData[head], tail, value)
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
return oldData
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes nested data by path
|
||||
*
|
||||
* @param {unknown} oldData Data to be updated
|
||||
* @param {Array<string>} deletePath Path to the data to be deleted
|
||||
* @returns newData without the deleted items by path
|
||||
*/
|
||||
export const deleteNestedDataByPath = (
|
||||
oldData: unknown,
|
||||
deletePath: Array<string>,
|
||||
): any => {
|
||||
if (oldData instanceof Map) {
|
||||
const newData = new Map(oldData)
|
||||
|
||||
if (deletePath.length === 1) {
|
||||
newData.delete(deletePath[0])
|
||||
return newData
|
||||
}
|
||||
|
||||
const [head, ...tail] = deletePath
|
||||
newData.set(head, deleteNestedDataByPath(newData.get(head), tail))
|
||||
return newData
|
||||
}
|
||||
|
||||
if (oldData instanceof Set) {
|
||||
const setAsArray = deleteNestedDataByPath(Array.from(oldData), deletePath)
|
||||
return new Set(setAsArray)
|
||||
}
|
||||
|
||||
if (Array.isArray(oldData)) {
|
||||
const newData = [...oldData]
|
||||
|
||||
if (deletePath.length === 1) {
|
||||
return newData.filter((_, idx) => idx.toString() !== deletePath[0])
|
||||
}
|
||||
|
||||
const [head, ...tail] = deletePath
|
||||
|
||||
// @ts-expect-error
|
||||
newData[head] = deleteNestedDataByPath(newData[head], tail)
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
if (oldData instanceof Object) {
|
||||
const newData = { ...oldData }
|
||||
|
||||
if (deletePath.length === 1) {
|
||||
// @ts-expect-error
|
||||
delete newData[deletePath[0]]
|
||||
return newData
|
||||
}
|
||||
|
||||
const [head, ...tail] = deletePath
|
||||
// @ts-expect-error
|
||||
newData[head] = deleteNestedDataByPath(newData[head], tail)
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
return oldData
|
||||
}
|
||||
|
||||
// Sets up the goober stylesheet
|
||||
// Adds a nonce to the style tag if needed
|
||||
export const setupStyleSheet = (nonce?: string, target?: ShadowRoot) => {
|
||||
if (!nonce)
|
||||
return // Goober reads window.__nonce__ every time it creates or accesses its style
|
||||
// element (el.nonce = window.__nonce__). Without this, goober overwrites the
|
||||
// nonce we set on the pre-created element with undefined, clearing it.
|
||||
;(window as any).__nonce__ = nonce
|
||||
|
||||
const root = target ?? document.head
|
||||
if (root.querySelector('#_goober')) return
|
||||
const styleTag = document.createElement('style')
|
||||
const textNode = document.createTextNode('')
|
||||
styleTag.appendChild(textNode)
|
||||
styleTag.id = '_goober'
|
||||
styleTag.setAttribute('nonce', nonce)
|
||||
root.appendChild(styleTag)
|
||||
}
|
||||
5
packages/query-devtools/test-setup.ts
Normal file
5
packages/query-devtools/test-setup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@solidjs/testing-library'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
16
packages/query-devtools/tsconfig.json
Normal file
16
packages/query-devtools/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-ts",
|
||||
"rootDir": ".",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"test-setup.ts",
|
||||
"*.config.ts",
|
||||
"*.config.js",
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
10
packages/query-devtools/tsconfig.prod.json
Normal file
10
packages/query-devtools/tsconfig.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"composite": false,
|
||||
"rootDir": "../../"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
84
packages/query-devtools/tsdown.config.ts
Normal file
84
packages/query-devtools/tsdown.config.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { defineConfig } from 'tsdown'
|
||||
import solid from 'unplugin-solid/rolldown'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const solidBrowserIds: Record<string, string> = {
|
||||
'solid-js/web': require.resolve('solid-js/web/dist/web.js'),
|
||||
'solid-js/store': require.resolve('solid-js/store/dist/store.js'),
|
||||
'solid-js/html': require.resolve('solid-js/html/dist/html.js'),
|
||||
'solid-js/h': require.resolve('solid-js/h/dist/h.js'),
|
||||
'solid-js': require.resolve('solid-js/dist/solid.js'),
|
||||
}
|
||||
|
||||
const solidBrowserImports = () => ({
|
||||
name: 'solid-browser-imports',
|
||||
resolveId(id: string, importer?: string) {
|
||||
// Resolve the browser runtime explicitly. This keeps Solid bundled in
|
||||
// query-devtools, as it was with tsup-preset-solid, while keeping d.ts
|
||||
// imports unchanged.
|
||||
if (importer?.includes('.d.')) return undefined
|
||||
const browserId = solidBrowserIds[id]
|
||||
if (browserId) {
|
||||
return { id: browserId, external: false }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const baseConfig = {
|
||||
target: 'esnext',
|
||||
platform: 'neutral' as const,
|
||||
alias: {
|
||||
'solid-transition-group': 'solid-transition-group/dist/index.js',
|
||||
},
|
||||
format: ['esm', 'cjs'] as ['esm', 'cjs'],
|
||||
outDir: 'build',
|
||||
fixedExtension: false,
|
||||
// Published source needs these dependencies declared, but the compiled
|
||||
// devtools must still bundle them with the Solid browser runtime.
|
||||
deps: {
|
||||
alwaysBundle: [/./],
|
||||
dts: { alwaysBundle: [] },
|
||||
},
|
||||
inputOptions: {
|
||||
// Keep package imports in declaration output. Runtime Solid imports are
|
||||
// bundled from the browser files by solidBrowserImports above.
|
||||
external: (id: string, importer?: string) =>
|
||||
(id.startsWith('solid-js') && importer?.includes('.d.')) ||
|
||||
(id === '@tanstack/query-core' && importer?.includes('.d.')),
|
||||
},
|
||||
}
|
||||
|
||||
const environment = (development: boolean) => ({
|
||||
NODE_ENV: development ? 'development' : 'production',
|
||||
PROD: !development,
|
||||
DEV: development,
|
||||
SSR: false,
|
||||
})
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
...baseConfig,
|
||||
entry: { index: 'src/index.ts' },
|
||||
clean: true,
|
||||
dts: true,
|
||||
env: environment(false),
|
||||
minify: {
|
||||
compress: {
|
||||
dropConsole: true,
|
||||
dropDebugger: true,
|
||||
},
|
||||
},
|
||||
plugins: [solid(), solidBrowserImports()],
|
||||
},
|
||||
{
|
||||
...baseConfig,
|
||||
entry: { dev: 'src/index.ts' },
|
||||
clean: false,
|
||||
dts: false,
|
||||
env: environment(true),
|
||||
plugins: [solid(), solidBrowserImports()],
|
||||
},
|
||||
])
|
||||
34
packages/query-devtools/vite.config.ts
Normal file
34
packages/query-devtools/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import solid from 'vite-plugin-solid'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
import packageJson from './package.json'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
// fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660
|
||||
resolve: {
|
||||
conditions: ['@tanstack/custom-condition'],
|
||||
},
|
||||
environments: {
|
||||
ssr: {
|
||||
resolve: {
|
||||
conditions: ['@tanstack/custom-condition'],
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: packageJson.name,
|
||||
dir: './src',
|
||||
watch: false,
|
||||
environment: 'jsdom',
|
||||
coverage: {
|
||||
enabled: !!process.env.CI,
|
||||
provider: 'istanbul',
|
||||
include: ['src/**/*'],
|
||||
exclude: ['src/__tests__/**'],
|
||||
},
|
||||
typecheck: { enabled: true },
|
||||
restoreMocks: true,
|
||||
setupFiles: ['test-setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user