This commit is contained in:
2026-03-18 20:54:43 +01:00
parent b3c8b77f12
commit 9fe656b34c
8058 changed files with 912898 additions and 23 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Tanner Linsley
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82
View File
@@ -0,0 +1,82 @@
{
"name": "@tanstack/query-devtools",
"version": "5.93.0",
"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"
},
"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__"
],
"devDependencies": {
"@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",
"clsx": "^2.1.1",
"goober": "^2.1.16",
"npm-run-all2": "^5.0.0",
"solid-js": "^1.9.7",
"solid-transition-group": "^0.2.3",
"superjson": "^2.2.2",
"tsup-preset-solid": "^2.2.0",
"vite-plugin-solid": "^2.11.6",
"@tanstack/query-core": "5.90.20"
},
"scripts": {
"clean": "premove ./build ./coverage ./dist-ts",
"compile": "tsc --build",
"test:eslint": "eslint --concurrency=auto ./src",
"test:types": "npm-run-all --serial test:types:*",
"test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build",
"test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build",
"test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js --build",
"test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build",
"test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build",
"test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build",
"test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build",
"test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build",
"test:types:tscurrent": "tsc --build",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "tsup --tsconfig tsconfig.prod.json",
"build:dev": "tsup --watch"
}
}
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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
View 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)
@@ -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'
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 }
@@ -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'
import type { Signal } from 'solid-js'
export interface TanstackQueryDevtoolsPanelConfig extends QueryDevtoolsProps {
styleNonce?: string
shadowDOMTarget?: ShadowRoot
onClose?: () => unknown
}
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<(() => unknown) | 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: () => unknown) {
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 }
+17
View 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]
@@ -0,0 +1,203 @@
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()
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
}
@@ -0,0 +1,47 @@
import { createContext, useContext } from 'solid-js'
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?: () => unknown
hideDisabledQueries?: boolean
theme?: Theme
}
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)
}
@@ -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
View File
@@ -0,0 +1,3 @@
export * from './PiPContext'
export * from './QueryDevtoolsContext'
export * from './ThemeContext'
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
export type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
Theme,
} from './contexts'
export {
TanstackQueryDevtools,
type TanstackQueryDevtoolsConfig,
} from './TanstackQueryDevtools'
export {
TanstackQueryDevtoolsPanel,
type TanstackQueryDevtoolsPanelConfig,
} from './TanstackQueryDevtoolsPanel'
+299
View 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
+323
View File
@@ -0,0 +1,323 @@
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']
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) =>
a.state.dataUpdatedAt < b.state.dataUpdatedAt ? 1 : -1
const statusAndDateSort: SortFn = (a, b) => {
if (getStatusRank(a) === getStatusRank(b)) {
return dateSort(a, b)
}
return getStatusRank(a) > getStatusRank(b) ? 1 : -1
}
export const sortFns: Record<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
const styleExists =
document.querySelector('#_goober') || target?.querySelector('#_goober')
if (styleExists) return
const styleTag = document.createElement('style')
const textNode = document.createTextNode('')
styleTag.appendChild(textNode)
styleTag.id = '_goober'
styleTag.setAttribute('nonce', nonce)
if (target) {
target.appendChild(styleTag)
} else {
document.head.appendChild(styleTag)
}
}