You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2dd4d3f
Browse filesBrowse the repository at this point in the historyBrowse files
feat(plugin-form): an edit save writes only the fields that changed (#10546)
Fixes#10156
Clause-②: no (conditional on the stop above)
The stop did not fire. No exported type, prop or signature changed.
`submitHandler` keeps its declared type, and `@object-ui/plugin-form`
publishes `.` only, from `index.tsx`, which re-exports neither
`sanitize` nor `masterDetailTx`. After `pnpm --filter
@object-ui/plugin-form run build` on this branch, `grep -c` over
`dist/index.d.ts` prints `0` for each of `dirtyEditPayload`,
`snapshotLoadedRecord`, `advanceLoadedRecord`, `LoadedRecordSnapshot`,
`EditSaveTarget`, `isSameStoredValue`, `changedFields` and
`sanitizeFormData`. The positive control, `ObjectForm`, prints `6`. ⚠️
One published behaviour does move: in edit mode, a host `submitHandler`
now receives the payload the form would have written (see "What
shipped"). The seat confirmed `Clause-②: no` in comment `5829229738` on
objectui#10156, and ruled that the published JSDoc describing the seam
is corrected in this PR. Round 2 below does that.
## What shipped
An edit form now diffs its sanitized payload against the record it read
with `findOne`, and writes only the fields that differ.
- **Where the loaded record lives.** `SimpleObjectForm` (in
`ObjectForm.tsx`), `ModalForm` and `DrawerForm` each hold the record in
a ref, set by their own `findOne` and read only by the save path. The
ref is tagged with the object and record it was read for. A save for any
other record therefore finds no baseline and sends everything.
`initialData` / `formData` are left alone, because they seed the form
and supply the OCC token. No exported type or prop was needed.
- **One place, both write routes.** In `SimpleObjectForm`, the diff runs
once, before the route is chosen. It covers the host-owned
`submitHandler` seam, which is how a master-detail form builds its
parent operation, and the plain OCC-guarded `update`. `ModalForm` and
`DrawerForm` compute it in the same `writePayload` their create path
already used.
- **One comparison.** `isSameStoredValue` and `changedFields` moved from
`masterDetailTx.ts` into `sanitize.ts`, and both callers import that
copy. The function bodies are byte-identical to the originals: a `diff`
of each body, extracted from the base blob and from this branch, is
empty. The master-detail child rows and the edit form use the same rule.
- **Empty diff.** A save with nothing changed still sends the full
sanitized payload, as it did before. It stays a real request, with the
same OCC guard and a real server record for `onSuccess`. Skipping the
request instead would report success for a save no server saw. That is
the one outcome a wrong baseline must never produce.
- **Baseline advance.** After a successful save, the snapshot takes on
the fields just written. A form that stays mounted therefore diffs its
next save against the record as it now stands. Without this, changing a
field back to its first-read value would compare clean and be dropped,
while the server kept the first save's value. The row `a second save
from the same mounted form diffs against what the first save wrote` pins
it.
- **Untouched:** `occSave.tsx`; the OCC token (`baseRecord` is still
`initialData` / `formData`); the 409 dialog; the create path; and the
`sections` branch of `ObjectForm.tsx`. The save handler is shared by the
flat and sectioned layouts, so a sectioned simple form also gets the
diff. No line of the `sections` branch changed.
A one-field edit, on a record that also carries a numeric string, a
null, an ownership column and `updated_at`. The "full payload" line is
the same pin under ablation A1 below, which puts the pre-branch full
payload back:
full payload update("deal", "d1", {"amount":10.5,"name":"Mine
v2","qty":"5","remark":null,"stage":"open"}, {"ifMatch":"2026-09-25
00:00:00.000"})
this branch update("deal", "d1", {"name":"Mine v2"},
{"ifMatch":"2026-09-25 00:00:00.000"})
## Round 2 (patch): the published `submitHandler` doc says what an
edit-mode handler receives
The seat asked for this in comment `5829229738` on objectui#10156. It is
one commit, `13ac7751e`, fast-forward, and JSDoc plus changeset only: 20
lines added, 0 removed, no type moves.
- `packages/types/src/objectql.ts`: the `ObjectFormSchema.submitHandler`
JSDoc. Its first paragraph is unchanged, so "hands the collected values"
stays true for create. A new paragraph says that in `edit` mode, for a
record the form read itself, the simple, `modal` and `drawer` layouts
hand over what the form would have written: the fields that differ from
that read, or the full sanitized payload when nothing changed. It also
says a field whose sameness cannot be settled counts as changed, and
that the `tabbed`, `wizard` and `split` layouts, and a simple form the
mobile `stepper` routes through the wizard, still hand over every
collected value. The `BulkActionDef` icon example that draft PR
objectui#8941 edits is untouched.
- `ModalForm.tsx` and `DrawerForm.tsx`: their exported `ModalFormSchema`
/ `DrawerFormSchema` carry their own copy of the same "hands the
collected values" sentence, under a comment that says the copy is
declared from the canonical key so the two "can never drift apart". Each
gains the same edit-mode clause. ⚠️ The round-2 instruction said "no
other source change this round". These two lines are JSDoc only, in
files already on this claim's surface. They were published text that
round 1 made incomplete in the same way as the canonical sentence. The
deviation is named in the report.
- `.changeset/10156-edit-form-writes-only-changed-fields.md`: declares
`@object-ui/types` as a patch, and one bullet names the correction.
- The zod mirror (`packages/types/src/zod/objectql.zod.ts`, held by
objectui#9618) carries no `submitHandler` at all: 0 hits in the zod
directory, against 2 files there that carry `onSuccess` as the control.
There was nothing to report or to leave alone.
- A search for source-text pins quoting the old sentence found none
outside the edited JSDoc blocks. The control is the same search run with
nothing excluded, which hits the source line. The two pending changesets
that paraphrase "documented as handing the collected values" remain
true: that first paragraph is unchanged.
## The comparison rule
A field counts as clean only when every case below says "same". Anything
this cannot settle is SENT.
| pair | same? |
|:--|:--|
| identical values (`===`) | yes |
| `null` / `undefined` | yes, the one blank a round-trip interchanges |
| `null` / `''`, `undefined` / `''` | no |
| `1` / `'1'` (number vs numeric string) | no |
| lookup id / expanded lookup object | no |
| two Dates with the same finite time | yes |
| a Date / a date string; two date strings in different formats | no |
| objects or arrays with identical JSON | yes |
| reordered keys or elements | no |
| `NaN` / `NaN` | no |
Each row is pinned both ways round in `the one comparison — every pair
the rule states`.
## Premise checks (the dispatch's mechanism assumptions, measured)
- **A1 — holds, with one correction.** The record is read in
`SimpleObjectForm`'s fetch effect, and in `ModalForm` / `DrawerForm`'s
fetch effects, and was already kept in component state (`initialData` /
`formData`). A ref beside it was enough. Correction to the dispatch: the
child-row comparison lived in `masterDetailTx.ts`, not in `sanitize.ts`.
It now lives in `sanitize.ts`.
- **A2 — holds.** There is one rule, stated above. It is the rule
objectui#10108's child rows already used, moved and not rewritten.
- **A3 — holds.** `ifMatch` is still the `updated_at` the form read.
**Overwrite** resends the dirty fields only, pinned in `"Overwrite"
after a 409 re-sends the changed fields only, re-keyed to the server
version`.
- **A4 — the full payload, as today.** Before this branch, a save with
no changes sent a `PATCH` carrying every sanitized field and reported
the server's answer. It still does. Pinned for the plain edit and for
the master-detail parent operation.
- **A5 — holds.** A field the form moved itself differs from the read
record, so it is sent. Pinned with a cascade clear: the user moves
`region`, the form empties `tier`, and `tier: null` is on the payload.
- **A6 — holds.** Create is untouched. The control row posts a seeded
value the user never changed, and `update` and `findOne` are never
called.
## Pins (`editDirtyPayload-10156.test.tsx`, real components, real
`sanitizeFormData`)
- plain edit, modal edit and drawer edit: changing one field sends
exactly that field, with `ifMatch`;
- master-detail parent operation: `ops[0]` is exactly `{ref: 'PO-2'}`;
- type drift is sent. Real widgets produce it: retyping `5` into a
number input whose stored value is the string `'5'`, and typing into
then emptying a textarea whose stored value is `null`. A unit row covers
the lookup id against the expanded object;
- a cascade clear is sent;
- **Overwrite** after a 409 sends dirty fields only;
- a save with nothing changed sends the full sanitized payload (plain
and master-detail);
- a second save from a still-mounted form;
- create is unchanged (control).
## Reverse verification (head `cb00385aa`)
The fix was committed first. Each mutation went through
`ablation-replace.mjs`: the anchor had to hit exactly once, the blob
change was verified on disk, and the file was trap-restored from `HEAD`
on an absolute path. After every leg, the `sanitize.ts` blob equals
`HEAD` (`9c115af88c2a`) and `git diff HEAD` is empty. No rebuild was
needed, because every consumer imports `./sanitize` relatively inside
the package.
| ablation | pin file result | what went red | what stayed green |
|:--|:--|:--|:--|
| A1 remove the dirty filter (`dirtyEditPayload` returns the payload) |
8 failed / 25 passed | every exact "only that field" row (plain, modal,
drawer, master-detail parent, Overwrite, second save) | every type-drift
row, the cascade row, both empty-diff rows, create |
| A2 comparison forced to "all clean" | 17 failed / 16 passed | the 9
"different" rule rows and the same exact rows as A1 | **type-drift and
cascade rows** |
| A3 all clean AND empty-diff fallback removed | 24 failed / 9 passed |
all type-drift rows, the cascade row, both empty-diff rows, everything
above | the "same" rule rows, create |
| A4 loosened equality (`String(a ?? '') === String(b ?? '')`) | 9
failed / 24 passed | all three component type-drift rows, the unit drift
row, 5 rule rows | every "only that field" row, the cascade row |
| A5 no baseline advance | 2 failed / 31 passed | the second-save row
and its unit row | the rest |
| A6 `sanitizeFormData` returns its input (over the three retriaged
files) | 14 failed / 15 passed | every retriaged filter row (below) |
the new `an edit writes the edited field alone — never the FLS-refused
one` rows |
⚠️ **The dispatch expected A2 to turn the type-drift and cascade pins
red. It did not.** Under an "all clean" comparison the diff comes out
empty, and the empty-diff rule then resends the full payload, so nothing
can be dropped. That is the fallback working as a second line of
defence. A3 removes the fallback as well, and they go red. A4 shows that
the comparison's strictness is load-bearing on its own terms: loosen it,
and a drifted field is dropped the moment another field is dirty.
## Fixtures retriaged, and why
A row that asserts a column ABSENT after a real edit now stays green
with its filter deleted, because the dirty diff alone keeps an unchanged
column off the wire. The new FLS edit rows show exactly this: they
stayed green under A6. So each row whose job is the sanitizer, the
roster or the field-level filter now submits with **nothing changed**.
That is the one edit route whose payload is the filter's whole output,
and A6 turns every one of them red.
- `ObjectForm.test.tsx` `strips computed and server-managed fields from
the edit-mode update payload`: now submits unchanged, and its control is
the stored `name` / `budget`.
- `systemManagedPayload.test.tsx` `sends no server-owned column, from a
form that declares none of them`: same move. This row was still passing,
but vacuously.
- `fieldSecurityPayload.test.tsx`: the filter row and the CONTROL row
submit unchanged in all three containers. A new row per container pins
that an edit writes the edited field alone. The file header says why.
- `cascadePruneWire-10291.test.tsx`, two EDIT rows: the negative-control
select is now asserted ABSENT from the wire body, not present with its
stored value. A wrongful null would still show, because it differs from
the stored `'gold'`.
- `drawerFirstLoadWindow-10190.test.tsx` CONTROL: now `toEqual({title:
'typed'})`. That is stronger than before: `note` is absent only because
the form still holds the value the record landed with.
## Checks at the round-2 head (`13ac7751e`)
| check | command | result |
|:--|:--|:--|
| dependency closure build | `turbo run build` over the plugin-form
dependency closure, `--concurrency=2` (includes `@object-ui/types`) |
11/11 tasks, exit 0 |
| `@object-ui/types` type-check | `pnpm --filter @object-ui/types run
type-check` (`tsc --noEmit`, `tsconfig.examples.json`,
`tsconfig.test.json`) | exit 0 |
| `@object-ui/types` suites, including every suite that reads
`objectql.ts` text | `pnpm exec vitest run packages/types/` | **233
files, 5184 passed**, exit 0 |
| package tests | `pnpm exec vitest run packages/plugin-form/` | **119
files, 1235 passed, 1 skipped**, exit 0 |
| plugin-form type-check | `pnpm --filter @object-ui/plugin-form run
type-check` | exit 0 |
| lint, touched files | `eslint` on `objectql.ts`, `ModalForm.tsx`,
`DrawerForm.tsx` | 0 errors |
| changeset presence / no-major / fixed / overwrite / pending literals |
`check-changeset-*`, `check:pending-changeset-literals` | exit 0 all.
Presence: 12 source files of 2 released packages, 1 changeset declared |
| changeset claims | `check:changeset-claims` | exit 0 (report-only).
Self-contradiction reading: both bodies clean. It now also flags the
pending changesets that name `objectql.ts`. Those paragraphs concern
other faces or other `ObjectFormSchema` keys, and none describes the
`submitHandler` doc |
| line citations, control bytes, doc expression carriage, handler-key
reads | `check:*` | exit 0 all, 0 new citations |
| governed surface | `check-governed-queue-guard.mjs --test` over the
round-2 paths | NOT GOVERNED |
Round 2 changed no executable line. So the round-1 reverse verification
below, measured at `cb00385aa`, still describes this code.
## Checks on the round-1 head (`cb00385aa`)
| check | command | result |
|:--|:--|:--|
| dependency closure build | `turbo run build
--filter='@object-ui/plugin-form^...' --concurrency=2` | 11/11 tasks,
exit 0 |
| package build | `pnpm --filter @object-ui/plugin-form run build` |
exit 0 |
| package tests | `pnpm exec vitest run packages/plugin-form/` | **119
files, 1235 passed, 1 skipped**, exit 0 |
| suites outside the package that name `sanitizeFormData`, `ObjectForm`,
`ModalForm`, `DrawerForm`, `saveWithOcc`, `occSave` or `masterDetailTx`
(99 files, enumerated with `git grep -l`) | `pnpm exec vitest run` over
them in two batches | 40 files / 1169 passed and 59 files / 1359 passed,
exit 0 both. Run at `e111bfd68`; everything after it touches only the
changeset, README and docs page |
| type-check | `pnpm --filter @object-ui/plugin-form run type-check`
(`tsc --noEmit && tsc -p tsconfig.test.json`) | exit 0. `--listFiles` on
the test project lists all six touched test files |
| lint | `eslint .` in `packages/plugin-form` (its own `lint` script),
`--format json` | 153 files, **0 errors**, 1000 warnings. Per-file rule
counts in the four edited source files match the base, except that 4
`no-explicit-any` moved with the comparison from `masterDetailTx.ts` to
`sanitize.ts` |
| changeset presence / no-major / fixed / overwrite |
`check-changeset-*` | exit 0 all. Overwrite reports the intended body
correction of objectui#10120's changeset |
| changeset claims | `check:changeset-claims` | exit 0 (report-only).
The three flagged paragraphs (objectui#10108, #6237, #8738) were read
and are still true |
| pending changeset literals, line citations, control bytes | `check:*`
| exit 0, 0 new citations |
| test path roots, vi-mock ×3, unreferenced sources, handler-key reads,
metadata write doors, type-check coverage, lint coverage | `check:*` /
`scripts/check-*` | exit 0 all |
| doc links, doc fences, doc example ids, doc component types, doc
expression carriage, shell escape residue | `scripts/check-*` | exit 0
all |
| governed surface | `check-governed-queue-guard.mjs --test` over the
changed paths | NOT GOVERNED |
**NOT MEASURED:** `check:readme-exports`, which answered exit 1 with
"population collapsed", and `check:doc-snippets`, which answered exit 2
PRECONDITION NOT MET. Both need packages outside this closure built, and
neither answer is a verdict. Both read fenced code blocks, and this
branch's README and docs-page additions contain 0 fences (`git diff`
over the two files, counting added lines that open a fence). Remote CI
on `13ac7751e`: 43 check-runs completed, 40 success and 3 skipped by
design (`dependabot`, `Test (coverage)` and the coverage-shard matrix),
0 red.
**Declared narrowing on lint.** `pnpm lint` is `turbo run lint`, i.e.
each package's own `eslint .`. The run above is that command for the one
package this branch changes, over its whole tree. `eslint.config.js`
sets neither `parserOptions.project` nor `projectService`, so type-aware
linting is not enabled, and a change inside this package cannot move the
verdict on a file it does not touch. The `.changeset`, README and
docs-page files are not eslint inputs.
## File surface
Round 2 added `packages/types/src/objectql.ts` (the `submitHandler`
JSDoc only) through the seat's amendment, plus the JSDoc copies on
`ModalFormSchema` / `DrawerFormSchema` (see Round 2). In round 1, the
claim named `sanitize.ts`, `ObjectForm.tsx`, `ModalForm.tsx`,
`DrawerForm.tsx`, the tests beside them and one changeset. This branch
also touches:
- `masterDetailTx.ts`: the comparison's old home. It loses its private
copy and imports the shared one; the function bodies are byte-identical;
- `packages/plugin-form/README.md` and
`content/docs/plugins/plugin-form.mdx`: the docs AGENTS.md #2 requires;
- `.changeset/10120-form-omits-fls-denied-fields.md`: body only,
frontmatter byte-identical. Its sentence "`ObjectForm` and `ModalForm`
produce the same payloads they did before" would be false beside this
change in the same release. It now says both already withheld the
refused field and still do.
## Acceptance notes
- Out-of-scope findings, **handed to the seat, which files them at
ACCEPT** (measured with probes that were not committed):
- The `tabbed`, `split` and `wizard` edit arms never call
`sanitizeFormData`. Their edit `PATCH` carries the whole record: `id`,
`owner_id`, `created_by`, `updated_at` and a `formula` column, on this
branch and on `main` alike. This is objectui#10108's 403 and the
"unknown field" rejection, on the arms that fix did not reach. The same
arms also skip the field-level filter. A simple form with
`mobile.stepper` routes through the wizard arm too. The docs and
changeset name all of these as not covered.
- A master-detail EDIT form that stays mounted after a save sends stale
child rows on the next save. It re-creates every row the first save
created, so the second batch carries the same `create` again. It also
drops a child cell changed back to its first-read value, so the second
batch carries no child operation and still reports success. The row
state's `original` never advances, and created rows never learn their
ids. This is the child-row twin of the baseline advance above. The fix
shape (refetch the children, or map the batch results) is not pinned, so
it is not fixed in place here.
- Observations, not findings:
- A second save from a still-mounted edit form still sends the
`updated_at` first read as `ifMatch`, so against a real server it would
409 on the user's own first save. OCC was out of scope and unchanged;
this was not measured against a server.
- `MasterDetailForm`'s fallback echo `{...parentData, id}` is used only
when the batch returns no `results`. It now carries the changed parent
fields, not all of them.
- Two cross-file line addresses into `objectql.ts` were already false
before this PR, and are left as they are: the `groupBy` sentence in the
pending changeset for the kanban group-by control, and the
`KanbanConditionalFormattingRule` comment in the
one-authority-per-exported-name script test. At `cb00385aa`, the
changeset's address lands on a map-config comment and the script test's
on a `View type` comment. Round 2's inserted lines sit before both, but
neither was true to begin with.
- The `sections` branch of `ObjectForm.tsx`, objectui#10475 and
objectui#10476 were not touched.
Session: `https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C`
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: .changeset/10120-form-omits-fls-denied-fields.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -13,7 +13,7 @@ The verdict that answers 「may this caller edit this field」 already existed a
13
13
14
14
**What changed, in observable terms.**
15
15
16
-
- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm`produce the same payloads they did before — their loops were correct, they were just copies.
16
+
- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm`already withheld the refused field, and still do — their loops were correct, they were just copies.
17
17
- The render pass is likewise one function for all three containers. ⚠️ `DrawerForm` previously drew a field the caller may read but not edit as a live input; it now draws it read-only and disabled, exactly as the other two already did. A field the caller may not READ is dropped, also as before.
18
18
- Both halves stay fail-open with no `PermissionProvider` / `MePermissionsProvider` mounted, unchanged: a standalone form, a designer preview and a guest surface have no resolvable principal, and the server still enforces.
19
19
- A lookup's selected chip no longer offers its remove ✕ when the field is disabled. ⚠️ This is how BOTH refusals reach the widget — a field the object declares `readonly` is folded into `disabled` by the form's section builder, and a field the permission set refuses is marked disabled by the pass above — so a reporter could previously clear a master-detail parent the server would then refuse to unset. The trigger and the browse button were already disabled; the chip's ✕ was the one control the gate had missed. The chips themselves stay: the value is readable, only the affordance goes.
An edit form now writes only the fields that changed (objectui#10156).
7
+
8
+
**Clause-②: no.** No exported symbol, type or prop changes. `@object-ui/plugin-form` publishes `.` only, from `index.tsx`, and `index.tsx` re-exports neither `sanitize` nor `masterDetailTx`. After a build, `dist/index.d.ts` names none of the new helpers. The change is in what the client sends, and ⚠️ in what a host `submitHandler` receives in edit mode (see below).
9
+
10
+
**Before.** A master-detail child row already sent only the cells that differed from its loaded snapshot (objectui#10108). The other two edit payloads did not. The plain record edit `PATCH` and the parent operation of a master-detail batch sent every sanitized field on every save, including fields the user never touched. With the concurrency guard, a `409` followed by **Overwrite** therefore rewrote every field, not only the ones this user changed.
11
+
12
+
**What changed, in observable terms.**
13
+
14
+
- In edit mode, `ObjectForm`, `ModalForm` and `DrawerForm` compare their save with the record they read through `findOne`, and write only the fields that differ. The parent operation of a master-detail batch follows, because its header is a simple `ObjectForm`.
15
+
- There is one comparison, shared with the master-detail child rows. It sends anything it cannot prove unchanged. `null` and `undefined` count as the same value. `null` and `''` are different. So are `5` and `'5'`, a lookup id and its expanded object, a `Date` and a date string, and two objects whose keys come in a different order. A field the form changed by itself after the read, such as a cascade clear, is sent.
16
+
- A save with nothing changed still sends the full sanitized payload. It stays a real request, with the same concurrency guard and a real server record for `onSuccess`.
17
+
- A form that did not read the record itself still sends every field. That covers a create, a record supplied as `initialData`, and inline `customFields`.
18
+
- After a successful save, the form counts the fields it just wrote as saved. A form that stays open compares its next save with the record as it is now. Changing a field back to its first-read value is therefore still sent.
19
+
- The concurrency guard is unchanged. The update still carries `ifMatch` = the `updated_at` the form read, and a `409` still offers **Keep editing** or **Overwrite**. **Overwrite** now resends only the changed fields.
20
+
- ⚠️ A host `submitHandler` on an edit form receives the payload the form would have written. That is the changed fields, or the full sanitized payload when nothing changed. A host that needs the whole record must read it itself. In this repository, only `MasterDetailForm` passes a `submitHandler` to an edit form. Its header form receives the changed fields. Its row editor has no `recordId`, so it still receives every value.
21
+
- The JSDoc of `ObjectFormSchema.submitHandler` in `@object-ui/types`, and its copies on `ModalFormSchema` and `DrawerFormSchema`, now say what an edit-mode handler receives.
22
+
23
+
**Not covered.** The `tabbed`, `wizard` and `split` variants have save paths of their own and still send every value they hold. That includes a master-detail header laid out `tabbed`, and a simple form whose mobile `stepper` option shows it one step at a time through the wizard.
0 commit comments