diff --git a/.changeset/little-impalas-applaud.md b/.changeset/little-impalas-applaud.md new file mode 100644 index 00000000000..43a9b4119ca --- /dev/null +++ b/.changeset/little-impalas-applaud.md @@ -0,0 +1,14 @@ +--- +#'@graphiql/plugin-history': minor +#'@graphiql/toolkit': minor +#'@graphiql/react': minor +#'graphiql': minor +--- + +remove `createLocalStorage` from `@graphiql/toolkit` + +deprecate `useStorage` and `useTheme` hooks, use `useGraphiQLActions` and `useGraphiQL` hooks instead. + +remove `StorageAPI`, replace it with `persist` and `createJSONStorage` from `zustand/middleware` + +remove unused CodeMirror CSS classes from GraphiQL 4 diff --git a/docs/migration/graphiql-5.0.0.md b/docs/migration/graphiql-5.0.0.md index 1b500b78dec..1472b66f54e 100644 --- a/docs/migration/graphiql-5.0.0.md +++ b/docs/migration/graphiql-5.0.0.md @@ -138,14 +138,14 @@ function App() { > Clicking on a reference in the Query editor now works by holding `Cmd` on macOS or `Ctrl` on Windows/Linux. - `usePrettifyEditors`, `useCopyQuery`, `useMergeQuery`, `useExecutionContext`, `usePluginContext`, `useSchemaContext`, `useStorageContext` hooks are deprecated. -- Add new `useGraphiQL` and `useGraphiQLActions` hooks instead. See updated [README](../../packages/graphiql-react/README.md#available-stores) for more details about them. +- Add new `useGraphiQL` and `useGraphiQLActions` hooks instead. See updated [README](../../packages/graphiql-react/README.md#core-hooks) for more details about them. - remove `useSynchronizeValue` hook - fix `defaultQuery` with empty string does not result in an empty default query - fix `defaultQuery`, when is set will only be used for the first tab. When opening more tabs, the query editor will start out empty - fix execute query shortcut in query editor, run it even there are no operations in query editor - fix plugin store, save last opened plugin in storage - remove `onClickReference` from variable editor -- fix shortcut text per OS for default query and in run query in execute query button's tooltip +- fix shortcut text per OS for a default query and in run query in execute query button's tooltip The `ToolbarMenu` component has changed. diff --git a/examples/graphiql-webpack/src/index.jsx b/examples/graphiql-webpack/src/index.jsx index d7c8c00a03d..2510a848311 100644 --- a/examples/graphiql-webpack/src/index.jsx +++ b/examples/graphiql-webpack/src/index.jsx @@ -6,7 +6,7 @@ import { explorerPlugin } from '@graphiql/plugin-explorer'; import { getSnippets } from './snippets'; import { codeExporterPlugin } from '@graphiql/plugin-code-exporter'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; -import { useStorage } from '@graphiql/react'; +import { useGraphiQL } from '@graphiql/react'; import { serverSelectPlugin, LAST_URL_KEY } from './select-server-plugin'; import 'graphiql/setup-workers/webpack'; import './index.css'; @@ -91,8 +91,8 @@ function App() { * provider tree. `` must be rendered as a child of ``. */ function GraphiQLStorageBound({ setUrl }) { - const storage = useStorage(); - const lastUrl = storage.get(LAST_URL_KEY) ?? STARTING_URL; + const storage = useGraphiQL(state => state.storage); + const lastUrl = storage.getItem(LAST_URL_KEY) ?? STARTING_URL; useEffect(() => { setUrl(lastUrl); diff --git a/examples/graphiql-webpack/src/select-server-plugin.jsx b/examples/graphiql-webpack/src/select-server-plugin.jsx index 6c4715ab1fa..f61975f0b8a 100644 --- a/examples/graphiql-webpack/src/select-server-plugin.jsx +++ b/examples/graphiql-webpack/src/select-server-plugin.jsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useStorage, useSchemaStore } from '@graphiql/react'; +import { useGraphiQL, useSchemaStore } from '@graphiql/react'; export const LAST_URL_KEY = 'lastURL'; @@ -7,8 +7,8 @@ export const PREV_URLS_KEY = 'previousURLs'; const SelectServer = ({ url, setUrl }) => { const inputRef = React.useRef(null); - const storage = useStorage(); - const lastUrl = storage.get(LAST_URL_KEY); + const storage = useGraphiQL(state => state.storage); + const lastUrl = storage.getItem(LAST_URL_KEY); const currentUrl = lastUrl ?? url; const [inputValue, setInputValue] = React.useState(currentUrl); const [previousUrls, setPreviousUrls] = React.useState( diff --git a/packages/graphiql-plugin-doc-explorer/src/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index e0bb2c97a8a..0541411f072 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/context.tsx @@ -33,7 +33,7 @@ export const DOC_EXPLORER_PLUGIN: GraphiQLPlugin = { title: 'Documentation Explorer', icon: function Icon() { const visiblePlugin = useGraphiQL(state => state.visiblePlugin); - return visiblePlugin === DOC_EXPLORER_PLUGIN ? ( + return visiblePlugin === DOC_EXPLORER_PLUGIN.title ? ( ) : ( diff --git a/packages/graphiql-plugin-history/src/context.ts b/packages/graphiql-plugin-history/src/context.ts index a3375e7409f..b21856cda4e 100644 --- a/packages/graphiql-plugin-history/src/context.ts +++ b/packages/graphiql-plugin-history/src/context.ts @@ -4,12 +4,7 @@ import { HistoryStore as ToolkitHistoryStore, QueryStoreItem, } from '@graphiql/toolkit'; -import { - useGraphiQL, - pick, - useStorage, - createBoundedUseStore, -} from '@graphiql/react'; +import { useGraphiQL, pick, createBoundedUseStore } from '@graphiql/react'; const historyStore = createStore((set, get) => ({ historyStorage: null, @@ -44,7 +39,7 @@ type HistoryStoreType = { actions: { /** * Add an operation to the history. - * @param operation The operation that was executed, consisting of the query, + * @param operation - The operation that was executed, consisting of the query, * variables, headers, and operation name. */ addToHistory(operation: { @@ -55,11 +50,11 @@ type HistoryStoreType = { }): void; /** * Change the custom label of an item from the history. - * @param args An object containing the label (`undefined` if it should be + * @param args - An object containing the label (`undefined` if it should be * unset) and properties that identify the history item that the label should * be applied to. (This can result in the label being applied to multiple * history items.) - * @param index Index to edit. Without it, will look for the first index matching the + * @param index - Index to edit. Without it, will look for the first index matching the * operation, which may lead to misleading results if multiple items have the same label */ editLabel( @@ -90,9 +85,9 @@ type HistoryStoreType = { }): void; /** * Delete an operation from the history. - * @param args The operation that was executed, consisting of the query, + * @param args - The operation that was executed, consisting of the query, * variables, headers, and operation name. - * @param clearFavorites This is only if you press the 'clear' button + * @param clearFavorites - This is only if you press the 'clear' button */ deleteFromHistory(args: QueryStoreItem, clearFavorites?: boolean): void; /** @@ -126,7 +121,7 @@ export const HistoryStore: FC = ({ pick('isFetching', 'tabs', 'activeTabIndex'), ); const activeTab = tabs[activeTabIndex]!; - const storage = useStorage(); + const storage = useGraphiQL(state => state.storage); const historyStorage = // eslint-disable-line react-hooks/exhaustive-deps -- false positive, code is optimized by React Compiler new ToolkitHistoryStore(storage, maxHistoryLength); diff --git a/packages/graphiql-react/README.md b/packages/graphiql-react/README.md index 51e0eeec5a2..2f61ac5c237 100644 --- a/packages/graphiql-react/README.md +++ b/packages/graphiql-react/README.md @@ -81,28 +81,26 @@ Further details on how to use `@graphiql/react` can be found in the reference implementation of a GraphQL IDE - Graph*i*QL - in the [`graphiql` package](https://github.com/graphql/graphiql/blob/main/packages/graphiql/src/components/GraphiQL.tsx). -## Available Stores +## Core Hooks GraphiQL uses a set of state management stores, each responsible for a specific part of the IDE's behavior. These stores contain all logic related to state management and can be accessed via custom React hooks. -### Core Hooks - -- **`useStorage`**: Provides a storage API that can be used to persist state in the browser (by default using `localStorage`). -- **`useTheme`**: Manages the current theme and provides a method to update it. - **`useGraphiQL`**: Access the current state. - **`useGraphiQLActions`**: Trigger actions that mutate the state. This hook **never** rerenders. The `useGraphiQLActions` hook **exposes all actions** across store slices. The `useGraphiQL` hook **provides access to the following store slices**: -| Store Slice | Responsibilities | -| ----------- | -------------------------------------------------------------------------------- | -| `editor` | Manages **query**, **variables**, **headers**, and **response** editors and tabs | -| `execution` | Handles the execution of GraphQL requests | -| `plugin` | Manages plugins and the currently active plugin | -| `schema` | Fetches, validates, and stores the GraphQL schema | +| Store Slice | Responsibilities | +| ----------- | --------------------------------------------------------------------------------------------------------- | +| `storage` | Provides a storage API that can be used to persist state in the browser (by default using `localStorage`) | +| `theme` | Manages the current theme and provides a method to update it. | +| `editor` | Manages **query**, **variables**, **headers**, and **response** editors and tabs | +| `execution` | Handles the execution of GraphQL requests | +| `schema` | Fetches, validates, and stores the GraphQL schema | +| `plugin` | Manages plugins and the currently active plugin | ### Usage Example diff --git a/packages/graphiql-react/src/components/dialog/index.css b/packages/graphiql-react/src/components/dialog/index.css index 256b85b4871..58fa1711f9e 100644 --- a/packages/graphiql-react/src/components/dialog/index.css +++ b/packages/graphiql-react/src/components/dialog/index.css @@ -2,12 +2,6 @@ position: fixed; inset: 0; background-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); - /** - * CodeMirror has a `z-index` set for the container of the scrollbar of the - * editor, so we have to add one here to make sure that the dialog is shown - * above the editor scrollbar (if they are visible). - */ - z-index: 10; } .graphiql-dialog { diff --git a/packages/graphiql-react/src/components/header-editor.tsx b/packages/graphiql-react/src/components/header-editor.tsx index 9ecee471198..248942b0c81 100644 --- a/packages/graphiql-react/src/components/header-editor.tsx +++ b/packages/graphiql-react/src/components/header-editor.tsx @@ -1,13 +1,12 @@ import { FC, useEffect, useRef } from 'react'; import { useGraphiQL, useGraphiQLActions } from './provider'; import type { EditorProps } from '../types'; -import { HEADER_URI, KEY_BINDINGS, STORAGE_KEY } from '../constants'; +import { HEADER_URI, KEY_BINDINGS } from '../constants'; import { getOrCreateModel, createEditor, - useChangeHandler, + debounce, onEditorContainerKeyDown, - pick, cleanupDisposables, cn, } from '../utility'; @@ -21,21 +20,21 @@ interface HeaderEditorProps extends EditorProps { } export const HeaderEditor: FC = ({ onEdit, ...props }) => { - const { setEditor, run, prettifyEditors, mergeQuery } = useGraphiQLActions(); - const { initialHeaders, shouldPersistHeaders } = useGraphiQL( - pick('initialHeaders', 'shouldPersistHeaders'), - ); + const { setEditor, run, prettifyEditors, mergeQuery, updateActiveTabValues } = + useGraphiQLActions(); + const initialHeaders = useGraphiQL(state => state.initialHeaders); const ref = useRef(null!); - useChangeHandler( - onEdit, - shouldPersistHeaders ? STORAGE_KEY.headers : null, - 'headers', - ); useEffect(() => { const model = getOrCreateModel({ uri: HEADER_URI, value: initialHeaders }); const editor = createEditor(ref, { model }); setEditor({ headerEditor: editor }); + const handleChange = debounce(100, () => { + const value = model.getValue(); + updateActiveTabValues({ headers: value }); + onEdit?.(value); + }); const disposables = [ + model.onDidChangeContent(handleChange), editor.addAction({ ...KEY_BINDINGS.runQuery, run }), editor.addAction({ ...KEY_BINDINGS.prettify, run: prettifyEditors }), editor.addAction({ ...KEY_BINDINGS.mergeFragments, run: mergeQuery }), diff --git a/packages/graphiql-react/src/components/markdown-content/index.css b/packages/graphiql-react/src/components/markdown-content/index.css index c08f0866c17..d9cbba77197 100644 --- a/packages/graphiql-react/src/components/markdown-content/index.css +++ b/packages/graphiql-react/src/components/markdown-content/index.css @@ -7,11 +7,7 @@ */ .graphiql-markdown-description, -.graphiql-markdown-deprecation, -.CodeMirror-hint-information-description, -.CodeMirror-hint-information-deprecation-reason, -.CodeMirror-info .info-description, -.CodeMirror-info .info-deprecation { +.graphiql-markdown-deprecation { & blockquote { margin-left: 0; margin-right: 0; @@ -68,9 +64,7 @@ } } -.graphiql-markdown-description, -.CodeMirror-hint-information-description, -.CodeMirror-info .info-description { +.graphiql-markdown-description { & a { color: hsl(var(--color-primary)); text-decoration: none; @@ -95,9 +89,7 @@ } } -.graphiql-markdown-deprecation, -.CodeMirror-hint-information-deprecation-reason, -.CodeMirror-info .info-deprecation { +.graphiql-markdown-deprecation { & a { color: hsl(var(--color-warning)); text-decoration: underline; @@ -120,30 +112,3 @@ .graphiql-markdown-preview > :not(:first-child) { display: none; } - -/** - * We show deprecations in the following places: - * - In the hint tooltip when typing in the query editor. - * - In the info tooltip when hovering over a field in the query editor. - */ - -.CodeMirror-hint-information-deprecation, -.CodeMirror-info .info-deprecation { - background-color: hsla(var(--color-warning), var(--alpha-background-light)); - border: 1px solid hsl(var(--color-warning)); - border-radius: var(--border-radius-4); - color: hsl(var(--color-warning)); - margin-top: var(--px-12); - padding: var(--px-6) var(--px-8); -} - -.CodeMirror-hint-information-deprecation-label, -.CodeMirror-info .info-deprecation-label { - font-size: var(--font-size-hint); - font-weight: var(--font-weight-medium); -} - -.CodeMirror-hint-information-deprecation-reason, -.CodeMirror-info .info-deprecation-reason { - margin-top: var(--px-6); -} diff --git a/packages/graphiql-react/src/components/provider.tsx b/packages/graphiql-react/src/components/provider.tsx index 98df19e15df..30b88f9412c 100644 --- a/packages/graphiql-react/src/components/provider.tsx +++ b/packages/graphiql-react/src/components/provider.tsx @@ -1,20 +1,23 @@ /* eslint sort-keys: "error" */ -import type { ComponentPropsWithoutRef, FC, ReactNode, RefObject } from 'react'; +import type { FC, ReactNode, RefObject } from 'react'; import { createContext, useContext, useRef, useEffect } from 'react'; import { create, useStore, UseBoundStore, StoreApi } from 'zustand'; import { useShallow } from 'zustand/shallow'; +import { persist, createJSONStorage } from 'zustand/middleware'; import { createEditorSlice, createExecutionSlice, createPluginSlice, createSchemaSlice, + createThemeSlice, + createStorageSlice, EditorProps, ExecutionProps, PluginProps, SchemaProps, + ThemeProps, + StorageProps, } from '../stores'; -import { StorageStore, useStorage } from '../stores/storage'; -import { ThemeStore } from '../stores/theme'; import type { SlicesWithActions } from '../types'; import { useDidUpdate } from '../utility'; import { @@ -24,39 +27,66 @@ import { isSchema, validateSchema, } from 'graphql'; -import { - DEFAULT_PRETTIFY_QUERY, - DEFAULT_QUERY, - STORAGE_KEY, -} from '../constants'; +import { DEFAULT_PRETTIFY_QUERY, DEFAULT_QUERY } from '../constants'; import { getDefaultTabState } from '../utility/tabs'; +import { EDITOR_THEME } from '../utility/create-editor'; -interface InnerGraphiQLProviderProps +interface GraphiQLProviderProps extends EditorProps, ExecutionProps, PluginProps, - SchemaProps { + SchemaProps, + ThemeProps, + StorageProps { children: ReactNode; } -type GraphiQLProviderProps = - // - InnerGraphiQLProviderProps & - ComponentPropsWithoutRef & - ComponentPropsWithoutRef; - type GraphiQLStore = UseBoundStore>; const GraphiQLContext = createContext | null>(null); export const GraphiQLProvider: FC = ({ - storage, - defaultTheme, - editorTheme, + defaultHeaders, + defaultQuery = DEFAULT_QUERY, + defaultTabs, + externalFragments, + onEditOperationName, + onTabChange, + shouldPersistHeaders = false, + onCopyQuery, + onPrettifyQuery = DEFAULT_PRETTIFY_QUERY, + + dangerouslyAssumeSchemaIsValid = false, + fetcher, + inputValueDeprecation = false, + introspectionQueryName = 'IntrospectionQuery', + onSchemaChange, + schema, + schemaDescription = false, + + getDefaultFieldNames, + operationName = null, + + onTogglePluginVisibility, + plugins = [], + referencePlugin, + visiblePlugin, + + children, + + defaultTheme = null, + editorTheme = EDITOR_THEME, + + storage = createJSONStorage(() => localStorage), + + initialQuery, + initialVariables, + initialHeaders, + ...props }) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check - if (!props.fetcher) { + if (!fetcher) { throw new TypeError( 'The `GraphiQLProvider` component requires a `fetcher` function to be passed as prop.', ); @@ -115,153 +145,117 @@ useEffect(() => { }, [response])`, ); } - return ( - - - - - - ); -}; - -const InnerGraphiQLProvider: FC = ({ - defaultHeaders, - defaultQuery = DEFAULT_QUERY, - defaultTabs, - externalFragments, - onEditOperationName, - onTabChange, - shouldPersistHeaders = false, - onCopyQuery, - onPrettifyQuery = DEFAULT_PRETTIFY_QUERY, - - dangerouslyAssumeSchemaIsValid = false, - fetcher, - inputValueDeprecation = false, - introspectionQueryName = 'IntrospectionQuery', - onSchemaChange, - schema, - schemaDescription = false, - - getDefaultFieldNames, - operationName = null, - - onTogglePluginVisibility, - plugins = [], - referencePlugin, - visiblePlugin, - children, - - ...props -}) => { - const storage = useStorage(); const storeRef = useRef(null!); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- false positive if (storeRef.current === null) { - function getInitialVisiblePlugin() { - const storedValue = storage.get(STORAGE_KEY.visiblePlugin); - const pluginForStoredValue = plugins.find( - plugin => plugin.title === storedValue, - ); - if (pluginForStoredValue) { - return pluginForStoredValue; - } - if (storedValue) { - storage.set(STORAGE_KEY.visiblePlugin, ''); - } - return visiblePlugin; - } - function getInitialState() { - // We only need to compute it lazily during the initial render. - const query = props.initialQuery ?? storage.get(STORAGE_KEY.query); - const variables = - props.initialVariables ?? storage.get(STORAGE_KEY.variables); - const headers = props.initialHeaders ?? storage.get(STORAGE_KEY.headers); - + if (storage === undefined) { + throw new TypeError('Unexpected `storage` prop is undefined.'); + } const { tabs, activeTabIndex } = getDefaultTabState({ defaultHeaders, defaultQuery, defaultTabs, - headers, - query, - shouldPersistHeaders, - variables, + headers: initialHeaders, + query: initialQuery, + variables: initialVariables, }); - - const isStored = storage.get(STORAGE_KEY.persistHeaders) !== null; - - const $shouldPersistHeaders = - shouldPersistHeaders !== false && isStored - ? storage.get(STORAGE_KEY.persistHeaders) === 'true' - : shouldPersistHeaders; - - const store = create((...args) => { - const editorSlice = createEditorSlice({ - activeTabIndex, - defaultHeaders, - defaultQuery, - externalFragments: getExternalFragments(externalFragments), - initialHeaders: headers ?? defaultHeaders ?? '', - initialQuery: - query ?? (activeTabIndex === 0 ? tabs[0]!.query : null) ?? '', - initialVariables: variables ?? '', - onCopyQuery, - onEditOperationName, - onPrettifyQuery, - onTabChange, - shouldPersistHeaders: $shouldPersistHeaders, - tabs, - })(...args); - const executionSlice = createExecutionSlice({ - fetcher, - getDefaultFieldNames, - overrideOperationName: operationName, - })(...args); - const pluginSlice = createPluginSlice({ - onTogglePluginVisibility, - referencePlugin, - })(...args); - const schemaSlice = createSchemaSlice({ - inputValueDeprecation, - introspectionQueryName, - onSchemaChange, - schemaDescription, - })(...args); - return { - ...editorSlice, - ...executionSlice, - ...pluginSlice, - ...schemaSlice, - actions: { - ...editorSlice.actions, - ...executionSlice.actions, - ...pluginSlice.actions, - ...schemaSlice.actions, + const store = create()( + persist( + (...args) => { + const editorSlice = createEditorSlice({ + activeTabIndex, + defaultHeaders, + defaultQuery, + externalFragments: getExternalFragments(externalFragments), + onCopyQuery, + onEditOperationName, + onPrettifyQuery, + onTabChange, + shouldPersistHeaders, + tabs, + })(...args); + const executionSlice = createExecutionSlice({ + fetcher, + getDefaultFieldNames, + overrideOperationName: operationName, + })(...args); + const pluginSlice = createPluginSlice({ + onTogglePluginVisibility, + referencePlugin, + })(...args); + const schemaSlice = createSchemaSlice({ + inputValueDeprecation, + introspectionQueryName, + onSchemaChange, + schemaDescription, + })(...args); + const themeSlice = createThemeSlice({ editorTheme })(...args); + // @ts-expect-error -- fixme + const storageSlice = createStorageSlice({ storage })(...args); + return { + ...editorSlice, + ...executionSlice, + ...pluginSlice, + ...schemaSlice, + ...themeSlice, + ...storageSlice, + actions: { + ...editorSlice.actions, + ...executionSlice.actions, + ...pluginSlice.actions, + ...schemaSlice.actions, + ...themeSlice.actions, + }, + }; }, - }; - }); + { + name: 'graphiql:theme', + onRehydrateStorage(_state) { + return (state, error) => { + if (state) { + const theme = + state.theme === undefined ? defaultTheme : state.theme; + state.actions.setTheme(theme); + state.actions.setInitialValues({ + initialHeaders, + initialQuery, + initialVariables, + }); + } + if (error) { + // eslint-disable-next-line no-console + console.error('An error happened during hydration', error); + return; + } + // eslint-disable-next-line no-console + console.info('Hydration with storage finished'); + }; + }, + partialize(state) { + return { + activeTabIndex: state.activeTabIndex, + shouldPersistHeaders: state.shouldPersistHeaders, + tabs: state.shouldPersistHeaders + ? state.tabs + : state.tabs.map(tab => ({ ...tab, headers: null })), + theme: state.theme, + visiblePlugin: state.visiblePlugin, + }; + }, + storage, + }, + ), + ); const { actions } = store.getState(); - actions.storeTabs({ activeTabIndex, tabs }); actions.setPlugins(plugins); - const initialVisiblePlugin = getInitialVisiblePlugin(); - actions.setVisiblePlugin(initialVisiblePlugin); return store; } storeRef.current = getInitialState(); } - // TODO: - // const lastShouldPersistHeadersProp = useRef(undefined); - // useEffect(() => { - // const propValue = shouldPersistHeaders; - // if (lastShouldPersistHeadersProp.current !== propValue) { - // editorStore.getState().setShouldPersistHeaders(propValue); - // lastShouldPersistHeadersProp.current = propValue; - // } - // }, [shouldPersistHeaders]); // Execution sync useDidUpdate(() => { @@ -345,7 +339,7 @@ export function useGraphiQL(selector: (state: SlicesWithActions) => T): T { export const useGraphiQLActions = () => useGraphiQL(state => state.actions); function getExternalFragments( - externalFragments: InnerGraphiQLProviderProps['externalFragments'], + externalFragments: GraphiQLProviderProps['externalFragments'], ) { const map = new Map(); if (externalFragments) { diff --git a/packages/graphiql-react/src/components/query-editor.tsx b/packages/graphiql-react/src/components/query-editor.tsx index c32724715d0..26c13d07fa5 100644 --- a/packages/graphiql-react/src/components/query-editor.tsx +++ b/packages/graphiql-react/src/components/query-editor.tsx @@ -2,7 +2,6 @@ import { getSelectedOperationName } from '@graphiql/toolkit'; import type { DocumentNode } from 'graphql'; import { getOperationFacts } from 'graphql-language-service'; import { FC, useEffect, useRef } from 'react'; -import { useStorage } from '../stores'; import { useGraphiQL, useGraphiQLActions } from './provider'; import { debounce, @@ -14,12 +13,7 @@ import { cn, } from '../utility'; import type { MonacoEditor, EditorProps, SchemaReference } from '../types'; -import { - KEY_BINDINGS, - MONACO_GRAPHQL_API, - QUERY_URI, - STORAGE_KEY, -} from '../constants'; +import { KEY_BINDINGS, MONACO_GRAPHQL_API, QUERY_URI } from '../constants'; import { type editor as monacoEditor, languages, @@ -79,7 +73,6 @@ export const QueryEditor: FC = ({ 'externalFragments', ), ); - const storage = useStorage(); const ref = useRef(null!); const onClickReferenceRef = useRef( null!, @@ -210,13 +203,8 @@ export const QueryEditor: FC = ({ const model = getOrCreateModel({ uri: QUERY_URI, value: initialQuery }); const editor = createEditor(ref, { model }); setEditor({ queryEditor: editor }); - - // We don't use the generic `useChangeHandler` hook here because we want to - // have additional logic that updates the operation facts that we save in `editorStore` const handleChange = debounce(100, () => { - const query = editor.getValue(); - storage.set(STORAGE_KEY.query, query); - + const query = model.getValue(); const operationFacts = getAndUpdateOperationFacts(editor); // Invoke callback props only after the operation facts have been updated onEdit?.(query, operationFacts?.documentAST); diff --git a/packages/graphiql-react/src/components/response-editor.tsx b/packages/graphiql-react/src/components/response-editor.tsx index 8188d4efdab..9815d0cc7c3 100644 --- a/packages/graphiql-react/src/components/response-editor.tsx +++ b/packages/graphiql-react/src/components/response-editor.tsx @@ -21,6 +21,7 @@ type ResponseTooltipType = ComponentType<{ * A position in the editor. */ position: Position; + /** * Word that has been hovered over. */ diff --git a/packages/graphiql-react/src/components/variable-editor.tsx b/packages/graphiql-react/src/components/variable-editor.tsx index 11df4cc1041..a3fe0c92715 100644 --- a/packages/graphiql-react/src/components/variable-editor.tsx +++ b/packages/graphiql-react/src/components/variable-editor.tsx @@ -1,11 +1,11 @@ import { FC, useEffect, useRef } from 'react'; import { useGraphiQL, useGraphiQLActions } from './provider'; import type { EditorProps } from '../types'; -import { KEY_BINDINGS, STORAGE_KEY, VARIABLE_URI } from '../constants'; +import { KEY_BINDINGS, VARIABLE_URI } from '../constants'; import { getOrCreateModel, createEditor, - useChangeHandler, + debounce, onEditorContainerKeyDown, cleanupDisposables, cn, @@ -23,10 +23,10 @@ export const VariableEditor: FC = ({ onEdit, ...props }) => { - const { setEditor, run, prettifyEditors, mergeQuery } = useGraphiQLActions(); + const { setEditor, run, prettifyEditors, mergeQuery, updateActiveTabValues } = + useGraphiQLActions(); const initialVariables = useGraphiQL(state => state.initialVariables); const ref = useRef(null!); - useChangeHandler(onEdit, STORAGE_KEY.variables, 'variables'); useEffect(() => { const model = getOrCreateModel({ uri: VARIABLE_URI, @@ -34,7 +34,13 @@ export const VariableEditor: FC = ({ }); const editor = createEditor(ref, { model }); setEditor({ variableEditor: editor }); + const handleChange = debounce(100, () => { + const value = model.getValue(); + updateActiveTabValues({ variables: value }); + onEdit?.(value); + }); const disposables = [ + model.onDidChangeContent(handleChange), editor.addAction({ ...KEY_BINDINGS.runQuery, run }), editor.addAction({ ...KEY_BINDINGS.prettify, run: prettifyEditors }), editor.addAction({ ...KEY_BINDINGS.mergeFragments, run: mergeQuery }), diff --git a/packages/graphiql-react/src/constants.ts b/packages/graphiql-react/src/constants.ts index d52f87ad778..25f91c98f88 100644 --- a/packages/graphiql-react/src/constants.ts +++ b/packages/graphiql-react/src/constants.ts @@ -42,16 +42,6 @@ export const KEY_MAP = Object.freeze({ }, }); -export const STORAGE_KEY = { - headers: 'headers', - visiblePlugin: 'visiblePlugin', - query: 'query', - variables: 'variables', - tabs: 'tabState', - persistHeaders: 'shouldPersistHeaders', - theme: 'theme', -} as const; - export const DEFAULT_QUERY = `# Welcome to GraphiQL # # GraphiQL is an in-browser tool for writing, validating, and testing diff --git a/packages/graphiql-react/src/deprecated.ts b/packages/graphiql-react/src/deprecated.ts index 9faa27b9ac7..f7a8336cab9 100644 --- a/packages/graphiql-react/src/deprecated.ts +++ b/packages/graphiql-react/src/deprecated.ts @@ -1,7 +1,5 @@ -/* eslint-disable unicorn/prefer-export-from */ import { useGraphiQL, useGraphiQLActions } from './components'; import { pick } from './utility'; -import { useStorage } from './stores'; /** * @deprecated Use `const { prettifyEditors } = useGraphiQLActions()` instead. @@ -71,6 +69,22 @@ export function useSchemaContext() { } /** - * @deprecated Use `const storage = useStorage()` instead. + * @deprecated Use `const storage = useGraphiQL(state => state.storage)` instead. */ -export const useStorageContext = useStorage; +export function useStorageContext() { + return useGraphiQL(state => state.storage); +} + +/** + * @deprecated Use `const storage = useGraphiQL(state => state.storage)` instead. + */ +export const useStorage = useStorageContext; // eslint-disable-line @typescript-eslint/no-deprecated + +/** + * @deprecated Use `useGraphiQLActions` and `useGraphiQL` hooks instead. + */ +export function useTheme() { + const { setTheme } = useGraphiQLActions(); + const theme = useGraphiQL(state => state.theme); + return { setTheme, theme }; +} diff --git a/packages/graphiql-react/src/icons/history.svg b/packages/graphiql-react/src/icons/history.svg index ef69e5c9b70..1c0c9fa10e6 100644 --- a/packages/graphiql-react/src/icons/history.svg +++ b/packages/graphiql-react/src/icons/history.svg @@ -2,23 +2,14 @@ height="1em" viewBox="0 0 24 20" fill="none" + stroke-width="1.5" + stroke-linecap="square" + stroke="currentColor" xmlns="http://www.w3.org/2000/svg" > - - + + diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index bf030ad5b7f..ddc7d5a0642 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -1,13 +1,16 @@ import './style/root.css'; -export { useStorage, useTheme, type Theme } from './stores'; - export * from './utility'; export type { TabsState } from './utility/tabs'; export * from './icons'; export * from './components'; -export type { EditorProps, SchemaReference, SlicesWithActions } from './types'; +export type { + EditorProps, + SchemaReference, + SlicesWithActions, + Theme, +} from './types'; export type { GraphiQLPlugin } from './stores/plugin'; export { KEY_MAP, formatShortcutForOS, isMacOs } from './constants'; export * from './deprecated'; diff --git a/packages/graphiql-react/src/stores/editor.ts b/packages/graphiql-react/src/stores/editor.ts index fad01441b5a..17c1da6ab1b 100644 --- a/packages/graphiql-react/src/stores/editor.ts +++ b/packages/graphiql-react/src/stores/editor.ts @@ -7,19 +7,15 @@ import type { import type { OperationFacts } from 'graphql-language-service'; import { MaybePromise, mergeAst } from '@graphiql/toolkit'; import { print } from 'graphql'; -import { storageStore } from './storage'; import { createTab, setPropertiesInActiveTab, TabDefinition, TabsState, TabState, - clearHeadersFromTabs, - serializeTabState, } from '../utility/tabs'; import type { SlicesWithActions, MonacoEditor } from '../types'; -import { debounce, formatJSONC } from '../utility'; -import { STORAGE_KEY } from '../constants'; +import { formatJSONC } from '../utility'; export interface EditorSlice extends TabsState { /** @@ -199,8 +195,6 @@ export interface EditorActions { */ setShouldPersistHeaders(persist: boolean): void; - storeTabs(tabsState: TabsState): void; - setOperationFacts(facts: { documentAST?: DocumentNode; operationName?: string; @@ -221,6 +215,12 @@ export interface EditorActions { * Prettify query, variable and header editors. */ prettifyEditors: () => Promise; + + setInitialValues: (values: { + initialQuery?: string; + initialVariables?: string; + initialHeaders?: string; + }) => void; } export interface EditorProps @@ -276,9 +276,6 @@ type CreateEditorSlice = ( | 'shouldPersistHeaders' | 'tabs' | 'activeTabIndex' - | 'initialQuery' - | 'initialVariables' - | 'initialHeaders' | 'onEditOperationName' | 'externalFragments' | 'onTabChange' @@ -337,8 +334,18 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { } const $actions: EditorActions = { + setInitialValues({ initialHeaders, initialQuery, initialVariables }) { + set(({ tabs, activeTabIndex }) => { + const activeTab = tabs[activeTabIndex]!; + return { + initialHeaders: initialHeaders ?? activeTab.headers ?? '', + initialQuery: initialQuery ?? activeTab.query ?? '', + initialVariables: initialVariables ?? activeTab.variables ?? '', + }; + }); + }, addTab() { - set(({ defaultHeaders, onTabChange, tabs, activeTabIndex, actions }) => { + set(({ defaultHeaders, onTabChange, tabs, activeTabIndex }) => { // Make sure the current tab stores the latest values const updatedValues = synchronizeActiveTabValues({ tabs, @@ -348,7 +355,6 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { tabs: [...updatedValues.tabs, createTab({ headers: defaultHeaders })], activeTabIndex: updatedValues.tabs.length, }; - actions.storeTabs(updated); setEditorValues(updated.tabs[updated.activeTabIndex]!); onTabChange?.(updated); return updated; @@ -357,24 +363,19 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { changeTab(index) { set(({ actions, onTabChange, tabs }) => { actions.stop(); - const updated = { - tabs, - activeTabIndex: index, - }; - actions.storeTabs(updated); + const updated = { tabs, activeTabIndex: index }; setEditorValues(updated.tabs[updated.activeTabIndex]!); onTabChange?.(updated); return updated; }); }, moveTab(newOrder) { - set(({ onTabChange, actions, tabs, activeTabIndex }) => { + set(({ onTabChange, tabs, activeTabIndex }) => { const activeTab = tabs[activeTabIndex]!; const updated = { tabs: newOrder, activeTabIndex: newOrder.indexOf(activeTab), }; - actions.storeTabs(updated); setEditorValues(updated.tabs[updated.activeTabIndex]!); onTabChange?.(updated); return updated; @@ -389,19 +390,17 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { tabs: tabs.filter((_tab, i) => index !== i), activeTabIndex: Math.max(activeTabIndex - 1, 0), }; - actions.storeTabs(updated); setEditorValues(updated.tabs[updated.activeTabIndex]!); onTabChange?.(updated); return updated; }); }, updateActiveTabValues(partialTab) { - set(({ activeTabIndex, tabs, onTabChange, actions }) => { + set(({ activeTabIndex, tabs, onTabChange }) => { const updated = setPropertiesInActiveTab( { tabs, activeTabIndex }, partialTab, ); - actions.storeTabs(updated); onTabChange?.(updated); return updated; }); @@ -423,37 +422,11 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { return { operationName }; }); }, - setShouldPersistHeaders(persist) { - const { headerEditor, tabs, activeTabIndex } = get(); - const { storage } = storageStore.getState(); - if (persist) { - storage.set(STORAGE_KEY.headers, headerEditor?.getValue() ?? ''); - const serializedTabs = serializeTabState( - { tabs, activeTabIndex }, - true, - ); - storage.set(STORAGE_KEY.tabs, serializedTabs); - } else { - storage.set(STORAGE_KEY.headers, ''); - clearHeadersFromTabs(); - } - storage.set(STORAGE_KEY.persistHeaders, persist.toString()); - set({ shouldPersistHeaders: persist }); - }, - storeTabs({ tabs, activeTabIndex }) { - const { storage } = storageStore.getState(); - const { shouldPersistHeaders } = get(); - const store = debounce(500, (value: string) => { - storage.set(STORAGE_KEY.tabs, value); - }); - store(serializeTabState({ tabs, activeTabIndex }, shouldPersistHeaders)); + setShouldPersistHeaders(shouldPersistHeaders) { + set({ shouldPersistHeaders }); }, setOperationFacts({ documentAST, operationName, operations }) { - set({ - documentAST, - operationName, - operations, - }); + set({ documentAST, operationName, operations }); }, async copyQuery() { const { queryEditor, onCopyQuery } = get(); @@ -532,6 +505,9 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { return { ...initial, + initialQuery: '', + initialVariables: '', + initialHeaders: '', actions: $actions, }; }; diff --git a/packages/graphiql-react/src/stores/index.ts b/packages/graphiql-react/src/stores/index.ts index aa3401e9cf2..5a3705b2124 100644 --- a/packages/graphiql-react/src/stores/index.ts +++ b/packages/graphiql-react/src/stores/index.ts @@ -22,5 +22,14 @@ export { type SchemaActions, type SchemaProps, } from './schema'; -export { storageStore, useStorage } from './storage'; -export { themeStore, useTheme, type Theme } from './theme'; +export { + createStorageSlice, + type StorageSlice, + type StorageProps, +} from './storage'; +export { + createThemeSlice, + type ThemeSlice, + type ThemeActions, + type ThemeProps, +} from './theme'; diff --git a/packages/graphiql-react/src/stores/plugin.ts b/packages/graphiql-react/src/stores/plugin.ts index d87f4d0105d..a1d77c3c5dd 100644 --- a/packages/graphiql-react/src/stores/plugin.ts +++ b/packages/graphiql-react/src/stores/plugin.ts @@ -1,8 +1,6 @@ import type { ComponentType } from 'react'; import type { StateCreator } from 'zustand'; import type { SlicesWithActions } from '../types'; -import { storageStore } from './storage'; -import { STORAGE_KEY } from '../constants'; export interface GraphiQLPlugin { /** @@ -34,7 +32,7 @@ export interface PluginSlice { /** * The plugin which is currently visible. */ - visiblePlugin: GraphiQLPlugin | null; + visiblePlugin?: string; /** * The plugin which is used to display the reference documentation when selecting a type. @@ -94,10 +92,9 @@ type CreatePluginSlice = ( export const createPluginSlice: CreatePluginSlice = initial => set => ({ plugins: [], - visiblePlugin: null, ...initial, actions: { - setVisiblePlugin(plugin = null) { + setVisiblePlugin(plugin) { set(current => { const { visiblePlugin: currentVisiblePlugin, @@ -105,16 +102,14 @@ export const createPluginSlice: CreatePluginSlice = initial => set => ({ onTogglePluginVisibility, } = current; const byTitle = typeof plugin === 'string'; - const newVisiblePlugin: PluginSlice['visiblePlugin'] = - (plugin && plugins.find(p => (byTitle ? p.title : p) === plugin)) || - null; - if (newVisiblePlugin === currentVisiblePlugin) { + const newVisiblePlugin = plugins.find( + p => (byTitle ? p.title : p) === plugin, + ); + if (newVisiblePlugin?.title === currentVisiblePlugin) { return current; } - onTogglePluginVisibility?.(newVisiblePlugin); - const { storage } = storageStore.getState(); - storage.set(STORAGE_KEY.visiblePlugin, newVisiblePlugin?.title ?? ''); - return { visiblePlugin: newVisiblePlugin }; + onTogglePluginVisibility?.(newVisiblePlugin ?? null); + return { visiblePlugin: newVisiblePlugin?.title }; }); }, setPlugins(plugins) { diff --git a/packages/graphiql-react/src/stores/storage.ts b/packages/graphiql-react/src/stores/storage.ts index f4eafebe245..6061b4bb9e5 100644 --- a/packages/graphiql-react/src/stores/storage.ts +++ b/packages/graphiql-react/src/stores/storage.ts @@ -1,43 +1,34 @@ -import { Storage, StorageAPI } from '@graphiql/toolkit'; -import { FC, ReactElement, ReactNode, useEffect } from 'react'; -import { createStore } from 'zustand'; -import { createBoundedUseStore } from '../utility'; - -interface StorageStoreType { - storage: StorageAPI; +import type { StateCreator } from 'zustand'; +import type { PersistStorage, StateStorage } from 'zustand/middleware'; +import type { SlicesWithActions, Theme } from '../types'; +import type { TabState } from '../utility/tabs'; + +export type Storage = PersistStorage; + +interface GraphiQLPersistedState { + activeTabIndex: number; + shouldPersistHeaders: boolean; + tabs: TabState[]; + theme?: Theme; + visiblePlugin?: string; } -interface StorageStoreProps { - children: ReactNode; - +export interface StorageSlice { /** * Provide a custom storage API. - * @default localStorage - * @see {@link https://graphiql-test.netlify.app/typedoc/modules/graphiql_toolkit.html#storage-2|API docs} - * for details on the required interface. + * @default createJSONStorage(() => localStorage) + * @see https://zustand.docs.pmnd.rs/integrations/persisting-store-data#createjsonstorage */ - storage?: Storage; + storage: StateStorage; } -export const storageStore = createStore(() => ({ - storage: null!, -})); - -export const StorageStore: FC = ({ storage, children }) => { - const isMounted = useStorageStore(state => Boolean(state.storage)); - - useEffect(() => { - storageStore.setState({ storage: new StorageAPI(storage) }); - }, [storage]); - - if (!isMounted) { - // Ensure storage was initialized - return null; - } - - return children as ReactElement; -}; +export interface StorageProps { + storage?: Storage; +} -const useStorageStore = createBoundedUseStore(storageStore); +type CreateStorageSlice = ( + initial: StorageSlice, +) => StateCreator; -export const useStorage = () => useStorageStore(state => state.storage); +export const createStorageSlice: CreateStorageSlice = initial => _set => + initial; diff --git a/packages/graphiql-react/src/stores/theme.ts b/packages/graphiql-react/src/stores/theme.ts index eb90eb669b1..31884d3805f 100644 --- a/packages/graphiql-react/src/stores/theme.ts +++ b/packages/graphiql-react/src/stores/theme.ts @@ -1,34 +1,30 @@ -import { FC, ReactElement, ReactNode, useEffect } from 'react'; -import { storageStore } from './storage'; -import { createStore } from 'zustand'; -import { createBoundedUseStore } from '../utility'; -import { EDITOR_THEME } from '../utility/create-editor'; +import type { StateCreator } from 'zustand'; +import type { EDITOR_THEME } from '../utility/create-editor'; import { editor as monacoEditor } from '../monaco-editor'; -import { STORAGE_KEY } from '../constants'; - -/** - * The value `null` semantically means that the user does not explicitly choose - * any theme, so we use the system default. - */ -export type Theme = 'light' | 'dark' | null; +import type { SlicesWithActions, Theme } from '../types'; type MonacoTheme = | monacoEditor.BuiltinTheme | (typeof EDITOR_THEME)[keyof typeof EDITOR_THEME] | ({} & string); -interface ThemeStoreType { - theme: Theme; +export interface ThemeSlice { + theme?: Theme; + + editorTheme: { + dark: MonacoTheme; + light: MonacoTheme; + }; +} +export interface ThemeActions { /** * Set a new theme */ setTheme: (newTheme: Theme) => void; } -interface ThemeStoreProps { - children: ReactNode; - +export interface ThemeProps { /** * @default null */ @@ -38,60 +34,36 @@ interface ThemeStoreProps { * Sets the color theme for the monaco editors. * @default { dark: 'graphiql-DARK', light: 'graphiql-LIGHT' } */ - editorTheme?: { - dark: MonacoTheme; - light: MonacoTheme; - }; + editorTheme?: ThemeSlice['editorTheme']; } -export const themeStore = createStore(set => ({ - theme: null, - setTheme(theme) { - const { storage } = storageStore.getState(); - storage.set(STORAGE_KEY.theme, theme ?? ''); - set({ theme }); - }, -})); - -export const ThemeStore: FC = ({ - children, - defaultTheme = null, - editorTheme = EDITOR_THEME, -}) => { - const theme = useTheme(state => state.theme); - useEffect(() => { - const { storage } = storageStore.getState(); - - function getInitialTheme() { - const stored = storage.get(STORAGE_KEY.theme); - switch (stored) { - case 'light': - return 'light'; - case 'dark': - return 'dark'; - default: - if (typeof stored === 'string') { - // Remove the invalid stored value - storage.set(STORAGE_KEY.theme, ''); - } - return defaultTheme; - } - } +type CreateThemeSlice = ( + initial: Pick, +) => StateCreator< + SlicesWithActions, + [], + [], + ThemeSlice & { + actions: ThemeActions; + } +>; - themeStore.setState({ theme: getInitialTheme() }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount - - useEffect(() => { - document.body.classList.remove('graphiql-light', 'graphiql-dark'); - if (theme) { - document.body.classList.add(`graphiql-${theme}`); - } - const resolvedTheme = theme ?? getSystemTheme(); - monacoEditor.setTheme(editorTheme[resolvedTheme]); - }, [theme, editorTheme]); - - return children as ReactElement; -}; +export const createThemeSlice: CreateThemeSlice = initial => set => ({ + ...initial, + actions: { + setTheme(theme) { + set(({ editorTheme }) => { + document.body.classList.remove('graphiql-light', 'graphiql-dark'); + if (theme) { + document.body.classList.add(`graphiql-${theme}`); + } + const resolvedTheme = theme ?? getSystemTheme(); + monacoEditor.setTheme(editorTheme[resolvedTheme]); + return { theme }; + }); + }, + }, +}); /** * Get the resolved theme - dark or light @@ -103,5 +75,3 @@ function getSystemTheme() { const systemTheme = isDark ? 'dark' : 'light'; return systemTheme; } - -export const useTheme = createBoundedUseStore(themeStore); diff --git a/packages/graphiql-react/src/style/codemirror.css b/packages/graphiql-react/src/style/codemirror.css deleted file mode 100644 index ad674f80748..00000000000 --- a/packages/graphiql-react/src/style/codemirror.css +++ /dev/null @@ -1,159 +0,0 @@ -/* No padding around line numbers */ -.graphiql-container .CodeMirror-linenumber { - padding: 0; -} - -/* No border between gutter and editor */ -.graphiql-container .CodeMirror-gutters { - border: none; -} - -/** - * Editor theme - */ - -.cm-s-graphiql { - /* Default to punctuation */ - color: hsla(var(--color-neutral), var(--alpha-tertiary)); - - /* OperationType, `fragment`, `on` */ - & .cm-keyword { - color: hsl(var(--color-primary)); - } - /* Name (OperationDefinition), FragmentName */ - & .cm-def { - color: hsl(var(--color-tertiary)); - } - /* Punctuator (except `$` and `@`) */ - & .cm-punctuation { - color: hsla(var(--color-neutral), var(--alpha-tertiary)); - } - /* Variable */ - & .cm-variable { - color: hsl(var(--color-secondary)); - } - /* NamedType */ - & .cm-atom { - color: hsl(var(--color-tertiary)); - } - /* IntValue, FloatValue */ - & .cm-number { - color: hsl(var(--color-success)); - } - /* StringValue */ - & .cm-string { - color: hsl(var(--color-warning)); - } - /* BooleanValue */ - & .cm-builtin { - color: hsl(var(--color-success)); - } - /* EnumValue */ - & .cm-string-2 { - color: hsl(var(--color-secondary)); - } - /* Name (ObjectField, Argument) */ - & .cm-attribute { - color: hsl(var(--color-tertiary)); - } - /* Name (Directive) */ - & .cm-meta { - color: hsl(var(--color-tertiary)); - } - /* Name (Alias, Field without Alias) */ - & .cm-property { - color: hsl(var(--color-info)); - } - /* Name (Field with Alias) */ - & .cm-qualifier { - color: hsl(var(--color-secondary)); - } - /* Comment */ - & .cm-comment { - color: hsla(var(--color-neutral), var(--alpha-secondary)); - } - /* Whitespace */ - & .cm-ws { - color: hsla(var(--color-neutral), var(--alpha-tertiary)); - } - /* Invalid characters */ - & .cm-invalidchar { - color: hsl(var(--color-error)); - } - - /* Cursor */ - & .CodeMirror-cursor { - border-left: 2px solid hsla(var(--color-neutral), var(--alpha-secondary)); - } - - /* Color for line numbers and fold-gutters */ - & .CodeMirror-linenumber { - color: hsla(var(--color-neutral), var(--alpha-tertiary)); - } -} - -/* Matching bracket colors */ -.graphiql-container div.CodeMirror span.CodeMirror-matchingbracket, -.graphiql-container div.CodeMirror span.CodeMirror-nonmatchingbracket { - color: hsl(var(--color-warning)); -} - -/* Selected text blocks */ -.graphiql-container .CodeMirror-selected, -.graphiql-container .CodeMirror-focused .CodeMirror-selected { - background: hsla(var(--color-neutral), var(--alpha-background-heavy)); -} - -/* Position the search dialog */ -.graphiql-container .CodeMirror-dialog { - background: inherit; - color: inherit; - left: 0; - right: 0; - overflow: hidden; - padding: var(--px-2) var(--px-6); - position: absolute; - z-index: 6; -} -.graphiql-container .CodeMirror-dialog-top { - border-bottom: 1px solid - hsla(var(--color-neutral), var(--alpha-background-heavy)); - padding-bottom: var(--px-12); - top: 0; -} -.graphiql-container .CodeMirror-dialog-bottom { - border-top: 1px solid - hsla(var(--color-neutral), var(--alpha-background-heavy)); - bottom: 0; - padding-top: var(--px-12); -} - -/* Hide the search hint */ -.graphiql-container .CodeMirror-search-hint { - display: none; -} - -/* Style the input field for searching */ -.graphiql-container .CodeMirror-dialog input { - border: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); - border-radius: var(--border-radius-4); - padding: var(--px-4); -} -.graphiql-container .CodeMirror-dialog input:focus { - outline: hsl(var(--color-primary)) solid 2px; -} - -/* Set the highlight color for search results */ -.graphiql-container .cm-searching { - background-color: hsla(var(--color-warning), var(--alpha-background-light)); - /** - * When cycling through search results, CodeMirror overlays the current - * selection with another element that has the .CodeMirror-selected class - * applied. This adds another background color (see above), but this extra - * box does not quite match the height of this element. To match them up we - * add some extra padding here. (Note that this doesn't affect the line - * height of the CodeMirror editor as all line wrappers have a fixed height.) - */ - padding-bottom: 1.5px; - padding-top: 0.5px; -} diff --git a/packages/graphiql-react/src/style/fold.css b/packages/graphiql-react/src/style/fold.css deleted file mode 100644 index ce87eb3d8b5..00000000000 --- a/packages/graphiql-react/src/style/fold.css +++ /dev/null @@ -1,22 +0,0 @@ -.CodeMirror-foldgutter { - width: var(--px-12); -} - -.CodeMirror-foldmarker { - background-color: hsl(var(--color-info)); - border-radius: var(--border-radius-4); - color: hsl(var(--color-base)); - font-family: inherit; - margin: 0 var(--px-4); - padding: 0 var(--px-8); - text-shadow: none; -} - -.CodeMirror-foldgutter-open, -.CodeMirror-foldgutter-folded { - color: hsla(var(--color-neutral), var(--alpha-tertiary)); - - &::after { - margin: 0 var(--px-2); - } -} diff --git a/packages/graphiql-react/src/style/hint.css b/packages/graphiql-react/src/style/hint.css deleted file mode 100644 index 239cf258d79..00000000000 --- a/packages/graphiql-react/src/style/hint.css +++ /dev/null @@ -1,71 +0,0 @@ -/* Popup styles */ -.CodeMirror-hints { - background: hsl(var(--color-base)); - border: var(--popover-border); - border-radius: var(--border-radius-8); - box-shadow: var(--popover-box-shadow); - display: grid; - font-family: var(--font-family); - font-size: var(--font-size-body); - grid-template-columns: auto fit-content(300px); - /* By default this is equals exactly 8 items including margins */ - max-height: 264px; - padding: 0; -} - -/* Autocomplete items */ -.CodeMirror-hint { - border-radius: var(--border-radius-4); - color: hsla(var(--color-neutral), var(--alpha-secondary)); - grid-column: 1 / 2; - margin: var(--px-4); - /* Override element style added by codemirror */ - padding: var(--px-6) var(--px-8) !important; - - &:not(:first-child) { - margin-top: 0; - } -} -li.CodeMirror-hint-active { - background: hsla(var(--color-primary), var(--alpha-background-medium)); - color: hsl(var(--color-primary)); -} - -/* Sidebar with additional information */ -.CodeMirror-hint-information { - border-left: 1px solid - hsla(var(--color-neutral), var(--alpha-background-heavy)); - grid-column: 2 / 3; - grid-row: 1 / 99999; - /* Same as the popup */ - max-height: 264px; - overflow: auto; - padding: var(--px-12); -} -.CodeMirror-hint-information-header { - display: flex; - align-items: baseline; -} -.CodeMirror-hint-information-field-name { - font-size: var(--font-size-h4); - font-weight: var(--font-weight-medium); -} -.CodeMirror-hint-information-type-name-pill { - border: 1px solid hsla(var(--color-neutral), var(--alpha-tertiary)); - border-radius: var(--border-radius-4); - color: hsla(var(--color-neutral), var(--alpha-secondary)); - margin-left: var(--px-6); - padding: var(--px-4); -} -.CodeMirror-hint-information-type-name { - color: inherit; - text-decoration: none; - - &:hover { - text-decoration: underline dotted; - } -} -.CodeMirror-hint-information-description { - color: hsla(var(--color-neutral), var(--alpha-secondary)); - margin-top: var(--px-12); -} diff --git a/packages/graphiql-react/src/style/info.css b/packages/graphiql-react/src/style/info.css deleted file mode 100644 index a1a84335c92..00000000000 --- a/packages/graphiql-react/src/style/info.css +++ /dev/null @@ -1,60 +0,0 @@ -/* Popup styles */ -.CodeMirror-info { - background-color: hsl(var(--color-base)); - border: var(--popover-border); - border-radius: var(--border-radius-8); - box-shadow: var(--popover-box-shadow); - color: hsl(var(--color-neutral)); - max-height: 300px; - max-width: 400px; - opacity: 0; - overflow: auto; - padding: var(--px-12); - position: fixed; - transition: opacity 0.15s; - z-index: 10; - - /* Link styles */ - & a { - color: inherit; - text-decoration: none; - - &:hover { - text-decoration: underline dotted; - } - } - - /* Align elements in header */ - & .CodeMirror-info-header { - display: flex; - align-items: baseline; - } - - /* Main elements */ - & .CodeMirror-info-header { - & > .type-name, - & > .field-name, - & > .arg-name, - & > .directive-name, - & > .enum-value { - font-size: var(--font-size-h4); - font-weight: var(--font-weight-medium); - } - } - - /* Type names */ - & .type-name-pill { - border: 1px solid hsla(var(--color-neutral), var(--alpha-tertiary)); - border-radius: var(--border-radius-4); - color: hsla(var(--color-neutral), var(--alpha-secondary)); - margin-left: var(--px-6); - padding: var(--px-4); - } - - /* Descriptions */ - & .info-description { - color: hsla(var(--color-neutral), var(--alpha-secondary)); - margin-top: var(--px-12); - overflow: hidden; - } -} diff --git a/packages/graphiql-react/src/style/jump.css b/packages/graphiql-react/src/style/jump.css deleted file mode 100644 index 7ba8e49c214..00000000000 --- a/packages/graphiql-react/src/style/jump.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Underline the clickable token */ -.CodeMirror-jump-token { - text-decoration: underline dotted; - cursor: pointer; -} diff --git a/packages/graphiql-react/src/style/lint.css b/packages/graphiql-react/src/style/lint.css deleted file mode 100644 index 372534550c9..00000000000 --- a/packages/graphiql-react/src/style/lint.css +++ /dev/null @@ -1,93 +0,0 @@ -/* Text styles */ -.CodeMirror-lint-mark-error, -.CodeMirror-lint-mark-warning { - background-repeat: repeat-x; - /** - * The following two are very specific to the font size, so we use - * "magic values" instead of variables. - */ - background-size: 10px 3px; - background-position: 0 95%; -} -.cm-s-graphiql .CodeMirror-lint-mark-error { - color: hsl(var(--color-error)); -} -.CodeMirror-lint-mark-error { - background-image: linear-gradient( - 45deg, - transparent 65%, - hsl(var(--color-error)) 80%, - transparent 90% - ), - linear-gradient( - 135deg, - transparent 5%, - hsl(var(--color-error)) 15%, - transparent 25% - ), - linear-gradient( - 135deg, - transparent 45%, - hsl(var(--color-error)) 55%, - transparent 65% - ), - linear-gradient( - 45deg, - transparent 25%, - hsl(var(--color-error)) 35%, - transparent 50% - ); -} -.cm-s-graphiql .CodeMirror-lint-mark-warning { - color: hsl(var(--color-warning)); -} -.CodeMirror-lint-mark-warning { - background-image: linear-gradient( - 45deg, - transparent 65%, - hsl(var(--color-warning)) 80%, - transparent 90% - ), - linear-gradient( - 135deg, - transparent 5%, - hsl(var(--color-warning)) 15%, - transparent 25% - ), - linear-gradient( - 135deg, - transparent 45%, - hsl(var(--color-warning)) 55%, - transparent 65% - ), - linear-gradient( - 45deg, - transparent 25%, - hsl(var(--color-warning)) 35%, - transparent 50% - ); -} - -/* Popup styles */ -.CodeMirror-lint-tooltip { - background-color: hsl(var(--color-base)); - border: var(--popover-border); - border-radius: var(--border-radius-8); - box-shadow: var(--popover-box-shadow); - font-size: var(--font-size-body); - font-family: var(--font-family); - max-width: 600px; - overflow: hidden; - padding: var(--px-12); -} -.CodeMirror-lint-message-error, -.CodeMirror-lint-message-warning { - background-image: none; - padding: 0; -} -.CodeMirror-lint-message-error { - color: hsl(var(--color-error)); -} -.CodeMirror-lint-message-warning { - color: hsl(var(--color-warning)); -} diff --git a/packages/graphiql-react/src/style/root.css b/packages/graphiql-react/src/style/root.css index e1f44632566..77910c1797f 100644 --- a/packages/graphiql-react/src/style/root.css +++ b/packages/graphiql-react/src/style/root.css @@ -1,11 +1,5 @@ @import 'auto-insertion.css'; -@import 'codemirror.css'; @import 'editor.css'; -@import 'fold.css'; -@import 'hint.css'; -@import 'info.css'; -@import 'jump.css'; -@import 'lint.css'; /* a very simple box-model reset, intentionally does not include pseudo elements */ .graphiql-container * { @@ -14,8 +8,6 @@ } .graphiql-container, -.CodeMirror-info, -.CodeMirror-lint-tooltip, .graphiql-dialog, .graphiql-dialog-overlay, .graphiql-tooltip, @@ -82,8 +74,6 @@ @media (prefers-color-scheme: dark) { body:not(.graphiql-light) .graphiql-container, - body:not(.graphiql-light) .CodeMirror-info, - body:not(.graphiql-light) .CodeMirror-lint-tooltip, body:not(.graphiql-light) .graphiql-dialog, body:not(.graphiql-light) .graphiql-dialog-overlay, body:not(.graphiql-light) .graphiql-tooltip, @@ -104,8 +94,6 @@ } body.graphiql-dark .graphiql-container, -body.graphiql-dark .CodeMirror-info, -body.graphiql-dark .CodeMirror-lint-tooltip, body.graphiql-dark .graphiql-dialog, body.graphiql-dark .graphiql-dialog-overlay, body.graphiql-dark .graphiql-tooltip, @@ -125,8 +113,6 @@ body.graphiql-dark [data-radix-popper-content-wrapper] { } .graphiql-container, -.CodeMirror-info, -.CodeMirror-lint-tooltip, .graphiql-dialog { &, &:is(button) { diff --git a/packages/graphiql-react/src/types.test-d.ts b/packages/graphiql-react/src/types.test-d.ts index 1dcb2b57c4e..ccab5f5f83e 100644 --- a/packages/graphiql-react/src/types.test-d.ts +++ b/packages/graphiql-react/src/types.test-d.ts @@ -3,11 +3,14 @@ import type { ExecutionSlice, PluginSlice, SchemaSlice, + ThemeSlice, + StorageSlice, // EditorActions, ExecutionActions, PluginActions, SchemaActions, + ThemeActions, } from './stores'; import type { AllSlices, AllActions } from './types'; @@ -34,7 +37,14 @@ describe('Should not have conflicting types', () => { it('AllSlices', () => { type Actual = MergeMany< - [EditorSlice, ExecutionSlice, PluginSlice, SchemaSlice] + [ + EditorSlice, + ExecutionSlice, + PluginSlice, + SchemaSlice, + ThemeSlice, + StorageSlice, + ] >; // eslint-disable-next-line @typescript-eslint/no-unused-expressions expectTypeOf().toEqualTypeOf; @@ -42,7 +52,13 @@ describe('Should not have conflicting types', () => { it('AllActions', () => { type Actual = MergeMany< - [EditorActions, ExecutionActions, PluginActions, SchemaActions] + [ + EditorActions, + ExecutionActions, + PluginActions, + SchemaActions, + ThemeActions, + ] >; // eslint-disable-next-line @typescript-eslint/no-unused-expressions expectTypeOf().toEqualTypeOf; diff --git a/packages/graphiql-react/src/types.ts b/packages/graphiql-react/src/types.ts index 92436b07b2a..2095600c548 100644 --- a/packages/graphiql-react/src/types.ts +++ b/packages/graphiql-react/src/types.ts @@ -6,14 +6,23 @@ import type { ExecutionSlice, PluginSlice, SchemaSlice, + ThemeSlice, + StorageSlice, // EditorActions, ExecutionActions, PluginActions, SchemaActions, + ThemeActions, } from './stores'; import type { RuleKind } from 'graphql-language-service'; +/** + * The value `null` semantically means that the user does not explicitly choose + * any theme, so we use the system default. + */ +export type Theme = 'light' | 'dark' | null; + export type EditorProps = ComponentPropsWithoutRef<'div'>; export interface SchemaReference { @@ -26,12 +35,15 @@ export type MonacoEditor = monacoEditor.IStandaloneCodeEditor; export type AllSlices = EditorSlice & ExecutionSlice & PluginSlice & - SchemaSlice; + SchemaSlice & + ThemeSlice & + StorageSlice; export type AllActions = EditorActions & ExecutionActions & PluginActions & - SchemaActions; + SchemaActions & + ThemeActions; export interface SlicesWithActions extends AllSlices { actions: AllActions; diff --git a/packages/graphiql-react/src/utility/hooks.ts b/packages/graphiql-react/src/utility/hooks.ts index f7b83435889..571b4f8271c 100644 --- a/packages/graphiql-react/src/utility/hooks.ts +++ b/packages/graphiql-react/src/utility/hooks.ts @@ -1,48 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-restricted-imports -- TODO: check why query builder update only 1st field https://github.com/graphql/graphiql/issues/3836 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { storageStore } from '../stores'; -import { debounce } from './debounce'; -import type { editor as monacoEditor } from '../monaco-editor'; -import { useGraphiQL, useGraphiQLActions } from '../components'; - -export function useChangeHandler( - callback: ((value: string) => void) | undefined, - storageKey: string | null, - tabProperty: 'variables' | 'headers', -) { - const { updateActiveTabValues } = useGraphiQLActions(); - const editor = useGraphiQL( - state => - state[tabProperty === 'variables' ? 'variableEditor' : 'headerEditor'], - ); - useEffect(() => { - if (!editor) { - return; - } - const { storage } = storageStore.getState(); - - const store = debounce(500, (value: string) => { - if (storageKey === null) { - return; - } - storage.set(storageKey, value); - }); - const updateTab = debounce(100, (value: string) => { - updateActiveTabValues({ [tabProperty]: value }); - }); - - const handleChange = (_event: monacoEditor.IModelContentChangedEvent) => { - const newValue = editor.getValue(); - store(newValue); - updateTab(newValue); - callback?.(newValue); - }; - const disposable = editor.getModel()!.onDidChangeContent(handleChange); - return () => { - disposable.dispose(); - }; - }, [callback, editor, storageKey, tabProperty, updateActiveTabValues]); -} +import { useGraphiQL } from '../components'; // https://react.dev/learn/you-might-not-need-an-effect export const useEditorState = ( diff --git a/packages/graphiql-react/src/utility/index.ts b/packages/graphiql-react/src/utility/index.ts index d52f006c9ce..ba2500184e3 100644 --- a/packages/graphiql-react/src/utility/index.ts +++ b/packages/graphiql-react/src/utility/index.ts @@ -17,6 +17,5 @@ export { useOperationsEditorState, useVariablesEditorState, useHeadersEditorState, - useChangeHandler, useDidUpdate, } from './hooks'; diff --git a/packages/graphiql-react/src/utility/resize.ts b/packages/graphiql-react/src/utility/resize.ts index ba4372e815a..3704a606bf6 100644 --- a/packages/graphiql-react/src/utility/resize.ts +++ b/packages/graphiql-react/src/utility/resize.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useStorage } from '../stores'; +import { useGraphiQL } from '../components'; import { debounce } from './debounce'; type ResizableElement = 'first' | 'second'; @@ -11,10 +11,12 @@ interface UseDragResizeArgs { * @default 1 */ defaultSizeRelation?: number; + /** * The direction in which the two halves should be resizable. */ direction: 'horizontal' | 'vertical'; + /** * Choose one of the two halves that should initially be hidden. */ @@ -33,17 +35,19 @@ interface UseDragResizeArgs { * @default 100 */ sizeThresholdFirst?: number; + /** * The minimum width in pixels for the second half. If it is resized to a * width smaller than this threshold, the half will be hidden. * @default 100 */ sizeThresholdSecond?: number; + /** * A key for which the state of resizing is persisted in storage (if storage * is available). */ - storageKey?: string; + storageKey: string; } export function useDragResize({ @@ -53,47 +57,43 @@ export function useDragResize({ onHiddenElementChange, sizeThresholdFirst = 100, sizeThresholdSecond = 100, - storageKey, + storageKey: key, }: UseDragResizeArgs) { - const storage = useStorage(); - + const storage = useGraphiQL(state => state.storage); + const storageKey = `graphiql:${key}`; const [hiddenElement, setHiddenElement] = useState( - () => { - const storedValue = storageKey && storage.get(storageKey); - if (storedValue === HIDE_FIRST || initiallyHidden === 'first') { - return 'first'; - } - if (storedValue === HIDE_SECOND || initiallyHidden === 'second') { - return 'second'; - } - return null; - }, + null, ); - const firstRef = useRef(null); const dragBarRef = useRef(null); const secondRef = useRef(null); + const defaultFlex = String(defaultSizeRelation); - const defaultFlexRef = useRef(`${defaultSizeRelation}`); - - /** - * Set initial flex values - */ useEffect(() => { - const storedValue = - (storageKey && storage.get(storageKey)) || defaultFlexRef.current; - - if (firstRef.current) { - firstRef.current.style.flex = - storedValue === HIDE_FIRST || storedValue === HIDE_SECOND - ? defaultFlexRef.current - : storedValue; - } + async function initFlexValues() { + const $storedValue = storageKey && (await storage.getItem(storageKey)); + const initialHiddenElement = + $storedValue === HIDE_FIRST || initiallyHidden === 'first' + ? 'first' + : $storedValue === HIDE_SECOND || initiallyHidden === 'second' + ? 'second' + : null; + setHiddenElement(initialHiddenElement); - if (secondRef.current) { - secondRef.current.style.flex = '1'; + if (firstRef.current) { + const storedValue = $storedValue || defaultFlex; + firstRef.current.style.flex = + storedValue === HIDE_FIRST || storedValue === HIDE_SECOND + ? defaultFlex + : storedValue; + } + if (secondRef.current) { + secondRef.current.style.flex = '1'; + } } - }, [direction, storage, storageKey]); + + void initFlexValues(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount /** * Hide and show items when the state changes @@ -115,21 +115,17 @@ export function useDragResize({ } } - function show(element: HTMLDivElement) { + async function show(element: HTMLDivElement) { element.style.left = ''; element.style.position = ''; element.style.opacity = ''; - - if (!storageKey) { - return; - } - const storedValue = storage.get(storageKey); + const storedValue = await storage.getItem(storageKey); if ( firstRef.current && storedValue !== HIDE_FIRST && storedValue !== HIDE_SECOND ) { - firstRef.current.style.flex = storedValue || defaultFlexRef.current; + firstRef.current.style.flex = storedValue || defaultFlex; } } @@ -139,20 +135,18 @@ export function useDragResize({ if (id === hiddenElement) { hide(element); } else { - show(element); + void show(element); } } } - }, [hiddenElement, storage, storageKey]); + }, [defaultFlex, hiddenElement, storage, storageKey]); useEffect(() => { if (!dragBarRef.current || !firstRef.current || !secondRef.current) { return; } const store = debounce(500, (value: string) => { - if (storageKey) { - storage.set(storageKey, value); - } + storage.setItem(storageKey, value); }); function setHiddenElementWithCallback(element: ResizableElement | null) { @@ -233,9 +227,9 @@ export function useDragResize({ function reset() { if (firstRef.current) { - firstRef.current.style.flex = defaultFlexRef.current; + firstRef.current.style.flex = defaultFlex; } - store(defaultFlexRef.current); + store(defaultFlex); setHiddenElementWithCallback(null); } @@ -246,6 +240,7 @@ export function useDragResize({ dragBarContainer.removeEventListener('dblclick', reset); }; }, [ + defaultFlex, direction, onHiddenElementChange, sizeThresholdFirst, diff --git a/packages/graphiql-react/src/utility/tabs.spec.ts b/packages/graphiql-react/src/utility/tabs.spec.ts index 5ccdb96c0e2..9abd85690a0 100644 --- a/packages/graphiql-react/src/utility/tabs.spec.ts +++ b/packages/graphiql-react/src/utility/tabs.spec.ts @@ -1,13 +1,8 @@ -import { act } from 'react'; -import { StorageAPI } from '@graphiql/toolkit'; import { createTab, fuzzyExtractOperationName, getDefaultTabState, - clearHeadersFromTabs, } from './tabs'; -import { storageStore } from '../stores'; -import { STORAGE_KEY } from '../constants'; describe('createTab', () => { it('creates with default title', () => { @@ -105,21 +100,8 @@ describe('fuzzyExtractionOperationTitle', () => { }); describe('getDefaultTabState', () => { - beforeEach(() => { - act(() => { - storageStore.setState({ storage: new StorageAPI() }); - }); - }); - it('returns default tab', () => { - expect( - getDefaultTabState({ - defaultQuery: '# Default', - headers: null, - query: null, - variables: null, - }), - ).toEqual({ + expect(getDefaultTabState({ defaultQuery: '# Default' })).toEqual({ activeTabIndex: 0, tabs: [ expect.objectContaining({ @@ -134,7 +116,6 @@ describe('getDefaultTabState', () => { expect( getDefaultTabState({ defaultQuery: '# Default', - headers: null, defaultTabs: [ { headers: null, @@ -147,8 +128,6 @@ describe('getDefaultTabState', () => { variables: null, }, ], - query: null, - variables: null, }), ).toEqual({ activeTabIndex: 0, @@ -167,24 +146,3 @@ describe('getDefaultTabState', () => { }); }); }); - -describe('clearHeadersFromTabs', () => { - it('preserves tab state except for headers', () => { - const { storage } = storageStore.getState(); - const stateWithHeaders = { - operationName: 'test', - query: 'query test {\n test {\n id\n }\n}', - test: { - a: 'test', - }, - headers: '{ "authorization": "secret" }', - }; - storage.set(STORAGE_KEY.tabs, JSON.stringify(stateWithHeaders)); - clearHeadersFromTabs(); - - expect(JSON.parse(storage.get(STORAGE_KEY.tabs)!)).toEqual({ - ...stateWithHeaders, - headers: null, - }); - }); -}); diff --git a/packages/graphiql-react/src/utility/tabs.ts b/packages/graphiql-react/src/utility/tabs.ts index 849d413b9ab..a205a090d0e 100644 --- a/packages/graphiql-react/src/utility/tabs.ts +++ b/packages/graphiql-react/src/utility/tabs.ts @@ -1,17 +1,16 @@ 'use no memo'; // can't figure why it isn't optimized -import { STORAGE_KEY } from '../constants'; -import { storageStore } from '../stores'; - export interface TabDefinition { /** * The contents of the query editor of this tab. */ query: string | null; + /** * The contents of the variable editor of this tab. */ variables?: string | null; + /** * The contents of the headers editor of this tab. */ @@ -29,7 +28,7 @@ export interface TabState extends TabDefinition { /** * A hash that is unique for a combination of the contents of the query - * editor, the variable editor and the header editor (i.e. all the editor + * editor, the variable editor and the header editor (i.e., all the editor * where the contents are persisted in storage). */ hash: string; @@ -59,6 +58,7 @@ export type TabsState = { * A list of state objects for each tab. */ tabs: TabState[]; + /** * The index of the currently active tab with regards to the `tabs` list of * this object. @@ -79,125 +79,65 @@ export function getDefaultTabState({ headers: headers ?? defaultHeaders, }, ], - shouldPersistHeaders, }: { - defaultQuery: string; defaultHeaders?: string; - headers: string | null; + defaultQuery: string; defaultTabs?: TabDefinition[]; - query: string | null; - variables: string | null; - shouldPersistHeaders?: boolean; + headers?: string; + query?: string; + variables?: string; }) { - const { storage } = storageStore.getState(); - const storedState = storage.get(STORAGE_KEY.tabs); - try { - if (!storedState) { - throw new Error('Storage for tabs is empty'); - } - const parsed = JSON.parse(storedState); - // if headers are not persisted, do not derive the hash using default headers state - // or else you will get new tabs on every refresh - const headersForHash = shouldPersistHeaders ? headers : undefined; - if (isTabsState(parsed)) { - const expectedHash = hashFromTabContents({ - query, - variables, - headers: headersForHash, - }); - let matchingTabIndex = -1; - - for (let index = 0; index < parsed.tabs.length; index++) { - const tab = parsed.tabs[index]!; - tab.hash = hashFromTabContents({ - query: tab.query, - variables: tab.variables, - headers: tab.headers, - }); - if (tab.hash === expectedHash) { - matchingTabIndex = index; - } - } - - if (matchingTabIndex >= 0) { - parsed.activeTabIndex = matchingTabIndex; - } else { - const operationName = query ? fuzzyExtractOperationName(query) : null; - parsed.tabs.push({ - id: guid(), - hash: expectedHash, - title: operationName || DEFAULT_TITLE, - query, - variables, - headers, - operationName, - response: null, - }); - parsed.activeTabIndex = parsed.tabs.length - 1; - } - - return parsed; - } - throw new Error('Storage for tabs is invalid'); - } catch { - return { - activeTabIndex: 0, - tabs: defaultTabs.map(createTab), - }; - } -} - -function isTabsState(obj: any): obj is TabsState { - return ( - obj && - typeof obj === 'object' && - !Array.isArray(obj) && - hasNumberKey(obj, 'activeTabIndex') && - 'tabs' in obj && - Array.isArray(obj.tabs) && - obj.tabs.every(isTabState) - ); -} - -function isTabState(obj: any): obj is TabState { - // We don't persist the hash, so we skip the check here - return ( - obj && - typeof obj === 'object' && - !Array.isArray(obj) && - hasStringKey(obj, 'id') && - hasStringKey(obj, 'title') && - hasStringOrNullKey(obj, 'query') && - hasStringOrNullKey(obj, 'variables') && - hasStringOrNullKey(obj, 'headers') && - hasStringOrNullKey(obj, 'operationName') && - hasStringOrNullKey(obj, 'response') - ); -} - -function hasNumberKey(obj: Record, key: string) { - return key in obj && typeof obj[key] === 'number'; -} - -function hasStringKey(obj: Record, key: string) { - return key in obj && typeof obj[key] === 'string'; -} - -function hasStringOrNullKey(obj: Record, key: string) { - return key in obj && (typeof obj[key] === 'string' || obj[key] === null); -} - -export function serializeTabState( - tabState: TabsState, - shouldPersistHeaders = false, -) { - return JSON.stringify(tabState, (key, value) => - key === 'hash' || - key === 'response' || - (!shouldPersistHeaders && key === 'headers') - ? null - : value, - ); + // try { + // const parsed = storedState; + // // if headers are not persisted, do not derive the hash using default headers state + // // or else you will get new tabs on every refresh + // const headersForHash = shouldPersistHeaders ? headers : undefined; + // if (isTabsState(parsed)) { + // const expectedHash = hashFromTabContents({ + // query, + // variables, + // headers: headersForHash, + // }); + // let matchingTabIndex = -1; + // + // for (let index = 0; index < parsed.tabs.length; index++) { + // const tab = parsed.tabs[index]!; + // tab.hash = hashFromTabContents({ + // query: tab.query, + // variables: tab.variables, + // headers: tab.headers, + // }); + // if (tab.hash === expectedHash) { + // matchingTabIndex = index; + // } + // } + // + // if (matchingTabIndex >= 0) { + // parsed.activeTabIndex = matchingTabIndex; + // } else { + // const operationName = query ? fuzzyExtractOperationName(query) : null; + // parsed.tabs.push({ + // id: guid(), + // hash: expectedHash, + // title: operationName || DEFAULT_TITLE, + // query, + // variables, + // headers, + // operationName, + // response: null, + // }); + // parsed.activeTabIndex = parsed.tabs.length - 1; + // } + // + // return parsed; + // } + // throw new Error('Storage for tabs is invalid'); + // } catch { + return { + activeTabIndex: 0, + tabs: defaultTabs.map(createTab), + }; + // } } export function createTab({ @@ -269,18 +209,4 @@ export function fuzzyExtractOperationName(str: string): string | null { return match?.[2] ?? null; } -export function clearHeadersFromTabs() { - const { storage } = storageStore.getState(); - const persistedTabs = storage.get(STORAGE_KEY.tabs); - if (persistedTabs) { - const parsedTabs = JSON.parse(persistedTabs); - storage.set( - STORAGE_KEY.tabs, - JSON.stringify(parsedTabs, (key, value) => - key === 'headers' ? null : value, - ), - ); - } -} - const DEFAULT_TITLE = ''; diff --git a/packages/graphiql-toolkit/package.json b/packages/graphiql-toolkit/package.json index 8b456840bc9..b32770a8bd9 100644 --- a/packages/graphiql-toolkit/package.json +++ b/packages/graphiql-toolkit/package.json @@ -33,7 +33,8 @@ "graphql-ws": "^5.5.5", "isomorphic-fetch": "^3.0.0", "subscriptions-transport-ws": "0.11.0", - "tsup": "^8.2.4" + "tsup": "^8.2.4", + "zustand": "^5" }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index f44d0b6a315..035483ed90c 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -38,12 +38,12 @@ export type FetcherOpts = { export type ExecutionResultPayload = | { data: IntrospectionQuery; - errors?: Array; + errors?: any[]; } // normal result payloads - | { data?: any; errors?: Array } + | { data?: any; errors?: any[] } // for the initial Stream/Defer payload - | { data?: any; errors?: Array; hasNext: boolean } + | { data?: any; errors?: any[]; hasNext: boolean } // for successive Stream/Defer payloads | { data?: any; diff --git a/packages/graphiql-toolkit/src/graphql-helpers/auto-complete.ts b/packages/graphiql-toolkit/src/graphql-helpers/auto-complete.ts index 4000b4672a4..cc4c5339c54 100644 --- a/packages/graphiql-toolkit/src/graphql-helpers/auto-complete.ts +++ b/packages/graphiql-toolkit/src/graphql-helpers/auto-complete.ts @@ -108,7 +108,7 @@ function defaultGetDefaultFieldNames(type: GraphQLType) { } // Include all leaf-type fields. - const leafFieldNames: Array = []; + const leafFieldNames: string[] = []; for (const fieldName of Object.keys(fields)) { if (isLeafType(fields[fieldName].type)) { leafFieldNames.push(fieldName); diff --git a/packages/graphiql-toolkit/src/storage/__tests__/base.spec.ts b/packages/graphiql-toolkit/src/storage/__tests__/base.spec.ts deleted file mode 100644 index 376aaf34ff1..00000000000 --- a/packages/graphiql-toolkit/src/storage/__tests__/base.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { StorageAPI } from '../base'; - -describe('StorageAPI', () => { - let storage = new StorageAPI(); - - beforeEach(() => { - storage = new StorageAPI(); - }); - - it('returns nothing if no value set', () => { - const result = storage.get('key1'); - expect(result).toBeNull(); - }); - - it('sets and gets a value correctly', () => { - const result = storage.set('key2', 'value'); - expect(result).toEqual({ - error: null, - isQuotaError: false, - }); - - const newResult = storage.get('key2'); - expect(newResult).toEqual('value'); - }); - - it('sets and removes a value correctly', () => { - let result = storage.set('key3', 'value'); - expect(result).toEqual({ - error: null, - isQuotaError: false, - }); - - result = storage.set('key3', ''); - expect(result).toEqual({ - error: null, - isQuotaError: false, - }); - - const getResult = storage.get('key3'); - expect(getResult).toBeNull(); - }); - - it('sets and overrides a value correctly', () => { - let result = storage.set('key4', 'value'); - expect(result).toEqual({ - error: null, - isQuotaError: false, - }); - - result = storage.set('key4', 'value2'); - expect(result).toEqual({ - error: null, - isQuotaError: false, - }); - - const getResult = storage.get('key4'); - expect(getResult).toEqual('value2'); - }); - - it('cleans up `null` value', () => { - storage.set('key5', 'null'); - const result = storage.get('key5'); - expect(result).toBeNull(); - }); - - it('cleans up `undefined` value', () => { - storage.set('key6', 'undefined'); - const result = storage.get('key6'); - expect(result).toBeNull(); - }); - - it('returns any error while setting a value', () => { - // @ts-expect-error - const throwingStorage = new StorageAPI({ - setItem() { - throw new DOMException('Terrible Error'); - }, - length: 1, - }); - const result = throwingStorage.set('key', 'value'); - - expect(result.error!.message).toEqual('Error: Terrible Error'); - expect(result.isQuotaError).toBe(false); - }); - - it('returns isQuotaError to true if isQuotaError is thrown', () => { - // @ts-expect-error - const throwingStorage = new StorageAPI({ - setItem() { - throw new DOMException('Terrible Error', 'QuotaExceededError'); - }, - length: 1, - }); - const result = throwingStorage.set('key', 'value'); - - expect(result.error!.message).toEqual('QuotaExceededError: Terrible Error'); - expect(result.isQuotaError).toBe(true); - }); -}); diff --git a/packages/graphiql-toolkit/src/storage/__tests__/query.spec.ts b/packages/graphiql-toolkit/src/storage/__tests__/query.spec.ts index ddfab64c5b1..647d4d3b291 100644 --- a/packages/graphiql-toolkit/src/storage/__tests__/query.spec.ts +++ b/packages/graphiql-toolkit/src/storage/__tests__/query.spec.ts @@ -1,11 +1,11 @@ -import { StorageAPI } from '../base'; import { QueryStore } from '../query'; +import { createJSONStorage } from 'zustand/middleware'; class StorageMock { shouldThrow: () => boolean; // @ts-expect-error count: number; - map = {}; + map: Record = {}; // @ts-expect-error storage: Storage; @@ -13,34 +13,26 @@ class StorageMock { this.shouldThrow = shouldThrow; } - set(key: string, value: string) { + setItem(key: string, value: string) { this.count++; - if (this.shouldThrow()) { - return { - error: new Error('boom'), - isQuotaError: true, - }; + throw new Error('boom'); } - // @ts-expect-error this.map[key] = value; - - return { - error: null, - isQuotaError: false, - }; } - get(key: string) { - // @ts-expect-error + getItem(key: string) { return this.map[key] || null; } } describe('QueryStore', () => { + const storage = createJSONStorage(() => localStorage); + describe('with no max items', () => { - it('can push multiple items', () => { - const store = new QueryStore('normal', new StorageAPI()); + it('can push multiple items', async () => { + // @ts-expect-error -- fixme + const store = await QueryStore.create('normal', storage); for (let i = 0; i < 100; i++) { store.push({ query: `item${i}` }); @@ -49,10 +41,13 @@ describe('QueryStore', () => { expect(store.items.length).toBe(100); }); - it('will fail silently on quota error', () => { + it('will fail silently on quota error', async () => { let i = 0; - // @ts-expect-error - const store = new QueryStore('normal', new StorageMock(() => i > 4)); + const store = await QueryStore.create( + 'normal', + // @ts-expect-error + new StorageMock(() => i > 4), + ); for (; i < 10; i++) { store.push({ query: `item${i}` }); @@ -65,8 +60,9 @@ describe('QueryStore', () => { }); describe('with max items', () => { - it('can push a limited number of items', () => { - const store = new QueryStore('limited', new StorageAPI(), 20); + it('can push a limited number of items', async () => { + // @ts-expect-error -- fixme + const store = await QueryStore.create('limited', storage, 20); for (let i = 0; i < 100; i++) { store.push({ query: `item${i}` }); @@ -78,10 +74,10 @@ describe('QueryStore', () => { expect(store.items[19].query).toBe('item99'); }); - it('tries to remove on quota error until it succeeds', () => { + it('tries to remove on quota error until it succeeds', async () => { let shouldThrow: () => boolean; let retryCounter = 0; - const store = new QueryStore( + const store = await QueryStore.create( 'normal', // @ts-expect-error new StorageMock(() => { @@ -111,7 +107,7 @@ describe('QueryStore', () => { expect(store.items[7].query).toBe('finalItem'); }); - it('tries to remove a maximum of 5 times', () => { + it('tries to remove a maximum of 5 times', async () => { let shouldThrow: () => boolean; let retryCounter = 0; const store = new QueryStore( diff --git a/packages/graphiql-toolkit/src/storage/base.ts b/packages/graphiql-toolkit/src/storage/base.ts deleted file mode 100644 index f059b11df6d..00000000000 --- a/packages/graphiql-toolkit/src/storage/base.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * This describes the attributes and methods that a store has to support in - * order to be used with GraphiQL. It closely resembles the `localStorage` - * API as it is the default storage used in GraphiQL. - */ -export type Storage = { - /** - * Retrieve an item from the store by its key. - * @param key The key of the item to retrieve. - * @returns {?string} The stored value for the given key if it exists, `null` - * otherwise. - */ - getItem(key: string): string | null; - /** - * Add a value to the store for a given key. If there already exists a value - * for the given key, this method will override the value. - * @param key The key to store the value for. - * @param value The value to store. - */ - setItem(key: string, value: string): void; - /** - * Remove the value for a given key from the store. If there is no value for - * the given key this method does nothing. - * @param key The key to remove the value from the store. - */ - removeItem(key: string): void; - /** - * Remove all items from the store. - */ - clear(): void; - /** - * The number of items that are currently stored. - */ - length: number; -}; - -function isQuotaError(storage: Storage, e: unknown) { - return ( - e instanceof DOMException && - // everything except Firefox - (e.code === 22 || - // Firefox - e.code === 1014 || - // test name field too, because code might not be present - // everything except Firefox - e.name === 'QuotaExceededError' || - // Firefox - e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && - // acknowledge QuotaExceededError only if there's something already stored - storage.length !== 0 - ); -} - -export class StorageAPI { - storage: Storage | null; - - constructor(storage?: Storage | null) { - if (storage) { - this.storage = storage; - } else if (storage === null) { - // Passing `null` creates a noop storage - this.storage = null; - } else if (typeof window === 'undefined') { - this.storage = null; - } else { - this.storage = { - getItem: localStorage.getItem.bind(localStorage), - setItem: localStorage.setItem.bind(localStorage), - removeItem: localStorage.removeItem.bind(localStorage), - - get length() { - let keys = 0; - for (const key in localStorage) { - if (key.indexOf(`${STORAGE_NAMESPACE}:`) === 0) { - keys += 1; - } - } - return keys; - }, - - clear() { - // We only want to clear the namespaced items - for (const key in localStorage) { - if (key.indexOf(`${STORAGE_NAMESPACE}:`) === 0) { - localStorage.removeItem(key); - } - } - }, - }; - } - } - - get(name: string): string | null { - if (!this.storage) { - return null; - } - - const key = `${STORAGE_NAMESPACE}:${name}`; - const value = this.storage.getItem(key); - // Clean up any inadvertently saved null/undefined values. - if (value === 'null' || value === 'undefined') { - this.storage.removeItem(key); - return null; - } - - return value || null; - } - - set( - name: string, - value: string, - ): { isQuotaError: boolean; error: Error | null } { - let quotaError = false; - let error: Error | null = null; - - if (this.storage) { - const key = `${STORAGE_NAMESPACE}:${name}`; - if (value) { - try { - this.storage.setItem(key, value); - } catch (e) { - error = e instanceof Error ? e : new Error(`${e}`); - quotaError = isQuotaError(this.storage, e); - } - } else { - // Clean up by removing the item if there's no value to set - this.storage.removeItem(key); - } - } - - return { isQuotaError: quotaError, error }; - } - - clear() { - if (this.storage) { - this.storage.clear(); - } - } -} - -const STORAGE_NAMESPACE = 'graphiql'; diff --git a/packages/graphiql-toolkit/src/storage/custom.ts b/packages/graphiql-toolkit/src/storage/custom.ts deleted file mode 100644 index 7e82cdd5b62..00000000000 --- a/packages/graphiql-toolkit/src/storage/custom.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * This function enables a custom namespace for localStorage - */ - -import { Storage } from './base'; - -export type CreateLocalStorageOptions = { - /** - * specify a different storage namespace prefix from the default of 'graphiql' - */ - namespace?: string; -}; -/** - * generate a custom local storage adapter for GraphiQL `storage` prop. - */ -export function createLocalStorage({ - namespace, -}: CreateLocalStorageOptions): Storage { - const storageKeyPrefix = `${namespace}:`; - const getStorageKey = (key: string) => `${storageKeyPrefix}${key}`; - - const storage: Storage = { - setItem: (key, value) => localStorage.setItem(getStorageKey(key), value), - getItem: key => localStorage.getItem(getStorageKey(key)), - removeItem: key => localStorage.removeItem(getStorageKey(key)), - get length() { - let keys = 0; - for (const key in localStorage) { - if (key.indexOf(storageKeyPrefix) === 0) { - keys += 1; - } - } - return keys; - }, - - clear() { - // We only want to clear the namespaced items - for (const key in localStorage) { - if (key.indexOf(storageKeyPrefix) === 0) { - localStorage.removeItem(key); - } - } - }, - }; - - return storage; -} diff --git a/packages/graphiql-toolkit/src/storage/history.ts b/packages/graphiql-toolkit/src/storage/history.ts index 3f301b5e4ef..afbf80fc0d4 100644 --- a/packages/graphiql-toolkit/src/storage/history.ts +++ b/packages/graphiql-toolkit/src/storage/history.ts @@ -1,17 +1,16 @@ import { parse } from 'graphql'; - -import { StorageAPI } from './base'; +import type { StateStorage } from 'zustand/middleware'; import { QueryStore, QueryStoreItem } from './query'; const MAX_QUERY_SIZE = 100000; export class HistoryStore { - queries: QueryStoreItem[]; + queries: QueryStoreItem[] = []; history: QueryStore; favorite: QueryStore; constructor( - private storage: StorageAPI, + private storage: StateStorage, private maxHistoryLength: number, ) { this.history = new QueryStore( @@ -22,7 +21,11 @@ export class HistoryStore { // favorites are not automatically deleted, so there's no need for a max length this.favorite = new QueryStore('favorites', this.storage, null); - this.queries = [...this.history.fetchAll(), ...this.favorite.fetchAll()]; + void Promise.all([this.history.fetchAll(), this.favorite.fetchAll()]).then( + ([history, favorite]) => { + this.queries = [...history, ...favorite]; + }, + ); } private shouldSaveQuery( diff --git a/packages/graphiql-toolkit/src/storage/index.ts b/packages/graphiql-toolkit/src/storage/index.ts index fe60fb4e201..a715d16c793 100644 --- a/packages/graphiql-toolkit/src/storage/index.ts +++ b/packages/graphiql-toolkit/src/storage/index.ts @@ -1,4 +1,2 @@ -export * from './base'; export * from './history'; export * from './query'; -export * from './custom'; diff --git a/packages/graphiql-toolkit/src/storage/query.ts b/packages/graphiql-toolkit/src/storage/query.ts index 9fea0e93b4a..c9a7136d812 100644 --- a/packages/graphiql-toolkit/src/storage/query.ts +++ b/packages/graphiql-toolkit/src/storage/query.ts @@ -1,4 +1,4 @@ -import { StorageAPI } from './base'; +import type { StateStorage } from 'zustand/middleware'; export type QueryStoreItem = { query?: string; @@ -10,14 +10,20 @@ export type QueryStoreItem = { }; export class QueryStore { - items: Array; + items: QueryStoreItem[] = []; constructor( private key: string, - private storage: StorageAPI, + private storage: StateStorage, private maxSize: number | null = null, - ) { - this.items = this.fetchAll(); + ) {} + + static async create( + ...args: ConstructorParameters + ): Promise { + const store = new this(...args); + store.items = await store.fetchAll(); + return store; } get length() { @@ -80,12 +86,12 @@ export class QueryStore { return this.items.at(-1); } - fetchAll() { - const raw = this.storage.get(this.key); - if (raw) { - return JSON.parse(raw)[this.key] as Array; + async fetchAll() { + const items = await this.storage.getItem(this.key); + if (!items) { + return []; } - return []; + return items as unknown as QueryStoreItem[]; } push(item: QueryStoreItem) { @@ -94,24 +100,13 @@ export class QueryStore { if (this.maxSize && items.length > this.maxSize) { items.shift(); } - - for (let attempts = 0; attempts < 5; attempts++) { - const response = this.storage.set( - this.key, - JSON.stringify({ [this.key]: items }), - ); - if (!response?.error) { - this.items = items; - } else if (response.isQuotaError && this.maxSize) { - // Only try to delete last items on LRU stores - items.shift(); - } else { - return; // We don't know what happened in this case, so just bailing out - } - } + try { + this.storage.setItem(this.key, JSON.stringify(this.items)); + this.items = items; + } catch {} } save() { - this.storage.set(this.key, JSON.stringify({ [this.key]: this.items })); + this.storage.setItem(this.key, JSON.stringify(this.items)); } } diff --git a/packages/graphiql/cypress/e2e/lint.cy.ts b/packages/graphiql/cypress/e2e/lint.cy.ts index 8a87737de6f..bf5a95678b9 100644 --- a/packages/graphiql/cypress/e2e/lint.cy.ts +++ b/packages/graphiql/cypress/e2e/lint.cy.ts @@ -1,22 +1,6 @@ import { version as graphqlVersion } from 'graphql'; describe('Linting', () => { - it('Does not mark valid fields', () => { - cy.visitWithOp({ - query: /* GraphQL */ ` - { - myAlias: id - test { - id - } - } - `, - }) - .contains('myAlias') - .should('not.have.class', 'CodeMirror-lint-mark') - .and('not.have.class', 'CodeMirror-lint-mark-error'); - }); - it('Marks invalid fields as error', () => { cy.visitWithOp({ query: /* GraphQL */ ` diff --git a/packages/graphiql/src/GraphiQL.tsx b/packages/graphiql/src/GraphiQL.tsx index 985212e986b..876754be6e0 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -67,11 +67,13 @@ const GraphiQL_: FC = ({ responseTooltip, defaultEditorToolsVisibility, isHeadersEditorEnabled, - showPersistHeadersSettings, forcedTheme, confirmCloseTab, className, + shouldPersistHeaders, + showPersistHeadersSettings = Boolean(shouldPersistHeaders), + children, ...props }) => { @@ -98,9 +100,7 @@ const GraphiQL_: FC = ({ throw new TypeError('The `readOnly` prop has been removed.'); } const interfaceProps: GraphiQLInterfaceProps = { - // TODO check if `showPersistHeadersSettings` prop is needed, or we can just use `shouldPersistHeaders` instead - showPersistHeadersSettings: - showPersistHeadersSettings ?? props.shouldPersistHeaders !== false, + showPersistHeadersSettings, onEditQuery, onEditVariables, onEditHeaders, @@ -120,6 +120,7 @@ const GraphiQL_: FC = ({ @@ -151,6 +152,7 @@ interface GraphiQLInterfaceProps 'forcedTheme' | 'showPersistHeadersSettings' > { children?: ReactNode; + /** * Set the default state for the editor tools. * - `false` hides the editor tools @@ -161,11 +163,13 @@ interface GraphiQLInterfaceProps * editors has contents. */ defaultEditorToolsVisibility?: boolean | 'variables' | 'headers'; + /** * Toggle if the headers' editor should be shown inside the editor tools. * @default true */ isHeadersEditorEnabled?: boolean; + /** * Additional class names which will be appended to the container element. */ @@ -211,6 +215,7 @@ const GraphiQLInterface: FC = ({ activeTabIndex, isFetching, visiblePlugin, + plugins, } = useGraphiQL( pick( 'initialVariables', @@ -219,10 +224,13 @@ const GraphiQLInterface: FC = ({ 'activeTabIndex', 'isFetching', 'visiblePlugin', + 'plugins', ), ); - const PluginContent = visiblePlugin?.content; + const PluginContent = plugins.find( + plugin => plugin.title === visiblePlugin, + )?.content; const pluginResize = useDragResize({ defaultSizeRelation: 1 / 3, @@ -234,26 +242,31 @@ const GraphiQLInterface: FC = ({ } }, sizeThresholdSecond: 200, - storageKey: 'docExplorerFlex', + storageKey: 'flex:plugin', }); const editorResize = useDragResize({ direction: 'horizontal', - storageKey: 'editorFlex', + storageKey: 'flex:response', + }); + const [initiallyHiddenEditorTools] = useState<'second' | undefined>(() => { + if ( + defaultEditorToolsVisibility === 'variables' || + defaultEditorToolsVisibility === 'headers' + ) { + return; + } + if (typeof defaultEditorToolsVisibility === 'boolean') { + return defaultEditorToolsVisibility ? undefined : 'second'; + } + return initialVariables || initialHeaders ? undefined : 'second'; }); + const editorToolsResize = useDragResize({ defaultSizeRelation: 3, direction: 'vertical', - initiallyHidden: ((d: typeof defaultEditorToolsVisibility) => { - if (d === 'variables' || d === 'headers') { - return; - } - if (typeof d === 'boolean') { - return d ? undefined : 'second'; - } - return initialVariables || initialHeaders ? undefined : 'second'; - })(defaultEditorToolsVisibility), + initiallyHidden: initiallyHiddenEditorTools, sizeThresholdSecond: 60, - storageKey: 'secondaryEditorFlex', + storageKey: 'flex:editor-tools', }); const [activeSecondaryEditor, setActiveSecondaryEditor] = useState< diff --git a/packages/graphiql/src/cdn.ts b/packages/graphiql/src/cdn.ts index 84a1296ba61..2e02a63c809 100644 --- a/packages/graphiql/src/cdn.ts +++ b/packages/graphiql/src/cdn.ts @@ -6,7 +6,7 @@ */ import { version } from 'react'; import * as GraphiQLReact from '@graphiql/react'; -import { createGraphiQLFetcher, createLocalStorage } from '@graphiql/toolkit'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; import * as GraphQL from 'graphql'; import { GraphiQL } from './GraphiQL'; import './setup-workers/vite'; @@ -32,10 +32,6 @@ export default Object.assign(GraphiQL, { * This function is needed in order to easily create a fetcher function. */ createFetcher: createGraphiQLFetcher, - /** - * This function is needed in order to easily generate a custom storage namespace - */ - createLocalStorage, /** * We also add the complete `graphiql-js` exports so that this instance of * `graphiql-js` can be reused from plugin CDN bundles. diff --git a/packages/graphiql/src/ui/sidebar.tsx b/packages/graphiql/src/ui/sidebar.tsx index 17ff1bfa8a6..dc9d20fdb51 100644 --- a/packages/graphiql/src/ui/sidebar.tsx +++ b/packages/graphiql/src/ui/sidebar.tsx @@ -14,8 +14,6 @@ import { useDragResize, useGraphiQL, useGraphiQLActions, - useStorage, - useTheme, VisuallyHidden, } from '@graphiql/react'; import { ShortKeys } from './short-keys'; @@ -55,19 +53,25 @@ export const Sidebar: FC = ({ const forcedTheme = $forcedTheme && THEMES.includes($forcedTheme) ? $forcedTheme : undefined; - const storage = useStorage(); - const { theme, setTheme } = useTheme(); - const { setShouldPersistHeaders, introspect, setVisiblePlugin } = + const { setShouldPersistHeaders, introspect, setVisiblePlugin, setTheme } = useGraphiQLActions(); - const { shouldPersistHeaders, isIntrospecting, visiblePlugin, plugins } = - useGraphiQL( - pick( - 'shouldPersistHeaders', - 'isIntrospecting', - 'visiblePlugin', - 'plugins', - ), - ); + const { + shouldPersistHeaders, + isIntrospecting, + visiblePlugin, + plugins, + theme, + storage, + } = useGraphiQL( + pick( + 'shouldPersistHeaders', + 'isIntrospecting', + 'visiblePlugin', + 'plugins', + 'theme', + 'storage', + ), + ); useEffect(() => { if (forcedTheme === 'system') { @@ -99,7 +103,7 @@ export const Sidebar: FC = ({ function handleClearData() { try { - storage.clear(); + storage.removeItem('graphiql:theme'); setClearStorageStatus('success'); } catch { setClearStorageStatus('error'); @@ -127,7 +131,7 @@ export const Sidebar: FC = ({ const handlePluginClick: ButtonHandler = event => { const pluginIndex = Number(event.currentTarget.dataset.index!); const plugin = plugins.find((_, index) => pluginIndex === index)!; - const isVisible = plugin === visiblePlugin; + const isVisible = plugin.title === visiblePlugin; if (isVisible) { setVisiblePlugin(null); setHiddenElement('first'); @@ -140,7 +144,7 @@ export const Sidebar: FC = ({ return (
{plugins.map((plugin, index) => { - const isVisible = plugin === visiblePlugin; + const isVisible = plugin.title === visiblePlugin; const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}`; return ( @@ -228,7 +232,7 @@ export const Sidebar: FC = ({
- {showPersistHeadersSettings ? ( + {showPersistHeadersSettings && (
@@ -261,7 +265,7 @@ export const Sidebar: FC = ({
- ) : null} + )} {!forcedTheme && (
diff --git a/resources/custom-words.txt b/resources/custom-words.txt index 947d2a3752c..d524199cb20 100644 --- a/resources/custom-words.txt +++ b/resources/custom-words.txt @@ -154,6 +154,7 @@ outlineable ovsx paas pabbati +partialize picomatch pieas pnpm diff --git a/yarn.lock b/yarn.lock index 0921747f231..7fdd1c58bb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3352,6 +3352,7 @@ __metadata: meros: "npm:^1.1.4" subscriptions-transport-ws: "npm:0.11.0" tsup: "npm:^8.2.4" + zustand: "npm:^5" peerDependencies: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 graphql-ws: ">= 4.5.0"