diff --git a/.changeset/tricky-lions-try.md b/.changeset/tricky-lions-try.md new file mode 100644 index 00000000000..ea766149ba2 --- /dev/null +++ b/.changeset/tricky-lions-try.md @@ -0,0 +1,7 @@ +--- +'graphiql': minor +'@graphiql/react': minor +'@graphiql/toolkit': minor +--- + +Add extensions to GraphQL request payload and add Extensions tab to UI diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b4c17ab4d7b..f8670047823 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,7 +44,7 @@ this repo._ If you are focused on GraphiQL development, you can run — ```sh - yarn start-graphiql + yarn dev-graphiql ``` 5. Get coding! If you've added code, add tests. If you've changed APIs, update @@ -89,7 +89,7 @@ First, you'll need to `yarn build` all the packages from the root. Then, you can run these commands: -- `yarn start-graphiql` — which will launch `webpack` dev server for graphiql +- `yarn dev-graphiql` — which will launch `webpack` dev server for graphiql from the root > The GraphiQL UI is available at http://localhost:8080/dev.html diff --git a/packages/graphiql-react/README.md b/packages/graphiql-react/README.md index 5bf7b262082..54ac1e2615e 100644 --- a/packages/graphiql-react/README.md +++ b/packages/graphiql-react/README.md @@ -130,6 +130,6 @@ elements background. If you want to develop with `@graphiql/react` locally - in particular when working on the `graphiql` package - all you need to do is run `yarn dev` in the package folder in a separate terminal. This will build the package using Vite. -When using it in combination with `yarn start-graphiql` (running in the repo +When using it in combination with `yarn dev-graphiql` (running in the repo root) this will give you auto-reloading when working on `graphiql` and `@graphiql/react` simultaneously. diff --git a/packages/graphiql-react/src/editor/__tests__/tabs.spec.ts b/packages/graphiql-react/src/editor/__tests__/tabs.spec.ts index 0314d220f9d..f8d6726cdb7 100644 --- a/packages/graphiql-react/src/editor/__tests__/tabs.spec.ts +++ b/packages/graphiql-react/src/editor/__tests__/tabs.spec.ts @@ -110,6 +110,7 @@ describe('getDefaultTabState', () => { headers: null, query: null, variables: null, + extensions: null, storage: null, }), ).toEqual({ @@ -138,10 +139,12 @@ describe('getDefaultTabState', () => { headers: '{"x-header":"foo"}', query: 'query Image { image }', variables: null, + extensions: '{"myExtension":"myString"}', }, ], query: null, variables: null, + extensions: null, storage: null, }), ).toEqual({ @@ -156,6 +159,7 @@ describe('getDefaultTabState', () => { headers: '{"x-header":"foo"}', query: 'query Image { image }', title: 'Image', + extensions: '{"myExtension":"myString"}', }), ], }); diff --git a/packages/graphiql-react/src/editor/components/extension-editor.tsx b/packages/graphiql-react/src/editor/components/extension-editor.tsx new file mode 100644 index 00000000000..ddb499b5f9d --- /dev/null +++ b/packages/graphiql-react/src/editor/components/extension-editor.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import { clsx } from 'clsx'; + +import { useEditorContext } from '../context'; +import { + useExtensionEditor, + UseExtensionEditorArgs, +} from '../extension-editor'; + +import '../style/codemirror.css'; +import '../style/fold.css'; +import '../style/lint.css'; +import '../style/hint.css'; +import '../style/editor.css'; + +type ExtensionEditorProps = UseExtensionEditorArgs & { + /** + * Visually hide the header editor. + * @default false + */ + isHidden?: boolean; +}; + +export function ExtensionEditor({ + isHidden, + ...hookArgs +}: ExtensionEditorProps) { + const { extensionEditor } = useEditorContext({ + nonNull: true, + caller: ExtensionEditor, + }); + const ref = useExtensionEditor(hookArgs, ExtensionEditor); + + useEffect(() => { + if (extensionEditor && !isHidden) { + extensionEditor.refresh(); + } + }, [extensionEditor, isHidden]); + + return ( +
+ ); +} diff --git a/packages/graphiql-react/src/editor/components/index.ts b/packages/graphiql-react/src/editor/components/index.ts index 9fbe6db2a47..adc9647457e 100644 --- a/packages/graphiql-react/src/editor/components/index.ts +++ b/packages/graphiql-react/src/editor/components/index.ts @@ -3,3 +3,4 @@ export { ImagePreview } from './image-preview'; export { QueryEditor } from './query-editor'; export { ResponseEditor } from './response-editor'; export { VariableEditor } from './variable-editor'; +export { ExtensionEditor } from './extension-editor'; diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index 8515cb8c763..aeaf8f6ff5c 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -19,6 +19,7 @@ import { import { useStorageContext } from '../storage'; import { createContextHook, createNullableContext } from '../utility/context'; import { STORAGE_KEY as STORAGE_KEY_HEADERS } from './header-editor'; +import { STORAGE_KEY as STORAGE_KEY_EXTENSIONS } from './extension-editor'; import { useSynchronizeValue } from './hooks'; import { STORAGE_KEY_QUERY } from './query-editor'; import { @@ -96,6 +97,10 @@ export type EditorContextType = TabsState & { * The CodeMirror editor instance for the variables editor. */ variableEditor: CodeMirrorEditor | null; + /** + * The CodeMirror editor instance for the extensions editor. + */ + extensionEditor: CodeMirrorEditor | null; /** * Set the CodeMirror editor instance for the headers editor. */ @@ -112,6 +117,10 @@ export type EditorContextType = TabsState & { * Set the CodeMirror editor instance for the variables editor. */ setVariableEditor(newEditor: CodeMirrorEditor): void; + /** + * Set the CodeMirror editor instance for the extensions editor. + */ + setExtensionEditor(newEditor: CodeMirrorEditor): void; /** * Changes the operation name and invokes the `onEditOperationName` callback. @@ -138,6 +147,11 @@ export type EditorContextType = TabsState & { * component. */ initialVariables: string; + /** + * The contents of the extensions editor when initially rendering the provider + * component. + */ + initialExtensions: string; /** * A map of fragment definitions using the fragment name as key which are @@ -255,6 +269,14 @@ export type EditorContextProviderProps = { */ variables?: string; + /** + * This prop can be used to set the contents of the extensions editor. Every + * time this prop changes, the contents of the extensions editor are replaced. + * Note that the editor contents can be changed in between these updates by + * typing in the editor. + */ + extensions?: string; + /** * Headers to be set when opening a new tab */ @@ -274,6 +296,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) { const [variableEditor, setVariableEditor] = useState( null, ); + const [extensionEditor, setExtensionEditor] = + useState(null); const [shouldPersistHeaders, setShouldPersistHeadersInternal] = useState( () => { @@ -288,6 +312,7 @@ export function EditorContextProvider(props: EditorContextProviderProps) { useSynchronizeValue(queryEditor, props.query); useSynchronizeValue(responseEditor, props.response); useSynchronizeValue(variableEditor, props.variables); + useSynchronizeValue(extensionEditor, props.extensions); const storeTabs = useStoreTabs({ storage, @@ -300,12 +325,15 @@ export function EditorContextProvider(props: EditorContextProviderProps) { const query = props.query ?? storage?.get(STORAGE_KEY_QUERY) ?? null; const variables = props.variables ?? storage?.get(STORAGE_KEY_VARIABLES) ?? null; + const extensions = + props.variables ?? storage?.get(STORAGE_KEY_EXTENSIONS) ?? null; const headers = props.headers ?? storage?.get(STORAGE_KEY_HEADERS) ?? null; const response = props.response ?? ''; const tabState = getDefaultTabState({ query, variables, + extensions, headers, defaultTabs: props.defaultTabs, defaultQuery: props.defaultQuery || DEFAULT_QUERY, @@ -321,6 +349,7 @@ export function EditorContextProvider(props: EditorContextProviderProps) { (tabState.activeTabIndex === 0 ? tabState.tabs[0].query : null) ?? '', variables: variables ?? '', + extensions: extensions ?? '', headers: headers ?? props.defaultHeaders ?? '', response, tabState, @@ -357,12 +386,14 @@ export function EditorContextProvider(props: EditorContextProviderProps) { const synchronizeActiveTabValues = useSynchronizeActiveTabValues({ queryEditor, variableEditor, + extensionEditor, headerEditor, responseEditor, }); const setEditorValues = useSetEditorValues({ queryEditor, variableEditor, + extensionEditor, headerEditor, responseEditor, }); @@ -504,15 +535,18 @@ export function EditorContextProvider(props: EditorContextProviderProps) { queryEditor, responseEditor, variableEditor, + extensionEditor, setHeaderEditor, setQueryEditor, setResponseEditor, setVariableEditor, + setExtensionEditor, setOperationName, initialQuery: initialState.query, initialVariables: initialState.variables, + initialExtensions: initialState.extensions, initialHeaders: initialState.headers, initialResponse: initialState.response, @@ -534,6 +568,7 @@ export function EditorContextProvider(props: EditorContextProviderProps) { queryEditor, responseEditor, variableEditor, + extensionEditor, setOperationName, diff --git a/packages/graphiql-react/src/editor/extension-editor.ts b/packages/graphiql-react/src/editor/extension-editor.ts new file mode 100644 index 00000000000..59019127513 --- /dev/null +++ b/packages/graphiql-react/src/editor/extension-editor.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef } from 'react'; + +import { useExecutionContext } from '../execution'; +import { + commonKeys, + DEFAULT_EDITOR_THEME, + DEFAULT_KEY_MAP, + importCodeMirror, +} from './common'; +import { useEditorContext } from './context'; +import { + useChangeHandler, + useKeyMap, + useMergeQuery, + usePrettifyEditors, + useSynchronizeOption, +} from './hooks'; +import { WriteableEditorProps } from './types'; + +export type UseExtensionEditorArgs = WriteableEditorProps & { + /** + * Invoked when the contents of the extension editor change. + * @param value The new contents of the editor. + */ + onEdit?(value: string): void; +}; + +export function useExtensionEditor( + { + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + onEdit, + readOnly = false, + }: UseExtensionEditorArgs = {}, + caller?: Function, +) { + const { initialExtensions, extensionEditor, setExtensionEditor } = + useEditorContext({ + nonNull: true, + caller: caller || useExtensionEditor, + }); + const executionContext = useExecutionContext(); + const merge = useMergeQuery({ caller: caller || useExtensionEditor }); + const prettify = usePrettifyEditors({ caller: caller || useExtensionEditor }); + const ref = useRef(null); + + useEffect(() => { + let isActive = true; + + void importCodeMirror([ + // @ts-expect-error + import('codemirror/mode/javascript/javascript'), + ]).then(CodeMirror => { + // Don't continue if the effect has already been cleaned up + if (!isActive) { + return; + } + + const container = ref.current; + if (!container) { + return; + } + + const newEditor = CodeMirror(container, { + value: initialExtensions, + lineNumbers: true, + tabSize: 2, + mode: { name: 'javascript', json: true }, + theme: editorTheme, + autoCloseBrackets: true, + matchBrackets: true, + showCursorWhenSelecting: true, + readOnly: readOnly ? 'nocursor' : false, + foldGutter: true, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + extraKeys: commonKeys, + }); + + newEditor.addKeyMap({ + 'Cmd-Space'() { + newEditor.showHint({ completeSingle: false, container }); + }, + 'Ctrl-Space'() { + newEditor.showHint({ completeSingle: false, container }); + }, + 'Alt-Space'() { + newEditor.showHint({ completeSingle: false, container }); + }, + 'Shift-Space'() { + newEditor.showHint({ completeSingle: false, container }); + }, + }); + + newEditor.on('keyup', (editorInstance, event) => { + const { code, key, shiftKey } = event; + const isLetter = code.startsWith('Key'); + const isNumber = !shiftKey && code.startsWith('Digit'); + if (isLetter || isNumber || key === '_' || key === '"') { + editorInstance.execCommand('autocomplete'); + } + }); + + setExtensionEditor(newEditor); + }); + + return () => { + isActive = false; + }; + }, [editorTheme, initialExtensions, readOnly, setExtensionEditor]); + + useSynchronizeOption(extensionEditor, 'keyMap', keyMap); + + useChangeHandler( + extensionEditor, + onEdit, + STORAGE_KEY, + 'extensions', + useExtensionEditor, + ); + + useKeyMap( + extensionEditor, + ['Cmd-Enter', 'Ctrl-Enter'], + executionContext?.run, + ); + useKeyMap(extensionEditor, ['Shift-Ctrl-P'], prettify); + useKeyMap(extensionEditor, ['Shift-Ctrl-M'], merge); + + return ref; +} + +export const STORAGE_KEY = 'extensions'; diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index a9c37f5da68..fc0ee7d7f48 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -41,7 +41,7 @@ export function useChangeHandler( editor: CodeMirrorEditor | null, callback: ((value: string) => void) | undefined, storageKey: string | null, - tabProperty: 'variables' | 'headers', + tabProperty: 'variables' | 'headers' | 'extensions', caller: Function, ) { const { updateActiveTabValues } = useEditorContext({ nonNull: true, caller }); @@ -212,10 +212,11 @@ type UsePrettifyEditorsArgs = { }; export function usePrettifyEditors({ caller }: UsePrettifyEditorsArgs = {}) { - const { queryEditor, headerEditor, variableEditor } = useEditorContext({ - nonNull: true, - caller: caller || usePrettifyEditors, - }); + const { queryEditor, headerEditor, variableEditor, extensionEditor } = + useEditorContext({ + nonNull: true, + caller: caller || usePrettifyEditors, + }); return useCallback(() => { if (variableEditor) { const variableEditorContent = variableEditor.getValue(); @@ -233,6 +234,23 @@ export function usePrettifyEditors({ caller }: UsePrettifyEditorsArgs = {}) { } } + if (extensionEditor) { + const extensionEditorContent = extensionEditor.getValue(); + + try { + const prettifiedExtensionEditorContent = JSON.stringify( + JSON.parse(extensionEditorContent), + null, + 2, + ); + if (prettifiedExtensionEditorContent !== extensionEditorContent) { + extensionEditor.setValue(prettifiedExtensionEditorContent); + } + } catch { + /* Parsing JSON failed, skip prettification */ + } + } + if (headerEditor) { const headerEditorContent = headerEditor.getValue(); @@ -258,7 +276,7 @@ export function usePrettifyEditors({ caller }: UsePrettifyEditorsArgs = {}) { queryEditor.setValue(prettifiedEditorContent); } } - }, [queryEditor, variableEditor, headerEditor]); + }, [queryEditor, variableEditor, headerEditor, extensionEditor]); } export type UseAutoCompleteLeafsArgs = { diff --git a/packages/graphiql-react/src/editor/index.ts b/packages/graphiql-react/src/editor/index.ts index 8a4f8fe1687..11d8612296a 100644 --- a/packages/graphiql-react/src/editor/index.ts +++ b/packages/graphiql-react/src/editor/index.ts @@ -4,6 +4,7 @@ export { QueryEditor, ResponseEditor, VariableEditor, + ExtensionEditor, } from './components'; export { EditorContext, @@ -22,6 +23,7 @@ export { export { useQueryEditor } from './query-editor'; export { useResponseEditor } from './response-editor'; export { useVariableEditor } from './variable-editor'; +export { useExtensionEditor } from './extension-editor'; export type { EditorContextType, EditorContextProviderProps } from './context'; export type { UseHeaderEditorArgs } from './header-editor'; @@ -32,5 +34,6 @@ export type { } from './response-editor'; export type { TabsState } from './tabs'; export type { UseVariableEditorArgs } from './variable-editor'; +export type { UseExtensionEditorArgs } from './extension-editor'; export type { CommonEditorProps, KeyMap, WriteableEditorProps } from './types'; diff --git a/packages/graphiql-react/src/editor/tabs.ts b/packages/graphiql-react/src/editor/tabs.ts index b9110dd5135..5c3280b7883 100644 --- a/packages/graphiql-react/src/editor/tabs.ts +++ b/packages/graphiql-react/src/editor/tabs.ts @@ -14,6 +14,10 @@ export type TabDefinition = { * The contents of the variable editor of this tab. */ variables?: string | null; + /** + * The contents of the extensions editor of this tab. + */ + extensions?: string | null; /** * The contents of the headers editor of this tab. */ @@ -30,7 +34,7 @@ export type TabState = TabDefinition & { id: string; /** * 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, the extension editor, and the header editor (i.e. all the editor * where the contents are persisted in storage). */ hash: string; @@ -71,6 +75,7 @@ export function getDefaultTabState({ defaultTabs, query, variables, + extensions, storage, shouldPersistHeaders, }: { @@ -80,6 +85,7 @@ export function getDefaultTabState({ defaultTabs?: TabDefinition[]; query: string | null; variables: string | null; + extensions: string | null; storage: StorageAPI | null; shouldPersistHeaders?: boolean; }) { @@ -96,6 +102,7 @@ export function getDefaultTabState({ const expectedHash = hashFromTabContents({ query, variables, + extensions, headers: headersForHash, }); let matchingTabIndex = -1; @@ -105,6 +112,7 @@ export function getDefaultTabState({ tab.hash = hashFromTabContents({ query: tab.query, variables: tab.variables, + extensions: tab.extensions, headers: tab.headers, }); if (tab.hash === expectedHash) { @@ -122,6 +130,7 @@ export function getDefaultTabState({ title: operationName || DEFAULT_TITLE, query, variables, + extensions, headers, operationName, response: null, @@ -140,6 +149,7 @@ export function getDefaultTabState({ { query: query ?? defaultQuery, variables, + extensions, headers: headers ?? defaultHeaders, }, ] @@ -170,6 +180,7 @@ function isTabState(obj: any): obj is TabState { hasStringKey(obj, 'title') && hasStringOrNullKey(obj, 'query') && hasStringOrNullKey(obj, 'variables') && + hasStringOrNullKey(obj, 'extensions') && hasStringOrNullKey(obj, 'headers') && hasStringOrNullKey(obj, 'operationName') && hasStringOrNullKey(obj, 'response') @@ -191,11 +202,13 @@ function hasStringOrNullKey(obj: Record, key: string) { export function useSynchronizeActiveTabValues({ queryEditor, variableEditor, + extensionEditor, headerEditor, responseEditor, }: { queryEditor: CodeMirrorEditorWithOperationFacts | null; variableEditor: CodeMirrorEditor | null; + extensionEditor: CodeMirrorEditor | null; headerEditor: CodeMirrorEditor | null; responseEditor: CodeMirrorEditor | null; }) { @@ -203,18 +216,26 @@ export function useSynchronizeActiveTabValues({ state => { const query = queryEditor?.getValue() ?? null; const variables = variableEditor?.getValue() ?? null; + const extensions = extensionEditor?.getValue() ?? null; const headers = headerEditor?.getValue() ?? null; const operationName = queryEditor?.operationName ?? null; const response = responseEditor?.getValue() ?? null; return setPropertiesInActiveTab(state, { query, variables, + extensions, headers, response, operationName, }); }, - [queryEditor, variableEditor, headerEditor, responseEditor], + [ + queryEditor, + variableEditor, + extensionEditor, + headerEditor, + responseEditor, + ], ); } @@ -256,11 +277,13 @@ export function useStoreTabs({ export function useSetEditorValues({ queryEditor, variableEditor, + extensionEditor, headerEditor, responseEditor, }: { queryEditor: CodeMirrorEditorWithOperationFacts | null; variableEditor: CodeMirrorEditor | null; + extensionEditor: CodeMirrorEditor | null; headerEditor: CodeMirrorEditor | null; responseEditor: CodeMirrorEditor | null; }) { @@ -268,26 +291,36 @@ export function useSetEditorValues({ ({ query, variables, + extensions, headers, response, }: { query: string | null; variables?: string | null; + extensions?: string | null; headers?: string | null; response: string | null; }) => { queryEditor?.setValue(query ?? ''); variableEditor?.setValue(variables ?? ''); + extensionEditor?.setValue(extensions ?? ''); headerEditor?.setValue(headers ?? ''); responseEditor?.setValue(response ?? ''); }, - [headerEditor, queryEditor, responseEditor, variableEditor], + [ + headerEditor, + queryEditor, + responseEditor, + variableEditor, + extensionEditor, + ], ); } export function createTab({ query = null, variables = null, + extensions = null, headers = null, }: Partial = {}): TabState { return { @@ -296,6 +329,7 @@ export function createTab({ title: (query && fuzzyExtractOperationName(query)) || DEFAULT_TITLE, query, variables, + extensions, headers, operationName: null, response: null, @@ -340,9 +374,15 @@ function guid(): string { function hashFromTabContents(args: { query: string | null; variables?: string | null; + extensions?: string | null; headers?: string | null; }): string { - return [args.query ?? '', args.variables ?? '', args.headers ?? ''].join('|'); + return [ + args.query ?? '', + args.variables ?? '', + args.extensions ?? '', + args.headers ?? '', + ].join('|'); } export function fuzzyExtractOperationName(str: string): string | null { diff --git a/packages/graphiql-react/src/execution.tsx b/packages/graphiql-react/src/execution.tsx index d66b4eea78b..91e1d56e86b 100644 --- a/packages/graphiql-react/src/execution.tsx +++ b/packages/graphiql-react/src/execution.tsx @@ -87,6 +87,7 @@ export function ExecutionContextProvider({ queryEditor, responseEditor, variableEditor, + extensionEditor, updateActiveTabValues, } = useEditorContext({ nonNull: true, caller: ExecutionContextProvider }); const history = useHistoryContext(); @@ -141,6 +142,19 @@ export function ExecutionContextProvider({ return; } + const extensionsString = extensionEditor?.getValue(); + let extensions: Record | undefined; + try { + extensions = tryParseJsonObject({ + json: extensionsString, + errorMessageParse: 'Extensions are invalid JSON', + errorMessageType: 'Extensions are not a JSON object.', + }); + } catch (error) { + setResponse(error instanceof Error ? error.message : `${error}`); + return; + } + const headersString = headerEditor?.getValue(); let headers: Record | undefined; try { @@ -178,6 +192,7 @@ export function ExecutionContextProvider({ history?.addToHistory({ query, variables: variablesString, + extensions: extensionsString, headers: headersString, operationName: opName, }); @@ -251,6 +266,7 @@ export function ExecutionContextProvider({ { query, variables, + extensions, operationName: opName, }, { @@ -312,6 +328,7 @@ export function ExecutionContextProvider({ subscription, updateActiveTabValues, variableEditor, + extensionEditor, ]); const isSubscribed = Boolean(subscription); diff --git a/packages/graphiql-react/src/history/__tests__/components.spec.tsx b/packages/graphiql-react/src/history/__tests__/components.spec.tsx index 602e68d9e89..e623a8dde1c 100644 --- a/packages/graphiql-react/src/history/__tests__/components.spec.tsx +++ b/packages/graphiql-react/src/history/__tests__/components.spec.tsx @@ -8,12 +8,14 @@ import { Tooltip } from '../../ui'; jest.mock('../../editor', () => { const mockedSetQueryEditor = jest.fn(); const mockedSetVariableEditor = jest.fn(); + const mockedSetExtensionEditor = jest.fn(); const mockedSetHeaderEditor = jest.fn(); return { useEditorContext() { return { queryEditor: { setValue: mockedSetQueryEditor }, variableEditor: { setValue: mockedSetVariableEditor }, + extensionEditor: { setValue: mockedSetExtensionEditor }, headerEditor: { setValue: mockedSetHeaderEditor }, }; }, @@ -30,6 +32,8 @@ const mockQuery = /* GraphQL */ ` const mockVariables = JSON.stringify({ string: 'string' }); +const mockExtensions = JSON.stringify({ myExtension: 'myString' }); + const mockHeaders = JSON.stringify({ foo: 'bar' }); const mockOperationName = 'Test'; @@ -50,6 +54,7 @@ const baseMockProps: QueryHistoryItemProps = { item: { query: mockQuery, variables: mockVariables, + extensions: mockExtensions, headers: mockHeaders, favorite: false, }, @@ -70,11 +75,14 @@ describe('QueryHistoryItem', () => { ?.setValue as jest.Mock; const mockedSetVariableEditor = useEditorContext()?.variableEditor ?.setValue as jest.Mock; + const mockedSetExtensionEditor = useEditorContext()?.extensionEditor + ?.setValue as jest.Mock; const mockedSetHeaderEditor = useEditorContext()?.headerEditor ?.setValue as jest.Mock; beforeEach(() => { mockedSetQueryEditor.mockClear(); mockedSetVariableEditor.mockClear(); + mockedSetExtensionEditor.mockClear(); mockedSetHeaderEditor.mockClear(); }); it('renders operationName if label is not provided', () => { @@ -112,6 +120,10 @@ describe('QueryHistoryItem', () => { expect(mockedSetVariableEditor).toHaveBeenCalledWith( mockProps.item.variables, ); + expect(mockedSetExtensionEditor).toHaveBeenCalledTimes(1); + expect(mockedSetExtensionEditor).toHaveBeenCalledWith( + mockProps.item.extensions, + ); expect(mockedSetHeaderEditor).toHaveBeenCalledTimes(1); expect(mockedSetHeaderEditor).toHaveBeenCalledWith(mockProps.item.headers); }); diff --git a/packages/graphiql-react/src/history/components.tsx b/packages/graphiql-react/src/history/components.tsx index 9ee49574be3..e8819b2e30a 100644 --- a/packages/graphiql-react/src/history/components.tsx +++ b/packages/graphiql-react/src/history/components.tsx @@ -112,10 +112,11 @@ export function HistoryItem(props: QueryHistoryItemProps) { nonNull: true, caller: HistoryItem, }); - const { headerEditor, queryEditor, variableEditor } = useEditorContext({ - nonNull: true, - caller: HistoryItem, - }); + const { headerEditor, queryEditor, variableEditor, extensionEditor } = + useEditorContext({ + nonNull: true, + caller: HistoryItem, + }); const inputRef = useRef(null); const buttonRef = useRef(null); const [isEditable, setIsEditable] = useState(false); @@ -151,12 +152,20 @@ export function HistoryItem(props: QueryHistoryItemProps) { const handleHistoryItemClick: MouseEventHandler = useCallback(() => { - const { query, variables, headers } = props.item; + const { query, variables, extensions, headers } = props.item; queryEditor?.setValue(query ?? ''); variableEditor?.setValue(variables ?? ''); + extensionEditor?.setValue(extensions ?? ''); headerEditor?.setValue(headers ?? ''); setActive(props.item); - }, [headerEditor, props.item, queryEditor, setActive, variableEditor]); + }, [ + headerEditor, + props.item, + queryEditor, + setActive, + variableEditor, + extensionEditor, + ]); const handleDeleteItemFromHistory: MouseEventHandler = useCallback( diff --git a/packages/graphiql-react/src/history/context.tsx b/packages/graphiql-react/src/history/context.tsx index 1a51e4a8487..1c9fc7e8df2 100644 --- a/packages/graphiql-react/src/history/context.tsx +++ b/packages/graphiql-react/src/history/context.tsx @@ -8,11 +8,12 @@ export type HistoryContextType = { /** * Add an operation to the history. * @param operation The operation that was executed, consisting of the query, - * variables, headers, and operation name. + * variables, extensions, headers, and operation name. */ addToHistory(operation: { query?: string; variables?: string; + extensions?: string; headers?: string; operationName?: string; }): void; @@ -29,6 +30,7 @@ export type HistoryContextType = { args: { query?: string; variables?: string; + extensions?: string; headers?: string; operationName?: string; label?: string; @@ -50,6 +52,7 @@ export type HistoryContextType = { toggleFavorite(args: { query?: string; variables?: string; + extensions?: string; headers?: string; operationName?: string; label?: string; @@ -58,7 +61,7 @@ export type HistoryContextType = { /** * Delete an operation from the history. * @param args The operation that was executed, consisting of the query, - * variables, headers, and operation name. + * variables, extensions, headers, and operation name. * @param clearFavorites This is only if you press the 'clear' button */ deleteFromHistory(args: QueryStoreItem, clearFavorites?: boolean): void; diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 26cf7756193..155408af31f 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -19,6 +19,8 @@ export { useOperationsEditorState, useVariablesEditorState, VariableEditor, + ExtensionEditor, + useExtensionEditor, } from './editor'; export { ExecutionContext, @@ -84,6 +86,7 @@ export type { UseQueryEditorArgs, UseResponseEditorArgs, UseVariableEditorArgs, + UseExtensionEditorArgs, WriteableEditorProps, } from './editor'; export type { diff --git a/packages/graphiql-react/src/provider.tsx b/packages/graphiql-react/src/provider.tsx index ead1907de8e..0cf8d59167e 100644 --- a/packages/graphiql-react/src/provider.tsx +++ b/packages/graphiql-react/src/provider.tsx @@ -47,6 +47,7 @@ export function GraphiQLProvider({ storage, validationRules, variables, + extensions, visiblePlugin, }: GraphiQLProviderProps) { return ( @@ -65,6 +66,7 @@ export function GraphiQLProvider({ shouldPersistHeaders={shouldPersistHeaders} validationRules={validationRules} variables={variables} + extensions={extensions} > { @@ -87,6 +89,7 @@ export class HistoryStore { this.history.push({ query, variables, + extensions, headers, operationName, }); @@ -98,6 +101,7 @@ export class HistoryStore { toggleFavorite({ query, variables, + extensions, headers, operationName, label, @@ -106,6 +110,7 @@ export class HistoryStore { const item: QueryStoreItem = { query, variables, + extensions, headers, operationName, label, @@ -126,6 +131,7 @@ export class HistoryStore { { query, variables, + extensions, headers, operationName, label, @@ -136,6 +142,7 @@ export class HistoryStore { const item = { query, variables, + extensions, headers, operationName, label, @@ -149,7 +156,14 @@ export class HistoryStore { } deleteHistory = ( - { query, variables, headers, operationName, favorite }: QueryStoreItem, + { + query, + variables, + extensions, + headers, + operationName, + favorite, + }: QueryStoreItem, clearFavorites = false, ) => { function deleteFromStore(store: QueryStore) { @@ -157,6 +171,7 @@ export class HistoryStore { x => x.query === query && x.variables === variables && + x.extensions === extensions && x.headers === headers && x.operationName === operationName, ); diff --git a/packages/graphiql-toolkit/src/storage/query.ts b/packages/graphiql-toolkit/src/storage/query.ts index 9fea0e93b4a..ea59590075c 100644 --- a/packages/graphiql-toolkit/src/storage/query.ts +++ b/packages/graphiql-toolkit/src/storage/query.ts @@ -3,6 +3,7 @@ import { StorageAPI } from './base'; export type QueryStoreItem = { query?: string; variables?: string; + extensions?: string; headers?: string; operationName?: string; label?: string; @@ -29,6 +30,7 @@ export class QueryStore { x => x.query === item.query && x.variables === item.variables && + x.extensions === item.extensions && x.headers === item.headers && x.operationName === item.operationName, ); @@ -40,6 +42,7 @@ export class QueryStore { if ( found.query === item.query && found.variables === item.variables && + found.extensions === item.extensions && found.headers === item.headers && found.operationName === item.operationName ) { @@ -53,6 +56,7 @@ export class QueryStore { x => x.query === item.query && x.variables === item.variables && + x.extensions === item.extensions && x.headers === item.headers && x.operationName === item.operationName, ); @@ -67,6 +71,7 @@ export class QueryStore { x => x.query === item.query && x.variables === item.variables && + x.extensions === item.extensions && x.headers === item.headers && x.operationName === item.operationName, ); diff --git a/packages/graphiql/cypress/e2e/tabs.cy.ts b/packages/graphiql/cypress/e2e/tabs.cy.ts index 4ad14f76d14..434fdab2883 100644 --- a/packages/graphiql/cypress/e2e/tabs.cy.ts +++ b/packages/graphiql/cypress/e2e/tabs.cy.ts @@ -31,6 +31,12 @@ describe('Tabs', () => { .eq(1) .type('{"someHeader":"someValue"', { force: true }); + // Enter extensions + cy.contains('Extensions').click(); + cy.get('.graphiql-editor-tool textarea') + .eq(2) + .type('{"myExtension":"myString"', { force: true }); + // Run the query cy.clickExecuteQuery(); @@ -45,6 +51,7 @@ describe('Tabs', () => { cy.assertHasValues({ query: '{id}', variablesString: '', + extensionsString: '', headersString: '', response: { data: { id: 'abc123' } }, }); @@ -60,6 +67,7 @@ describe('Tabs', () => { cy.assertHasValues({ query: 'query Foo {image}', variablesString: '{"someVar":42}', + extensionsString: '{"myExtension":"myString"}', headersString: '{"someHeader":"someValue"}', response: { data: { image: '/images/logo.svg' } }, }); @@ -74,6 +82,7 @@ describe('Tabs', () => { cy.assertHasValues({ query: '{id}', variablesString: '', + extensionsString: '', headersString: '', response: { data: { id: 'abc123' } }, }); diff --git a/packages/graphiql/cypress/support/commands.ts b/packages/graphiql/cypress/support/commands.ts index 0f2254afbf9..6d38f55814d 100644 --- a/packages/graphiql/cypress/support/commands.ts +++ b/packages/graphiql/cypress/support/commands.ts @@ -14,6 +14,7 @@ type Op = { query: string; variables?: Record; variablesString?: string; + extensionsString?: string; headersString?: string; response?: Record; }; @@ -73,7 +74,14 @@ Cypress.Commands.add('visitWithOp', ({ query, variables, variablesString }) => { Cypress.Commands.add( 'assertHasValues', - ({ query, variables, variablesString, headersString, response }: Op) => { + ({ + query, + variables, + variablesString, + extensionsString, + headersString, + response, + }: Op) => { cy.get('.graphiql-query-editor').should(element => { expect(normalize(element.get(0).innerText)).to.equal( codeWithLineNumbers(query), @@ -109,6 +117,16 @@ Cypress.Commands.add( ); }); } + if (extensionsString !== undefined) { + cy.contains('Extensions').click(); + cy.get('.graphiql-editor-tool .graphiql-editor') + .eq(2) + .should(element => { + expect(normalize(element.get(0).innerText)).to.equal( + codeWithLineNumbers(extensionsString), + ); + }); + } if (response !== undefined) { cy.get('.result-window').should(element => { expect(normalizeWhitespace(element.get(0).innerText)).to.equal( diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index 0bb9cd1c5f2..ddca4c54ff2 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -24,6 +24,7 @@ import { CopyIcon, Dialog, ExecuteButton, + ExtensionEditor, GraphiQLProvider, GraphiQLProviderProps, HeaderEditor, @@ -45,6 +46,7 @@ import { useDragResize, useEditorContext, useExecutionContext, + UseExtensionEditorArgs, UseHeaderEditorArgs, useMergeQuery, usePluginContext, @@ -120,6 +122,7 @@ export function GraphiQL({ storage, validationRules, variables, + extensions, visiblePlugin, defaultHeaders, ...props @@ -159,6 +162,7 @@ export function GraphiQL({ storage={storage} validationRules={validationRules} variables={variables} + extensions={extensions} > , 'Query'> & Pick & AddSuffix, 'Variables'> & + AddSuffix, 'Extensions'> & AddSuffix, 'Headers'> & Pick & { children?: ReactNode; @@ -189,11 +194,16 @@ export type GraphiQLInterfaceProps = WriteableEditorProps & * - `false` hides the editor tools * - `true` shows the editor tools * - `'variables'` specifically shows the variables editor + * - `'extensions'` specifically shows the extensions editor * - `'headers'` specifically shows the headers editor * By default the editor tools are initially shown when at least one of the * editors has contents. */ - defaultEditorToolsVisibility?: boolean | 'variables' | 'headers'; + defaultEditorToolsVisibility?: + | boolean + | 'variables' + | 'extensions' + | 'headers'; /** * Toggle if the headers editor should be shown inside the editor tools. * @default true @@ -249,6 +259,7 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { initiallyHidden: (() => { if ( props.defaultEditorToolsVisibility === 'variables' || + props.defaultEditorToolsVisibility === 'extensions' || props.defaultEditorToolsVisibility === 'headers' ) { return; @@ -258,7 +269,9 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { return props.defaultEditorToolsVisibility ? undefined : 'second'; } - return editorContext.initialVariables || editorContext.initialHeaders + return editorContext.initialVariables || + editorContext.initialExtensions || + editorContext.initialHeaders ? undefined : 'second'; })(), @@ -267,19 +280,26 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { }); const [activeSecondaryEditor, setActiveSecondaryEditor] = useState< - 'variables' | 'headers' + 'variables' | 'extensions' | 'headers' >(() => { if ( props.defaultEditorToolsVisibility === 'variables' || + props.defaultEditorToolsVisibility === 'extensions' || props.defaultEditorToolsVisibility === 'headers' ) { return props.defaultEditorToolsVisibility; } - return !editorContext.initialVariables && - editorContext.initialHeaders && - isHeadersEditorEnabled - ? 'headers' - : 'variables'; + + if (editorContext.initialVariables) { + return 'variables'; + } + if (editorContext.initialHeaders && isHeadersEditorEnabled) { + return 'headers'; + } + if (editorContext.initialExtensions) { + return 'extensions'; + } + return 'variables'; }); const [showDialog, setShowDialog] = useState< 'settings' | 'short-keys' | null @@ -318,6 +338,18 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { isChildComponentType(child, GraphiQL.Footer), ); + const tabName = (currentTab: String) => { + if (currentTab === 'variables') { + return 'Variables'; + } + if (currentTab === 'extensions') { + return 'Extensions'; + } + if (currentTab === 'headers') { + return 'Headers'; + } + }; + const onClickReference = useCallback(() => { if (pluginResize.hiddenElement === 'first') { pluginResize.setHiddenElement(null); @@ -390,7 +422,10 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { editorToolsResize.setHiddenElement(null); } setActiveSecondaryEditor( - event.currentTarget.dataset.name as 'variables' | 'headers', + event.currentTarget.dataset.name as + | 'variables' + | 'extensions' + | 'headers', ); }, [editorToolsResize], @@ -618,6 +653,19 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { Headers )} + + Extensions +
)} +
diff --git a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx index 8eacd3b6719..6ded4219e77 100644 --- a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx +++ b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx @@ -239,6 +239,20 @@ describe('GraphiQL', () => { }); }); + it('correctly displays extensions editor when using defaultEditorToolsVisibility prop', async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect( + container.querySelector('[aria-label="Extensions"]'), + ).toBeVisible(); + }); + }); + it('correctly displays headers editor when using defaultEditorToolsVisibility prop', async () => { const { container } = render(