DocumentProperties.test.tsx10.1 KBView on GitHub /**
* The document properties rail — what it DRAWS for a given (schema, values) pair.
*
* Mounted directly, like `CardFieldList.test.tsx`: the whole point of this component is which
* rows appear, in which groups, in which order, and a view harness would add a tRPC query and a
* Y.js provider around that one assertion.
*
* One case here is structural rather than cosmetic. Openness — a document with no schema still
* shows everything it carries, and an unknown type says it fell back rather than hiding — is
* the same rule the board applies, and a rail that quietly dropped either would let a reader
* conclude an agent wrote nothing.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import type { BoardField } from '@zero/server/board';
import {
buildPropertyGroups,
type GraphPropertyContext,
} from '@/modules/documents/properties/property-groups';
import { DocumentProperties } from '@/modules/documents/properties/DocumentProperties';
const OWNER: BoardField = { key=[redacted], label: 'Owner', type: 'text' };
/** The document's own bag — `documents.metadata.fields`, the same on every graph. */
const SUBJECT = { schema: [OWNER], fields: { owner: 'Jesse' } };
const BOUND: GraphPropertyContext['boundFields'] = [
{
field: { key=[redacted], label: 'Title', type: 'text' },
value: 'VP Engineering',
},
];
const ORG_CHART: GraphPropertyContext = {
graphName: 'Acme org chart',
schema: [{ key=[redacted], label: 'Stance', type: 'text' }],
fields: { stance: 'champion' },
boundFields: BOUND,
};
const BUYING_COMMITTEE: GraphPropertyContext = {
graphName: 'Acme buying committee',
schema: [{ key=[redacted], label: 'Buying role', type: 'text' }],
fields: { buying_role: 'economic buyer' },
boundFields: BOUND,
};
/** Where each caption lands in the rendered text — document order, read off the DOM. */
function order(container: HTMLElement, ...captions: string[]): number[] {
const text = container.textContent ?? '';
return captions.map((caption) => text.indexOf(caption));
}
describe('DocumentProperties — openness', () => {
it('renders EVERY key a document carries when it declares no schema at all', () => {
// The degenerate case of the board's rule: a UI that hides unknown keys lets a reader
// conclude the agent wrote nothing, and then "fix" it by writing a schema without them.
render(
<DocumentProperties
subject={{ fields: { headcount: '240', segment: 'mid-market', mood: 'warm' } }}
/>,
);
expect(screen.getByText('Not declared')).toBeInTheDocument();
for (const [key, value] of [
['headcount', '240'],
['segment', 'mid-market'],
['mood', 'warm'],
]) {
expect(screen.getByText(key)).toBeInTheDocument();
expect(screen.getByText(value)).toBeInTheDocument();
}
});
it('renders a declared field the document has no value for, rather than skipping it', () => {
render(
<DocumentProperties
subject={{
schema: [OWNER, { key=[redacted], label: 'Renewal', type: 'date' }],
fields: { owner: 'Jesse' },
}}
/>,
);
expect(screen.getByText('Renewal')).toBeInTheDocument();
expect(screen.getByText('Empty')).toBeInTheDocument();
});
it('gives a typed key its own control — a select is a pill, a url is a link', () => {
render(
<DocumentProperties
subject={{
schema: [
{ key=[redacted], label: 'Stage', type: 'select', options: ['discovery', 'closed'] },
{ key=[redacted], label: 'Site', type: 'url' },
],
fields: { stage: 'discovery', site: 'https://acme.test' },
}}
/>,
);
expect(screen.getByText('discovery')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://acme.test');
});
it('falls back to text for an unknown type and SAYS so, rather than hiding the field', () => {
// An agent that invented `type: 'sentiment'` gets a working text field today. Naming the
// fallback is what stops someone "fixing" the field by deleting it.
render(
<DocumentProperties
subject={{
schema: [{ key=[redacted], label: 'Mood', type: 'sentiment' }],
fields: { mood: 'frustrated' },
}}
/>,
);
expect(screen.getByText('Mood')).toBeInTheDocument();
expect(screen.getByText('frustrated')).toBeInTheDocument();
expect(screen.getByText('sentiment · as text')).toBeInTheDocument();
});
it('opens a value in place and commits it — the value IS the control', () => {
const onChange = jest.fn();
render(<DocumentProperties subject={{ ...SUBJECT, onChange }} />);
fireEvent.click(screen.getByRole('button', { name: /Jesse/ }));
const input = screen.getByLabelText('Owner');
fireEvent.change(input, { target: { value: 'Ashkon' } });
fireEvent.keyDown(input, { key=[redacted] });
expect(onChange).toHaveBeenCalledWith('owner', 'Ashkon');
});
it('renders read-only with no onChange — the rows are there but do not open', () => {
render(<DocumentProperties subject={SUBJECT} />);
expect(screen.getByRole('button', { name: /Jesse/ })).toBeDisabled();
});
});
describe('DocumentProperties — opened from a graph', () => {
it('shows two groups in order: the document, then this graph', () => {
const { container } = render(<DocumentProperties subject={SUBJECT} graphContext={ORG_CHART} />);
const [own, graph] = order(container, 'Properties', 'In Acme org chart');
expect(own).toBeGreaterThanOrEqual(0);
expect(graph).toBeGreaterThan(own);
// A resolved value is a row of the graph's group, not a third block with a caption naming
// the mechanism that produced it.
expect(screen.queryByText('Resolved elsewhere')).not.toBeInTheDocument();
expect(screen.getByText('VP Engineering')).toBeInTheDocument();
});
it('differs between two graphs over one document in the MIDDLE group alone', () => {
// A node's fields are per-graph: `stance` on an org chart and `buying_role` on a buying
// committee are different judgements about one human. The document and the resolved values
// are the same person either way, and must not move.
const { rerender } = render(<DocumentProperties subject={SUBJECT} graphContext={ORG_CHART} />);
expect(screen.getByText('Stance')).toBeInTheDocument();
expect(screen.queryByText('Buying role')).not.toBeInTheDocument();
rerender(<DocumentProperties subject={SUBJECT} graphContext={BUYING_COMMITTEE} />);
expect(screen.getByText('In Acme buying committee')).toBeInTheDocument();
expect(screen.getByText('Buying role')).toBeInTheDocument();
expect(screen.queryByText('Stance')).not.toBeInTheDocument();
// Unchanged on both sides of the middle group.
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(screen.getByText('Jesse')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('VP Engineering')).toBeInTheDocument();
});
it('makes a resolved ROW read-only inside an otherwise editable group', () => {
// A bound field is never written into `fields`, so offering an editor here would write into
// a bag the next read ignores — and now that it shares a group with authored fields, that
// has to hold per ROW rather than per group.
render(
<DocumentProperties
subject={{ ...SUBJECT, onChange: jest.fn() }}
graphContext={{ ...ORG_CHART, onChange: jest.fn() }}
/>,
);
expect(screen.getByRole('button', { name: /VP Engineering/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Jesse/ })).not.toBeDisabled();
expect(screen.getByRole('button', { name: /champion/ })).not.toBeDisabled();
});
it('draws an EDGE with the same component, typed by its kind when the kind declares fields', () => {
// An edge needs no mechanism of its own: its kind's `fields` are the schema, its bag is the
// values. One properties idiom, not two.
render(
<DocumentProperties
subject={{
label: 'Reports to',
schema: [{ key=[redacted], label: 'Since', type: 'date' }],
fields: { since: '2024-02-01', dotted: 'true' },
}}
/>,
);
expect(screen.getByText('Reports to')).toBeInTheDocument();
expect(screen.getByText('2024-02-01')).toBeInTheDocument();
// An undeclared key on an edge follows the same rule it follows on a document.
expect(screen.getByText('Not declared')).toBeInTheDocument();
expect(screen.getByText('dotted')).toBeInTheDocument();
});
});
describe('buildPropertyGroups', () => {
it('omits the graph groups entirely when there is no graph context', () => {
const groups = buildPropertyGroups({ subject: SUBJECT });
expect(groups.map((group) => group.id)).toEqual(['own']);
});
it('renders no empty rail at all — a caption over nothing is furniture', () => {
const { container } = render(<DocumentProperties subject={{ schema: [], fields: {} }} />);
expect(container).toBeEmptyDOMElement();
});
it('keeps the graph group when nothing resolved — there is no third group to omit', () => {
const groups = buildPropertyGroups({
subject: SUBJECT,
graph: { graphName: 'Acme org chart', schema: [], fields: { stance: 'champion' } },
});
expect(groups.map((group) => group.id)).toEqual(['own', 'graph']);
});
it('never renders a bound key twice when a cached copy is left in the graph bag', () => {
// `derived` is a read cache, so a stale copy of a bound key can sit in `fields`. It must not
// surface as an editable "not declared" row beside the read-only one it duplicates.
const groups = buildPropertyGroups({
subject: SUBJECT,
graph: { ...ORG_CHART, fields: { stance: 'champion', title: 'Director' } },
});
const graphGroup = groups.find((group) => group.id === 'graph');
expect(graphGroup?.rows.map((row) => row.field.key)).toEqual(['stance', 'title']);
expect(graphGroup?.rows.map((row) => row.readOnly)).toEqual([undefined, true]);
// The editable copy is the one that lost: `title` renders once, and as the resolved value.
expect(graphGroup?.rows.find((row) => row.field.key === 'title')?.value).toBe('VP Engineering');
});
});