Skip to content

Commit b8b4499

Browse files
Laboratory: add built in docs component (#8355)
1 parent 181c898 commit b8b4499

28 files changed

Lines changed: 2058 additions & 253 deletions

.changeset/large-seas-chew.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@graphql-hive/render-laboratory': patch
3+
'@graphql-hive/laboratory': patch
4+
---
5+
6+
Lab: Add a schema documentation pane, opt-in via the new `enableDocs` prop.
7+
8+
When enabled, a third icon appears in the left rail and opens documentation in the same slot as Collections and History: browse root types, types and fields, search across the whole type map (including input objects and enum values), and read descriptions, deprecations and argument defaults. Builder rows gain an "Open in Docs" context menu entry, and the GraphQL editor hover gains an "Open in Docs" link. `renderLaboratory` enables the pane by default, so standalone embedders of the UMD bundle get it without passing `enableDocs`.
9+
10+
`enableDocs` also decides whether introspection asks for descriptions, so a host that supplies `defaultSchemaIntrospection` must build it with descriptions itself. Building it with `introspectionFromSchema` does that by default.
11+
12+
**Removed:** the `introspection.schemaDescription` setting and its toggle in the settings dialog. It was wired to graphql-js's `descriptions` option rather than `schemaDescription`, and defaulted to `false` where graphql-js defaults to `true`, so nothing rendered descriptions and the toggle had no discoverable effect. Descriptions now follow `enableDocs`. `render-laboratory` no longer maps Yoga's `schemaDescription` option.
13+

packages/libraries/laboratory/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Hive Console and can be embedded into any page that talks to a GraphQL endpoint.
1616
- Query builder: click schema fields/arguments to build the operation
1717
- Schema explorer with search (list and tree modes)
1818
- Collections (saved operations) and request history
19+
- Schema documentation pane (opt-in via `enableDocs`), reachable from Builder rows and editor hovers
1920
- Preflight scripts: run JavaScript before a request in a sandboxed Web Worker
2021
- Environment variables with `{{variable}}` interpolation
2122
- Renders a federation query plan when a server includes one in the response `extensions`
@@ -102,6 +103,18 @@ Pass a `permissions` object to gate actions per resource (`preflight`, `collecti
102103
(controls are hidden/disabled) with a backstop in the operations logic; anything unspecified
103104
defaults to allowed.
104105

106+
### Documentation pane
107+
108+
`enableDocs` adds a documentation icon to the left rail, opening a schema browser in the same slot
109+
as Collections and History. It is off unless you pass it. Builder rows get an "Open in Docs" context
110+
menu entry, and the GraphQL editor hover gets an "Open in Docs" link (the Lab serves that hover
111+
itself when docs are on, instead of monaco-graphql).
112+
113+
The prop also decides whether introspection requests descriptions, since nothing else renders them.
114+
That only reaches introspection the Lab performs itself: if you pass `defaultSchemaIntrospection`,
115+
build it with descriptions or the pane will have nothing to show. `introspectionFromSchema` includes
116+
them by default, so the usual `introspectionFromSchema(buildSchema(sdl))` needs no extra options.
117+
105118
### Styling and rendering
106119

107120
The Lab bundles its own styles and injects them into its shadow root, so there is no CSS file to

packages/libraries/laboratory/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@
4444
"dependencies": {
4545
"@base-ui/react": "^1.1.0",
4646
"@graphql-tools/url-loader": "^9.1.0",
47+
"dompurify": "3.4.12",
48+
"graphql-language-service": "^5.5.0",
4749
"radix-ui": "^1.4.3",
4850
"react-zoom-pan-pinch": "^3.7.0",
51+
"snarkdown": "2.0.0",
4952
"uuid": "^14.0.0"
5053
},
5154
"devDependencies": {
@@ -106,6 +109,7 @@
106109
"graphql": "^16.14.0",
107110
"graphql-yoga": "5.21.1",
108111
"happy-dom": "^20.10.6",
112+
"jsdom": "^30.0.1",
109113
"lodash": "^4.18.1",
110114
"lucide-react": "^0.548.0",
111115
"lz-string": "^1.5.0",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// @vitest-environment jsdom
2+
import { buildSchema, type GraphQLObjectType } from 'graphql';
3+
import { fireEvent, render, screen } from '@testing-library/react';
4+
import { BuilderObjectField, BuilderScalarField } from './builder';
5+
6+
const laboratory = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
7+
8+
vi.mock('./context', () => ({
9+
useLaboratory: () => laboratory.current,
10+
}));
11+
12+
const schema = buildSchema(`
13+
type Profile { city: String }
14+
type User { id: ID!, profile: Profile }
15+
type Query { me: User }
16+
`);
17+
18+
const idField = (schema.getType('User') as GraphQLObjectType).getFields().id;
19+
const meField = schema.getQueryType()!.getFields().me;
20+
21+
const openDocs = vi.fn();
22+
23+
const mount = (node: React.ReactElement, enableDocs: boolean) => {
24+
laboratory.current = {
25+
schema,
26+
enableDocs,
27+
openDocs,
28+
activeOperation: null,
29+
activeTab: { type: 'operation' },
30+
addPathToActiveOperation: vi.fn(),
31+
deletePathFromActiveOperation: vi.fn(),
32+
addArgToActiveOperation: vi.fn(),
33+
deleteArgFromActiveOperation: vi.fn(),
34+
};
35+
36+
return render(node);
37+
};
38+
39+
const scalarRow = (disableChildren: boolean) => (
40+
<BuilderScalarField
41+
field={idField}
42+
path={['query', 'me', 'id']}
43+
openPaths={[]}
44+
setOpenPaths={vi.fn()}
45+
disableChildren={disableChildren}
46+
/>
47+
);
48+
49+
describe('Builder row docs context menu', () => {
50+
beforeEach(() => {
51+
openDocs.mockReset();
52+
});
53+
54+
it('offers Open in Docs on a plain row', () => {
55+
const { container } = mount(scalarRow(false), true);
56+
57+
fireEvent.contextMenu(container.firstElementChild!);
58+
59+
expect(screen.getByText('Open in Docs')).toBeDefined();
60+
});
61+
62+
it('opens the field the row represents, not its return type', () => {
63+
const { container } = mount(scalarRow(false), true);
64+
65+
fireEvent.contextMenu(container.firstElementChild!);
66+
fireEvent.click(screen.getByText('Open in Docs'));
67+
68+
expect(openDocs).toHaveBeenCalledWith({ kind: 'field', typeName: 'User', fieldName: 'id' });
69+
});
70+
71+
it('offers nothing when docs are disabled', () => {
72+
const { container } = mount(scalarRow(false), false);
73+
74+
fireEvent.contextMenu(container.firstElementChild!);
75+
76+
expect(screen.queryByText('Open in Docs')).toBeNull();
77+
});
78+
79+
it('still renders the sticky row variant', () => {
80+
const { container } = mount(scalarRow(true), true);
81+
82+
expect(container.firstElementChild).not.toBeNull();
83+
});
84+
85+
// The collapsible variant nests ContextMenuTrigger asChild around
86+
// CollapsibleTrigger asChild, so both slots have to resolve onto the Button.
87+
it('renders the collapsible row without breaking the asChild chain', () => {
88+
const { container } = mount(
89+
<BuilderObjectField
90+
field={meField}
91+
path={['query', 'me']}
92+
openPaths={[]}
93+
setOpenPaths={vi.fn()}
94+
/>,
95+
true,
96+
);
97+
98+
const button = container.querySelector('button');
99+
expect(button).not.toBeNull();
100+
101+
fireEvent.contextMenu(button!);
102+
103+
expect(screen.getByText('Open in Docs')).toBeDefined();
104+
});
105+
});

0 commit comments

Comments
 (0)