import type { StateCreator } from 'zustand'; import type { FragmentDefinitionNode, OperationDefinitionNode, DocumentNode, } from 'graphql'; import type { OperationFacts } from 'graphql-language-service'; import { MaybePromise, mergeAst } from '@graphiql/toolkit'; import { print } from 'graphql'; 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'; export interface EditorSlice extends TabsState { /** * Unique ID of the GraphiQL instance, which will be suffixed to the URIs for operations, * variables, headers, and response editors. * * @see https://github.com/microsoft/monaco-editor#uris */ uriInstanceId: string; /** * The Monaco Editor instance used in the request headers editor, used to edit HTTP headers. */ headerEditor?: MonacoEditor; /** * The Monaco Editor instance used in the operation editor. */ queryEditor?: MonacoEditor; /** * The Monaco Editor instance used in the response editor. */ responseEditor?: MonacoEditor; /** * The Monaco Editor instance used in the variables editor. */ variableEditor?: MonacoEditor; /** * The contents of the request headers editor when initially rendering the provider * component. */ initialHeaders: string; /** * The contents of the operation editor when initially rendering the provider * component. */ initialQuery: string; /** * The contents of the variables editor when initially rendering the provider * component. */ initialVariables: string; /** * A map of fragment definitions using the fragment name as a key which are * made available to include in the query. */ externalFragments: Map; /** * If the contents of the request headers editor are persisted in storage. */ shouldPersistHeaders: boolean; /** * The initial content of the operation editor when loading GraphiQL and there is * no saved query in storage and no `initialQuery` prop. * * This value is used only for the first tab. Additional tabs will open with * an empty operation editor. * * @default "# Welcome to GraphiQL..." */ defaultQuery?: string; /** * Invoked when the operation name changes. Possible triggers are: * - Editing the contents of the operation editor * - Selecting an operation for execution in a document that contains multiple * operation definitions * @param operationName - The operation name after it has been changed. */ onEditOperationName?(operationName: string): void; /** * Invoked when the state of the tabs changes. Possible triggers are: * - Updating any editor contents inside the currently active tab * - Adding a tab * - Switching to a different tab * - Closing a tab * @param tabState - The tab state after it has been updated. */ onTabChange?(tabState: TabsState): void; /** * Headers to be set when opening a new tab. */ defaultHeaders?: string; /** * Invoked when the current contents of the operation editor are copied to the * clipboard. * @param query - The content that has been copied. */ onCopyQuery?: (query: string) => void; /** * Invoked when the prettify callback is invoked. * @param query - The current value of the operation editor. * @default * import prettier from 'prettier/standalone' * * prettier.format(query, { parser: 'graphql' }) * @returns The formatted query. */ onPrettifyQuery: (query: string) => MaybePromise; // Operation facts that are derived from the operation editor. /** * @remarks from graphiql 5 */ documentAST?: OperationFacts['documentAST']; /** * @remarks from graphiql 5 */ operationName?: string; /** * @remarks from graphiql 5 */ operations?: OperationFacts['operations']; } export interface EditorActions { /** * Add a new tab. */ addTab(): void; /** * Switch to a different tab. * @param index - The index of the tab that should be switched to. */ changeTab(index: number): void; /** * Move a tab to a new spot. * @param newOrder - The new order for the tabs. */ moveTab(newOrder: TabState[]): void; /** * Close a tab. If the currently active tab is closed, the tab before it will * become active. If there is no tab before the closed one, the tab after it * will become active. * @param index - The index of the tab that should be closed. */ closeTab(index: number): void; /** * Update the state for the tab that is currently active. This will be * reflected in the `tabs` object and the state will be persisted in storage * (if available). * @param partialTab - A partial tab state object that will override the * current values. The properties `id`, `hash` and `title` cannot be changed. */ updateActiveTabValues( partialTab: Partial>, ): void; /** * Set the Monaco Editor instance used in the specified editor. */ setEditor( state: Pick< EditorSlice, 'headerEditor' | 'queryEditor' | 'responseEditor' | 'variableEditor' >, ): void; /** * Changes the operation name and invokes the `onEditOperationName` callback. */ setOperationName(operationName: string): void; /** * Changes if headers should be persisted. */ setShouldPersistHeaders(persist: boolean): void; storeTabs(tabsState: TabsState): void; setOperationFacts(facts: { documentAST?: DocumentNode; operationName?: string; operations?: OperationDefinitionNode[]; }): void; /** * Copy a query to clipboard. */ copyQuery: () => Promise; /** * Merge fragments definitions into operation definition. */ mergeQuery: () => void; /** * Prettify query, variables and request headers editors. */ prettifyEditors: () => Promise; } export interface EditorProps extends Pick< EditorSlice, | 'onTabChange' | 'onEditOperationName' | 'defaultHeaders' | 'defaultQuery' | 'onCopyQuery' > { /** * With this prop you can pass so-called "external" fragments that will be * included in the query document (depending on usage). You can either pass * the fragments using SDL (passing a string) or you can pass a list of * `FragmentDefinitionNode` objects. */ externalFragments?: string | FragmentDefinitionNode[]; /** * This prop can be used to define the default set of tabs, with their * queries, variables, and headers. It will be used as default only if * there is no tab state persisted in storage. * * @example * ```tsx * *``` */ defaultTabs?: TabDefinition[]; /** * This prop toggles if the contents of the request headers editor are persisted in * storage. * @default false */ shouldPersistHeaders?: boolean; onPrettifyQuery?: EditorSlice['onPrettifyQuery']; initialQuery?: EditorSlice['initialQuery']; initialVariables?: EditorSlice['initialVariables']; initialHeaders?: EditorSlice['initialHeaders']; } type CreateEditorSlice = ( initial: Pick< EditorSlice, | 'shouldPersistHeaders' | 'tabs' | 'activeTabIndex' | 'initialQuery' | 'initialVariables' | 'initialHeaders' | 'onEditOperationName' | 'externalFragments' | 'onTabChange' | 'defaultQuery' | 'defaultHeaders' | 'onPrettifyQuery' | 'onCopyQuery' | 'uriInstanceId' >, ) => StateCreator< SlicesWithActions, [], [], EditorSlice & { actions: EditorActions } >; export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { function setEditorValues({ query, variables, headers, response, }: { query: string | null; variables?: string | null; headers?: string | null; response: string | null; }) { const { queryEditor, variableEditor, headerEditor, responseEditor, defaultHeaders, } = get(); queryEditor?.setValue(query ?? ''); variableEditor?.setValue(variables ?? ''); headerEditor?.setValue(headers ?? defaultHeaders ?? ''); responseEditor?.setValue(response ?? ''); } function synchronizeActiveTabValues(tabsState: TabsState): TabsState { const { queryEditor, variableEditor, headerEditor, responseEditor, operationName, } = get(); return setPropertiesInActiveTab(tabsState, { query: queryEditor?.getValue() ?? null, variables: variableEditor?.getValue() ?? null, headers: headerEditor?.getValue() ?? null, response: responseEditor?.getValue() ?? null, operationName: operationName ?? null, }); } const $actions: EditorActions = { addTab() { set(({ defaultHeaders, onTabChange, tabs, activeTabIndex, actions }) => { // Make sure the current tab stores the latest values const updatedValues = synchronizeActiveTabValues({ tabs, activeTabIndex, }); const updated = { tabs: [...updatedValues.tabs, createTab({ headers: defaultHeaders })], activeTabIndex: updatedValues.tabs.length, }; actions.storeTabs(updated); setEditorValues(updated.tabs[updated.activeTabIndex]!); onTabChange?.(updated); return updated; }); }, changeTab(index) { set(({ actions, onTabChange, tabs }) => { actions.stop(); const updated = { tabs, activeTabIndex: index, }; actions.storeTabs(updated); setEditorValues(updated.tabs[updated.activeTabIndex]!); onTabChange?.(updated); return updated; }); }, moveTab(newOrder) { set(({ onTabChange, actions, 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; }); }, closeTab(index) { set(({ activeTabIndex, onTabChange, actions, tabs }) => { if (activeTabIndex === index) { actions.stop(); } const updated = { 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 }) => { const updated = setPropertiesInActiveTab( { tabs, activeTabIndex }, partialTab, ); actions.storeTabs(updated); onTabChange?.(updated); return updated; }); }, setEditor({ headerEditor, queryEditor, responseEditor, variableEditor }) { const entries = Object.entries({ headerEditor, queryEditor, responseEditor, variableEditor, }).filter(([_key, value]) => value); const newState = Object.fromEntries(entries); set(newState); }, setOperationName(operationName) { set(({ onEditOperationName, actions }) => { actions.updateActiveTabValues({ operationName }); onEditOperationName?.(operationName); return { operationName }; }); }, setShouldPersistHeaders(persist) { const { headerEditor, tabs, activeTabIndex, storage } = get(); 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); } storage.set(STORAGE_KEY.persistHeaders, persist.toString()); set({ shouldPersistHeaders: persist }); }, storeTabs({ tabs, activeTabIndex }) { const { shouldPersistHeaders, storage } = get(); const store = debounce(500, (value: string) => { storage.set(STORAGE_KEY.tabs, value); }); store(serializeTabState({ tabs, activeTabIndex }, shouldPersistHeaders)); }, setOperationFacts({ documentAST, operationName, operations }) { set({ documentAST, operationName, operations, }); }, async copyQuery() { const { queryEditor, onCopyQuery } = get(); if (!queryEditor) { return; } const query = queryEditor.getValue(); onCopyQuery?.(query); try { await navigator.clipboard.writeText(query); } catch (error) { // eslint-disable-next-line no-console console.warn('Failed to copy query!', error); } }, async prettifyEditors() { const { queryEditor, headerEditor, variableEditor, onPrettifyQuery } = get(); if (variableEditor) { try { const content = variableEditor.getValue(); const formatted = await formatJSONC(content); if (formatted !== content) { variableEditor.setValue(formatted); } } catch (error) { // eslint-disable-next-line no-console console.warn( 'Parsing variables JSON failed, skip prettification.', error, ); } } if (headerEditor) { try { const content = headerEditor.getValue(); const formatted = await formatJSONC(content); if (formatted !== content) { headerEditor.setValue(formatted); } } catch (error) { // eslint-disable-next-line no-console console.warn( 'Parsing headers JSON failed, skip prettification.', error, ); } } if (!queryEditor) { return; } try { const content = queryEditor.getValue(); const formatted = await onPrettifyQuery(content); if (formatted !== content) { queryEditor.setValue(formatted); } } catch (error) { // eslint-disable-next-line no-console console.warn('Parsing query failed, skip prettification.', error); } }, mergeQuery() { const { queryEditor, documentAST, schema } = get(); const query = queryEditor?.getValue(); if (!documentAST || !query) { return; } queryEditor!.setValue(print(mergeAst(documentAST, schema))); }, }; return { ...initial, actions: $actions, }; };