Skip to content

Commit 340d33d

Browse files
v6: Query builder inline fragment extraction and editing (#4412)
## Summary Reworks how the query builder handles fragments end to end. **Extraction is now an inline row action.** Fragment extraction used to be a single "Create fragment from selection" button pinned to the bottom of the panel, acting on wherever the editor's text cursor happened to sit. It now lives on the field rows: expand a composite field, tick the children you want, and an "Extract to fragment" action appears on that row. Triggering it lifts the selection into a named fragment and replaces it with a spread (`user { ...UserFields }`). The generated name (`UserFields`, de-duped when taken) stays editable in place from the Fragments list. **Extracted rows stay editable.** An extracted field no longer collapses into a locked `...FragmentName` badge. It stays a normal, expandable composite: the spread renders as a `...FragmentName` reference row among the field's children, and ticking more fields adds them to the **base query** alongside the spread. A field's only tie to its fragment is the real spread in the document — it doesn't "remember" being extracted. **Fragments are editable in place.** Because a fragment's contents are shared across every field that spreads it, editing them needs its own surface. The active editing target now **follows the editor cursor**: move the cursor into a `fragment … { … }` block and the builder switches to editing that fragment's tree, rooted at its type condition. You can also jump in by clicking a `...FragmentName` reference row or a name in the Fragments list; "Back to query" returns to the operation. Fragments are flat peers — no nesting or breadcrumbs. **The fragment reuse suggestion buttons are gone.** The short-lived "Use ...FragmentName" reuse action (and its dead helpers) is removed — it crowded the row, and multiple matching fragments on one field overflowed the small row. The row actions are plain keyboard-focusable buttons. A dedicated shortcut and keycap hint are left for the app-wide accessibility pass. ### Implementation notes The document mutators and readers were generalized from an operation-only `operationName` to a `DefinitionTarget` (`operation | fragment`), so the same field tree and handlers drive operation and fragment editing alike. Emptying a fragment is a no-op (a fragment can't be emptied without orphaning its spreads); promote-arg-to-variable stays operation-only. ## Test plan - [x] Open the query builder against a schema. Expand a composite field (e.g. `person`), tick a scalar child (e.g. `name`), and confirm an "Extract to fragment" action appears on that row. - [x] Trigger it. Confirm the query reads `person { ...PersonFields }` with a `fragment PersonFields on Person { name }` block, and the `person` row now shows a `...PersonFields` reference among its children. - [x] With the field still expanded, tick another child (e.g. `age`). Confirm it lands in the **base query** — `person { ...PersonFields age }` — and the fragment is unchanged. - [x] Click the `...PersonFields` reference row. Confirm a focused fragment editor opens (header `PersonFields on Person`, "Back to query" control), rooted at `Person`. - [x] Tick a field in the fragment editor. Confirm it edits the fragment (`fragment PersonFields on Person { name age }`) and the operation still just spreads it. - [x] Move the editor cursor into the `fragment PersonFields …` block. Confirm the builder switches to editing that fragment. Move it back into the operation; confirm it switches back. - [x] Click a fragment's name in the Fragments list. Confirm it focuses that fragment and is highlighted as active. - [x] In the Fragments list, rename `PersonFields`. Confirm the definition and every `...PersonFields` reference update together. - [x] Keyboard-only: Tab to the extract action / reference row / fragment name, confirm each takes focus and activates with Enter/Space. - [x] Unit and `query-builder.cy.ts` suites pass; Storybook a11y check clean. Refs: #4219
1 parent 9341d55 commit 340d33d

38 files changed

Lines changed: 2778 additions & 527 deletions

.changeset/query-builder.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
'graphiql': minor
44
---
55

6-
Add `@graphiql/plugin-query-builder`, a first-party visual query builder. It renders the schema's root types as a collapsible tree; checking a field adds it to the current operation and unchecking removes it, with the document parsed, mutated, and reprinted through the `graphql` package's AST utilities. Fields expose argument inputs (scalars, enums, lists, and input objects, including lists of input objects), scalar arguments can be promoted to variables, named fragments can be created from a selection, and union/interface fields offer inline-fragment type-condition selectors.
6+
Add `@graphiql/plugin-query-builder`, a first-party visual query builder. It renders the schema's root types as a collapsible tree; checking a field adds it to the current operation and unchecking removes it, with the document parsed, mutated, and reprinted through the `graphql` package's AST utilities. Fields expose argument inputs (scalars, enums, lists, and input objects, including lists of input objects), scalar arguments can be promoted to variables, named fragments can be extracted from a field's selection and edited in place, and union/interface fields offer inline-fragment type-condition selectors.
77

88
The query builder is default-installed in the `graphiql` meta-package, so it is available with no extra setup.

packages/graphiql-plugin-query-builder/src/components/__tests__/arg-input-reactive.spec.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,14 @@ const ReactiveHarness: FC<HarnessProps> = ({
124124
}
125125
})();
126126

127-
const argValues = getFieldArgValues(doc, path);
127+
const argValues = getFieldArgValues(doc, path, { kind: 'operation' });
128128
const value: ArgValue = argValues[argName] ?? [];
129129

130130
const handleChange = (next: ArgValue) => {
131131
const valueNode = argValueToValueNode(arg.type, next);
132-
const nextDoc = setFieldArgument(doc, path, argName, valueNode);
132+
const nextDoc = setFieldArgument(doc, path, argName, valueNode, {
133+
kind: 'operation',
134+
});
133135
setQuery(print(nextDoc));
134136
};
135137

packages/graphiql-plugin-query-builder/src/components/__tests__/field-row.spec.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,63 @@ describe('FieldRow', () => {
271271
});
272272
});
273273

274+
describe('FieldRow — extract to fragment', () => {
275+
it('renders the extract action when onExtractFragment is supplied', () => {
276+
render(
277+
<FieldRow
278+
field={friendsField}
279+
path={[]}
280+
selected={false}
281+
hasChildren
282+
expanded
283+
onExtractFragment={() => {}}
284+
onToggle={() => {}}
285+
onExpand={() => {}}
286+
/>,
287+
);
288+
expect(
289+
screen.getByRole('button', { name: /extract friends to a fragment/i }),
290+
).toBeInTheDocument();
291+
});
292+
293+
it('does not render the extract action when onExtractFragment is absent', () => {
294+
render(
295+
<FieldRow
296+
field={friendsField}
297+
path={[]}
298+
selected={false}
299+
hasChildren
300+
expanded
301+
onToggle={() => {}}
302+
onExpand={() => {}}
303+
/>,
304+
);
305+
expect(
306+
screen.queryByRole('button', { name: /extract .* to a fragment/i }),
307+
).not.toBeInTheDocument();
308+
});
309+
310+
it('fires onExtractFragment when the action is clicked', async () => {
311+
const onExtractFragment = vi.fn();
312+
render(
313+
<FieldRow
314+
field={friendsField}
315+
path={[]}
316+
selected={false}
317+
hasChildren
318+
expanded
319+
onExtractFragment={onExtractFragment}
320+
onToggle={() => {}}
321+
onExpand={() => {}}
322+
/>,
323+
);
324+
await userEvent.click(
325+
screen.getByRole('button', { name: /extract friends to a fragment/i }),
326+
);
327+
expect(onExtractFragment).toHaveBeenCalledOnce();
328+
});
329+
});
330+
274331
describe('FieldRow — deprecated fields', () => {
275332
it('marks a deprecated field and surfaces the reason', async () => {
276333
render(

packages/graphiql-plugin-query-builder/src/components/__tests__/field-tree-cursor-reveal.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function renderTree(cursorPath?: PathSegment[]) {
4545
doc={doc('{ __typename }')}
4646
schema={schema}
4747
cursorPath={cursorPath}
48+
target={{ kind: 'operation' }}
4849
onToggle={() => {}}
4950
onSetArg={() => {}}
5051
/>,
@@ -78,6 +79,7 @@ describe('FieldTree — cursor reveal', () => {
7879
doc={doc('{ __typename }')}
7980
schema={schema}
8081
cursorPath={[fieldSegment('parent'), fieldSegment('child')]}
82+
target={{ kind: 'operation' }}
8183
onToggle={() => {}}
8284
onSetArg={() => {}}
8385
/>,

packages/graphiql-plugin-query-builder/src/components/__tests__/field-tree-union-interface.spec.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ describe('FieldTree — union field', () => {
132132
path={[]}
133133
doc={doc('{ __typename }')}
134134
schema={SchemaWithUnion}
135+
target={{ kind: 'operation' }}
135136
onToggle={() => {}}
136137
onSetArg={() => {}}
137138
/>,
@@ -146,6 +147,7 @@ describe('FieldTree — union field', () => {
146147
path={[]}
147148
doc={doc('{ __typename }')}
148149
schema={SchemaWithUnion}
150+
target={{ kind: 'operation' }}
149151
onToggle={() => {}}
150152
onSetArg={() => {}}
151153
/>,
@@ -163,6 +165,7 @@ describe('FieldTree — union field', () => {
163165
path={[]}
164166
doc={doc('{ __typename }')}
165167
schema={SchemaWithUnion}
168+
target={{ kind: 'operation' }}
166169
onToggle={() => {}}
167170
onSetArg={() => {}}
168171
/>,
@@ -186,6 +189,7 @@ describe('FieldTree — union field', () => {
186189
path={[]}
187190
doc={doc('{ __typename }')}
188191
schema={SchemaWithUnion}
192+
target={{ kind: 'operation' }}
189193
onToggle={() => {}}
190194
onSetArg={() => {}}
191195
/>,
@@ -212,6 +216,7 @@ describe('FieldTree — union field', () => {
212216
path={[]}
213217
doc={doc('{ __typename }')}
214218
schema={SchemaWithUnion}
219+
target={{ kind: 'operation' }}
215220
onToggle={() => {}}
216221
onSetArg={() => {}}
217222
onAddInlineFragment={onAdd}
@@ -234,6 +239,7 @@ describe('FieldTree — union field', () => {
234239
path={[]}
235240
doc={doc('{ search { ... on Human { name } } }')}
236241
schema={SchemaWithUnion}
242+
target={{ kind: 'operation' }}
237243
onToggle={() => {}}
238244
onSetArg={() => {}}
239245
onRemoveInlineFragment={onRemove}
@@ -257,6 +263,7 @@ describe('FieldTree — union field', () => {
257263
path={[]}
258264
doc={doc('{ search { ... on Droid { primaryFunction } } }')}
259265
schema={SchemaWithUnion}
266+
target={{ kind: 'operation' }}
260267
onToggle={() => {}}
261268
onSetArg={() => {}}
262269
/>,
@@ -278,6 +285,7 @@ describe('FieldTree — interface field', () => {
278285
path={[]}
279286
doc={doc('{ __typename }')}
280287
schema={SchemaWithInterface}
288+
target={{ kind: 'operation' }}
281289
onToggle={() => {}}
282290
onSetArg={() => {}}
283291
/>,
@@ -303,6 +311,7 @@ describe('FieldTree — interface field', () => {
303311
path={[]}
304312
doc={doc('{ __typename }')}
305313
schema={SchemaWithInterface}
314+
target={{ kind: 'operation' }}
306315
onToggle={() => {}}
307316
onSetArg={() => {}}
308317
/>,
@@ -325,6 +334,7 @@ describe('FieldTree — interface field', () => {
325334
path={[]}
326335
doc={doc('{ __typename }')}
327336
schema={SchemaWithInterface}
337+
target={{ kind: 'operation' }}
328338
onToggle={() => {}}
329339
onSetArg={() => {}}
330340
/>,
@@ -348,6 +358,7 @@ describe('FieldTree — interface field', () => {
348358
path={[]}
349359
doc={doc('{ __typename }')}
350360
schema={SchemaWithInterface}
361+
target={{ kind: 'operation' }}
351362
onToggle={() => {}}
352363
onSetArg={() => {}}
353364
/>,
@@ -364,6 +375,7 @@ describe('FieldTree — interface field', () => {
364375
path={[]}
365376
doc={doc('{ character { ... on HumanCharacter { name } } }')}
366377
schema={SchemaWithInterface}
378+
target={{ kind: 'operation' }}
367379
onToggle={() => {}}
368380
onSetArg={() => {}}
369381
/>,
@@ -383,6 +395,7 @@ describe('FieldTree — interface field', () => {
383395
path={[]}
384396
doc={doc('{ __typename }')}
385397
schema={SchemaWithInterface}
398+
target={{ kind: 'operation' }}
386399
onToggle={onToggle}
387400
onSetArg={() => {}}
388401
/>,
@@ -408,6 +421,7 @@ describe('FieldTree — interface implementing interface', () => {
408421
path={[]}
409422
doc={doc('{ __typename }')}
410423
schema={SchemaWithNode}
424+
target={{ kind: 'operation' }}
411425
onToggle={() => {}}
412426
onSetArg={() => {}}
413427
/>,
@@ -434,6 +448,7 @@ describe('FieldTree — interface implementing interface', () => {
434448
path={[]}
435449
doc={doc('{ __typename }')}
436450
schema={SchemaWithNode}
451+
target={{ kind: 'operation' }}
437452
onToggle={() => {}}
438453
onSetArg={() => {}}
439454
/>,

packages/graphiql-plugin-query-builder/src/components/__tests__/fragment-section.spec.tsx

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,43 +34,102 @@ describe('FragmentSection', () => {
3434
expect(screen.getByText('DroidFields')).toBeInTheDocument();
3535
});
3636

37-
it('renders the "create fragment from selection" button when onCreateFragment is supplied', () => {
37+
it('has an accessible section label', () => {
38+
render(<FragmentSection doc={doc('{ hero { name } }')} />);
39+
expect(
40+
screen.getByRole('region', { name: /fragments/i }),
41+
).toBeInTheDocument();
42+
});
43+
44+
it('no longer renders a "create fragment from selection" button', () => {
45+
const d = doc(`
46+
{ hero { ...HeroFields } }
47+
fragment HeroFields on Hero { name }
48+
`);
49+
render(<FragmentSection doc={d} onRenameFragment={() => {}} />);
50+
expect(
51+
screen.queryByRole('button', { name: /create fragment from selection/i }),
52+
).not.toBeInTheDocument();
53+
});
54+
});
55+
56+
describe('FragmentSection — inline rename', () => {
57+
const withFragment = () =>
58+
doc(`
59+
{ hero { ...HeroFields } }
60+
fragment HeroFields on Hero { name }
61+
`);
62+
63+
it('shows a rename affordance per fragment when onRenameFragment is supplied', () => {
3864
render(
39-
<FragmentSection
40-
doc={doc('{ hero { name } }')}
41-
onCreateFragment={() => {}}
42-
/>,
65+
<FragmentSection doc={withFragment()} onRenameFragment={() => {}} />,
4366
);
4467
expect(
45-
screen.getByRole('button', { name: /create fragment from selection/i }),
68+
screen.getByRole('button', { name: /rename fragment HeroFields/i }),
4669
).toBeInTheDocument();
4770
});
4871

49-
it('does not render the create button when onCreateFragment is not supplied', () => {
50-
render(<FragmentSection doc={doc('{ hero { name } }')} />);
72+
it('does not show a rename affordance without onRenameFragment', () => {
73+
render(<FragmentSection doc={withFragment()} />);
5174
expect(
52-
screen.queryByRole('button', { name: /create fragment from selection/i }),
75+
screen.queryByRole('button', { name: /rename fragment/i }),
5376
).not.toBeInTheDocument();
5477
});
5578

56-
it('calls onCreateFragment when the button is clicked', async () => {
57-
const onCreateFragment = vi.fn();
79+
it('commits a new name on Enter', async () => {
80+
const onRenameFragment = vi.fn();
5881
render(
5982
<FragmentSection
60-
doc={doc('{ hero { name } }')}
61-
onCreateFragment={onCreateFragment}
83+
doc={withFragment()}
84+
onRenameFragment={onRenameFragment}
6285
/>,
6386
);
6487
await userEvent.click(
65-
screen.getByRole('button', { name: /create fragment from selection/i }),
88+
screen.getByRole('button', { name: /rename fragment HeroFields/i }),
6689
);
67-
expect(onCreateFragment).toHaveBeenCalledOnce();
90+
const input = screen.getByRole('textbox', {
91+
name: /rename fragment HeroFields/i,
92+
});
93+
await userEvent.clear(input);
94+
await userEvent.type(input, 'HeroBasics{Enter}');
95+
expect(onRenameFragment).toHaveBeenCalledWith('HeroFields', 'HeroBasics');
6896
});
6997

70-
it('has an accessible section label', () => {
71-
render(<FragmentSection doc={doc('{ hero { name } }')} />);
72-
expect(
73-
screen.getByRole('region', { name: /fragments/i }),
74-
).toBeInTheDocument();
98+
it('cancels on Escape without renaming', async () => {
99+
const onRenameFragment = vi.fn();
100+
render(
101+
<FragmentSection
102+
doc={withFragment()}
103+
onRenameFragment={onRenameFragment}
104+
/>,
105+
);
106+
await userEvent.click(
107+
screen.getByRole('button', { name: /rename fragment HeroFields/i }),
108+
);
109+
const input = screen.getByRole('textbox', {
110+
name: /rename fragment HeroFields/i,
111+
});
112+
await userEvent.clear(input);
113+
await userEvent.type(input, 'Nope{Escape}');
114+
expect(onRenameFragment).not.toHaveBeenCalled();
115+
expect(screen.getByText('HeroFields')).toBeInTheDocument();
116+
});
117+
118+
it('does not fire a rename when the name is unchanged', async () => {
119+
const onRenameFragment = vi.fn();
120+
render(
121+
<FragmentSection
122+
doc={withFragment()}
123+
onRenameFragment={onRenameFragment}
124+
/>,
125+
);
126+
await userEvent.click(
127+
screen.getByRole('button', { name: /rename fragment HeroFields/i }),
128+
);
129+
const input = screen.getByRole('textbox', {
130+
name: /rename fragment HeroFields/i,
131+
});
132+
await userEvent.type(input, '{Enter}');
133+
expect(onRenameFragment).not.toHaveBeenCalled();
75134
});
76135
});

0 commit comments

Comments
 (0)