diff --git a/.changeset/chubby-otters-grin.md b/.changeset/chubby-otters-grin.md new file mode 100644 index 00000000..c6a6fb70 --- /dev/null +++ b/.changeset/chubby-otters-grin.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': minor +--- + +add defaultOpen to plugins diff --git a/.changeset/some-adults-sin.md b/.changeset/some-adults-sin.md new file mode 100644 index 00000000..1ec186ca --- /dev/null +++ b/.changeset/some-adults-sin.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-utils': patch +--- + +extend the plugins to accept config diff --git a/docs/config.json b/docs/config.json index cbdbfbbc..b0053346 100644 --- a/docs/config.json +++ b/docs/config.json @@ -21,6 +21,10 @@ "label": "Configuration", "to": "configuration" }, + { + "label": "Plugin Configuration", + "to": "plugin-configuration" + }, { "label": "Installation", "to": "installation" @@ -148,4 +152,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 523d2e7a..6b102c98 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,6 +112,7 @@ createRoot(document.getElementById('root')!).render( { name: 'TanStack Form', render: , + defaultOpen: true, }, ]} /> diff --git a/docs/plugin-configuration.md b/docs/plugin-configuration.md new file mode 100644 index 00000000..0b9139cd --- /dev/null +++ b/docs/plugin-configuration.md @@ -0,0 +1,48 @@ +--- +title: Plugin Configuration +id: plugin-configuration +--- + + +# Plugin Configuration + +You can configure TanStack Devtools plugins by passing them as an array to the `plugins` prop of the `TanStackDevtools` component. + +Each plugin can have the following configuration options: +- `render` (required) - A React component that renders the plugin's UI +- `defaultOpen` (optional) - A boolean that determines if the plugin panel is open by default (default: false). +- `id` (optional) - A unique identifier for the plugin. If not provided, a random id will be generated. + +Here's an example of how to configure plugins in the `TanStackDevtools` component: + +```tsx +import { TanStackDevtools } from '@tanstack/react-devtools' +import { FormDevtools } from '@tanstack/react-form-devtools' + +function App() { + return ( + <> + + , + defaultOpen: true, + }, + ]} + /> + + ) +} +``` + +## Default open + +You can set a plugin to be open by default by setting the `defaultOpen` property to `true` when configuring the plugin. This will make the plugin panel open when the devtools are first loaded. + +If you only have 1 plugin it will automatically be opened regardless of the `defaultOpen` setting. + +The limit to open plugins is at 3 panels at a time. If more than 3 plugins are set to `defaultOpen: true`, only the first 3 will be opened. + +This does not override the settings saved in localStorage. If you have previously opened the plugin panel, and selected some plugins to be open or closed, those settings will take precedence over the `defaultOpen` setting. \ No newline at end of file diff --git a/docs/quick-start.md b/docs/quick-start.md index 5ea7acd0..2f41aa9a 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -69,10 +69,12 @@ function App() { { name: 'TanStack Query', render: , + defaultOpen: true }, { name: 'TanStack Router', render: , + defaultOpen: false }, ]} /> diff --git a/packages/devtools-utils/src/react/plugin.tsx b/packages/devtools-utils/src/react/plugin.tsx index d911bdce..41c73428 100644 --- a/packages/devtools-utils/src/react/plugin.tsx +++ b/packages/devtools-utils/src/react/plugin.tsx @@ -1,13 +1,18 @@ import type { JSX } from 'react' import type { DevtoolsPanelProps } from './panel' -export function createReactPlugin( - name: string, - Component: (props: DevtoolsPanelProps) => JSX.Element, -) { +export function createReactPlugin({ + Component, + ...config +}: { + name: string + id?: string + defaultOpen?: boolean + Component: (props: DevtoolsPanelProps) => JSX.Element +}) { function Plugin() { return { - name: name, + config, render: (_el: HTMLElement, theme: 'light' | 'dark') => ( ), @@ -15,7 +20,7 @@ export function createReactPlugin( } function NoOpPlugin() { return { - name: name, + config, render: (_el: HTMLElement, _theme: 'light' | 'dark') => <>, } } diff --git a/packages/devtools-utils/src/solid/plugin.tsx b/packages/devtools-utils/src/solid/plugin.tsx index 967dfac4..2bc378e6 100644 --- a/packages/devtools-utils/src/solid/plugin.tsx +++ b/packages/devtools-utils/src/solid/plugin.tsx @@ -3,13 +3,18 @@ import type { JSX } from 'solid-js' import type { DevtoolsPanelProps } from './panel' -export function createSolidPlugin( - name: string, - Component: (props: DevtoolsPanelProps) => JSX.Element, -) { +export function createSolidPlugin({ + Component, + ...config +}: { + name: string + id?: string + defaultOpen?: boolean + Component: (props: DevtoolsPanelProps) => JSX.Element +}) { function Plugin() { return { - name: name, + ...config, render: (_el: HTMLElement, theme: 'light' | 'dark') => { return }, @@ -17,7 +22,7 @@ export function createSolidPlugin( } function NoOpPlugin() { return { - name: name, + ...config, render: (_el: HTMLElement, _theme: 'light' | 'dark') => <>, } } diff --git a/packages/devtools/src/context/devtools-context.test.ts b/packages/devtools/src/context/devtools-context.test.ts index 1adff919..40c4add6 100644 --- a/packages/devtools/src/context/devtools-context.test.ts +++ b/packages/devtools/src/context/devtools-context.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it } from 'vitest' import { TANSTACK_DEVTOOLS_STATE } from '../utils/storage' -import { getStateFromLocalStorage } from './devtools-context' +import { + getExistingStateFromStorage, + getStateFromLocalStorage, +} from './devtools-context' +import type { TanStackDevtoolsPlugin } from './devtools-context' describe('getStateFromLocalStorage', () => { beforeEach(() => { @@ -56,4 +60,267 @@ describe('getStateFromLocalStorage', () => { const state = getStateFromLocalStorage(undefined) expect(state).toEqual(undefined) }) + + it('should return undefined when no localStorage state exists (allowing defaultOpen to be applied)', () => { + // No existing state in localStorage - this allows defaultOpen logic to trigger + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + defaultOpen: true, + }, + { + id: 'plugin2', + render: () => {}, + name: 'Plugin 2', + defaultOpen: false, + }, + { + id: 'plugin3', + render: () => {}, + name: 'Plugin 3', + defaultOpen: true, + }, + ] + + // When undefined is returned, getExistingStateFromStorage will fill activePlugins with defaultOpen plugins + const state = getStateFromLocalStorage(plugins) + expect(state).toEqual(undefined) + }) + + it('should preserve existing activePlugins from localStorage (defaultOpen should not override)', () => { + const mockState = { + activePlugins: ['plugin2'], + settings: { + theme: 'dark', + }, + } + localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) + + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + defaultOpen: true, + }, + { + id: 'plugin2', + render: () => {}, + name: 'Plugin 2', + defaultOpen: false, + }, + ] + + const state = getStateFromLocalStorage(plugins) + // Should keep existing activePlugins - defaultOpen logic won't override in getExistingStateFromStorage + expect(state?.activePlugins).toEqual(['plugin2']) + }) + + it('should automatically activate a single plugin when no active plugins exist', () => { + // No existing state in localStorage + const plugins: Array = [ + { + id: 'only-plugin', + render: () => {}, + name: 'Only Plugin', + }, + ] + + const state = getStateFromLocalStorage(plugins) + // Should return undefined - the single plugin activation happens in getExistingStateFromStorage + expect(state).toEqual(undefined) + }) +}) + +describe('getExistingStateFromStorage - integration tests', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('should automatically activate a single plugin when no localStorage state exists', () => { + const plugins: Array = [ + { + id: 'only-plugin', + render: () => {}, + name: 'Only Plugin', + }, + ] + + const state = getExistingStateFromStorage(undefined, plugins) + expect(state.state.activePlugins).toEqual(['only-plugin']) + expect(state.plugins).toHaveLength(1) + expect(state.plugins![0]?.id).toBe('only-plugin') + }) + + it('should activate plugins with defaultOpen: true when no localStorage state exists', () => { + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + defaultOpen: true, + }, + { + id: 'plugin2', + render: () => {}, + name: 'Plugin 2', + defaultOpen: false, + }, + { + id: 'plugin3', + render: () => {}, + name: 'Plugin 3', + defaultOpen: true, + }, + ] + + const state = getExistingStateFromStorage(undefined, plugins) + expect(state.state.activePlugins).toEqual(['plugin1', 'plugin3']) + expect(state.plugins).toHaveLength(3) + }) + + it('should limit defaultOpen plugins to MAX_ACTIVE_PLUGINS (3) when 5 have defaultOpen: true', () => { + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + defaultOpen: true, + }, + { + id: 'plugin2', + render: () => {}, + name: 'Plugin 2', + defaultOpen: true, + }, + { + id: 'plugin3', + render: () => {}, + name: 'Plugin 3', + defaultOpen: true, + }, + { + id: 'plugin4', + render: () => {}, + name: 'Plugin 4', + defaultOpen: true, + }, + { + id: 'plugin5', + render: () => {}, + name: 'Plugin 5', + defaultOpen: true, + }, + ] + + const state = getExistingStateFromStorage(undefined, plugins) + // Should only activate first 3 plugins + expect(state.state.activePlugins).toEqual(['plugin1', 'plugin2', 'plugin3']) + expect(state.state.activePlugins).toHaveLength(3) + expect(state.state.activePlugins).not.toContain('plugin4') + expect(state.state.activePlugins).not.toContain('plugin5') + // All 5 plugins should still be in the plugins array + expect(state.plugins).toHaveLength(5) + }) + + it('should preserve existing activePlugins from localStorage even when plugins have defaultOpen', () => { + const mockState = { + activePlugins: ['plugin2', 'plugin4'], + settings: { + theme: 'dark', + }, + } + localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) + + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + defaultOpen: true, + }, + { + id: 'plugin2', + render: () => {}, + name: 'Plugin 2', + defaultOpen: false, + }, + { + id: 'plugin3', + render: () => {}, + name: 'Plugin 3', + defaultOpen: true, + }, + { + id: 'plugin4', + render: () => {}, + name: 'Plugin 4', + defaultOpen: false, + }, + ] + + const state = getExistingStateFromStorage(undefined, plugins) + // Should preserve the localStorage state, not use defaultOpen + expect(state.state.activePlugins).toEqual(['plugin2', 'plugin4']) + expect(state.plugins).toHaveLength(4) + }) + + it('should return empty activePlugins when no defaultOpen and multiple plugins', () => { + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + }, + { + id: 'plugin2', + render: () => {}, + name: 'Plugin 2', + }, + { + id: 'plugin3', + render: () => {}, + name: 'Plugin 3', + }, + ] + + const state = getExistingStateFromStorage(undefined, plugins) + expect(state.state.activePlugins).toEqual([]) + expect(state.plugins).toHaveLength(3) + }) + + it('should handle single plugin with defaultOpen: false by activating it anyway', () => { + const plugins: Array = [ + { + id: 'only-plugin', + render: () => {}, + name: 'Only Plugin', + defaultOpen: false, + }, + ] + + const state = getExistingStateFromStorage(undefined, plugins) + // Single plugin should be activated regardless of defaultOpen flag + expect(state.state.activePlugins).toEqual(['only-plugin']) + }) + + it('should merge config settings into the returned state', () => { + const plugins: Array = [ + { + id: 'plugin1', + render: () => {}, + name: 'Plugin 1', + }, + ] + + const config = { + theme: 'light' as const, + } + + const state = getExistingStateFromStorage(config as any, plugins) + expect(state.settings.theme).toBe('light') + expect(state.state.activePlugins).toEqual(['plugin1']) + }) }) diff --git a/packages/devtools/src/context/devtools-context.tsx b/packages/devtools/src/context/devtools-context.tsx index 813690e8..fff9a3ac 100644 --- a/packages/devtools/src/context/devtools-context.tsx +++ b/packages/devtools/src/context/devtools-context.tsx @@ -1,5 +1,6 @@ import { createContext, createEffect } from 'solid-js' import { createStore } from 'solid-js/store' +import { getDefaultActivePlugins } from '../utils/get-default-active-plugins' import { tryParseJson } from '../utils/sanitize' import { TANSTACK_DEVTOOLS_SETTINGS, @@ -49,6 +50,12 @@ export interface TanStackDevtoolsPlugin { * If not provided, it will be generated based on the name. */ id?: string + /** + * Whether the plugin should be open by default when there are no active plugins. + * If true, this plugin will be added to activePlugins on initial load when activePlugins is empty. + * @default false + */ + defaultOpen?: boolean /** * Render the plugin UI by using the provided element. This function will be called * when the plugin tab is clicked and expected to be mounted. @@ -127,26 +134,39 @@ export function getStateFromLocalStorage( return existingState } -const getExistingStateFromStorage = ( +export const getExistingStateFromStorage = ( config?: TanStackDevtoolsConfig, plugins?: Array, ) => { const existingState = getStateFromLocalStorage(plugins) const settings = getSettings() + const pluginsWithIds = + plugins?.map((plugin, i) => { + const id = generatePluginId(plugin, i) + return { + ...plugin, + id, + } + }) || [] + + // If no active plugins exist, add plugins with defaultOpen: true + // Or if there's only 1 plugin, activate it automatically + let activePlugins = existingState?.activePlugins || [] + + const shouldFillWithDefaultOpenPlugins = + activePlugins.length === 0 && pluginsWithIds.length > 0 + if (shouldFillWithDefaultOpenPlugins) { + activePlugins = getDefaultActivePlugins(pluginsWithIds) + } + const state: DevtoolsStore = { ...initialState, - plugins: - plugins?.map((plugin, i) => { - const id = generatePluginId(plugin, i) - return { - ...plugin, - id, - } - }) || [], + plugins: pluginsWithIds, state: { ...initialState.state, ...existingState, + activePlugins, }, settings: { ...initialState.settings, diff --git a/packages/devtools/src/context/use-devtools-context.ts b/packages/devtools/src/context/use-devtools-context.ts index d61115d0..217c3d6b 100644 --- a/packages/devtools/src/context/use-devtools-context.ts +++ b/packages/devtools/src/context/use-devtools-context.ts @@ -1,4 +1,5 @@ import { createEffect, createMemo, useContext } from 'solid-js' +import { MAX_ACTIVE_PLUGINS } from '../utils/constants.js' import { DevtoolsContext } from './devtools-context.jsx' import { useDrawContext } from './draw-context.jsx' @@ -50,7 +51,7 @@ export const usePlugins = () => { const updatedPlugins = isActive ? prev.state.activePlugins.filter((id) => id !== pluginId) : [...prev.state.activePlugins, pluginId] - if (updatedPlugins.length > 3) return prev + if (updatedPlugins.length > MAX_ACTIVE_PLUGINS) return prev return { ...prev, state: { diff --git a/packages/devtools/src/utils/constants.ts b/packages/devtools/src/utils/constants.ts new file mode 100644 index 00000000..16bd59f0 --- /dev/null +++ b/packages/devtools/src/utils/constants.ts @@ -0,0 +1,4 @@ +/** + * Maximum number of plugins that can be active simultaneously in the devtools + */ +export const MAX_ACTIVE_PLUGINS = 3 diff --git a/packages/devtools/src/utils/get-default-active-plugins.test.ts b/packages/devtools/src/utils/get-default-active-plugins.test.ts new file mode 100644 index 00000000..e740f3b6 --- /dev/null +++ b/packages/devtools/src/utils/get-default-active-plugins.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultActivePlugins } from './get-default-active-plugins' +import type { PluginWithId } from './get-default-active-plugins' + +describe('getDefaultActivePlugins', () => { + it('should return empty array when no plugins provided', () => { + const result = getDefaultActivePlugins([]) + expect(result).toEqual([]) + }) + + it('should automatically activate a single plugin', () => { + const plugins: Array = [ + { + id: 'only-plugin', + }, + ] + + const result = getDefaultActivePlugins(plugins) + expect(result).toEqual(['only-plugin']) + }) + + it('should automatically activate a single plugin even if defaultOpen is false', () => { + const plugins: Array = [ + { + id: 'only-plugin', + defaultOpen: false, + }, + ] + + const result = getDefaultActivePlugins(plugins) + expect(result).toEqual(['only-plugin']) + }) + + it('should return empty array when multiple plugins without defaultOpen', () => { + const plugins: Array = [ + { + id: 'plugin1', + }, + { + id: 'plugin2', + }, + { + id: 'plugin3', + }, + ] + + const result = getDefaultActivePlugins(plugins) + expect(result).toEqual([]) + }) + + it('should activate plugins with defaultOpen: true', () => { + const plugins: Array = [ + { + id: 'plugin1', + defaultOpen: true, + }, + { + id: 'plugin2', + defaultOpen: false, + }, + { + id: 'plugin3', + defaultOpen: true, + }, + ] + + const result = getDefaultActivePlugins(plugins) + expect(result).toEqual(['plugin1', 'plugin3']) + }) + + it('should limit defaultOpen plugins to MAX_ACTIVE_PLUGINS (3)', () => { + const plugins: Array = [ + { + id: 'plugin1', + defaultOpen: true, + }, + { + id: 'plugin2', + defaultOpen: true, + }, + { + id: 'plugin3', + defaultOpen: true, + }, + { + id: 'plugin4', + defaultOpen: true, + }, + { + id: 'plugin5', + defaultOpen: true, + }, + ] + + const result = getDefaultActivePlugins(plugins) + // Should only return first 3 + expect(result).toEqual(['plugin1', 'plugin2', 'plugin3']) + expect(result.length).toBe(3) + }) + + it('should activate exactly MAX_ACTIVE_PLUGINS when that many have defaultOpen', () => { + const plugins: Array = [ + { + id: 'plugin1', + defaultOpen: true, + }, + { + id: 'plugin2', + defaultOpen: true, + }, + { + id: 'plugin3', + defaultOpen: true, + }, + { + id: 'plugin4', + defaultOpen: false, + }, + ] + + const result = getDefaultActivePlugins(plugins) + expect(result).toEqual(['plugin1', 'plugin2', 'plugin3']) + expect(result.length).toBe(3) + }) + + it('should handle mix of defaultOpen true/false/undefined', () => { + const plugins: Array = [ + { + id: 'plugin1', + defaultOpen: true, + }, + { + id: 'plugin2', + // undefined defaultOpen + }, + { + id: 'plugin3', + defaultOpen: false, + }, + { + id: 'plugin4', + defaultOpen: true, + }, + ] + + const result = getDefaultActivePlugins(plugins) + // Only plugin1 and plugin4 have defaultOpen: true + expect(result).toEqual(['plugin1', 'plugin4']) + }) + + it('should return single plugin even if it has defaultOpen: true', () => { + const plugins: Array = [ + { + id: 'only-plugin', + defaultOpen: true, + }, + ] + + const result = getDefaultActivePlugins(plugins) + expect(result).toEqual(['only-plugin']) + }) + + it('should stop at MAX_ACTIVE_PLUGINS limit when 5 plugins have defaultOpen: true', () => { + const plugins: Array = [ + { + id: 'plugin1', + defaultOpen: true, + }, + { + id: 'plugin2', + defaultOpen: true, + }, + { + id: 'plugin3', + defaultOpen: true, + }, + { + id: 'plugin4', + defaultOpen: true, + }, + { + id: 'plugin5', + defaultOpen: true, + }, + ] + + const result = getDefaultActivePlugins(plugins) + // Should only activate the first 3, plugin4 and plugin5 should be ignored + expect(result).toEqual(['plugin1', 'plugin2', 'plugin3']) + expect(result.length).toBe(3) + expect(result).not.toContain('plugin4') + expect(result).not.toContain('plugin5') + }) +}) diff --git a/packages/devtools/src/utils/get-default-active-plugins.ts b/packages/devtools/src/utils/get-default-active-plugins.ts new file mode 100644 index 00000000..b6d6814d --- /dev/null +++ b/packages/devtools/src/utils/get-default-active-plugins.ts @@ -0,0 +1,36 @@ +import { MAX_ACTIVE_PLUGINS } from './constants' + +export interface PluginWithId { + id: string + defaultOpen?: boolean +} + +/** + * Determines which plugins should be active by default when no plugins are currently active. + * + * Rules: + * 1. If there's only 1 plugin, activate it automatically + * 2. If there are multiple plugins, activate those with defaultOpen: true (up to MAX_ACTIVE_PLUGINS limit) + * 3. If no plugins have defaultOpen: true, return empty array + * + * @param plugins - Array of plugins with IDs + * @returns Array of plugin IDs that should be active by default + */ +export function getDefaultActivePlugins( + plugins: Array, +): Array { + if (plugins.length === 0) { + return [] + } + + // If there's only 1 plugin, activate it automatically + if (plugins.length === 1) { + return [plugins[0]!.id] + } + + // Otherwise, activate plugins with defaultOpen: true (up to the limit) + return plugins + .filter((plugin) => plugin.defaultOpen === true) + .slice(0, MAX_ACTIVE_PLUGINS) + .map((plugin) => plugin.id) +}