Skip to content

v6: Add a visual query builder (@graphiql/plugin-query-builder)#4352

Merged
trevor-scheer merged 65 commits into
graphiql-6from
trevor/query-builder
Jun 22, 2026
Merged

v6: Add a visual query builder (@graphiql/plugin-query-builder)#4352
trevor-scheer merged 65 commits into
graphiql-6from
trevor/query-builder

Conversation

@trevor-scheer

@trevor-scheer trevor-scheer commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

New package @graphiql/plugin-query-builder, a first-party visual query builder for GraphiQL. People have asked for one since #734.

Pick fields from the schema with checkboxes and the operation in the editor stays in sync, both ways. Every change runs through the same parse → mutate → reprint cycle using the graphql package's AST utilities, so the builder and the editor never drift apart.

What it covers:

  • Schema tree. Root types render as collapsible sections. Scalar fields have checkboxes; object, interface, and union fields are expand-only, since a composite isn't a valid selection without subfields. Checking a nested scalar adds it and any parents; unchecking removes it and prunes anything left empty (including the operation itself when nothing is left).
  • Operation-aware. The builder reads and edits the operation the editor cursor sits in. In a multi-operation document only that operation's root type is enabled; the others collapse. Moving the cursor onto a field expands the tree down to it and highlights it.
  • Argument inputs. Checked fields show inputs matched to each argument's type: scalars (Int/Float as number inputs, Boolean as a checkbox, String/ID as text), enums as a dropdown, lists with an add/remove UI, and input objects as recursive nested editors, including the list-of-input-objects case.
  • Variables. "Use as variable" moves an argument's value into the variables editor and references it as $name in the operation; an "Inline argument" toggle reverses it. Suggested names are unique across the whole document, and removing a field cleans up any variable it orphaned.
  • Unions and interfaces. Each concrete type shows as a ... on TypeName selector; checking a field inside one adds it within that inline fragment. Interface fields shared by every type can also be selected directly, without a type condition. Named fragments already in the document are listed.

The plugin is default-installed in the graphiql meta-package, so the rail icon is present with no extra configuration. Omit it from your plugins list to opt out.

This also changes @graphiql/react: the active operationName now follows the editor cursor, so the Run button, the operation dropdown, and operation-aware plugins reflect the operation you are editing. See the graphiql@6 migration guide for details.

Test plan

  • Open the default GraphiQL example with no extra plugin config; confirm the query builder icon shows in the rail and opens the panel.
  • Expand a root type. Confirm object/interface/union fields are expand-only (chevron, no checkbox) and scalar fields have checkboxes. Check a scalar; confirm it lands in the editor. Uncheck it; confirm it's removed and any emptied parent is pruned.
  • Check a field that takes arguments and fill in a scalar, an enum, a list, and an input-object arg; confirm each is written into the query.
  • Click "Use as variable" on a scalar arg. Confirm the value moves into the Variables panel, the operation header gets a bare $name, and the button now reads "Inline argument". Click it; confirm the literal returns and the Variables entry is gone.
  • In a document with two named operations, click between them in the editor. Confirm the builder switches to the cursor's operation, the active tab shows that operation's name with a +N count, and editing an argument in one operation doesn't jump the builder to the other. Confirm only the active operation's root type is enabled.
  • Expand a union or interface field. Toggle a ... on TypeName (with no prior selection on the field) and confirm ... on TypeName { __typename } is added. Check a field inside it; confirm it lands inside the fragment, not as a sibling. For an interface, confirm its shared fields can be checked directly.
  • Promote an argument to a variable, then uncheck its field. Confirm the $name definition and its Variables entry are both removed, leaving valid GraphQL.
  • Place the cursor on a deeply nested field in a collapsed tree; confirm the builder expands down to it and highlights it.
  • Run a query you built against the demo schema and confirm a valid response.

Refs: #734, #4219

@changeset-bot

changeset-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 071aeab

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@graphiql/react Minor
graphiql Minor
@graphiql/plugin-query-builder Major
@graphiql/plugin-code-exporter Major
@graphiql/plugin-doc-explorer Major
@graphiql/plugin-explorer Major
@graphiql/plugin-history Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

New first-party plugin: pick fields from the schema with checkboxes and the
operation in the editor stays in sync both ways. Every change runs through a
parse, mutate, reprint cycle over the `graphql` AST, so the builder and the
editor never drift apart.

Covers a schema tree of collapsible root types (scalar fields are checkboxes,
composites are expand-only), argument inputs typed per argument (scalars, enums,
lists, and recursive input objects including the list-of-input-objects case),
promoting arguments to and from variables with orphaned-variable cleanup, and
union and interface type conditions via `... on TypeName` selectors. The builder
reads and edits the operation the editor cursor sits in, and moving the cursor
expands the tree down to the field it lands on.

The plugin is default-installed in the `graphiql` meta-package; omit it from your
`plugins` list to opt out.

Also changes `@graphiql/react`: the active `operationName` now follows the editor
cursor, so the Run button, the operation dropdown, and operation-aware plugins
reflect the operation you are editing.
@trevor-scheer trevor-scheer force-pushed the trevor/query-builder branch from f66df5d to daf647d Compare June 18, 2026 23:00
`replaceVariableInSelectionSet` only walked `Kind.FIELD` selections, so demoting a variable referenced inside an `... on Type` inline fragment removed the variable definition but left the dangling `$var` reference, producing an invalid document. Recurse into inline-fragment selection sets too.
`argValueToValueNode` always returned a `ListValueNode`/`ObjectValueNode` for list and input-object types, so emptying a list left `arg: []` and clearing every field of a nested input object left `arg: {}` in the document. Scalars already follow the documented "empty input removes the argument" contract; make lists and input objects consistent by returning `undefined` when nothing is left to emit.
`resolveSchemaArg` walked path segments through `getFields()`, so a `... on TypeName` segment failed the field lookup and returned undefined. Editing an argument on a field inside a union or interface type condition then silently did nothing. Switch the current type on `... on` segments, mirroring `resolveFieldNamedType`.
A number input let the user type a decimal or scientific value straight into the AST, so an `Int` field could hold `{ kind: INT, value: "1.5" }` and a `Float` could hold a literal with no integer part, both rejected at execution. `scalarToValueNode` now coerces `Int` values through `Math.trunc` (rejecting non-finite or unsafe values) and `Float` values through `Number`, and the `Int` input carries `step="1"`.
The teardown cancelled the debounced cursor sync but not the debounced `handleChange`, so a content change landing within the debounce window of unmount could fire `editor.getValue()` and `storage.set()` against a disposed editor. Cancel both.
`onDidChangeCursorPosition` fired the operation-name sync on every cursor change reason, including `ContentFlush` (a `setValue`) and programmatic `setPosition` calls. The query builder writing an edit back to the editor could therefore retarget the active operation. Guard the sync on `CursorChangeReason.Explicit`, matching the query builder`s own cursor handler.
`overrideOperationName` was set only at store creation, so pinning an operation by setting the `operationName` prop after mount (e.g. from a URL or app state) never took effect, and clearing it never restored cursor tracking. Sync it on prop change like the other prop syncs.
The expanded state was computed once at mount, so a type condition added to the document later left the section collapsed. Derive it reactively and open (never force-close) via an effect, so the section reflects the document while still allowing manual collapse.
The variable toggle fell through to `onPromote` whenever it was not able to demote, so clicking "Inline argument" on an already-promoted arg without an `onDemote` handler re-promoted it. Only promote when the arg is not already a variable.
Deprecated fields rendered identically to the rest. Strike through the field name and show a "deprecated" badge whose tooltip carries the deprecation reason.
Present the shipped capabilities directly instead of describing them as in-progress, since the plugin is default-installed in `graphiql`. Keep the real known limitations (aliases, explicit null).
Note the new plugin, how to opt out via the `plugins` prop, and that its stylesheet is bundled into `graphiql` regardless of the plugin list.
A brand-new package should enter the release flow as a patch rather than a minor; the `graphiql` bump stays a minor for the new default plugin.
Declare `@testing-library/user-event` (used across the tests but only resolved transitively), add the `main` field, and widen the `graphql` peer range to `^17.0.0-alpha.2` so it matches v17 prereleases, matching `@graphiql/plugin-explorer`.
@trevor-scheer trevor-scheer force-pushed the trevor/query-builder branch from bf4c723 to 8ec5c7a Compare June 19, 2026 06:29
The plugin was scaffolded without React Compiler, unlike the other first-party plugins (\`doc-explorer\`, \`history\`), so its components shipped un-memoized. Wire the compiler into the build and make every component compiler-clean so it optimizes them rather than relying on hand-written memoization:

- Replace the prop-sync \`useEffect\` in \`ListArgInput\` with an adjust-state-during-render guard, removing the disabled \`exhaustive-deps\` rule the compiler rejects. Behavior is unchanged: local rows still survive an echoed write.
- Replace the module-global \`nextListItemId\` counter with \`crypto.randomUUID()\` ids, dropping an unsupported global mutation (and the latent cross-instance id sharing).
- Rewrite a computed-property destructure the compiler does not yet support.
The plugin replaced the whole \`@graphiql/react\` module with a hand-written stub via a vitest alias, which silently drifts from the real package. Match the sibling plugins (\`doc-explorer\`, \`history\`): import the real module and \`vi.mock\` only the hooks (\`useGraphiQL\`, \`useGraphiQLActions\`), so icons, \`PanelHeader\`, and types come from the real package and stay honest.

A small \`graphiql-react-mock\` helper keeps the \`__state\` control surface the tests already used. Drops the module alias, adds the \`monaco-editor\` resolution alias and \`vi.mock(monaco-editor|zustand)\` the siblings use, and a \`use no memo\` directive in the test setup.
- Rename test files \`.test.*\` to \`.spec.*\` (the convention across the other plugins).
- Move \`src/test-setup.ts\` to \`setup-files.ts\` at the package root, matching siblings.
- Rename \`src/index.css\` to \`src/style.css\` (history`s naming) and update importers.
- Drop the redundant \`main\` field (siblings rely on \`exports\`) and align the \`graphql\` peer range to \`^17.0.0\`.
Export \`plugins\` from \`vite.config.mts\` and consume them in \`vitest.config.mts\`, so the test run exercises compiler-optimized components rather than raw source, matching the sibling plugins.
Match the sibling plugins (\`HISTORY_PLUGIN\`, \`DOC_EXPLORER_PLUGIN\`): export a ready-made plugin constant instead of a \`queryBuilderPlugin()\` factory. The builder takes no per-instance configuration, so the factory added no value. Updates the \`graphiql\` meta-package wiring and the README usage.
Source the rail icon from the shared \`@graphiql/react\` icon set (\`QueryBuilderIcon\`) like the other plugins, instead of bundling a local SVG. Drops the \`vite-plugin-svgr\` dependency, the svgr build/test plugin, and the svgr type reference from the query-builder package.
The plugin shipped a stories file with no \`.storybook\` config, so nothing could serve or build it. Add \`main.ts\`/\`preview.tsx\` and the storybook deps/scripts mirroring \`doc-explorer\`, so the stories are runnable and the conventions match.
…gleton

The `graphiql-react-mock` helper exposed a module-level mutable `__state`
object that every spec mutated and had to remember to reset, which risked
state leaking between tests. `installGraphiQLReactMock()` now builds a fresh
state object per call and returns it; the mocks read that object live, so
write-back tests still observe mutations on re-render. Each test owns its own
state, so there is nothing to reset and nothing to leak.
@trevor-scheer trevor-scheer force-pushed the trevor/query-builder branch from 775bcf7 to f56ca63 Compare June 20, 2026 18:38
The spec clicked `.view-line` nodes twice in sequence; Monaco repaints those
on layout passes, so the second click could race a repaint and silently miss
in headless runs. Drive the cursor through Monaco's textarea with keyboard
navigation instead: it is DOM-stable and produces the `Explicit` cursor change
that operation tracking keys off of, landing at deterministic offsets.
Introduce `cy.typeInEditor`, `cy.setCursorToLine`, and `cy.activateOperation`
so editor input and cursor positioning have one documented, correct-by-default
pattern for future specs to copy. `activateOperation` finds an operation's line
from the `?query=` URL and navigates there by keyboard, replacing the
`.view-line` clicks that race Monaco's layout repaints. Adopt the commands in
the operation-cursor, query-builder, keyboard, and tabs specs.
@trevor-scheer trevor-scheer marked this pull request as ready for review June 21, 2026 02:07
@trevor-scheer trevor-scheer merged commit f8a9445 into graphiql-6 Jun 22, 2026
13 checks passed
@trevor-scheer trevor-scheer deleted the trevor/query-builder branch June 22, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant