diff --git a/.changeset/extensions-support.md b/.changeset/extensions-support.md new file mode 100644 index 00000000000..21f7c84525e --- /dev/null +++ b/.changeset/extensions-support.md @@ -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. + diff --git a/packages/graphiql-plugin-history/src/components.tsx b/packages/graphiql-plugin-history/src/components.tsx index 1710eaf6ee7..4006dbb215d 100644 --- a/packages/graphiql-plugin-history/src/components.tsx +++ b/packages/graphiql-plugin-history/src/components.tsx @@ -113,8 +113,8 @@ type QueryHistoryItemProps = { export const HistoryItem: FC = 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(null); const buttonRef = useRef(null); @@ -147,10 +147,11 @@ export const HistoryItem: FC = props => { }; const handleHistoryItemClick: MouseEventHandler = () => { - 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); }; diff --git a/packages/graphiql-plugin-history/src/context.ts b/packages/graphiql-plugin-history/src/context.ts index b6e26f1f268..98ba6f3805e 100644 --- a/packages/graphiql-plugin-history/src/context.ts +++ b/packages/graphiql-plugin-history/src/context.ts @@ -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; /** @@ -62,6 +63,7 @@ type HistoryStoreType = { query?: string; variables?: string; headers?: string; + extensions?: string; operationName?: string; label?: string; favorite?: boolean; @@ -79,6 +81,7 @@ type HistoryStoreType = { query?: string; variables?: string; headers?: string; + extensions?: string; operationName?: string; label?: string; favorite?: boolean; @@ -137,6 +140,7 @@ export const HistoryStore: FC = ({ query: activeTab.query ?? undefined, variables: activeTab.variables ?? undefined, headers: activeTab.headers ?? undefined, + extensions: activeTab.extensions ?? undefined, operationName: activeTab.operationName ?? undefined, }); }, [isFetching, activeTab]); diff --git a/packages/graphiql-react/src/components/extensions-editor.tsx b/packages/graphiql-react/src/components/extensions-editor.tsx new file mode 100644 index 00000000000..1990e415782 --- /dev/null +++ b/packages/graphiql-react/src/components/extensions-editor.tsx @@ -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 = ({ + onEdit, + ...props +}) => { + const { setEditor, run, prettifyEditors, mergeQuery } = useGraphiQLActions(); + const { initialExtensions, uriInstanceId } = useGraphiQL( + pick('initialExtensions', 'uriInstanceId'), + ); + const ref = useRef(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 ( +
+ ); +}; + diff --git a/packages/graphiql-react/src/components/index.ts b/packages/graphiql-react/src/components/index.ts index eac918748b0..da5e08226fd 100644 --- a/packages/graphiql-react/src/components/index.ts +++ b/packages/graphiql-react/src/components/index.ts @@ -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'; diff --git a/packages/graphiql-react/src/components/provider.tsx b/packages/graphiql-react/src/components/provider.tsx index 7df7b030331..07b0c09c11e 100644 --- a/packages/graphiql-react/src/components/provider.tsx +++ b/packages/graphiql-react/src/components/provider.tsx @@ -211,11 +211,14 @@ const InnerGraphiQLProvider: FC = ({ 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, @@ -241,6 +244,7 @@ const InnerGraphiQLProvider: FC = ({ initialQuery: query ?? (activeTabIndex === 0 ? tabs[0]!.query : null) ?? '', initialVariables: variables ?? '', + initialExtensions: extensions ?? '', onCopyQuery, onEditOperationName, onPrettifyQuery, diff --git a/packages/graphiql-react/src/constants.ts b/packages/graphiql-react/src/constants.ts index fe89c7a5e0b..41bd282fd27 100644 --- a/packages/graphiql-react/src/constants.ts +++ b/packages/graphiql-react/src/constants.ts @@ -47,6 +47,7 @@ export const STORAGE_KEY = { visiblePlugin: 'visiblePlugin', query: 'query', variables: 'variables', + extensions: 'extensions', tabs: 'tabState', persistHeaders: 'shouldPersistHeaders', theme: 'theme', @@ -118,6 +119,7 @@ export const URI_NAME = { variables: 'variables.json', requestHeaders: 'request-headers.json', + extensions: 'extensions.json', response: 'response.json', } as const; diff --git a/packages/graphiql-react/src/stores/editor.ts b/packages/graphiql-react/src/stores/editor.ts index d553e1027ac..c2c4c3dca49 100644 --- a/packages/graphiql-react/src/stores/editor.ts +++ b/packages/graphiql-react/src/stores/editor.ts @@ -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. @@ -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. @@ -191,7 +202,11 @@ export interface EditorActions { setEditor( state: Pick< EditorSlice, - 'headerEditor' | 'queryEditor' | 'responseEditor' | 'variableEditor' + | 'headerEditor' + | 'queryEditor' + | 'responseEditor' + | 'variableEditor' + | 'extensionsEditor' >, ): void; @@ -274,6 +289,7 @@ export interface EditorProps initialQuery?: EditorSlice['initialQuery']; initialVariables?: EditorSlice['initialVariables']; initialHeaders?: EditorSlice['initialHeaders']; + initialExtensions?: EditorSlice['initialExtensions']; } type CreateEditorSlice = ( @@ -285,6 +301,7 @@ type CreateEditorSlice = ( | 'initialQuery' | 'initialVariables' | 'initialHeaders' + | 'initialExtensions' | 'onEditOperationName' | 'externalFragments' | 'onTabChange' @@ -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 ?? ''); } @@ -331,6 +352,7 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { queryEditor, variableEditor, headerEditor, + extensionsEditor, responseEditor, operationName, } = get(); @@ -338,6 +360,7 @@ export const createEditorSlice: CreateEditorSlice = initial => (set, get) => { query: queryEditor?.getValue() ?? null, variables: variableEditor?.getValue() ?? null, headers: headerEditor?.getValue() ?? null, + extensions: extensionsEditor?.getValue() ?? null, response: responseEditor?.getValue() ?? null, operationName: operationName ?? null, }); @@ -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); @@ -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 { @@ -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; } diff --git a/packages/graphiql-react/src/stores/execution.ts b/packages/graphiql-react/src/stores/execution.ts index e4d94ecdd15..e6629039028 100644 --- a/packages/graphiql-react/src/stores/execution.ts +++ b/packages/graphiql-react/src/stores/execution.ts @@ -191,6 +191,7 @@ export const createExecutionSlice: CreateExecutionSlice = queryEditor, responseEditor, variableEditor, + extensionsEditor, actions, operationName, documentAST, @@ -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}` })); } @@ -245,6 +250,13 @@ export const createExecutionSlice: CreateExecutionSlice = setError(error as Error, headerEditor); return; } + let extensions: Record | undefined; + try { + extensions = tryParseJSONC(extensionsEditor?.getValue()); + } catch (error) { + setError(error as Error, extensionsEditor); + return; + } const fragmentDependencies = documentAST ? getFragmentDependenciesForAST(documentAST, externalFragments) : []; @@ -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 }, ); diff --git a/packages/graphiql-react/src/utility/hooks.ts b/packages/graphiql-react/src/utility/hooks.ts index 07d9898e08d..926e17481c5 100644 --- a/packages/graphiql-react/src/utility/hooks.ts +++ b/packages/graphiql-react/src/utility/hooks.ts @@ -6,12 +6,18 @@ import { useGraphiQL, useGraphiQLActions } from '../components'; export function useChangeHandler( callback: ((value: string) => void) | undefined, storageKey: string | null, - tabProperty: 'variables' | 'headers', + tabProperty: 'variables' | 'headers' | 'extensions', ) { const { updateActiveTabValues } = useGraphiQLActions(); const { editor, storage } = useGraphiQL(state => ({ editor: - state[tabProperty === 'variables' ? 'variableEditor' : 'headerEditor'], + state[ + tabProperty === 'variables' + ? 'variableEditor' + : tabProperty === 'extensions' + ? 'extensionsEditor' + : 'headerEditor' + ], storage: state.storage, })); useEffect(() => { diff --git a/packages/graphiql-react/src/utility/tabs.spec.ts b/packages/graphiql-react/src/utility/tabs.spec.ts index 5c93e8d1987..05e06e3f6c1 100644 --- a/packages/graphiql-react/src/utility/tabs.spec.ts +++ b/packages/graphiql-react/src/utility/tabs.spec.ts @@ -110,6 +110,7 @@ describe('getDefaultTabState', () => { headers: null, query: null, variables: null, + extensions: null, storage: new StorageAPI(), }), ).toEqual({ @@ -142,6 +143,7 @@ describe('getDefaultTabState', () => { ], query: null, variables: null, + extensions: null, storage: new StorageAPI(), }), ).toEqual({ diff --git a/packages/graphiql-react/src/utility/tabs.ts b/packages/graphiql-react/src/utility/tabs.ts index bda2e643c68..4f90d8546f6 100644 --- a/packages/graphiql-react/src/utility/tabs.ts +++ b/packages/graphiql-react/src/utility/tabs.ts @@ -16,6 +16,10 @@ export interface TabDefinition { * The contents of the request headers editor of this tab. */ headers?: string | null; + /** + * The contents of the extensions editor of this tab. + */ + extensions?: string | null; } /** @@ -29,7 +33,7 @@ export interface TabState extends TabDefinition { /** * A hash that is unique for a combination of the contents of the query - * editor, the variables editor and the request headers editor (i.e., all the editor + * editor, the variables editor, the request headers editor, and the extensions editor (i.e., all the editor * where the contents are persisted in storage). */ hash: string; @@ -72,11 +76,13 @@ export function getDefaultTabState({ headers, query, variables, + extensions, defaultTabs = [ { query: query ?? defaultQuery, variables, headers: headers ?? defaultHeaders, + extensions, }, ], shouldPersistHeaders, @@ -88,6 +94,7 @@ export function getDefaultTabState({ defaultTabs?: TabDefinition[]; query: string | null; variables: string | null; + extensions: string | null; shouldPersistHeaders?: boolean; storage: AllSlices['storage']; }) { @@ -105,6 +112,7 @@ export function getDefaultTabState({ query, variables, headers: headersForHash, + extensions, }); let matchingTabIndex = -1; @@ -114,6 +122,7 @@ export function getDefaultTabState({ query: tab.query, variables: tab.variables, headers: tab.headers, + extensions: tab.extensions, }); if (tab.hash === expectedHash) { matchingTabIndex = index; @@ -131,6 +140,7 @@ export function getDefaultTabState({ query, variables, headers, + extensions, operationName, response: null, }); @@ -171,6 +181,7 @@ function isTabState(obj: any): obj is TabState { hasStringOrNullKey(obj, 'query') && hasStringOrNullKey(obj, 'variables') && hasStringOrNullKey(obj, 'headers') && + hasStringOrNullKey(obj, 'extensions') && hasStringOrNullKey(obj, 'operationName') && hasStringOrNullKey(obj, 'response') ); @@ -205,15 +216,17 @@ export function createTab({ query = null, variables = null, headers = null, + extensions = null, }: Partial = {}): TabState { const operationName = query ? fuzzyExtractOperationName(query) : null; return { id: guid(), - hash: hashFromTabContents({ query, variables, headers }), + hash: hashFromTabContents({ query, variables, headers, extensions }), title: operationName || DEFAULT_TITLE, query, variables, headers, + extensions, operationName, response: null, }; @@ -258,8 +271,14 @@ function hashFromTabContents(args: { query: string | null; variables?: string | null; headers?: string | null; + extensions?: string | null; }): string { - return [args.query ?? '', args.variables ?? '', args.headers ?? ''].join('|'); + return [ + args.query ?? '', + args.variables ?? '', + args.headers ?? '', + args.extensions ?? '', + ].join('|'); } export function fuzzyExtractOperationName(str: string): string | null { diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index 7d3a9dac011..079e78a5e2e 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -28,6 +28,7 @@ export type FetcherParams = { query: string; operationName?: string | null; variables?: any; + extensions?: any; }; export type FetcherOpts = { diff --git a/packages/graphiql-toolkit/src/storage/history.ts b/packages/graphiql-toolkit/src/storage/history.ts index 3f301b5e4ef..5cca54c306a 100644 --- a/packages/graphiql-toolkit/src/storage/history.ts +++ b/packages/graphiql-toolkit/src/storage/history.ts @@ -29,6 +29,7 @@ export class HistoryStore { query?: string, variables?: string, headers?: string, + extensions?: string, lastQuerySaved?: QueryStoreItem, ) { if (!query) { @@ -55,7 +56,15 @@ export class HistoryStore { if ( JSON.stringify(headers) === JSON.stringify(lastQuerySaved.headers) ) { - return false; + if ( + JSON.stringify(extensions) === + JSON.stringify(lastQuerySaved.extensions) + ) { + return false; + } + if (extensions && !lastQuerySaved.extensions) { + return false; + } } if (headers && !lastQuerySaved.headers) { return false; @@ -72,6 +81,7 @@ export class HistoryStore { query, variables, headers, + extensions, operationName, }: QueryStoreItem) => { if ( @@ -79,6 +89,7 @@ export class HistoryStore { query, variables, headers, + extensions, this.history.fetchRecent(), ) ) { @@ -88,6 +99,7 @@ export class HistoryStore { query, variables, headers, + extensions, operationName, }); const historyQueries = this.history.items; @@ -99,6 +111,7 @@ export class HistoryStore { query, variables, headers, + extensions, operationName, label, favorite, @@ -107,6 +120,7 @@ export class HistoryStore { query, variables, headers, + extensions, operationName, label, }; @@ -127,6 +141,7 @@ export class HistoryStore { query, variables, headers, + extensions, operationName, label, favorite, @@ -137,6 +152,7 @@ export class HistoryStore { query, variables, headers, + extensions, operationName, label, }; @@ -149,7 +165,7 @@ export class HistoryStore { } deleteHistory = ( - { query, variables, headers, operationName, favorite }: QueryStoreItem, + { query, variables, headers, extensions, operationName, favorite }: QueryStoreItem, clearFavorites = false, ) => { function deleteFromStore(store: QueryStore) { @@ -158,6 +174,7 @@ export class HistoryStore { x.query === query && x.variables === variables && x.headers === headers && + x.extensions === extensions && x.operationName === operationName, ); if (found) { diff --git a/packages/graphiql-toolkit/src/storage/query.ts b/packages/graphiql-toolkit/src/storage/query.ts index 9fea0e93b4a..2280bf763a3 100644 --- a/packages/graphiql-toolkit/src/storage/query.ts +++ b/packages/graphiql-toolkit/src/storage/query.ts @@ -4,6 +4,7 @@ export type QueryStoreItem = { query?: string; variables?: string; headers?: string; + extensions?: string; operationName?: string; label?: string; favorite?: boolean; @@ -30,6 +31,7 @@ export class QueryStore { x.query === item.query && x.variables === item.variables && x.headers === item.headers && + x.extensions === item.extensions && x.operationName === item.operationName, ); } @@ -41,6 +43,7 @@ export class QueryStore { found.query === item.query && found.variables === item.variables && found.headers === item.headers && + found.extensions === item.extensions && found.operationName === item.operationName ) { this.items.splice(index, 1, item); @@ -54,6 +57,7 @@ export class QueryStore { x.query === item.query && x.variables === item.variables && x.headers === item.headers && + x.extensions === item.extensions && x.operationName === item.operationName, ); if (itemIndex !== -1) { @@ -68,6 +72,7 @@ export class QueryStore { x.query === item.query && x.variables === item.variables && x.headers === item.headers && + x.extensions === item.extensions && x.operationName === item.operationName, ); if (itemIndex !== -1) { diff --git a/packages/graphiql/cypress/e2e/extensions.cy.ts b/packages/graphiql/cypress/e2e/extensions.cy.ts new file mode 100644 index 00000000000..b8b04d23031 --- /dev/null +++ b/packages/graphiql/cypress/e2e/extensions.cy.ts @@ -0,0 +1,75 @@ +describe('Extensions', () => { + it('should render the extensions tab', () => { + cy.visit('/'); + cy.contains('Extensions').should('be.visible'); + }); + + it('should send extensions in the request', () => { + cy.visit('/'); + cy.contains('Extensions').click(); + cy.get('.graphiql-editor-tool .graphiql-editor') + .eq(2) + .click() + .focused() + .type('{"testKey": "testValue"}', { parseSpecialCharSequences: false }); + + // Type a simple query + cy.get('.graphiql-query-editor').click().focused().type('{selectall}'); + cy.get('.graphiql-query-editor') + .click() + .focused() + .type('{{ test {{ id }} }}', { parseSpecialCharSequences: false }); + + // Intercept the request to verify extensions are included + cy.intercept('POST', '/graphql', req => { + expect(req.body).to.have.property('extensions'); + expect(req.body.extensions).to.deep.equal({ testKey: 'testValue' }); + req.reply({ + data: { test: { id: '123' } }, + }); + }).as('graphqlRequest'); + + cy.clickExecuteQuery(); + cy.wait('@graphqlRequest'); + }); + + it('should show error for invalid JSON in extensions', () => { + cy.visit('/'); + cy.contains('Extensions').click(); + cy.get('.graphiql-editor-tool .graphiql-editor') + .eq(2) + .click() + .focused() + .type('{invalid json}', { parseSpecialCharSequences: false }); + + cy.get('.graphiql-query-editor').click().focused().type('{selectall}'); + cy.get('.graphiql-query-editor') + .click() + .focused() + .type('{{ test {{ id }} }}', { parseSpecialCharSequences: false }); + + cy.clickExecuteQuery(); + cy.get('.result-window').should('contain', 'Extensions'); + }); + + it('should support prettify for extensions', () => { + cy.visit('/'); + cy.contains('Extensions').click(); + cy.get('.graphiql-editor-tool .graphiql-editor') + .eq(2) + .click() + .focused() + .type('{{"a":1,"b":2}}', { parseSpecialCharSequences: false }); + + cy.clickPrettify(); + + // Verify it's prettified (has newlines and indentation) + cy.get('.graphiql-editor-tool .graphiql-editor') + .eq(2) + .should('contain', '"a"') + .should('contain', '"b"'); + }); + +}); + + diff --git a/packages/graphiql/cypress/support/commands.ts b/packages/graphiql/cypress/support/commands.ts index 7e7f6c2f51f..f789ff06c23 100644 --- a/packages/graphiql/cypress/support/commands.ts +++ b/packages/graphiql/cypress/support/commands.ts @@ -13,6 +13,7 @@ interface Op { variables?: Record; variablesString?: string; headersString?: string; + extensionsString?: string; response?: Record; } @@ -63,19 +64,22 @@ Cypress.Commands.add('clickPrettify', () => { cy.get('[aria-label="Prettify query (Shift-Ctrl-P)"]').click(); }); -Cypress.Commands.add('visitWithOp', ({ query, variables, variablesString }) => { +Cypress.Commands.add('visitWithOp', ({ query, variables, variablesString, extensionsString }) => { let url = `?query=${encodeURIComponent(query)}`; if (variables || variablesString) { url += `&variables=${encodeURIComponent( JSON.stringify(variables, null, 2) || variablesString, )}`; } + if (extensionsString) { + url += `&extensions=${encodeURIComponent(extensionsString)}`; + } cy.visit(url); }); Cypress.Commands.add( 'assertHasValues', - ({ query, variables, variablesString, headersString, response }: Op) => { + ({ query, variables, variablesString, headersString, extensionsString, response }: Op) => { cy.get( '.graphiql-query-editor .view-lines.monaco-mouse-cursor-text', ).should(element => { @@ -119,6 +123,18 @@ Cypress.Commands.add( expect(actual).to.equal(expected); }); } + if (extensionsString !== undefined) { + cy.contains('Extensions').click(); + cy.get( + '.graphiql-editor-tool .graphiql-editor .view-lines.monaco-mouse-cursor-text', + ) + .eq(2) + .should(element => { + const actual = normalizeMonacoWhitespace(element.get(0).textContent!); + const expected = extensionsString; + expect(actual).to.equal(expected); + }); + } if (response !== undefined) { cy.get('.result-window').should(element => { const actual = normalizeMonacoWhitespace(element.get(0).innerText); // should be innerText diff --git a/packages/graphiql/src/GraphiQL.tsx b/packages/graphiql/src/GraphiQL.tsx index 859073808ba..27593dbc043 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -15,6 +15,7 @@ import { ChevronDownIcon, ChevronUpIcon, ExecuteButton, + ExtensionsEditor, GraphiQLProvider, HeaderEditor, PlusIcon, @@ -65,6 +66,7 @@ const GraphiQL_: FC = ({ onEditQuery, onEditVariables, onEditHeaders, + onEditExtensions, responseTooltip, defaultEditorToolsVisibility, isHeadersEditorEnabled, @@ -105,6 +107,7 @@ const GraphiQL_: FC = ({ onEditQuery, onEditVariables, onEditHeaders, + onEditExtensions, responseTooltip, defaultEditorToolsVisibility, isHeadersEditorEnabled, @@ -139,18 +142,20 @@ type AddSuffix, Suffix extends string> = { type QueryEditorProps = ComponentPropsWithoutRef; type VariableEditorProps = ComponentPropsWithoutRef; type HeaderEditorProps = ComponentPropsWithoutRef; +type ExtensionsEditorProps = ComponentPropsWithoutRef; type ResponseEditorProps = ComponentPropsWithoutRef; export interface GraphiQLInterfaceProps extends EditorProps, - AddSuffix, 'Query'>, - AddSuffix, 'Variables'>, - AddSuffix, 'Headers'>, - Pick, - Pick< - ComponentPropsWithoutRef, - 'forcedTheme' | 'showPersistHeadersSettings' - > { + AddSuffix, 'Query'>, + AddSuffix, 'Variables'>, + AddSuffix, 'Headers'>, + AddSuffix, 'Extensions'>, + Pick, + Pick< + ComponentPropsWithoutRef, + 'forcedTheme' | 'showPersistHeadersSettings' + > { children?: ReactNode; /** * Set the default state for the editor tools. @@ -158,10 +163,11 @@ export interface GraphiQLInterfaceProps * - `true` shows the editor tools * - `'variables'` specifically shows the variables editor * - `'headers'` specifically shows the request headers editor + * - `'extensions'` specifically shows the extensions 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' | 'headers' | 'extensions'; /** * Toggle if the headers' editor should be shown inside the editor tools. * @default true @@ -200,6 +206,7 @@ export const GraphiQLInterface: FC = ({ onEditQuery, onEditVariables, onEditHeaders, + onEditExtensions, responseTooltip, showPersistHeadersSettings, }) => { @@ -259,11 +266,12 @@ export const GraphiQLInterface: FC = ({ }); const [activeSecondaryEditor, setActiveSecondaryEditor] = useState< - 'variables' | 'headers' + 'variables' | 'headers' | 'extensions' >(() => { if ( defaultEditorToolsVisibility === 'variables' || - defaultEditorToolsVisibility === 'headers' + defaultEditorToolsVisibility === 'headers' || + defaultEditorToolsVisibility === 'extensions' ) { return defaultEditorToolsVisibility; } @@ -401,6 +409,18 @@ export const GraphiQLInterface: FC = ({ Headers )} + + Extensions + = ({
@@ -434,6 +458,10 @@ export const GraphiQLInterface: FC = ({ onEdit={onEditHeaders} /> )} +
);