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
24 changes: 24 additions & 0 deletions .changeset/8071-object-form-member-pins-slice-10.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 43 additions & 4 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2304,6 +2304,18 @@ const MEMBER_PINS: Record<string, MemberPin> = {
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<string, any>`, 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<string, any>`, 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).',
Expand Down Expand Up @@ -2580,11 +2592,8 @@ const MEMBER_PIN_EXEMPTIONS: Record<string, string> = {
// 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,
Expand Down Expand Up @@ -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.
Expand Down
121 changes: 120 additions & 1 deletion packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof vi.spyOn>;
let assign: ReturnType<typeof vi.spyOn>;

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<string, unknown>) {
const ds = makeDS();
const { container, queryByText } = render(
<ObjectForm
schema={{
type: 'object-form', objectName: 'o', mode: 'create',
...behavior,
} as any}
dataSource={ds as any}
/>,
);
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();
});
});
Loading
Loading