Skip to content

Commit d5cb261

Browse files
fix(plugin-form): draw customFields members inside explicit sections on the drawer, modal, tabbed, wizard and split arms (#10451)
Fixes #10254 Clause-②: yes — measured on the built declarations at `49dc4dd3c`. `TabbedFormSchema`, `WizardFormSchema` and `SplitFormSchema` (re-exported from `dist/index.d.ts`) each gain an optional `customFields?: FormField[]` member, the one `DrawerFormSchema` and `ModalFormSchema` already declare. Nothing else on the package face moves: `findCustomFieldMember` and `SectionFieldsContext` are absent from `dist/index.d.ts`. The claim line was amended to `yes` in `5825785976`, and the changeset therefore declares `minor`. ## What changes With explicit `sections` plus `customFields`, five of the six `object-form` arms built every section member from the object schema alone, so the authored definition was dropped: `drawer`, `modal`, `tabbed`, `wizard` and `split`. The default arm resolves section names against its merged pool and drew the member. Now all six agree. - `customFieldsMerge.ts`: the override direction's lookup is extracted as `findCustomFieldMember(customFields, name)`, and `mergeCustomFields` (the default arm's merge) now resolves every name through it. Same precedence, one spelling: first member naming the field wins, an empty `customFields` supplies nothing. `withCustomFieldMembers` is retired. - `sectionFields.ts`: `SectionFieldsContext` gains an optional `customFields`. In `normalizeSectionField`, a bare name that a member names is drawn as that member (shape 3). For a spec `{ field }` entry, the member (a copy) is the BASE the entry's overrides are applied to, in place of `fromObjectSchema`. Over a member base, the widget is re-decided only from the entry's own `type`. - `DrawerForm.tsx` / `ModalForm.tsx`: the `buildSectionFields` callback passes `customFields: schema.customFields`. Derived (`fieldGroups`) sections go through the same callback, which retires the `withCustomFieldMembers` pre-map. Derived output is unchanged, and #10073's FIELD GROUPS rows pin it. - `TabbedForm.tsx` / `WizardForm.tsx` / `SplitForm.tsx` (patch round 1): each `buildSectionFields` callback passes `customFields: schema.customFields`. That callback is the only route by which each arm builds section fields. The wizard calls it for the rendered step and again in its final-submit required gate (`missingRequiredByStep`), so both see the member. The mobile auto-stepper hands `WizardForm` runtime `FormField` objects already merged, which `normalizeSectionField` draws verbatim, so nothing is applied twice. The three schema interfaces declare `customFields?: FormField[]`, as the drawer and modal schemas do, because `schema.customFields` does not compile without it. - `submitTarget.ts` (comment-only): three passages said these three arms neither declare nor render `customFields`, and gave that as the reason not to widen their interfaces. This change makes both false, so the passages are corrected. No code in the file moves. ## Surface Claim `5825076379`, amended by seat comment `5825490852`, adds `TabbedForm.tsx`, `WizardForm.tsx` and `SplitForm.tsx` at their `buildSectionFields` context. Two things beyond that one line per arm, both forced by the wiring: - the three optional interface members, since the wiring does not compile without them; - the `submitTarget.ts` comment corrections, since the change falsifies those sentences. `submitTarget.ts` (comment-only) joined the claim in `5825785976`. ## Which semantics this unifies (triage note 3) **Unified:** the base definition a section member starts from. A `customFields` member naming the field supplies the whole definition, in place of the one generated from object metadata. It uses the same lookup and the same precedence as the default arm's merge. A field no member names is generated exactly as before. **Deliberately not unified (each arm keeps what it had):** - Section-entry override rules. The default arm copies only `visibleOn` / `colSpan` / `span` from a spec entry. The other five apply `normalizeSectionField`'s full set on top of the base. - Pool membership. The default arm intersects sections with top-level `fields` and drops a name nothing declares; the other five do not. - Field order inside a section. ⛔ No default-arm behaviour change: `ObjectForm.tsx` is untouched. The default arm's section order and its dropped section-entry overrides are separate findings, which the seat files at ACCEPT. ## Mechanism assumptions, measured - **Probe on `main` (`0d39dc6e5`), card schema:** simple `INLINE NOTE`; drawer, modal, tabbed, wizard and split `Note`. After this PR all six read `INLINE NOTE`. - **A1 (precedence):** one lookup, `findCustomFieldMember`, is shared by `mergeCustomFields` and `normalizeSectionField`. No second merge. - **A2 (member no section lists):** the default arm does not draw it under explicit sections, and the other five already matched. Pinned as UNNAMED MEMBER on all six arms. - **A3 (override rules):** kept. A scratch probe through the real `ObjectForm`, re-run on all six arms at `2c6e298ef` (not a pin), reads: - Member case. The entry `{ field: 'note', label: 'SECTION LABEL' }` does not restate `required`, and the member (labelled `INLINE NOTE`) declares `required: true`. The default arm renders `INLINE NOTE*`; the other five render `SECTION LABEL*`, whose marker comes from the member. - No-member case. The entry `{ field: 'note', label: 'SECTION LABEL', required: true }` does restate `required`. The default arm renders `Note` with no required marker; the other five render `SECTION LABEL*`. The label split, and the no-member case's dropped `required`, are the pre-existing override split. No pin authors an override the two rule sets answer differently. - **Round-1 assumption, falsified:** "each fix is one line". The three schema interfaces did not declare `customFields`, so `schema.customFields` did not type-check. See Surface. - **A4 (pins):** `__tests__/sectionsCustomFields-10254.test.tsx`, renamed from `drawerModalSectionsCustomFields-10254.test.tsx` now that it covers every arm. It mounts the real `ObjectForm` per `formType`, with five rows on each of the six arms: `simple` is the default-arm control, then `drawer`, `modal`, `tabbed`, `wizard` and `split`. The rows are: - LABEL; - REQUIRED: `aria-required`, then an empty submit refused with `INLINE NOTE is required` and no `create`, then a filled submit reaching `create`; - SPEC ENTRY: a `{ field, colSpan }` entry keeps the member's label, required marker and `textarea` type; - UNDECLARED NAME; - UNNAMED MEMBER. Every schema has ONE section, so the wizard's rendered step is the section under test, and the `drawnFields()` equalities pin that only that step's fields are drawn. ## Changeset `.changeset/10254-sections-customfields-drawer-modal.md` declares `'@object-ui/plugin-form': minor`: a public-surface widening (`Clause-②: yes`) takes at least `minor`, and ⛔ never `major`. **Patch round 2 (`2c6e298ef`):** no source change. The seat's contract review at `49dc4dd3c` passed every code item, and this round moves only the changeset level from `patch` to `minor`. That is frontmatter line 2 only: lines 1 and 3 and the body bytes are unchanged (SHA-256 compared before and after), and the body names no level. Every test below ran at `49dc4dd3c`, which differs from `2c6e298ef` only in that one line. The gates were re-run at `2c6e298ef`. ## Tests (at `49dc4dd3c`) Every run went through `os-verify-lock.sh`. Verdict lines are quoted. - Pins: `Tests 30 passed (30)`. - Reverse proof, wizard leg (round 1): `ablation-replace.mjs` replaced the wizard's wiring line with a marker. On disk the wiring count went 1 → 0 and the marker count to 1; the blob went `b9dfeef7dab8` → `6588dafdd06f`. Result: `Tests 4 failed | 26 passed (30)`. The four are exactly the wizard's LABEL, REQUIRED, SPEC ENTRY and UNDECLARED NAME rows. Restore: blob equals HEAD (`b9dfeef7dab8`) and `git diff HEAD` is empty. - Reverse proof, three-arm leg (round 1): `TabbedForm.tsx`, `WizardForm.tsx` and `SplitForm.tsx` were checked out at `bbe2b1e4b` (wiring count 0 in each), trap-restored. Result: `Tests 12 failed | 18 passed (30)`. The twelve are tabbed, wizard and split × LABEL, REQUIRED, SPEC ENTRY and UNDECLARED NAME. Restore: blobs equal HEAD and `git diff HEAD` is 0 bytes. - Round-0 legs, run at `bbe2b1e4b` under the file's old name: - drawer wiring removed: `Tests 5 failed | 25 passed (30)`, the drawer's four member rows plus #10073's drawer FIELD GROUPS row; - member-base type guard reverted: `Tests 2 failed | 13 passed (15)`, the drawer and modal SPEC ENTRY rows (`TEXTAREA` vs `INPUT`); - the four round-0 sources at `BASE`: `Tests 8 failed | 7 passed (15)`. - `pnpm exec vitest run --maxWorkers=2 packages/plugin-form/`: `Test Files 117 passed (117)`, `Tests 1196 passed | 1 skipped (1197)`, `VERDICT command-exit 0`. - The 38 suites outside `plugin-form` that name `DrawerForm` / `ModalForm` / `TabbedForm` / `WizardForm` / `SplitForm` / `buildSectionFields` / `normalizeSectionField` / `customFields` / `sectionFields` / `submitTarget` / `hasInlineFieldSource`: `Test Files 38 passed (38)`, `Tests 1733 passed (1733)`, `VERDICT command-exit 0`. - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-form^...' build`, then `pnpm --filter @object-ui/plugin-form type-check` (`tsc --noEmit` plus `tsconfig.test.json`): both `VERDICT command-exit 0`. `--listFiles` counts the renamed test once. ## Gates (at `2c6e298ef`, exit codes recorded) - `check-changeset-presence`: exit 0, `9 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`. - `check-changeset-no-major`: exit 0, `No changeset declares a major bump`. - `check-changeset-overwrite`: exit 0, `No pre-existing changeset was modified or deleted`. - `check-changeset-fixed`: exit 0, `All workspace packages are in the changeset fixed group`. - `check:new-line-citations` (at `49dc4dd3c`; since then only the changeset's level line changed): `0 new citation(s)`. - `check:control-bytes`: exit 0, `OK`. - `check:changeset-claims`: exit 0. It flags five pending changesets that name a touched file: #6237's wizard-step split (`WizardForm.tsx`), #8738, #9884 and #5594 (`sectionFields.ts`), and the simple-form submit-handler one (`submitTarget.ts`). Each paragraph was read and is still true. By symbol, 14 other pending changesets name `TabbedForm` / `WizardForm` / `SplitForm`, and none speaks to `customFields` under `sections` on those arms, or is falsified by this change. The changeset body names all five arms and was not changed in round 2. - At `49dc4dd3c`: ESLint on every touched file gave 0 errors (the config is not type-aware; per-file warnings are unchanged or fewer), and the governed-surface test answered `NOT GOVERNED`. ## Acceptance notes - **Default-arm section order and dropped section-entry overrides:** these are separate defects on the default arm, left unchanged here. The seat files them at ACCEPT. - Two registration descriptions in `index.tsx` quote `ObjectForm.tsx:361` and `ModalForm.tsx:638`. At base `0d39dc6e5`, neither line holds what its description says. There, the quoted `ObjectForm.tsx` line is a comment, and `modalCloseButton` is read further down the file. The quoted `ModalForm.tsx` line is not the `contentLayout` check. This PR does not change `ObjectForm.tsx` or `index.tsx`; the quotes are left as `index.tsx` carries them. --- _Generated by [Claude Code](https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 557abc2 commit d5cb261

10 files changed

Lines changed: 334 additions & 63 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@object-ui/plugin-form': minor
3+
---
4+
5+
fix(plugin-form): `customFields` members render inside explicit `sections` on the drawer, modal, tabbed, wizard and split arms
6+
7+
The registered description of `object-form.customFields` is one sentence for
8+
every `formType`: "Field definitions merged over the set generated from object
9+
metadata." With explicit `sections`, an `object-form` with `formType: 'drawer'`,
10+
`'modal'`, `'tabbed'`, `'wizard'` or `'split'` (and a `DrawerForm`, `ModalForm`,
11+
`TabbedForm`, `WizardForm` or `SplitForm` mounted directly) built every field a
12+
section names from the object schema alone, so a member naming that field was
13+
dropped: its label, its `required` and the rest of its definition never reached
14+
the form. The default arm already drew the member.
15+
16+
Those five arms now take a section field's definition from the member naming
17+
it, through the same lookup the default arm's merge uses; a field no member
18+
names is built from the object schema as before. This holds for a bare field
19+
name and for a spec `{ field }` entry, whose own overrides still apply on top
20+
of the member. Over a member, the widget changes only when the entry restates
21+
`type`. A section naming a field that only a member supplies now renders that
22+
member, where it used to render a plain text input labelled with the field
23+
name. A member that no section lists is still not drawn when `sections` are
24+
given, as on the default arm. `TabbedFormSchema`, `WizardFormSchema` and
25+
`SplitFormSchema` now declare `customFields`, as `DrawerFormSchema` and
26+
`ModalFormSchema` already did.
27+
28+
Unchanged: the rules for what a section entry may override, which still differ
29+
between the default arm and the other five.

‎packages/plugin-form/src/DrawerForm.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import { createSafeTranslation } from '@object-ui/i18n';
4040
import { MasterDetailForm } from './MasterDetailForm';
4141
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
4242
import { buildFlatFields } from './flatFields';
43-
import { withCustomFieldMembers } from './customFieldsMerge';
4443
import { useUploadGate, UploadGateProvider, UploadInFlightNotice } from './uploadGate';
4544
import {
4645
applyAutoColSpan,
@@ -344,8 +343,13 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
344343
// `defaultValue` excuses a field from `required` (#4069).
345344
recordId: schema.recordId,
346345
fieldLabel,
346+
// A member naming a section's field is that field's definition, as it
347+
// is in ObjectForm's merged pool — for explicit and derived sections
348+
// alike (objectui#10254; the explicit push used to regenerate every
349+
// named field from the object schema and drop the member).
350+
customFields: schema.customFields,
347351
}),
348-
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
352+
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
349353
);
350354

351355
// Build fields from flat field list (when no sections provided)
@@ -424,12 +428,10 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
424428
const columns = (schema.columns && schema.columns > 0
425429
? Math.min(Math.floor(schema.columns), 4)
426430
: inferColumns(fs.length)) as 1 | 2 | 3 | 4;
427-
return sections.map((s) => ({
428-
...s,
429-
columns,
430-
fields: withCustomFieldMembers(s.fields ?? [], schema.customFields),
431-
})) as DrawerFormSectionConfig[];
432-
}, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]);
431+
// A member's definition reaches a group's body through `buildSectionFields`,
432+
// the route an explicit section's takes (objectui#10254).
433+
return sections.map((s) => ({ ...s, columns })) as DrawerFormSectionConfig[];
434+
}, [schema.sections, schema.columns, schema.mode, formFields, objectSchema]);
433435

434436
// Handle form submission
435437
const handleSubmit = useCallback(async (data: Record<string, any>) => {

‎packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/re
4040
import { createSafeTranslation } from '@object-ui/i18n';
4141
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
4242
import { buildFlatFields } from './flatFields';
43-
import { withCustomFieldMembers } from './customFieldsMerge';
4443
import {
4544
applyAutoColSpan,
4645
applyAutoLayout,
@@ -296,12 +295,10 @@ export const ModalForm: React.FC<ModalFormProps> = ({
296295
const columns = (schema.columns && schema.columns > 0
297296
? Math.min(Math.floor(schema.columns), 4)
298297
: inferColumns(fs.length)) as 1 | 2 | 3 | 4;
299-
return sections.map((s) => ({
300-
...s,
301-
columns,
302-
fields: withCustomFieldMembers(s.fields ?? [], schema.customFields),
303-
})) as ModalFormSectionConfig[];
304-
}, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]);
298+
// A member's definition reaches a group's body through `buildSectionFields`,
299+
// the route an explicit section's takes (objectui#10254).
300+
return sections.map((s) => ({ ...s, columns })) as ModalFormSectionConfig[];
301+
}, [schema.sections, schema.columns, schema.mode, formFields, objectSchema]);
305302

306303
const effectiveSections = schema.sections?.length ? schema.sections : (derivedSections ?? undefined);
307304

@@ -424,8 +421,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
424421
// `defaultValue` excuses a field from `required` (#4069).
425422
recordId: schema.recordId,
426423
fieldLabel,
424+
// A member naming a section's field is that field's definition, as it
425+
// is in ObjectForm's merged pool — for explicit and derived sections
426+
// alike (objectui#10254; the explicit path used to regenerate every
427+
// named field from the object schema and drop the member).
428+
customFields: schema.customFields,
427429
}),
428-
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
430+
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
429431
);
430432

431433
// Build fields from flat field list (when no sections)

‎packages/plugin-form/src/SplitForm.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ export interface SplitFormSchema {
7474
/** Record ID (for edit/view modes). A string, per the one record-id rule on `DataSource` (objectui#9511) — `ObjectForm` builds this schema from the authorable `ObjectFormSchema.recordId`, which is a string, and `findOne` takes a string. */
7575
recordId?: string;
7676
sections: SplitFormSectionConfig[];
77+
/**
78+
* Inline field definitions (`object-form.customFields`). A member naming a
79+
* field a section lists is that field's definition, as on every other
80+
* `formType` (objectui#10254); `ObjectForm` hands the key over in its
81+
* `{...schema}` spread.
82+
*/
83+
customFields?: FormField[];
7784

7885
/**
7986
* Split direction.
@@ -240,8 +247,11 @@ export const SplitForm: React.FC<SplitFormProps> = ({
240247
// `defaultValue` excuses a field from `required` (#4069).
241248
recordId: schema.recordId,
242249
fieldLabel,
250+
// A member naming a section's field is that field's definition, as it
251+
// is on every other arm (objectui#10254).
252+
customFields: schema.customFields,
243253
}),
244-
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
254+
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
245255
);
246256

247257
// Handle form submission

‎packages/plugin-form/src/TabbedForm.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ export interface TabbedFormSchema {
126126
* Tab sections configuration
127127
*/
128128
sections: FormSectionConfig[];
129+
130+
/**
131+
* Inline field definitions (`object-form.customFields`). A member naming a
132+
* field a section lists is that field's definition, as on every other
133+
* `formType` (objectui#10254); `ObjectForm` hands the key over in its
134+
* `{...schema}` spread.
135+
*/
136+
customFields?: FormField[];
129137

130138
/**
131139
* Grid width for the whole form (1–4). Aligns with @objectstack/spec
@@ -343,8 +351,11 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
343351
// `defaultValue` excuses a field from `required` (#4069).
344352
recordId: schema.recordId,
345353
fieldLabel,
354+
// A member naming a section's field is that field's definition, as it
355+
// is on every other arm (objectui#10254).
356+
customFields: schema.customFields,
346357
}),
347-
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
358+
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
348359
);
349360

350361
// Handle form submission

‎packages/plugin-form/src/WizardForm.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,14 @@ export interface WizardFormSchema {
225225
* Wizard step sections
226226
*/
227227
sections: WizardStepConfig[];
228+
229+
/**
230+
* Inline field definitions (`object-form.customFields`). A member naming a
231+
* field a section lists is that field's definition, as on every other
232+
* `formType` (objectui#10254); `ObjectForm` hands the key over in its
233+
* `{...schema}` spread.
234+
*/
235+
customFields?: FormField[];
228236

229237
/**
230238
* Allow navigation to any step (not just sequential).
@@ -531,8 +539,11 @@ export const WizardForm: React.FC<WizardFormProps> = ({
531539
// this produces, so it agrees with the renderer for free.
532540
recordId: schema.recordId,
533541
fieldLabel,
542+
// A member naming a section's field is that field's definition, as it
543+
// is on every other arm (objectui#10254).
544+
customFields: schema.customFields,
534545
}),
535-
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
546+
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
536547
);
537548

538549
// The same "no persisted record" test the seeding and the create-mode
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `object-form.customFields` under explicit `sections`, on every arm that
11+
* takes them (objectui#10254).
12+
*
13+
* The registered description of `customFields` is one sentence for every
14+
* `formType`: "Field definitions merged over the set generated from object
15+
* metadata." objectui#10073 brought the drawer and modal to the default arm's
16+
* merge for a form with NO sections (`drawerModalCustomFieldsMerge-10073`).
17+
* With explicit `sections`, the `drawer`, `modal`, `tabbed`, `wizard` and
18+
* `split` arms still rebuilt every section member from the object schema
19+
* alone, so the authored member was dropped: same schema, `INLINE NOTE` on the
20+
* default arm, `Note` on the other five.
21+
*
22+
* What this card unifies is the BASE definition a section member starts from:
23+
* a member naming the field supplies the whole definition, in place of the
24+
* generated one, through the one lookup the default arm's merge uses
25+
* (`findCustomFieldMember`). What it deliberately does NOT unify is each arm's
26+
* section-entry override rules, which differ (the default arm copies only
27+
* `visibleOn` / `colSpan` / `span` from a spec entry; the other five apply
28+
* `normalizeSectionField`'s full set) — no row below authors an override the
29+
* two rule sets answer differently.
30+
*
31+
* Every row mounts the real `ObjectForm` with the arm's `formType`, the path an
32+
* author reaches. The `simple` rows are the default-arm control: that arm
33+
* already resolved section members against its merged pool, and they stay
34+
* green without the fix. Every schema below has ONE section, so the wizard's
35+
* rendered step is the section under test — and a wizard draws only its
36+
* current step's fields, which the `drawnFields()` equalities pin. On a tree
37+
* where an arm's `buildSectionFields` context does not carry `customFields`,
38+
* that arm's LABEL, REQUIRED, SPEC ENTRY and UNDECLARED NAME rows are red;
39+
* UNNAMED MEMBER is green on every arm either way (the arms already agreed
40+
* there, and it pins that the fix did not start drawing members no section
41+
* lists).
42+
*/
43+
44+
import { describe, it, expect, vi } from 'vitest';
45+
import { render, waitFor, fireEvent } from '@testing-library/react';
46+
import React from 'react';
47+
import { registerAllFields } from '@object-ui/fields';
48+
import { ObjectForm } from '../ObjectForm';
49+
50+
registerAllFields();
51+
52+
const OBJECT_SCHEMA = {
53+
name: 'invoice',
54+
fields: {
55+
customer: { type: 'text', label: 'Customer' },
56+
note: { type: 'text', label: 'Note' },
57+
amount: { type: 'text', label: 'Amount' },
58+
},
59+
};
60+
61+
const makeDataSource = () =>
62+
({
63+
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA),
64+
findOne: vi.fn(),
65+
create: vi.fn(async (_object: string, data: Record<string, unknown>) => ({ id: 'r1', ...data })),
66+
update: vi.fn(),
67+
}) as any;
68+
69+
const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard', 'split'] as const;
70+
type Arm = (typeof ARMS)[number];
71+
72+
/** The drawer and modal portal their content, so every read is off `document.body`. */
73+
const drawnFields = (): string[] =>
74+
[...document.body.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field') as string);
75+
76+
const labelOf = (name: string): string | null =>
77+
document.body.querySelector(`[data-field="${name}"] label`)?.textContent ?? null;
78+
79+
const controlOf = (name: string): HTMLInputElement | HTMLTextAreaElement | null =>
80+
document.body.querySelector(`[data-field="${name}"] input, [data-field="${name}"] textarea`);
81+
82+
const MAIN_SECTION = (fields: Array<string | Record<string, unknown>>) => [
83+
{ name: 'main', label: 'Main', fields },
84+
];
85+
86+
async function mount(arm: Arm, schema: Record<string, unknown>) {
87+
const adapter = makeDataSource();
88+
const utils = render(
89+
<ObjectForm
90+
schema={
91+
{
92+
type: 'object-form',
93+
objectName: 'invoice',
94+
mode: 'create',
95+
...(arm === 'simple' ? {} : { formType: arm, open: true }),
96+
...schema,
97+
} as any
98+
}
99+
dataSource={adapter}
100+
/>,
101+
);
102+
await waitFor(() => {
103+
if (!document.body.querySelector('form [data-field]')) throw new Error('form not ready');
104+
});
105+
return { ...utils, adapter };
106+
}
107+
108+
describe.each(ARMS)('`object-form` `formType: %s` — explicit `sections` draw a `customFields` member as the field', (arm) => {
109+
it('LABEL — a section naming a member field renders the member\'s label', async () => {
110+
const { unmount } = await mount(arm, {
111+
sections: MAIN_SECTION(['customer', 'note']),
112+
customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text' }],
113+
});
114+
expect(drawnFields()).toEqual(['customer', 'note']);
115+
expect(labelOf('note'), 'the member supplies the whole definition').toBe('INLINE NOTE');
116+
expect(labelOf('customer'), 'a field no member names keeps the object\'s definition').toBe('Customer');
117+
unmount();
118+
});
119+
120+
it('REQUIRED — a member\'s `required` refuses an empty submit and lets a filled one through', async () => {
121+
const { adapter, unmount } = await mount(arm, {
122+
sections: MAIN_SECTION(['customer', 'note']),
123+
customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text', required: true }],
124+
});
125+
expect(controlOf('note')?.getAttribute('aria-required')).toBe('true');
126+
expect(controlOf('customer')?.getAttribute('aria-required'), 'the object does not require customer').toBeNull();
127+
128+
const form = document.body.querySelector('form') as HTMLFormElement;
129+
fireEvent.submit(form);
130+
await waitFor(() => expect(document.body.textContent).toContain('INLINE NOTE is required'));
131+
expect(adapter.create, 'the member\'s required refused the empty submit').not.toHaveBeenCalled();
132+
133+
fireEvent.change(controlOf('note') as HTMLInputElement, { target: { value: 'filled' } });
134+
fireEvent.submit(form);
135+
await waitFor(() =>
136+
expect(adapter.create).toHaveBeenCalledWith('invoice', expect.objectContaining({ note: 'filled' })),
137+
);
138+
unmount();
139+
});
140+
141+
it('SPEC ENTRY — a spec `{ field }` entry starts from the member, not the generated field', async () => {
142+
const { unmount } = await mount(arm, {
143+
// `colSpan` is a layout key both override rule sets carry; nothing here
144+
// restates `label`, `required` or `type`, so the member is what shows.
145+
sections: MAIN_SECTION(['customer', { field: 'note', colSpan: 1 }]),
146+
customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'textarea', required: true }],
147+
});
148+
expect(labelOf('note')).toBe('INLINE NOTE*');
149+
expect(controlOf('note')?.getAttribute('aria-required')).toBe('true');
150+
expect(
151+
controlOf('note')?.tagName,
152+
'the member\'s own `type` renders — the object schema\'s `text` is the generated definition\'s',
153+
).toBe('TEXTAREA');
154+
unmount();
155+
});
156+
157+
it('UNDECLARED NAME — a section naming a field only a member supplies renders the member', async () => {
158+
const { unmount } = await mount(arm, {
159+
sections: MAIN_SECTION(['customer', 'extra']),
160+
customFields: [{ name: 'extra', label: 'EXTRA', type: 'text' }],
161+
});
162+
expect(drawnFields()).toEqual(['customer', 'extra']);
163+
expect(labelOf('extra')).toBe('EXTRA');
164+
unmount();
165+
});
166+
167+
it('UNNAMED MEMBER — a member no section lists is not drawn under explicit sections', async () => {
168+
const { unmount } = await mount(arm, {
169+
sections: MAIN_SECTION(['customer', 'note']),
170+
customFields: [
171+
{ name: 'extra', label: 'EXTRA', type: 'text' },
172+
{ name: 'amount', label: 'INLINE AMOUNT', type: 'text' },
173+
],
174+
});
175+
expect(
176+
drawnFields(),
177+
'sections draw what they list — neither an appended member nor an overriding one',
178+
).toEqual(['customer', 'note']);
179+
unmount();
180+
});
181+
});

0 commit comments

Comments
 (0)