From fb70e86cb18f0db72e2050e035682ed3622e5148 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:54:36 +0000 Subject: [PATCH] fix(console): FormPage degrades an unregistered widget to the type renderer The widget leg of resolveFieldWidgetKey took any authored widget and handed it to resolveFormWidgetType, whose answer for a spelling nothing registers is the text widget, so a select field with an unknown widget rendered a free-text input. The spec's FieldSchema.widget declares that an unregistered widget degrades to the type renderer. The leg now asks the same resolver whether the spelling names anything and falls through to the arity and type legs when it does not. Pinned with real widgets: an unknown widget on a select renders the select on both authored places, a registered widget still wins, and a retired spelling still reaches its tombstone. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy --- .../10472-formpage-widget-degrades-to-type.md | 19 +++ apps/console/src/components/FormPage.tsx | 50 ++++++- .../FormPage.widgetDegrade-10472.test.tsx | 135 ++++++++++++++++++ 3 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 .changeset/10472-formpage-widget-degrades-to-type.md create mode 100644 apps/console/src/components/FormPage.widgetDegrade-10472.test.tsx diff --git a/.changeset/10472-formpage-widget-degrades-to-type.md b/.changeset/10472-formpage-widget-degrades-to-type.md new file mode 100644 index 0000000000..d1704610f7 --- /dev/null +++ b/.changeset/10472-formpage-widget-degrades-to-type.md @@ -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. diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 42878cef49..e6e7fa6395 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -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. @@ -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 @@ -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)) { diff --git a/apps/console/src/components/FormPage.widgetDegrade-10472.test.tsx b/apps/console/src/components/FormPage.widgetDegrade-10472.test.tsx new file mode 100644 index 0000000000..e325c98c6b --- /dev/null +++ b/apps/console/src/components/FormPage.widgetDegrade-10472.test.tsx @@ -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:`) 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, 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( + + + } /> + + , + ); +} + +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); + }); +});