Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add extensions to request payload & UI #3409

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
7 changes: 7 additions & 0 deletions .changeset/tricky-lions-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'graphiql': minor
'@graphiql/react': minor
'@graphiql/toolkit': minor
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I set these as minor changes but I am happy to be corrected. I chose minor as

  • an empty Extensions tab will result in the same behaviour as before (no extensions key in the request payload)
  • FetcherParams are backwards compatible, it is fine to not set extensions
export type FetcherParams = {
  query: string;
  operationName?: string | null;
  variables?: any;
  extensions?: any;
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dondonz agreed on the minor release, and users will appreciate the backwards compatibility!

---

Add extensions to GraphQL request payload and add Extensions tab to UI
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ this repo._
If you are focused on GraphiQL development, you can run —

```sh
yarn start-graphiql
yarn dev-graphiql
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating the documentation

```

5. Get coding! If you've added code, add tests. If you've changed APIs, update
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/graphiql-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions packages/graphiql-react/src/editor/__tests__/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe('getDefaultTabState', () => {
headers: null,
query: null,
variables: null,
extensions: null,
storage: null,
}),
).toEqual({
Expand Down Expand Up @@ -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({
Expand All @@ -156,6 +159,7 @@ describe('getDefaultTabState', () => {
headers: '{"x-header":"foo"}',
query: 'query Image { image }',
title: 'Image',
extensions: '{"myExtension":"myString"}',
}),
],
});
Expand Down
43 changes: 43 additions & 0 deletions packages/graphiql-react/src/editor/components/extension-editor.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={clsx('graphiql-editor', isHidden && 'hidden')} ref={ref} />
);
}
1 change: 1 addition & 0 deletions packages/graphiql-react/src/editor/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
35 changes: 35 additions & 0 deletions packages/graphiql-react/src/editor/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -274,6 +296,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
const [variableEditor, setVariableEditor] = useState<CodeMirrorEditor | null>(
null,
);
const [extensionEditor, setExtensionEditor] =
useState<CodeMirrorEditor | null>(null);

const [shouldPersistHeaders, setShouldPersistHeadersInternal] = useState(
() => {
Expand All @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome stuff! i would prefer this to be possible via improvements to the plugin ecosystem, however extensions are already spec and 100% be a built-in feature of the execution context, though it will be tricky because as you can see there are many touchpoints. eventually each of these tabs will be provided by plugins, so someone could replace with a yaml and/or json schema react hook form implementation or some such

Copy link
Member

@acao acao Oct 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example to illustrate:

say you have 4 tabs, each with the exact same query/variables/headers combination, but each with unique extension values. when you reload the browser, you'll only have one tab, because without extensions, the persistence only finds one unique operation context


const storeTabs = useStoreTabs({
storage,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,

Expand All @@ -534,6 +568,7 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
queryEditor,
responseEditor,
variableEditor,
extensionEditor,

setOperationName,

Expand Down
132 changes: 132 additions & 0 deletions packages/graphiql-react/src/editor/extension-editor.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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';