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(