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
28 changes: 28 additions & 0 deletions .changeset/form-vocabulary-boundary-loudening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@object-ui/components": patch
"@object-ui/plugin-form": patch
"@object-ui/cli": patch
---

fix(form): a spec-vocabulary field no longer crashes the standalone form, and every surface now says which vocabulary you meant — #3090

Writing the regression test against the unfixed renderer proved the failure
was worse than the assumed silent drop: a `{ field: 'x' }` entry (spec
form-VIEW vocabulary) slipped past the `f?.name` guards into a
react-hook-form Controller with `name === undefined` and crashed the whole
standalone form on `name.split('.')`, with nothing naming the culprit entry.
The renderer now partitions such entries out — the rest of the form renders —
and surfaces them with an inline alert plus a console.error whose text is the
fix instruction (rename to `name`, or use an object-bound form whose sections
accept the spec shape).

`objectui validate` grows the same boundary awareness: on failure, a
`{ field: … }` entry in a standalone form gets a "likely cause" hint naming
the real fix instead of the bare `invalid_union` — the previous message read
as "bolt a `name` on", which converts spec metadata wrongly. On success,
mixed-vocabulary entries (`name` + string `field`) get a warning: they
validate, but the spec key is dead weight the renderer ignores.

`normalizeSectionField` warns (once per site) when an authored section field
mixes both identity keys — the spec branch derives the runtime name from
`field`, so an authored `name` was silently overwritten.
74 changes: 74 additions & 0 deletions packages/cli/src/__tests__/spec-vocabulary-hint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `findSpecVocabularyFormFields` (#3090) — the detector behind `objectui
* validate`'s directed hint. Its precision is the point: the same `field` key
* means three different things across the platform (spec string reference /
* runtime metadata object / absent), and flagging the wrong one would make
* the CLI teach agents the wrong lesson.
*/

import { describe, it, expect } from 'vitest';
import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js';

describe('findSpecVocabularyFormFields', () => {
it('finds a spec-shaped entry inside a nested standalone form, with its path', () => {
const schema = {
type: 'page',
children: [
{ type: 'text', content: 'hi' },
{
type: 'form',
fields: [
{ name: 'subject', type: 'input' },
{ field: 'owner', required: true },
],
},
],
};
expect(findSpecVocabularyFormFields(schema)).toEqual([
{ path: 'children[1].fields[1]', field: 'owner' },
]);
});

it('reports a mixed-vocabulary entry with its runtime name', () => {
const schema = {
type: 'form',
fields: [{ name: 'subject', field: 'subject_line', type: 'input' }],
};
expect(findSpecVocabularyFormFields(schema)).toEqual([
{ path: 'fields[0]', field: 'subject_line', mixedName: 'subject' },
]);
});

it('does NOT flag section fields — the object-form path accepts the spec shape', () => {
const schema = {
type: 'object-form',
objectName: 'case',
sections: [{ name: 's1', fields: [{ field: 'owner', required: true }] }],
};
expect(findSpecVocabularyFormFields(schema)).toEqual([]);
});

it('does NOT flag the runtime metadata-object `field` slot (the pun)', () => {
const schema = {
type: 'form',
fields: [{ name: 'amount', type: 'input', field: { type: 'currency', scale: 2 } }],
};
expect(findSpecVocabularyFormFields(schema)).toEqual([]);
});

it('handles primitives, nulls and arrays without faulting', () => {
expect(findSpecVocabularyFormFields(null)).toEqual([]);
expect(findSpecVocabularyFormFields('form')).toEqual([]);
expect(findSpecVocabularyFormFields([{ type: 'form', fields: [{ field: 'x' }] }])).toEqual([
{ path: '[0].fields[0]', field: 'x' },
]);
});
});
40 changes: 38 additions & 2 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { load as loadYaml } from 'js-yaml';
import { safeValidateSchema } from '@object-ui/types/zod';
import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js';

/**
* Validate a schema file
Expand Down Expand Up @@ -76,7 +77,21 @@ export async function validate(schemaPath: string) {
if (data.children && Array.isArray(data.children)) {
console.log(chalk.gray(' Children:'), data.children.length);
}


// Mixed-vocabulary entries validate (the runtime `name` satisfies the
// schema) but the spec `field` key is dead weight: the renderer ignores
// it and strip-mode validation drops it. Valid ≠ clean — say so (#3090).
const mixed = findSpecVocabularyFormFields(schema).filter((f) => f.mixedName);
if (mixed.length > 0) {
console.log(chalk.yellow('\nWarnings:'));
for (const m of mixed) {
console.log(chalk.yellow(
` ${m.path}: '${m.mixedName}' also carries { field: '${m.field}' } — mixed form-field ` +
`vocabularies. The renderer ignores \`field\` here; drop one of the two.`,
));
}
}

console.log('');
process.exit(0);
} else {
Expand All @@ -97,7 +112,28 @@ export async function validate(schemaPath: string) {
console.error(chalk.gray(` Code: ${issue.code}`));
}
});


// "name: expected string, received undefined" on a `{ field: … }` entry
// reads as an instruction to bolt a `name` on — which converts the
// metadata WRONGLY (the spec shape stands for an object-schema merge
// that a bare rename throws away). When the input matches that
// signature, name the actual boundary and the real fixes (#3090).
const specShaped = findSpecVocabularyFormFields(schema).filter((f) => !f.mixedName);
if (specShaped.length > 0) {
console.error(chalk.bold('\nLikely cause — spec form-view vocabulary in a standalone form:'));
for (const s of specShaped) {
console.error(chalk.yellow(
` ${s.path}: { field: '${s.field}' } is the spec form-VIEW shape (an object-field reference).`,
));
}
console.error(chalk.yellow(
` A standalone \`type: 'form'\` component uses { name: … } (the form data path).\n` +
` Fix: author the field with \`name\` and its own type/label — or, to reference object fields\n` +
` with the spec shape, use an object-bound form (\`type: 'object-form'\` with objectName +\n` +
` sections), whose section fields are translated by the renderer.`,
));
}

console.error('');
process.exit(1);
}
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/utils/spec-vocabulary-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Spec-vocabulary detector for `objectui validate` (#3090).
*
* `{ field: 'x' }` inside a standalone `type: 'form'` component is the spec
* form-VIEW vocabulary (an object-field reference) — a different layer from
* the runtime vocabulary this CLI validates (`name` = form data path). The
* zod error for it ("name: expected string, received undefined") reads as an
* instruction to bolt a `name` on, which converts the metadata WRONGLY: an
* agent following it loses the object-schema merge the spec shape stands for.
* This detector lets the CLI say what actually happened and what the real fix
* is. The error message is the prompt the next agent executes — it has to
* point at the boundary, not at the symptom.
*
* Scope is deliberately exact: only `fields` arrays hanging DIRECTLY off a
* `type: 'form'` node. Section fields (`type: 'object-form'` → `sections[]`)
* legitimately accept the spec shape — `normalizeSectionField` translates
* them — and a runtime FormField's `field` key legitimately holds the
* resolved metadata OBJECT, so only a STRING `field` marks the spec shape.
*/

export interface SpecVocabularyFinding {
/** JSON-ish path to the entry, e.g. `children[0].fields[2]`. */
path: string;
/** The spec `field` value (the referenced object field). */
field: string;
/** Set when the entry ALSO carries a runtime `name` (mixed vocabularies). */
mixedName?: string;
}

export function findSpecVocabularyFormFields(
node: unknown,
path = '',
): SpecVocabularyFinding[] {
if (Array.isArray(node)) {
return node.flatMap((child, i) => findSpecVocabularyFormFields(child, `${path}[${i}]`));
}
if (node === null || typeof node !== 'object') return [];

const record = node as Record<string, unknown>;
const findings: SpecVocabularyFinding[] = [];

if (record.type === 'form' && Array.isArray(record.fields)) {
record.fields.forEach((f, i) => {
if (f && typeof f === 'object' && typeof (f as any).field === 'string') {
const name = (f as any).name;
findings.push({
path: `${path ? `${path}.` : ''}fields[${i}]`,
field: (f as any).field,
...(typeof name === 'string' ? { mixedName: name } : {}),
});
}
});
}

for (const [key, value] of Object.entries(record)) {
// The entries themselves were just inspected; don't re-walk them as
// generic children or each finding would double-report.
if (record.type === 'form' && key === 'fields') continue;
findings.push(
...findSpecVocabularyFormFields(value, path ? `${path}.${key}` : key),
);
}
return findings;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Spec-vocabulary boundary in the standalone form renderer (#3090).
*
* `{ field: 'x' }` is the spec form-VIEW vocabulary — an object-field
* reference the standalone renderer cannot resolve (no object schema). Writing
* the first test here against the unfixed renderer proved the pre-#3090
* behavior was even worse than the assumed silent drop: the entry slipped past
* the `f?.name` guards into a react-hook-form Controller with
* `name === undefined` and CRASHED the whole form on `name.split('.')`,
* with nothing naming the culprit entry. The renderer now partitions such
* entries out (the rest of the form renders) and surfaces them: an inline
* alert naming the fields, and a console.error whose text doubles as the fix
* instruction.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../../../renderers';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function renderForm(fields: any[]) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />,
);
}

describe('form renderer — spec-vocabulary entries surface loudly (#3090)', () => {
it('renders an inline alert naming the unrenderable fields, and still renders the rest', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});

renderForm([
{ name: 'subject', label: 'Subject', type: 'input' },
{ field: 'owner', required: true }, // spec form-view shape — unrenderable here
]);

// The valid field still renders…
expect(document.body.querySelector('[data-field="subject"]')).toBeTruthy();
// …the spec-shaped one is called out instead of silently dropped.
const alert = screen.getByTestId('form-spec-vocabulary-error');
expect(alert.textContent).toContain('owner');
expect(alert.textContent).toContain('{ name:');

// The console message is the fix instruction an agent will follow.
const said = error.mock.calls.map((c) => String(c[0])).join('\n');
expect(said).toContain("{ field: 'owner' }");
expect(said).toContain('Rename the key to `name`');
});

it('renders no alert and logs nothing for a well-formed standalone form', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderForm([{ name: 'subject', label: 'Subject', type: 'input' }]);

expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull();
const noise = [...error.mock.calls, ...warn.mock.calls]
.map((c) => String(c[0]))
.filter((m) => m.includes('[object-ui]'));
expect(noise).toEqual([]);
});

it('warns (but renders by `name`) when an entry mixes both vocabularies', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderForm([{ name: 'subject', field: 'subject_line', label: 'Subject', type: 'input' }]);

// `name` wins — the field renders, no destructive alert…
expect(document.body.querySelector('[data-field="subject"]')).toBeTruthy();
expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull();
// …but the ignored spec key is called out.
const said = warn.mock.calls.map((c) => String(c[0])).join('\n');
expect(said).toContain('mixes vocabularies');
expect(said).toContain("{ field: 'subject_line' }");
});

it('does NOT flag the runtime metadata-object `field` slot (the #3090 pun)', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

// Object-bound paths stash the resolved field-metadata OBJECT under
// `field` — same key, different layer. Only a STRING `field` marks the
// spec authored shape; the object slot must pass without noise.
renderForm([
{ name: 'amount', type: 'input', field: { type: 'currency', scale: 2 } },
]);

expect(document.body.querySelector('[data-field="amount"]')).toBeTruthy();
expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull();
const noise = [...error.mock.calls, ...warn.mock.calls]
.map((c) => String(c[0]))
.filter((m) => m.includes('[object-ui]'));
expect(noise).toEqual([]);
});
});
Loading
Loading