Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/large-seas-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@graphql-hive/render-laboratory': patch
'@graphql-hive/laboratory': patch
---

Lab: Add a schema documentation pane, opt-in via the new `enableDocs` prop.

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`.

`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.

**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 changes: 13 additions & 0 deletions packages/libraries/laboratory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Hive Console and can be embedded into any page that talks to a GraphQL endpoint.
- Query builder: click schema fields/arguments to build the operation
- Schema explorer with search (list and tree modes)
- Collections (saved operations) and request history
- Schema documentation pane (opt-in via `enableDocs`), reachable from Builder rows and editor hovers
- Preflight scripts: run JavaScript before a request in a sandboxed Web Worker
- Environment variables with `{{variable}}` interpolation
- Renders a federation query plan when a server includes one in the response `extensions`
Expand Down Expand Up @@ -102,6 +103,18 @@ Pass a `permissions` object to gate actions per resource (`preflight`, `collecti
(controls are hidden/disabled) with a backstop in the operations logic; anything unspecified
defaults to allowed.

### Documentation pane

`enableDocs` adds a documentation icon to the left rail, opening a schema browser in the same slot
as Collections and History. It is off unless you pass it. Builder rows get an "Open in Docs" context
menu entry, and the GraphQL editor hover gets an "Open in Docs" link (the Lab serves that hover
itself when docs are on, instead of monaco-graphql).

The prop also decides whether introspection requests descriptions, since nothing else renders them.
That only reaches introspection the Lab performs itself: if you pass `defaultSchemaIntrospection`,
build it with descriptions or the pane will have nothing to show. `introspectionFromSchema` includes
them by default, so the usual `introspectionFromSchema(buildSchema(sdl))` needs no extra options.

### Styling and rendering

The Lab bundles its own styles and injects them into its shadow root, so there is no CSS file to
Expand Down
4 changes: 4 additions & 0 deletions packages/libraries/laboratory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@
"dependencies": {
"@base-ui/react": "^1.1.0",
"@graphql-tools/url-loader": "^9.1.0",
"dompurify": "3.4.12",
"graphql-language-service": "^5.5.0",
"radix-ui": "^1.4.3",
"react-zoom-pan-pinch": "^3.7.0",
"snarkdown": "2.0.0",
"uuid": "^14.0.0"
},
"devDependencies": {
Expand Down Expand Up @@ -106,6 +109,7 @@
"graphql": "^16.14.0",
"graphql-yoga": "5.21.1",
"happy-dom": "^20.10.6",
"jsdom": "^30.0.1",
"lodash": "^4.18.1",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { buildSchema, type GraphQLObjectType } from 'graphql';
import { fireEvent, render, screen } from '@testing-library/react';
import { BuilderObjectField, BuilderScalarField } from './builder';

const laboratory = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));

vi.mock('./context', () => ({
useLaboratory: () => laboratory.current,
}));

const schema = buildSchema(`
type Profile { city: String }
type User { id: ID!, profile: Profile }
type Query { me: User }
`);

const idField = (schema.getType('User') as GraphQLObjectType).getFields().id;
const meField = schema.getQueryType()!.getFields().me;

const openDocs = vi.fn();

const mount = (node: React.ReactElement, enableDocs: boolean) => {
laboratory.current = {
schema,
enableDocs,
openDocs,
activeOperation: null,
activeTab: { type: 'operation' },
addPathToActiveOperation: vi.fn(),
deletePathFromActiveOperation: vi.fn(),
addArgToActiveOperation: vi.fn(),
deleteArgFromActiveOperation: vi.fn(),
};

return render(node);
};

const scalarRow = (disableChildren: boolean) => (
<BuilderScalarField
field={idField}
path={['query', 'me', 'id']}
openPaths={[]}
setOpenPaths={vi.fn()}
disableChildren={disableChildren}
/>
);

describe('Builder row docs context menu', () => {
beforeEach(() => {
openDocs.mockReset();
});

it('offers Open in Docs on a plain row', () => {
const { container } = mount(scalarRow(false), true);

fireEvent.contextMenu(container.firstElementChild!);

expect(screen.getByText('Open in Docs')).toBeDefined();
});

it('opens the field the row represents, not its return type', () => {
const { container } = mount(scalarRow(false), true);

fireEvent.contextMenu(container.firstElementChild!);
fireEvent.click(screen.getByText('Open in Docs'));

expect(openDocs).toHaveBeenCalledWith({ kind: 'field', typeName: 'User', fieldName: 'id' });
});

it('offers nothing when docs are disabled', () => {
const { container } = mount(scalarRow(false), false);

fireEvent.contextMenu(container.firstElementChild!);

expect(screen.queryByText('Open in Docs')).toBeNull();
});

it('still renders the sticky row variant', () => {
const { container } = mount(scalarRow(true), true);

expect(container.firstElementChild).not.toBeNull();
});

// The collapsible variant nests ContextMenuTrigger asChild around
// CollapsibleTrigger asChild, so both slots have to resolve onto the Button.
it('renders the collapsible row without breaking the asChild chain', () => {
const { container } = mount(
<BuilderObjectField
field={meField}
path={['query', 'me']}
openPaths={[]}
setOpenPaths={vi.fn()}
/>,
true,
);

const button = container.querySelector('button');
expect(button).not.toBeNull();

fireEvent.contextMenu(button!);

expect(screen.getByText('Open in Docs')).toBeDefined();
});
});
Loading
Loading