Skip to content
9 changes: 9 additions & 0 deletions .changeset/extensions-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@graphiql/react': minor
'graphiql': minor
'@graphiql/toolkit': minor
'@graphiql/plugin-history': minor
---

Add support for GraphQL request extensions. Extensions can now be edited in a new tab alongside Variables and Headers, and are included in request payloads. Extensions are persisted in localStorage and query history.

7 changes: 4 additions & 3 deletions packages/graphiql-plugin-history/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ type QueryHistoryItemProps = {
export const HistoryItem: FC<QueryHistoryItemProps> = props => {
const { editLabel, toggleFavorite, deleteFromHistory, setActive } =
useHistoryActions();
const { headerEditor, queryEditor, variableEditor } = useGraphiQL(
pick('headerEditor', 'queryEditor', 'variableEditor'),
const { headerEditor, queryEditor, variableEditor, extensionsEditor } = useGraphiQL(
pick('headerEditor', 'queryEditor', 'variableEditor', 'extensionsEditor'),
);
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -147,10 +147,11 @@ export const HistoryItem: FC<QueryHistoryItemProps> = props => {
};

const handleHistoryItemClick: MouseEventHandler<HTMLButtonElement> = () => {
const { query, variables, headers } = props.item;
const { query, variables, headers, extensions } = props.item;
queryEditor?.setValue(query ?? '');
variableEditor?.setValue(variables ?? '');
headerEditor?.setValue(headers ?? '');
extensionsEditor?.setValue(extensions ?? '');
setActive(props.item);
};

Expand Down
6 changes: 5 additions & 1 deletion packages/graphiql-plugin-history/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ type HistoryStoreType = {
/**
* Add an operation to the history.
* @param operation The operation that was executed, consisting of the query,
* variables, headers, and operation name.
* variables, headers, extensions, and operation name.
*/
addToHistory(operation: {
query?: string;
variables?: string;
headers?: string;
extensions?: string;
operationName?: string;
}): void;
/**
Expand All @@ -62,6 +63,7 @@ type HistoryStoreType = {
query?: string;
variables?: string;
headers?: string;
extensions?: string;
operationName?: string;
label?: string;
favorite?: boolean;
Expand All @@ -79,6 +81,7 @@ type HistoryStoreType = {
query?: string;
variables?: string;
headers?: string;
extensions?: string;
operationName?: string;
label?: string;
favorite?: boolean;
Expand Down Expand Up @@ -137,6 +140,7 @@ export const HistoryStore: FC<HistoryStoreProps> = ({
query: activeTab.query ?? undefined,
variables: activeTab.variables ?? undefined,
headers: activeTab.headers ?? undefined,
extensions: activeTab.extensions ?? undefined,
operationName: activeTab.operationName ?? undefined,
});
}, [isFetching, activeTab]);
Expand Down
65 changes: 65 additions & 0 deletions packages/graphiql-react/src/components/extensions-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { FC, useEffect, useRef } from 'react';
import { useGraphiQL, useGraphiQLActions } from './provider';
import type { EditorProps } from '../types';
import { KEY_BINDINGS, STORAGE_KEY, URI_NAME } from '../constants';
import {
getOrCreateModel,
createEditor,
useChangeHandler,
onEditorContainerKeyDown,
cleanupDisposables,
cn,
pick,
} from '../utility';
import { useMonaco } from '../stores';

interface ExtensionsEditorProps extends EditorProps {
/**
* Invoked when the contents of the extensions editor change.
* @param value - The new contents of the editor.
*/
onEdit?(value: string): void;
}

export const ExtensionsEditor: FC<ExtensionsEditorProps> = ({
onEdit,
...props
}) => {
const { setEditor, run, prettifyEditors, mergeQuery } = useGraphiQLActions();
const { initialExtensions, uriInstanceId } = useGraphiQL(
pick('initialExtensions', 'uriInstanceId'),
);
const ref = useRef<HTMLDivElement>(null!);
const monaco = useMonaco(state => state.monaco);
useChangeHandler(onEdit, STORAGE_KEY.extensions, 'extensions');
useEffect(() => {
if (!monaco) {
return;
}
const model = getOrCreateModel({
uri: `${uriInstanceId}${URI_NAME.extensions}`,
value: initialExtensions,
});
const editor = createEditor(ref, { model });
setEditor({ extensionsEditor: editor });
const disposables = [
editor.addAction({ ...KEY_BINDINGS.runQuery, run }),
editor.addAction({ ...KEY_BINDINGS.prettify, run: prettifyEditors }),
editor.addAction({ ...KEY_BINDINGS.mergeFragments, run: mergeQuery }),
editor,
model,
];
return cleanupDisposables(disposables);
}, [monaco]); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount

return (
<div
ref={ref}
tabIndex={0}
onKeyDown={onEditorContainerKeyDown}
{...props}
className={cn('graphiql-editor', props.className)}
/>
);
};

1 change: 1 addition & 0 deletions packages/graphiql-react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { ExecuteButton } from './execute-button';
export { ToolbarButton } from './toolbar-button';
export { ToolbarMenu } from './toolbar-menu';

export { ExtensionsEditor } from './extensions-editor';
export { RequestHeadersEditor as HeaderEditor } from './request-headers-editor';
export { ImagePreview } from './image-preview';
export { GraphiQLProvider, useGraphiQL, useGraphiQLActions } from './provider';
Expand Down
4 changes: 4 additions & 0 deletions packages/graphiql-react/src/components/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,14 @@ const InnerGraphiQLProvider: FC<GraphiQLProviderProps> = ({
const variables =
props.initialVariables ?? storage.get(STORAGE_KEY.variables);
const headers = props.initialHeaders ?? storage.get(STORAGE_KEY.headers);
const extensions =
props.initialExtensions ?? storage.get(STORAGE_KEY.extensions);

const { tabs, activeTabIndex } = getDefaultTabState({
defaultHeaders,
defaultQuery,
defaultTabs,
extensions,
headers,
query,
shouldPersistHeaders,
Expand All @@ -241,6 +244,7 @@ const InnerGraphiQLProvider: FC<GraphiQLProviderProps> = ({
initialQuery:
query ?? (activeTabIndex === 0 ? tabs[0]!.query : null) ?? '',
initialVariables: variables ?? '',
initialExtensions: extensions ?? '',
onCopyQuery,
onEditOperationName,
onPrettifyQuery,
Expand Down
2 changes: 2 additions & 0 deletions packages/graphiql-react/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const STORAGE_KEY = {
visiblePlugin: 'visiblePlugin',
query: 'query',
variables: 'variables',
extensions: 'extensions',
tabs: 'tabState',
persistHeaders: 'shouldPersistHeaders',
theme: 'theme',
Expand Down Expand Up @@ -118,6 +119,7 @@ export const URI_NAME = {

variables: 'variables.json',
requestHeaders: 'request-headers.json',
extensions: 'extensions.json',
response: 'response.json',
} as const;

Expand Down
53 changes: 49 additions & 4 deletions packages/graphiql-react/src/stores/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export interface EditorSlice extends TabsState {
*/
variableEditor?: MonacoEditor;

/**
* The Monaco Editor instance used in the extensions editor.
*/
extensionsEditor?: MonacoEditor;

/**
* The contents of the request headers editor when initially rendering the provider
* component.
Expand All @@ -66,6 +71,12 @@ export interface EditorSlice extends TabsState {
*/
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 a key which are
* made available to include in the query.
Expand Down Expand Up @@ -191,7 +202,11 @@ export interface EditorActions {
setEditor(
state: Pick<
EditorSlice,
'headerEditor' | 'queryEditor' | 'responseEditor' | 'variableEditor'
| 'headerEditor'
| 'queryEditor'
| 'responseEditor'
| 'variableEditor'
| 'extensionsEditor'
>,
): void;

Expand Down Expand Up @@ -274,6 +289,7 @@ export interface EditorProps
initialQuery?: EditorSlice['initialQuery'];
initialVariables?: EditorSlice['initialVariables'];
initialHeaders?: EditorSlice['initialHeaders'];
initialExtensions?: EditorSlice['initialExtensions'];
}

type CreateEditorSlice = (
Expand All @@ -285,6 +301,7 @@ type CreateEditorSlice = (
| 'initialQuery'
| 'initialVariables'
| 'initialHeaders'
| 'initialExtensions'
| 'onEditOperationName'
| 'externalFragments'
| 'onTabChange'
Expand All @@ -306,23 +323,27 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => {
query,
variables,
headers,
extensions,
response,
}: {
query: string | null;
variables?: string | null;
headers?: string | null;
extensions?: string | null;
response: string | null;
}) {
const {
queryEditor,
variableEditor,
headerEditor,
extensionsEditor,
responseEditor,
defaultHeaders,
} = get();
queryEditor?.setValue(query ?? '');
variableEditor?.setValue(variables ?? '');
headerEditor?.setValue(headers ?? defaultHeaders ?? '');
extensionsEditor?.setValue(extensions ?? '');
responseEditor?.setValue(response ?? '');
}

Expand All @@ -331,13 +352,15 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => {
queryEditor,
variableEditor,
headerEditor,
extensionsEditor,
responseEditor,
operationName,
} = get();
return setPropertiesInActiveTab(tabsState, {
query: queryEditor?.getValue() ?? null,
variables: variableEditor?.getValue() ?? null,
headers: headerEditor?.getValue() ?? null,
extensions: extensionsEditor?.getValue() ?? null,
response: responseEditor?.getValue() ?? null,
operationName: operationName ?? null,
});
Expand Down Expand Up @@ -413,12 +436,13 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => {
return updated;
});
},
setEditor({ headerEditor, queryEditor, responseEditor, variableEditor }) {
setEditor({ headerEditor, queryEditor, responseEditor, variableEditor, extensionsEditor }) {
const entries = Object.entries({
headerEditor,
queryEditor,
responseEditor,
variableEditor,
extensionsEditor,
}).filter(([_key, value]) => value);
const newState = Object.fromEntries(entries);
set(newState);
Expand Down Expand Up @@ -476,8 +500,13 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => {
}
},
async prettifyEditors() {
const { queryEditor, headerEditor, variableEditor, onPrettifyQuery } =
get();
const {
queryEditor,
headerEditor,
variableEditor,
extensionsEditor,
onPrettifyQuery,
} = get();

if (variableEditor) {
try {
Expand Down Expand Up @@ -511,6 +540,22 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => {
}
}

if (extensionsEditor) {
try {
const content = extensionsEditor.getValue();
const formatted = await formatJSONC(content);
if (formatted !== content) {
extensionsEditor.setValue(formatted);
}
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
'Parsing extensions JSON failed, skip prettification.',
error,
);
}
}

if (!queryEditor) {
return;
}
Expand Down
16 changes: 14 additions & 2 deletions packages/graphiql-react/src/stores/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export const createExecutionSlice: CreateExecutionSlice =
queryEditor,
responseEditor,
variableEditor,
extensionsEditor,
actions,
operationName,
documentAST,
Expand Down Expand Up @@ -218,7 +219,11 @@ export const createExecutionSlice: CreateExecutionSlice =
return;
}
const name =
editor === variableEditor ? 'Variables' : 'Request headers';
editor === variableEditor
? 'Variables'
: editor === extensionsEditor
? 'Extensions'
: 'Request headers';
// Need to format since the response editor uses `json` language
setResponse(formatError({ message: `${name} ${error.message}` }));
}
Expand All @@ -245,6 +250,13 @@ export const createExecutionSlice: CreateExecutionSlice =
setError(error as Error, headerEditor);
return;
}
let extensions: Record<string, unknown> | undefined;
try {
extensions = tryParseJSONC(extensionsEditor?.getValue());
} catch (error) {
setError(error as Error, extensionsEditor);
return;
}
const fragmentDependencies = documentAST
? getFragmentDependenciesForAST(documentAST, externalFragments)
: [];
Expand Down Expand Up @@ -287,7 +299,7 @@ export const createExecutionSlice: CreateExecutionSlice =
};
const opName = overrideOperationName ?? operationName;
const fetch = fetcher(
{ query, variables, operationName: opName },
{ query, variables, extensions, operationName: opName },
{ headers, documentAST },
);

Expand Down
Loading