GraphSchemaEditor.test.tsx7.5 KBView on GitHub /**
* The schema editor — the form that replaced editing `view` as JSON.
*
* Every assertion here is about the CALLBACK, never about a request: the editor emits
* `onSetView` / `onSetFields` and the canvas wires them to `graphs.setView` / `graphs.addFields`.
* That split is the thing worth pinning — a form that reached for tRPC itself could not be
* mounted in a test at all without a provider, and could not be reused by a read-only surface.
*
* The other recurring assertion is that "nothing" is emitted as `undefined` rather than as an
* empty string. `setView` MERGES, so a stored `''` would be a key every reader downstream has
* to remember is falsy; omitting it puts the graph back in the state it was in before anyone
* chose.
*/
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { GraphSchema } from '@zero/server/graph';
import { GraphSchemaEditor } from '@/modules/graph/GraphSchemaEditor';
const SCHEMA: GraphSchema = {
version: 1,
visibility: 'team',
nodeSchema: {
fields: [
{ key=[redacted], label: 'Title', type: 'text', from: 'person.title', displayOnCard: true },
{
key=[redacted],
label: 'Stance',
type: 'select',
options: ['supporter', 'skeptic'],
optionColors: { supporter: 'green', skeptic: 'amber' },
},
{ key=[redacted], label: 'Seniority', type: 'select', options: ['c_level', 'vp'] },
],
},
edgeKinds: [
{ key=[redacted], label: 'Reports to' },
{ key=[redacted], label: 'Influences' },
],
view: { colorField: 'stance', tierField: 'seniority', layoutEdgeKind: 'reports_to' },
};
/**
* The control in a named row.
*
* Scoped to the ROW rather than queried globally because a field's label is not unique on this
* panel — "Stance" is both the background row's value and a chip on the node face, and a test
* that reached for either by name would pick whichever the DOM happened to order first.
*
* Two roles, because the panel deliberately mixes two controls: radix's `Select` presents as a
* `combobox`, the hand-built trigger that opens the colour picker as a `button`.
*/
function rowControl(label: string): HTMLElement {
const row = screen.getByText(label).closest('[data-settings-row]');
if (!row) throw new Error(`No settings row labelled ${label}`);
return within(row).queryByRole('combobox') ?? within(row).getByRole('button');
}
function renderEditor(schema: GraphSchema = SCHEMA) {
const onSetView = jest.fn();
const onSetFields = jest.fn();
render(<GraphSchemaEditor schema={schema} onSetView={onSetView} onSetFields={onSetFields} />);
return { onSetView, onSetFields };
}
describe('GraphSchemaEditor — which field paints', () => {
it('names the field that paints, and repoints it', async () => {
const { onSetView } = renderEditor();
const trigger = rowControl('Background');
expect(trigger).toHaveTextContent('Stance');
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole('option', { name: /Seniority/ }));
expect(onSetView).toHaveBeenCalledWith({ colorField: 'seniority' });
});
/**
* The point of the picker over a `<select>`: the rows carry the colours, so the choice is
* made by looking rather than by remembering what `stance` is worth. A field that declares
* no colours must NOT borrow a palette — it is the one that will paint nothing.
*/
it('draws each field’s own palette on its row', async () => {
renderEditor();
await userEvent.click(rowControl('Background'));
const stance = await screen.findByRole('option', { name: /Stance/ });
expect(stance.querySelector('.bg-green-500')).not.toBeNull();
expect(stance.querySelector('.bg-amber-500')).not.toBeNull();
const seniority = screen.getByRole('option', { name: /Seniority/ });
expect(seniority.querySelector('[class*="bg-"]')).toBeNull();
expect(seniority.querySelector('.border-dashed')).not.toBeNull();
});
it('clears the tint by omitting the key, never by storing an empty string', async () => {
const { onSetView } = renderEditor();
await userEvent.click(rowControl('Background'));
await userEvent.click(await screen.findByRole('option', { name: /no tint/ }));
expect(onSetView).toHaveBeenCalledWith({ colorField: undefined });
});
/**
* A field an agent dropped out from under the view. Offered back, or the control matches
* nothing, renders blank, and the graph is tinted by something the form cannot name.
*/
it('offers back a colour field the schema no longer declares', async () => {
renderEditor({ ...SCHEMA, view: { ...SCHEMA.view, colorField: 'buying_role' } });
const trigger = rowControl('Background');
expect(trigger).toHaveTextContent('buying_role');
await userEvent.click(trigger);
expect(await screen.findByRole('option', { name: /no longer a field/ })).toBeInTheDocument();
});
});
describe('GraphSchemaEditor — the layout axes', () => {
it('repoints the tier field', async () => {
const { onSetView } = renderEditor();
await userEvent.click(rowControl('Tiers'));
await userEvent.click(await screen.findByRole('option', { name: 'Stance' }));
expect(onSetView).toHaveBeenCalledWith({ tierField: 'stance' });
});
it('drops the tier field rather than storing a blank one', async () => {
const { onSetView } = renderEditor();
await userEvent.click(rowControl('Tiers'));
await userEvent.click(await screen.findByRole('option', { name: /one row/ }));
expect(onSetView).toHaveBeenCalledWith({ tierField: undefined });
});
it('repoints the hierarchy axis onto another relation', async () => {
const { onSetView } = renderEditor();
await userEvent.click(rowControl('Hierarchy'));
await userEvent.click(await screen.findByRole('option', { name: 'Influences' }));
expect(onSetView).toHaveBeenCalledWith({ layoutEdgeKind: 'influences' });
});
/** A row offering exactly one choice is a control that cannot be used. */
it('hides the hierarchy row when the graph declares no relations', () => {
renderEditor({ ...SCHEMA, edgeKinds: [], view: { colorField: 'stance' } });
expect(screen.queryByText('Hierarchy')).toBeNull();
});
});
describe('GraphSchemaEditor — the node face', () => {
it('ticks a field onto the face, sending only that field', async () => {
const { onSetFields } = renderEditor();
await userEvent.click(screen.getByRole('button', { name: 'Stance', pressed: false }));
expect(onSetFields).toHaveBeenCalledWith([
{
key=[redacted],
label: 'Stance',
type: 'select',
options: ['supporter', 'skeptic'],
optionColors: { supporter: 'green', skeptic: 'amber' },
displayOnCard: true,
},
]);
});
/**
* Off, too. `addFields` merges, so the removal has to be an explicit `false` — leaving the
* key out would merge to "unchanged" and the chip would light straight back up.
*/
it('takes a field back off the face with an explicit false', async () => {
const { onSetFields } = renderEditor();
await userEvent.click(screen.getByRole('button', { name: 'Title', pressed: true }));
expect(onSetFields).toHaveBeenCalledWith([
expect.objectContaining({ key=[redacted], displayOnCard: false }),
]);
});
it('says so, rather than showing empty controls, when there are no fields', () => {
renderEditor({ ...SCHEMA, nodeSchema: { fields: [] }, view: {} });
expect(screen.getByText('This graph declares no node fields yet.')).toBeInTheDocument();
expect(screen.queryByText('Background')).toBeNull();
});
});