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
19 changes: 19 additions & 0 deletions .changeset/10472-formpage-widget-degrades-to-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/console': patch
---

fix(console): a `FormPage` field whose `widget` names nothing registered renders its `type`, not a text box

The spec's `FieldSchema.widget` declares that the override names a registered
field component and "Degrades to the `type` renderer when unregistered".
`FormPage` took an authored `widget` whenever one was present and handed it to
`resolveFormWidgetType`, which answers a spelling nothing registers with the
`text` widget. So a `select` field carrying a `widget` that no widget is
registered under (on the object field or on the form view) rendered a
free-text input, and the user could type a value the picklist never offered.

The `widget` leg now asks the same shared resolver whether the spelling names
anything, and falls through to the arity and `type` legs when it does not.
The select renders its select. A registered widget still wins over the type, a retired spelling
still reaches its tombstone, and an alias the resolver knows keeps resolving
as before. No list of widgets was added: the answer is the resolver's own.
50 changes: 44 additions & 6 deletions apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@
* The page shell above is this file's own; the FIELD layer is not. Each row
* renders the widget the shared resolver names for it — `resolveFormWidgetType`
* + `getLazyFieldWidget`, the ADR-0059 door — including the two axes a
* type-keyed switch cannot see: an authored `widget` (which wins over `type`,
* exactly as `form.tsx` resolves `f?.widget || f?.field?.widget || f?.type`)
* type-keyed switch cannot see: an authored `widget` (which wins over `type`
* when it names a registered widget, and otherwise degrades to the `type`
* renderer as the spec's `FieldSchema.widget` declares — objectui#10472)
* and arity (`select` + `multiple: true` is the multiselect widget). Until
* #10179 a hand-rolled `switch (field.type)` stood here that knew a subset of
* the declared types and rendered every other one as a text box, silently.
Expand Down Expand Up @@ -621,15 +622,50 @@ export function normalizeOptions(opts: unknown): Array<{ value: string; label: s
});
}

/**
* What `resolveFormWidgetType` answers for a spelling nothing registers — its
* documented fallback ("anything unknown falls back to `text`"). Read by
* {@link namesRegisteredWidget} alone.
*/
const UNKNOWN_SPELLING_WIDGET = 'text';

/**
* Does an authored `widget` spelling name a widget the shared registry holds
* (objectui#10472)?
*
* The spec's `FieldSchema.widget` declares that the override "names a
* registered field component" and "Degrades to the `type` renderer when
* unregistered". The `widget` leg of {@link resolveFieldWidgetKey} used to
* return `resolveFormWidgetType(widget)` unconditionally, and that resolver
* answers a spelling nothing registers with its `text` fallback — so a
* `select` field carrying `widget: 'no_such_widget'` rendered a free-text box
* instead of its select.
*
* ⛔ No list of widgets lives here. The question goes to the SAME resolver the
* row renders through (`getLazyFieldWidget` mounts what it names), and is read
* for that one documented answer: a spelling it answers with `text`, when the
* author did not write `text`, named nothing. Everything else it knows keeps
* resolving as before — a registered key, a retired spelling (whose tombstone
* still refuses visibly) and an alias of a registered key (`secret` →
* `password`).
*/
function namesRegisteredWidget(widget: string): boolean {
return resolveFormWidgetType(widget) !== UNKNOWN_SPELLING_WIDGET || widget === UNKNOWN_SPELLING_WIDGET;
}

/**
* The registered widget key a row renders (objectui#10179, objectui#10177).
*
* ⛔ No mapping lives here. Every step is the shared chain's own answer, in the
* order the sibling renderer applies them:
*
* 1. an authored `widget` wins over `type` outright, un-arity'd — `form.tsx`
* resolves `f?.widget || f?.field?.widget || f?.type`, where only the
* `type` leg was produced by `mapFieldTypeToFormType(type, { multiple })`;
* 1. an authored `widget` that names a REGISTERED widget wins over `type`
* outright, un-arity'd — `form.tsx` resolves
* `f?.widget || f?.field?.widget || f?.type`, where only the `type` leg was
* produced by `mapFieldTypeToFormType(type, { multiple })`. One that names
* nothing takes the next two steps instead (objectui#10472), because the
* spec's `FieldSchema.widget` declares that it "Degrades to the `type`
* renderer when unregistered" — see {@link namesRegisteredWidget};
* 2. otherwise ARITY: `mapFieldTypeToFormType` is asked for the multi-value
* answer, and it is taken only where the shared table says the arity MOVES
* the widget (`select` + `multiple` → `multiselect`). Every other
Expand All @@ -646,7 +682,9 @@ function resolveFieldWidgetKey(field: {
widget?: string;
multiple?: boolean;
}): string {
if (field.widget) return resolveFormWidgetType(field.widget);
if (field.widget && namesRegisteredWidget(field.widget)) {
return resolveFormWidgetType(field.widget);
}
if (field.multiple) {
const multi = mapFieldTypeToFormType(field.type, { multiple: true });
if (multi !== mapFieldTypeToFormType(field.type)) {
Expand Down
135 changes: 135 additions & 0 deletions apps/console/src/components/FormPage.widgetDegrade-10472.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10472 — an authored `widget` that names nothing registered degrades
* to the field's `type` renderer, and a registered one still wins over `type`.
*
* ## The contract
*
* The spec's `FieldSchema.widget` describe: "Form widget override — names a
* registered field component (resolved as `field:<widget>`) to render this
* field instead of the `type` default. Degrades to the `type` renderer when
* unregistered."
*
* ## The defect
*
* The `widget` leg of `resolveFieldWidgetKey` returned
* `resolveFormWidgetType(field.widget)` whenever a `widget` was present, and
* that resolver answers a spelling nothing registers with `text`. So a `select`
* field carrying `widget: 'no_such_widget'` rendered a free-text input, where
* the contract says it renders its select.
*
* ## How it is observed
*
* Real widgets, not stubs: the question is what the page puts on screen, and a
* stubbed `getLazyFieldWidget` would answer it by construction. Each control is
* found by ROLE and by the row's visible label, so the same query also proves
* the label reaches the control the row really renders (the label association
* reads the same resolver).
*
* The lit control is the other half of the rule: a REGISTERED widget
* (`radio`) on the same `select` field still beats the type. Without it, a
* change that ignored `widget` altogether would pass the degrade pins.
*/

import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { FormPage } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

const OPTIONS = [
{ value: 'new', label: 'New' },
{ value: 'won', label: 'Won' },
];

/** A spelling no widget is registered under. */
const UNREGISTERED = 'no_such_widget';

/** Render the internal route over one object schema and one field list. */
function renderForm(objectFields: Record<string, unknown>, viewFields: unknown[]) {
const routes: Array<{ match: string; body: unknown }> = [
{
match: '/meta/view/',
body: {
name: 'probe.form',
object: 'probe',
viewKind: 'form',
label: 'Probe',
config: { type: 'simple', sections: [{ label: 'Probe', fields: viewFields }] },
},
},
{ match: '/meta/object/', body: { name: 'probe', label: 'Probe', fields: objectFields } },
];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
const route = routes.find((r) => String(url).includes(r.match));
if (!route) throw new Error(`unstubbed fetch: ${url}`);
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => route.body,
text: async () => JSON.stringify(route.body),
} as unknown as Response;
}),
);
return render(
<MemoryRouter initialEntries={['/forms/probe.form']}>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
</Routes>
</MemoryRouter>,
);
}

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

describe('objectui#10472 — an unregistered `widget` degrades to the `type` renderer', () => {
it('renders the select for a `select` OBJECT field whose `widget` names nothing', async () => {
renderForm({ stage: { type: 'select', label: 'Stage', options: OPTIONS, widget: UNREGISTERED } }, ['stage']);
expect(await screen.findByRole('combobox', { name: 'Stage' })).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: 'Stage' })).toBeNull();
});

it('renders the select for a FORM-VIEW field whose `widget` names nothing', async () => {
renderForm(
{ stage: { type: 'select', label: 'Stage', options: OPTIONS } },
[{ field: 'stage', widget: UNREGISTERED }],
);
expect(await screen.findByRole('combobox', { name: 'Stage' })).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: 'Stage' })).toBeNull();
});

it('CONTROL — a REGISTERED `widget` still wins over the type: `radio` on the same select field', async () => {
renderForm({ stage: { type: 'select', label: 'Stage', options: OPTIONS, widget: 'radio' } }, ['stage']);
expect(await screen.findByRole('radiogroup', { name: 'Stage' })).toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: 'Stage' })).toBeNull();
expect(screen.queryByRole('textbox', { name: 'Stage' })).toBeNull();
});

it('CONTROL — a RETIRED `widget` spelling is not "unregistered": `owner` still reaches its tombstone', async () => {
// The tombstone reports the retirement on the console; that report is not
// what this file judges. Nor is WHICH spelling the tombstone names — only
// that the refusal renders instead of the type's live picker.
vi.spyOn(console, 'error').mockImplementation(() => {});
renderForm(
{
who: { type: 'user', label: 'Who', widget: 'owner' },
live: { type: 'text', label: 'Live' },
},
['who', 'live'],
);
// The live row's widget is lazy. Once it is on screen, every row is.
await screen.findByRole('textbox', { name: 'Live' });
const row = screen.getByText('Who', { selector: 'label' }).parentElement!;
expect(row.querySelector('[data-testid="field-retired-tombstone"]')).not.toBeNull();
expect(row.querySelectorAll('input, button, [role="combobox"]')).toHaveLength(0);
});
});
Loading