From b7681dc6c08ffd1b3e1537e59c7da0a3c90c1e32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 02:12:00 +0000 Subject: [PATCH] test(plugin-form,console): pin the member shape of `object-form`'s `initialValues`, `initialData` and `submitBehavior` objectui#8071 slice 10. Three keys leave `MEMBER_PIN_EXEMPTIONS` and gain real per-block member pins; `MEMBER_PIN_EXEMPTION_CEILING` follows 31 -> 28 in the same change. `initialValues` / `initialData` get a new file: the two keys are chosen between as whole objects (`schema.initialData || schema.initialValues`), so with both authored every `initialValues` member is dropped rather than merged, and an empty `initialData` shadows a populated `initialValues` entirely. The plausible `{ ...initialValues, ...initialData }` spelling reds rows 3 and 4. `submitBehavior` already had a file whose subject is the key and which drives the real renderer, but it left two members of the read unasserted, so it was grown before being registered: `delayMs` is pinned on the number the redirect wait is armed with (with the same declaration minus the member as the lit control), and `next-record` on reaching the confirmation panel rather than `continue`'s no-op. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- .../8071-object-form-member-pins-slice-10.md | 24 ++++ .../registry-inputs-spec-parity.test.ts | 47 ++++++- .../src/ObjectForm.submitBehavior.test.tsx | 121 ++++++++++++++++- .../objectFormInitialMembers-8071.test.tsx | 128 ++++++++++++++++++ 4 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 .changeset/8071-object-form-member-pins-slice-10.md create mode 100644 packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx diff --git a/.changeset/8071-object-form-member-pins-slice-10.md b/.changeset/8071-object-form-member-pins-slice-10.md new file mode 100644 index 0000000000..b8e4619180 --- /dev/null +++ b/.changeset/8071-object-form-member-pins-slice-10.md @@ -0,0 +1,24 @@ +--- +--- + +objectui#8071 slice 10 — three `object-form` keys leave `MEMBER_PIN_EXEMPTIONS` and +become real per-block member pins: `initialValues`, `initialData` and +`submitBehavior`. `MEMBER_PIN_EXEMPTION_CEILING` follows 31 -> 28 in the same +change, as every slice of that card owes. + +The pins constrain what the RENDERER reads, which is objectui#8068's criterion: +`initialData` and `initialValues` are chosen between as WHOLE OBJECTS +(`schema.initialData || schema.initialValues`), so with both authored every +`initialValues` member is dropped rather than merged, and an empty `initialData` +shadows a populated `initialValues` entirely; `submitBehavior`'s `delayMs` is the +number the redirect wait is armed with rather than a constant, and `next-record` +is a declared arm that reaches the confirmation panel and reads no members of its +own. + +Tests and one exemption ledger only. Measured rather than assumed: with +`@object-ui/plugin-form` and `@object-ui/console` both built, no byte of this +change reaches either package's published `files[]` output (markers +`objectFormInitialMembers`, `MEMBER_PIN_EXEMPTION_CEILING`, `AWAITING_A_PIN`: 0 +hits) while the positive controls on the same corpus are lit (`submitBehavior`: 7 +files in `packages/plugin-form/dist`; `object-form`: 12 files in +`apps/console/dist`). No package is released by this change. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 6c0da73302..87ba9526d2 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2304,6 +2304,18 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-form/src/__tests__/objectFormFieldsMembers-8071.test.tsx', pins: 'Members are BARE FIELD NAMES resolved against the object schema — authored order preserved (against a control with no `fields`, whose order differs), a name the object does not declare dropped rather than rendered as an untyped stub, and the `{ name }` object spelling recorded as the read site\'s tolerance rather than a second contract. The sharp row is the one no other file can make: `object-form` carries a SECOND surface spelled `fields` (`sections[].fields`), whose canonical member is the spec `FormFieldSchema` object keyed on `field` — and that exact entry as a member of the TOP-LEVEL key resolves to no name and is dropped from render (no throw) — SILENTLY until objectui#8738 route 1 added a named `console.warn` for exactly this case (also pinned in the same file), with the same entry inside a section rendering as the live control so the negative cannot come from an object that never renders that field. Asserted through the real `ObjectForm`, because the sink is its own `fieldsToShow` loop rather than the `normalizeSectionField` chokepoint the sibling key uses. The spec row is `z.array(z.unknown())` and the registration declares no `of`, so the read site is the whole member contract (objectui#8071).', }, + 'object-form.initialData': { + file: 'packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx', + pins: 'Members are FIELD NAMES and each member value is that control\'s OPENING value, and — the row a plausible improvement breaks — the two keys are chosen between as WHOLE OBJECTS (`schema.initialData || schema.initialValues`), never merged per member: with both authored, every `initialValues` member is DROPPED including the ones `initialData` says nothing about, so an author who prefills through `initialValues` and adds a one-member `initialData` loses the rest with no warning and no empty state. Nothing declared distinguishes that from the `{ ...initialValues, ...initialData }` spelling the prose ("alternate spelling … read FIRST") reads like. The sharp edge is pinned too: `||` tests the OBJECT\'s truthiness, so an EMPTY `initialData` shadows a populated `initialValues` completely — recorded as the renderer\'s behaviour and handed back as a finding rather than fixed, because fixing it is a renderer change and this card writes pins only. A no-keys row renders the SAME controls empty as the non-vacuity control. Both keys are registered `type: \'object\'` and typed `Record`, so every object parses on both declared sides and the read site is the whole member contract (objectui#8071).', + }, + 'object-form.initialValues': { + file: 'packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx', + pins: 'Members are FIELD NAMES and each member value is that control\'s OPENING value, and — the row a plausible improvement breaks — the two keys are chosen between as WHOLE OBJECTS (`schema.initialData || schema.initialValues`), never merged per member: with both authored, every `initialValues` member is DROPPED including the ones `initialData` says nothing about, so an author who prefills through `initialValues` and adds a one-member `initialData` loses the rest with no warning and no empty state. Nothing declared distinguishes that from the `{ ...initialValues, ...initialData }` spelling the prose ("alternate spelling … read FIRST") reads like. The sharp edge is pinned too: `||` tests the OBJECT\'s truthiness, so an EMPTY `initialData` shadows a populated `initialValues` completely — recorded as the renderer\'s behaviour and handed back as a finding rather than fixed, because fixing it is a renderer change and this card writes pins only. A no-keys row renders the SAME controls empty as the non-vacuity control. Both keys are registered `type: \'object\'` and typed `Record`, so every object parses on both declared sides and the read site is the whole member contract (objectui#8071).', + }, + 'object-form.submitBehavior': { + file: 'packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx', + pins: 'Which MEMBERS each declared `kind` arm reads, driven through the real `ObjectForm` submit path: `url` and `delayMs` on `redirect`, `title` and `message` on `thank-you` (both guarded on the discriminant, so no other arm reaches them), and NOTHING on `continue` or `next-record`. The two rows objectui#8071 added are the ones the key could silently lose: `delayMs` is pinned on the number the wait is armed with — the arm spells it `behavior.delayMs ?? 0`, and collapsing it to a bare `0` removes the readable pause while every destination assertion in this file and in `ObjectForm.submitRedirect.test.tsx` stays green — with the SAME declaration minus the member as the lit control, so an empty `armed` can never be a spy that sees nothing; and `next-record`, the fourth declared arm, is pinned reaching the confirmation panel (form unmounted, `successMessage` toasted) rather than its members, because it has none — an edit dropping it into `continue`\'s no-op would leave a spec-legal authored value doing something else with nothing red. The registration declares `type: \'object\'` with the arms in prose and `SubmitBehavior` types them as a discriminated union, so what PARSES is settled and what is READ off each arm was not (objectui#8071).', + }, 'object-grid.bulkActionDefs': { file: 'packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx', pins: 'Members are FULL `BulkActionDef` OBJECTS, left as authored and never resolved against `objectDef.actions` — proven with a def naming an action the object does NOT declare, which still renders carrying its authored label. The negative is the pair\'s sharper half and is NOT silent: a bare-name member (the sibling key\'s vocabulary, which nothing on either declared side refuses) reaches `BulkActionBar` with no `name` and `formatActionLabel(undefined)` THROWS during render, taking the whole selection bar down — pinned as current behaviour, filed as objectui#8730, and it reds when that lands. The spec row is `z.array(z.unknown())`, so the read site is the whole member contract (objectui#8071).', @@ -2580,11 +2592,8 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // object-form 'object-form.customFields': AWAITING_A_PIN, 'object-form.dataSource': AWAITING_A_PIN, - 'object-form.initialData': AWAITING_A_PIN, - 'object-form.initialValues': AWAITING_A_PIN, 'object-form.mobile': AWAITING_A_PIN, 'object-form.sections': AWAITING_A_PIN, - 'object-form.submitBehavior': AWAITING_A_PIN, // object-grid 'object-grid.aggregations': AWAITING_A_PIN, @@ -3046,11 +3055,41 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS = [ * reading was not re-measured here either — slice 7's measurement still stands * as the last one taken. * + * ## 31 -> 28, and the first bite out of `object-form` + * + * objectui#8071's tenth slice takes three `object-form` keys — `initialValues`, + * `initialData` and `submitBehavior` — so the ceiling follows to 28 in the same + * commit. The block is NOT closed: `customFields`, `dataSource`, `mobile` and + * `sections` remain, which is the two-bite shape slice 9 said every remaining + * block now has. + * + * ⚠️ The dispatch that opened this slice named the three keys objectui#8068's + * card body flags as "near misses" (`object-form.fields`, + * `object-grid.exportOptions`, `object-grid.bulkActions`/`bulkActionDefs`). + * ALL FOUR have been pinned since slice 1 (PR objectui#8737). The card body's + * enumerated list has been stale since then and the comment at + * issuecomment-5599611864 already says so: the authoritative list is THIS + * constant's table, never the card's prose. Recorded here because the stale + * list has now mis-aimed a dispatch a second time. + * + * The two pins are new-file and grown-file respectively, and the distinction is + * worth the line because slice 1's finding was about exactly this. The + * `initialValues`/`initialData` pair had NO file constraining the choice between + * them, so it is a new one. `submitBehavior` already had + * `ObjectForm.submitBehavior.test.tsx`, whose whole subject is the key and which + * drives the real renderer — a genuine near miss, unlike slice 1's two — but it + * left two members of the read unasserted (`delayMs`, and the `next-record` + * arm), so it was GROWN before it was registered rather than credited as found. + * + * ⚠️ Unchanged by this slice: `NEWLY_JUDGED_UNPINNED_MEMBERS` (no block it names + * was touched) and `record:related_list.actions`, whose `NO_READ_SITE_TO_PIN` + * reading was not re-measured here either — slice 7's measurement still stands. + * * ⇒ The rule for every future slice of objectui#8071: delete the entry, register * the pin, and set this constant to the new count. Not to the new count plus * room. */ -const MEMBER_PIN_EXEMPTION_CEILING = 31; +const MEMBER_PIN_EXEMPTION_CEILING = 28; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. diff --git a/packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx b/packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx index f4703023e8..24e36f0465 100644 --- a/packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx +++ b/packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx @@ -4,8 +4,38 @@ * * Declarative `submitBehavior` handling for metadata-only (non-wizard) forms — * mirrors WizardForm.successBehavior.test.tsx for the flat ObjectForm path. + * + * ## The `object-form.submitBehavior` MEMBER pin (objectui#8071) + * + * This file is objectui#8071's registered member pin for `object-form` · + * `submitBehavior`, and the rows below are what makes it one rather than a + * restatement of the registration. The key is declared `{ type: 'object' }` + * with the four `kind` arms in its description and `SubmitBehavior` types them + * as a discriminated union — so what parses is settled, and what the RENDERER + * READS off each arm is not. `ObjectForm.tsx`'s `switch (behavior.kind)` is the + * read site, and every member it touches is touched ONCE there: + * `url` and `delayMs ?? 0` on `redirect`, `title` and `message` on `thank-you` + * (both guarded on `kind === 'thank-you'`, so no other arm can reach them), and + * nothing at all on `continue` or `next-record`. + * + * Two arms of that read had no assertion anywhere until objectui#8071 added the + * rows at the bottom of this file, and each is a way the key silently stops + * working: + * + * - **`delayMs` is a MEMBER, not a constant.** The arm spells it + * `behavior.delayMs ?? 0`. Collapsing that to `0` — the shape the + * declaration cannot distinguish, since an unset delay already means "go + * now" — takes away the readable pause without changing any destination, + * so every other row in this file and in `ObjectForm.submitRedirect.test.tsx` + * stays green. It is pinned on the number handed to `setTimeout`, with the + * same schema minus the member as the control. + * - **`next-record` is a declared arm that reads NO members.** It shares the + * confirmation-panel body with `thank-you` by falling through to it, so an + * edit that gave it its own arm, or dropped it into `continue`'s no-op, + * would leave a spec-legal authored value doing something else entirely + * with nothing red. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, fireEvent } from '@testing-library/react'; import React from 'react'; @@ -122,4 +152,93 @@ describe('ObjectForm — submitBehavior', () => { expect(el.value).toBe(''); }); }); + +}); + +/** + * The two arms of the `submitBehavior` member read that nothing else asserts — + * objectui#8071's member pin for `object-form.submitBehavior`. Kept in their own + * describe because the `delayMs` row installs a pass-through `setTimeout` spy, + * which the rows above neither need nor should run under. + */ +describe('ObjectForm — submitBehavior member shape (objectui#8071)', () => { + /** + * Distinctive enough that the pass-through spy can pick this form's own timer + * out of the ones React and the testing library schedule — the same device + * `submitRedirect.timerLifetime.test.tsx` uses, and for the same reason: the + * submit path is a chain of awaited promises, so faking the clock around it + * measures the resolution race rather than the declared delay. + */ + const DELAY_MS = 241; + + const realSetTimeout = globalThis.setTimeout; + let armed: number[]; + let setTimeoutSpy: ReturnType; + let assign: ReturnType; + + beforeEach(() => { + armed = []; + toastSuccess.mockClear(); + assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}); + setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((( + cb: any, + ms?: number, + ...rest: any[] + ) => { + if (ms === DELAY_MS) armed.push(ms); + return (realSetTimeout as any)(cb, ms, ...rest); + }) as any); + }); + + afterEach(() => { + setTimeoutSpy.mockRestore(); + assign.mockRestore(); + }); + + /** Submit a create form carrying `behavior`, and hand back its container. */ + async function submitWith(behavior: Record) { + const ds = makeDS(); + const { container, queryByText } = render( + , + ); + fireEvent.change(await waitInput(container, 'name'), { target: { value: 'Alpha' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1)); + return { container, queryByText, ds }; + } + + it('redirect: the declared `delayMs` is the number the wait is armed with, and the trip still happens', async () => { + await submitWith({ submitBehavior: { kind: 'redirect', url: '/apps/x/done', delayMs: DELAY_MS } }); + await waitFor(() => expect(armed).toEqual([DELAY_MS])); + await waitFor(() => expect(assign).toHaveBeenCalledWith('/apps/x/done')); + }); + + it('redirect: control — the SAME declaration without `delayMs` arms no such wait, and still travels', async () => { + // The lit half of the row above. Without it, `armed` staying empty would be + // indistinguishable from a spy that never sees this renderer's timers at + // all, and `delayMs ?? 0` collapsed to a bare `0` would read as pinned. + await submitWith({ submitBehavior: { kind: 'redirect', url: '/apps/x/done' } }); + await waitFor(() => expect(assign).toHaveBeenCalledWith('/apps/x/done')); + expect(armed).toEqual([]); + }); + + it('next-record: the fourth declared arm reaches the confirmation panel and reads no members of its own', async () => { + const { container, queryByText } = await submitWith({ + submitBehavior: { kind: 'next-record' }, + successMessage: 'Saved — next one', + }); + // The arm carries no `message`/`title` of its own, so the confirmation text + // is the form's `successMessage`. A `next-record` routed into `continue`'s + // no-op would leave the filled form mounted and toast nothing. + await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Saved — next one')); + await waitFor(() => expect(queryByText('Saved — next one')).toBeTruthy()); + expect(container.querySelector('form')).toBeNull(); + expect(assign).not.toHaveBeenCalled(); + }); }); diff --git a/packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx b/packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx new file mode 100644 index 0000000000..b31dfb8c46 --- /dev/null +++ b/packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx @@ -0,0 +1,128 @@ +/** + * 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. + */ + +/** + * `object-form.initialValues` and `object-form.initialData` — the MEMBER shape + * this renderer reads (objectui#8071, criterion from objectui#8068). + * + * Both are registered `{ type: 'object' }` with prose only — "Values to prefill + * in `create` mode" and "Alternate spelling of `initialValues` that the + * drawer/modal presentations read FIRST". `ObjectFormSchema` types them + * `Record`, so EVERY object parses on both sides and nothing + * declared says what a member is or what happens when both keys are authored. + * That is the population objectui#8068 refuses to leave unwatched. + * + * WHAT THE RENDERER READS, measured at `ObjectForm.tsx` — the two sites that + * seed `initialData` state, the inline-fields effect and the create branch of + * `fetchInitialData`, both spelled: + * + * setInitialData(schema.initialData || schema.initialValues || {}); + * + * 1. a MEMBER is a FIELD NAME, and its value is that control's opening value; + * 2. the two keys are chosen between as WHOLE OBJECTS — `||`, never a merge. + * + * ⛔ Row 3 is the one that makes this a pin rather than a restatement, and it is + * the row a plausible "improvement" breaks. Nothing declared distinguishes + * `schema.initialData || schema.initialValues` from + * `{ ...schema.initialValues, ...schema.initialData }`; the second reads like + * the friendlier spelling of "alternate spelling … read FIRST" and is what a + * per-member precedence would mean. It is NOT what the renderer does: with both + * authored, every member of `initialValues` is dropped, including the ones + * `initialData` says nothing about. An author who prefills three fields through + * `initialValues` and adds a one-member `initialData` loses the other two, with + * no warning and no empty state — the form simply opens blank where it used to + * open seeded. + * + * Row 4 is the same read at its sharp edge: `||` tests the OBJECT's + * truthiness, and `{}` is truthy, so an EMPTY `initialData` shadows a populated + * `initialValues` completely. Pinned as the renderer's behaviour, ⛔ not + * endorsed as the right one — see the report on objectui#8071 for the finding + * handed back rather than fixed here, because changing it is a renderer change + * and this card writes pins only. + * + * Row 5 is the non-vacuity control: with neither key authored the same controls + * render EMPTY, so rows 1-4 cannot be passing on a form that ignores both keys. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); + +const OBJECT_SCHEMA = { + name: 'invoice', + fields: { + customer: { type: 'text', label: 'Customer' }, + note: { type: 'text', label: 'Note' }, + }, +}; + +const makeDataSource = () => + ({ + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }) as any; + +/** Render a create-mode `object-form` and read back both controls' values. */ +async function openingValues( + schema: Record, +): Promise<{ customer: string | null; note: string | null }> { + const { container } = render( + , + ); + await waitFor(() => { + if (!container.querySelector('input[name="customer"]')) throw new Error('form not ready'); + }); + const read = (name: string) => + (container.querySelector(`input[name="${name}"]`) as HTMLInputElement | null)?.value ?? null; + return { customer: read('customer'), note: read('note') }; +} + +describe('`object-form` — the member shape of `initialValues` / `initialData`', () => { + it('1. an `initialValues` member is a FIELD NAME, and its value opens that control', async () => { + expect(await openingValues({ initialValues: { customer: 'Alpha', note: 'from initialValues' } })).toEqual({ + customer: 'Alpha', + note: 'from initialValues', + }); + }); + + it('2. `initialData` carries the same member vocabulary, and a field it omits opens empty', async () => { + expect(await openingValues({ initialData: { customer: 'Beta' } })).toEqual({ + customer: 'Beta', + note: '', + }); + }); + + it('3. with BOTH authored the choice is whole-object — every `initialValues` member is dropped, ⛔ not merged', async () => { + expect( + await openingValues({ + initialData: { customer: 'Beta' }, + initialValues: { customer: 'Alpha', note: 'from initialValues' }, + }), + 'a per-member merge would leave `note` seeded; the renderer picks one object and discards the other', + ).toEqual({ customer: 'Beta', note: '' }); + }); + + it('4. an EMPTY `initialData` still shadows a populated `initialValues` — `||` tests the object, not its size', async () => { + expect( + await openingValues({ initialData: {}, initialValues: { customer: 'Alpha', note: 'from initialValues' } }), + ).toEqual({ customer: '', note: '' }); + }); + + it('5. control: with neither key authored the same controls open EMPTY', async () => { + expect(await openingValues({})).toEqual({ customer: '', note: '' }); + }); +});