diff --git a/CLAUDE.md b/CLAUDE.md index f1338f0d..1dfee143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,12 @@ what the forms holding the document from its root hold of its values after a sub `submit` action calls as it writes; a submit a row's own document makes writes into the row's form, and moves no start). The rest of the row, a field its cells only show included, is what the document's form holds: what another form, such as the row's expanded content's, has edited there, and what a submit has written of the row's form. A -new write of a row's values into the document's form must move the start the same way. Its fields are every one it +new write of a row's values into the document's form must move the start the same way. An Add and a Remove write the +fields such rows changed into the document's form before they add or remove a row (`keepRowEdits`, +`engine/dataKindPush.ts`): a row whose data that changes starts again from it with its edits, and a row they leave where +it was, and as it was, goes back into the data as its form started from it, so its form keeps its start and what it +holds (`settleWrittenRows`); the start of every other row written moves, to what the document's form held of it once +written, where the row still was. Its fields are every one it has registered, the ones a `showIf` hid included (`fieldNamesOf` in `createBatchedForm`). A name that comes is no change of what the forms hold: the kept merge is checked against the values of the names that came (`fieldNamesCount`, `holdsFields`), so a burst of registrations, a mount's among them, merges nothing more. Where more diff --git a/src/core/engine/__tests__/formData.test.js b/src/core/engine/__tests__/formData.test.js index 6922926f..a2198e5d 100644 --- a/src/core/engine/__tests__/formData.test.js +++ b/src/core/engine/__tests__/formData.test.js @@ -1,12 +1,15 @@ import { changeOptionOrderForSelectFields, + changedRowsFor, getFormsData, getRawFormsData, getLiveMergedDataKindArray, holdsFields, restartWrittenRows, rowPlaceOf, + settleWrittenRows, } from '../formData' +import { cloneDeep } from '../../utils' /** * A form as the merge reads it: its values, the fields it registers and the values it started with, which a table @@ -504,6 +507,194 @@ describe('restartWrittenRows', () => { }) }) +describe('changedRowsFor', () => { + // A table row with a form of its own, which starts from the row at its place (`engine/Data.tsx`), and its fields. + const rowForm = (values, relativePath, relativeIndex, fields, initialValues) => ({ + form: { getState: () => ({ values, initialValues }), getRegisteredFields: () => fields }, + meta: { relativePath, relativeIndex }, + }) + + it('gives each row whose form changed a field, as the document\'s form holds it with those fields written', () => { + const document = makeForm({ title: 'T', items: [{ kind: 'a', note: 'new', when: '2020-01-01' }, { kind: 'b' }] }) + const forms = new Map([ + ['first', rowForm({ items: [{ kind: 'typed', note: 'old' }] }, 'items', 0, ['items[0].kind', 'items[0].when', 'items[0].note'], { items: [{ kind: 'a', note: 'old', when: '2020-01-01' }] })], + ['second', rowForm({ items: [undefined, { kind: 'b' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, { kind: 'b' }] })], + ['document', { form: document, meta: {} }], + ]) + + expect(changedRowsFor(document, forms)).toEqual([{ place: 'items[0]', row: { kind: 'typed', note: 'new' }, initial: undefined }]) + expect(document.getState().values.items[0]).toEqual({ kind: 'a', note: 'new', when: '2020-01-01' }) + }) + + it('leaves out a row its array does not hold, a row with no field in it, and the form the rows are for', () => { + const document = rowForm({ items: [{ kind: 'edited' }] }, 'items', 0, ['items[0].kind'], { items: [{ kind: 'a' }] }) + const forms = new Map([ + ['past the end', rowForm({ items: [undefined, { kind: 'typed' }] }, 'items', 1, ['items[1].kind'])], + ['elsewhere', rowForm({ others: [{ kind: 'typed' }] }, 'others', 0, ['others[0].kind'])], + ['no field in it', rowForm({ items: [{ kind: 'typed' }], kind: 'typed' }, 'items', 0, ['kind'])], + ['the document', document], + ]) + + expect(changedRowsFor(document.form, forms)).toEqual([]) + }) + + it('leaves out a row whose edits the form holds already, as a submit leaves them in the document\'s form', () => { + const document = makeForm({ items: [{ kind: 'typed' }] }) + const forms = new Map([ + ['row', rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'], { items: [{ kind: 'a' }] })], + ['document', { form: document, meta: {} }], + ]) + + expect(changedRowsFor(document, forms)).toEqual([]) + }) + + it('leaves out a row where the form\'s array has a hole, as a row\'s own form has before its row', () => { + const items = [] + items[1] = { kind: 'b' } + const row = rowForm({ items }, 'items', 1, ['items[1].kind']) + const forms = new Map([['before it', rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'])], ['the row', row]]) + + expect(changedRowsFor(row.form, forms)).toEqual([]) + }) + + it('leaves out a row whose place has another shape in the form, which final-form throws for', () => { + const document = makeForm({ orders: { 1: { lines: [{ sku: 'A' }] } } }) + const forms = new Map([ + ['line', rowForm({ orders: { 1: { lines: [{ sku: 'B' }] } } }, 'orders.1.lines', 0, ['orders.1.lines[0].sku'])], + ['document', { form: document, meta: {} }], + ]) + + expect(changedRowsFor(document, forms)).toEqual([]) + }) + + it('gives the row a row\'s form started from, where that form holds just it, an object, and the form holds it just so', () => { + const started = { kind: 'a' } + const values = { items: [{ kind: 'a' }, null, { kind: 'c', note: 'new' }, { kind: 'd' }] } + const document = makeForm(values) + const forms = new Map([ + ['as it started', rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'], { items: [started] })], + ['empty', rowForm({ items: [undefined, { kind: 'typed' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, null] })], + ['edited below', rowForm({ items: [undefined, undefined, { kind: 'typed' }] }, 'items', 2, ['items[2].kind'], { items: [undefined, undefined, { kind: 'c' }] })], + // A form that starts from the whole document, as a row whose cells read the document does. + ['the whole document', rowForm({ ...values, items: [...values.items.slice(0, 3), { kind: 'typed' }] }, 'items', 3, ['items[3].kind'], values)], + ['document', { form: document, meta: {} }], + ]) + + expect(changedRowsFor(document, forms).map(({ initial }) => initial)).toEqual([started, undefined, undefined, undefined]) + }) + + it('gives no `initial` for a row with a row given inside it that has none, whose value it would take away', () => { + const values = { project: 'P', phases: [{ title: 'Design', lines: [{ sku: 'A' }, null] }] } + const phase = rowForm({ phases: [{ title: 'Design 2', lines: [{ sku: 'A' }, null] }] }, 'phases', 0, ['phases[0].title'], { phases: [values.phases[0]] }) + const line = initialValues => rowForm({ phases: [{ lines: [undefined, { sku: 'B' }] }] }, 'phases.0.lines', 1, ['phases.0.lines[1].sku'], initialValues) + const initialsOf = (lineStartsFrom, documentValues) => changedRowsFor(makeForm(documentValues), new Map([ + ['phase', phase], ['line', line(lineStartsFrom)], ['document', { form: makeForm(documentValues), meta: {} }], + ])).map(({ initial }) => initial) + + // A `null` line: its form starts from the whole document. + expect(initialsOf(values, values)).toEqual([undefined, undefined]) + // A line whose form starts from its row: the phase goes back, and takes it along. + const withLine = { ...values, phases: [{ title: 'Design', lines: [{ sku: 'A' }, { sku: 'B0' }] }] } + const phaseOf = rowForm(phase.form.getState().values, 'phases', 0, ['phases[0].title'], { phases: [withLine.phases[0]] }) + expect(changedRowsFor(makeForm(withLine), new Map([ + ['phase', phaseOf], ['line', line({ phases: [{ lines: [undefined, { sku: 'B0' }] }] })], ['document', { form: makeForm(withLine), meta: {} }], + ])).map(({ initial }) => initial)).toEqual([withLine.phases[0], { sku: 'B0' }]) + }) + + it.each([['inner', ['line', 'phase']], ['outer', ['phase', 'line']]])('gives a row inside another after it, in it, the %s one registered first', (_, order) => { + const document = makeForm({ phases: [{ title: 'Design', lines: [{ sku: 'A' }, { sku: 'B' }] }] }) + const rows = { + phase: rowForm({ phases: [{ title: 'Design 2', lines: [{ sku: 'A' }, { sku: 'B' }] }] }, 'phases', 0, ['phases[0].title']), + line: rowForm({ phases: [{ lines: [undefined, { sku: 'B2' }] }] }, 'phases.0.lines', 1, ['phases.0.lines[1].sku']), + } + const forms = new Map([...order.map(name => [name, rows[name]]), ['document', { form: document, meta: {} }]]) + + expect(changedRowsFor(document, forms).map(({ place, row }) => [place, row])).toEqual([ + ['phases[0]', { title: 'Design 2', lines: [{ sku: 'A' }, { sku: 'B2' }] }], + ['phases.0.lines[1]', { sku: 'B2' }], + ]) + }) +}) + + +describe('settleWrittenRows', () => { + // A table row with a form of its own, which starts from the row at its place (`engine/Data.tsx`), and its fields. + const rowForm = (values, relativePath, relativeIndex, fields, initialValues) => ({ + form: { getState: () => ({ values, initialValues }), getRegisteredFields: () => fields }, + meta: { relativePath, relativeIndex }, + }) + /** + * Settles `rows` after an Add or a Remove: the document's form holds `json`, the data it rebuilt, and held `written` + * once the rows were written into it. Gives what the forms read as then. + */ + const settle = (json, rows, written, rowForms) => { + const document = makeForm(json) + const forms = new Map([...Object.entries(rowForms), ['document', { form: document, meta: {} }]]) + settleWrittenRows(json, rows, { form: document, values: written }, forms) + return getFormsData(forms) + } + + it('puts back a row the operation left where it was and as it was, whose form then gives its edit', () => { + const started = { kind: 'a' } + const row = rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'], { items: [started] }) + const json = { items: [{ kind: 'typed' }, { kind: 'b' }] } + + const read = settle(json, [{ place: 'items[0]', row: { kind: 'typed' }, initial: started }], { items: [{ kind: 'typed' }] }, { row }) + + expect(json).toEqual({ items: [{ kind: 'a' }, { kind: 'b' }] }) + expect(read).toEqual({ items: [{ kind: 'typed' }, { kind: 'b' }] }) + }) + + it('leaves a row the operation moved, and measures its form\'s changes from what was written, where it was', () => { + // Rows `x`, `a`, `a`, the second edited to `a1`, the first removed: the third, which reads as the second did, + // comes to the second's place, and its form is not started again. + const row = rowForm({ items: [undefined, { kind: 'a1' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, { kind: 'a' }] }) + const json = { items: [{ kind: 'a1' }, { kind: 'a' }] } + + const read = settle(json, [{ place: 'items[1]', row: { kind: 'a1' }, initial: { kind: 'a' } }], { items: [{ kind: 'x' }, { kind: 'a1' }, { kind: 'a' }] }, { row }) + + expect(json).toEqual({ items: [{ kind: 'a1' }, { kind: 'a' }] }) + expect(read).toEqual({ items: [{ kind: 'a1' }, { kind: 'a' }] }) + }) + + it('reads another form that holds the document from its root as it is, and moves no start of a row not written', () => { + const moved = rowForm({ items: [undefined, { kind: 'a1' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, { kind: 'a' }] }) + // Edited, and not written: its edit is what the data holds there. + const notWritten = rowForm({ items: [{ kind: 'a1' }] }, 'items', 0, ['items[0].kind'], { items: [{ kind: 'x' }] }) + const json = { items: [{ kind: 'a1' }, { kind: 'a' }] } + const document = makeForm(json) + const forms = new Map([ + ['moved', moved], ['not written', notWritten], ['document', { form: document, meta: {} }], + // A block's form of its own at the document's root, and one in a row, which holds no document. + ['block', { form: makeForm({ title: 'T' }) }], ['in a row', { form: makeForm({ note: 'n' }), meta: { relativePath: 'items.0' } }], + ]) + + settleWrittenRows(json, [{ place: 'items[1]', row: { kind: 'a1' }, initial: { kind: 'a' } }], + { form: document, values: { items: [{ kind: 'x' }, { kind: 'a1' }, { kind: 'a' }] } }, forms) + + forms.delete('in a row') + expect(getFormsData(forms)).toEqual({ title: 'T', items: [{ kind: 'a1' }, { kind: 'a' }] }) + }) + + it('puts back a row with the rows inside it, whose forms keep what they measure their changes from', () => { + const phase = rowForm({ phases: [{ title: 'D2', lines: [{ sku: 'A' }, { sku: 'B' }] }] }, 'phases', 0, ['phases[0].title'], + { phases: [{ title: 'D', lines: [{ sku: 'A' }, { sku: 'B' }] }] }) + const line = rowForm({ phases: [{ lines: [undefined, { sku: 'B2' }] }] }, 'phases.0.lines', 1, ['phases.0.lines[1].sku'], + { phases: [{ lines: [undefined, { sku: 'B' }] }] }) + const written = { phases: [{ title: 'D2', lines: [{ sku: 'A' }, { sku: 'B2' }] }] } + const json = { ...cloneDeep(written), others: [{ kind: 'new' }] } + const rows = [ + { place: 'phases[0]', row: written.phases[0], initial: { title: 'D', lines: [{ sku: 'A' }, { sku: 'B' }] } }, + { place: 'phases.0.lines[1]', row: written.phases[0].lines[1], initial: { sku: 'B' } }, + ] + + const read = settle(json, rows, written, { phase, line }) + + expect(json.phases).toEqual([{ title: 'D', lines: [{ sku: 'A' }, { sku: 'B' }] }]) + expect(read.phases).toEqual(written.phases) + }) +}) + describe('more than one form that holds the whole document', () => { // A document's own form holds the whole document, and so does a `useForm` block's that selects no values of its own // (`engine/Data.tsx`, `wholeDocument`): each holds a copy of what the other edits, as it started. Until 2026-10-27 the diff --git a/src/core/engine/__tests__/rules.row-form-values.test.js b/src/core/engine/__tests__/rules.row-form-values.test.js index 6150280f..14c3b0dc 100644 --- a/src/core/engine/__tests__/rules.row-form-values.test.js +++ b/src/core/engine/__tests__/rules.row-form-values.test.js @@ -40,15 +40,16 @@ const headers = [{ id: 'kind', label: 'Kind' }, { id: 'detail', label: 'Detail' /** * Mounts the meta on the data; the host records what `getFormData` reads, and what a submit sends, and can hand the - * document other data. + * document other data. Asked to, it records the fields whose errors it is told of, each time. */ -function mount (meta, data) { - const host = { reader: null, submitted: [] } +function mount (meta, data, { hearErrors = false } = {}) { + const host = { reader: null, submitted: [], errors: [] } const document = values => ( @@ -71,8 +72,9 @@ const type = (name, value) => { fireEvent.blur(input) } -const click = name => act(async () => { - fireEvent.click(screen.getByRole('button', { name })) +/** Clicks the button `name`, the only one, or the `index`th of those of that name. */ +const click = (name, index) => act(async () => { + fireEvent.click(index === undefined ? screen.getByRole('button', { name }) : screen.getAllByRole('button', { name })[index]) await new Promise(resolve => setTimeout(resolve, 0)) }) @@ -890,3 +892,452 @@ describe.each([['`useForm` rows', true], ['rows sharing the document\'s form', f expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'b', note: '' }, { kind: 'x', note: 'n' }] } }) }) }) + +// An Add and a Remove rebuild the document's data from its form (`dataKindPush.ts`), and a `useForm` row whose data that +// changes starts again from it (`WithForm`). Until 2026-10-27 the document's form held none of what such a row's cells +// changed, so the row lost it: found in review, in a row whose expanded table gained or lost a line, and in a row whose +// expanded content had an edit when a row was added. A removal lost what was typed in the rows after the removed one, +// which move up a row. Rows that share the document's form kept theirs, and are driven the same way. +const rowsOfBothKinds = [['have forms of their own', true], ['share the document\'s form', false]] + +/** Phases whose cells are a `title`, each with its lines below it, which a line's button removes and the add row adds. */ +const phasesWithLines = (phasesUseForm, linesUseForm) => ({ + view: 'Col', + items: [{ + view: 'Table', + name: 'dataKind.phases', + headers: [{ id: 'title', label: 'Phase' }], + itemsExpanded: true, + renderItemCells: { + view: 'Data', kind: 'phases', embedded: true, ...phasesUseForm && { useForm: true }, + meta: { view: 'TableCells', items: [{ view: 'Input', name: 'title', type: 'text' }] }, + }, + renderItem: { + view: 'Col', + items: [{ + view: 'Table', + name: 'dataKind.lineItems', + headers: [{ id: 'sku', label: 'SKU' }, { id: 'null', label: '' }], + renderItemCells: { + view: 'Data', kind: 'lineItems', embedded: true, ...linesUseForm && { useForm: true }, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'sku', type: 'text' }, + { view: 'Button', children: 'Remove line', onClick: 'removeData' }, + ], + }, + }, + renderExtraItem: { + view: 'Data', kind: 'lineItems', embedded: true, initialValues: {}, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'sku', type: 'text', label: 'New SKU' }, + { view: 'Button', children: 'Add line', onClick: 'addData' }, + ], + }, + }, + }], + }, + }, { view: 'Button', children: 'Submit', onClick: 'submit' }], +}) + +const phases = { dataKind: { phases: [{ title: 'Design', dataKind: { lineItems: [{ sku: 'D-1' }, { sku: 'D-2' }] } }] } } + +describe.each(rowsOfBothKinds)('a table of lines below each row of a table whose cells %s', (_, useForm) => { + const meta = phasesWithLines(useForm, false) + + it('keeps the title typed in a row as a line is removed from its table', async () => { + const { host } = mount(meta, phases) + const edited = { dataKind: { phases: [{ title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-2' }] } }] } } + + type('dataKind.phases[0].title', 'Design 2') + await click('Remove line', 0) + + expect(field('dataKind.phases[0].title')).toHaveValue('Design 2') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) + + it('keeps the title typed in a row as a line is added to its table', async () => { + const { host } = mount(meta, phases) + const edited = { dataKind: { phases: [{ title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-1' }, { sku: 'D-2' }, { sku: 'D-3' }] } }] } } + + type('dataKind.phases[0].title', 'Design 2') + type('dataKind.phases.0.dataKind.lineItems[2].sku', 'D-3') + await click('Add line') + + expect(field('dataKind.phases[0].title')).toHaveValue('Design 2') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) +}) + +describe('a table of lines with forms of their own below each row of a table whose cells have forms of their own', () => { + it('keeps what was typed in the row, and in the line after a removed one, which moves up', async () => { + const { host } = mount(phasesWithLines(true, true), phases) + const edited = { dataKind: { phases: [{ title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-2 edited' }] } }] } } + + type('dataKind.phases[0].title', 'Design 2') + type('dataKind.phases.0.dataKind.lineItems[1].sku', 'D-2 edited') + await click('Remove line', 0) + + expect(field('dataKind.phases[0].title')).toHaveValue('Design 2') + expect(field('dataKind.phases.0.dataKind.lineItems[0].sku')).toHaveValue('D-2 edited') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) +}) + +describe.each(rowsOfBothKinds)('an Add and a Remove in a table whose cells %s', (_, useForm) => { + // Each row's cells are its `kind` and a Remove button, and its `note` shows below it, in the document's form. + const meta = { + view: 'Col', + items: [{ + view: 'Table', + name: 'dataKind.items', + headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }], + itemsExpanded: true, + renderItemCells: { + view: 'Data', kind: 'items', embedded: true, ...useForm && { useForm: true }, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text' }, + { view: 'Button', children: 'Remove', onClick: 'removeData' }, + ], + }, + }, + renderItem: { view: 'Col', items: [{ view: 'Input', name: 'note', type: 'text' }] }, + renderExtraItem: { + view: 'Data', kind: 'items', embedded: true, initialValues: {}, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text', label: 'New kind' }, + { view: 'Button', children: 'Add', onClick: 'addData' }, + ], + }, + }, + }, { view: 'Button', children: 'Submit', onClick: 'submit' }], + } + + it('keeps what was typed in a row and below it as a row is added', async () => { + const { host } = mount(meta, { dataKind: { items: [{ kind: 'a', note: 'old' }] } }) + const edited = { dataKind: { items: [{ kind: 'typed', note: 'new' }, { kind: 'b' }] } } + + type('dataKind.items[0].kind', 'typed') + type('dataKind.items[0].note', 'new') + type('dataKind.items[1].kind', 'b') + await click('Add') + + expect(field('dataKind.items[0].kind')).toHaveValue('typed') + expect(field('dataKind.items[0].note')).toHaveValue('new') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) + + it('moves what was typed in the rows after a removed one up with them', async () => { + const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'b' }, { kind: 'c' }] } }) + const edited = { dataKind: { items: [{ kind: 'a-edited' }, { kind: 'c-edited' }] } } + + type('dataKind.items[0].kind', 'a-edited') + type('dataKind.items[1].kind', 'b-edited') + type('dataKind.items[2].kind', 'c-edited') + await click('Remove', 1) + + expect(field('dataKind.items[0].kind')).toHaveValue('a-edited') + expect(field('dataKind.items[1].kind')).toHaveValue('c-edited') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) + + // The second row moves up with its edit, and the third, which reads as the second did, takes its place. A form of + // its own handed a row that reads as the one it started from is not started again (`WithForm`): until its start + // moved to what the data holds of it (`settleWrittenRows`), it wrote its edit over that row too. + it('keeps an edit to the row it was made in, as a row that reads the same moves into its place', async () => { + const { host } = mount(meta, { dataKind: { items: [{ kind: 'x' }, { kind: 'a' }, { kind: 'a' }] } }) + const edited = { dataKind: { items: [{ kind: 'a1' }, { kind: 'a' }] } } + + type('dataKind.items[1].kind', 'a1') + await click('Remove', 0) + + expect(field('dataKind.items[0].kind')).toHaveValue('a1') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) + + it('shows the rows a host hands it after an Add kept the edits in the data', async () => { + const { host, handOver } = mount(meta, { dataKind: { items: [{ kind: 'a', note: 'old' }] } }) + type('dataKind.items[0].kind', 'typed') + type('dataKind.items[0].note', 'new') + type('dataKind.items[1].kind', 'b') + await click('Add') + + handOver({ dataKind: { items: [{ kind: 'from the host', note: 'from the host' }] } }) + + expect(field('dataKind.items[0].kind')).toHaveValue('from the host') + expect(field('dataKind.items[0].note')).toHaveValue('from the host') + expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'from the host', note: 'from the host' }] } }) + }) +}) + +// What an Add writes of a row is what the row's form changed (`changedInside`): a field its cells only show holds the +// value the row started with, which, written, took back an edit the expanded content made of a field of the same name. +describe.each(rowsOfBothKinds)('an Add in a table whose cells %s, one of them showing a field edited below them', (_, useForm) => { + it('keeps the edit below, and the one in the cells', async () => { + const meta = { + view: 'Col', + items: [{ + view: 'Table', + name: 'dataKind.items', + headers: [{ id: 'kind', label: 'Kind' }, { id: 'note', label: 'Note' }], + itemsExpanded: true, + renderItemCells: { + view: 'Data', kind: 'items', embedded: true, ...useForm && { useForm: true }, + meta: { + view: 'TableCells', + items: [{ view: 'Input', name: 'kind', type: 'text' }, { view: 'Input', name: 'note', type: 'text', readonly: true }], + }, + }, + renderItem: { view: 'Col', items: [{ view: 'Input', name: 'note', type: 'text' }] }, + renderExtraItem: { + view: 'Data', kind: 'items', embedded: true, initialValues: {}, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text', label: 'New kind' }, + { view: 'Button', children: 'Add', onClick: 'addData' }, + ], + }, + }, + }, { view: 'Button', children: 'Submit', onClick: 'submit' }], + } + const { host } = mount(meta, { dataKind: { items: [{ kind: 'a', note: 'old' }] } }) + const expandedNote = () => Array.from(document.querySelectorAll('[name="dataKind.items[0].note"]')).pop() + const edited = { dataKind: { items: [{ kind: 'typed', note: 'new' }, { kind: 'b' }] } } + + type('dataKind.items[0].kind', 'typed') + fireEvent.focus(expandedNote()) + fireEvent.change(expandedNote(), { target: { value: 'new' } }) + fireEvent.blur(expandedNote()) + type('dataKind.items[1].kind', 'b') + await click('Add') + + expect(field('dataKind.items[0].kind')).toHaveValue('typed') + expect(expandedNote()).toHaveValue('new') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) +}) + +// A `useForm` block whose `name` or `data` selects no values holds the whole document, as the document's own form does +// (`wholeDocument`), and the merges take from each the fields it changed (`mergeDocuments`). An Add and a Remove in a +// table beside it write the rows' edits into the document's form all the same, and move the start of each row they keep +// written to what that form held of it once written. +describe.each(rowsOfBothKinds)('an Add and a Remove beside a `useForm` block that holds the whole document, in a table whose cells %s', (_, useForm) => { + const meta = { + view: 'Col', + items: [ + { view: 'Data', kind: 'block', useForm: true, meta: { view: 'Col', items: [{ view: 'Input', name: 'title', type: 'text' }] } }, + { + view: 'Table', + name: 'dataKind.items', + headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }], + renderItemCells: { + view: 'Data', kind: 'items', embedded: true, ...useForm && { useForm: true }, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text' }, + { view: 'Button', children: 'Remove', onClick: 'removeData' }, + ], + }, + }, + renderExtraItem: { + view: 'Data', kind: 'items', embedded: true, initialValues: {}, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text', label: 'New kind' }, + { view: 'Button', children: 'Add', onClick: 'addData' }, + ], + }, + }, + }, + { view: 'Button', children: 'Submit', onClick: 'submit' }, + ], + } + + it('keeps what was typed in a row as a row is added and another removed', async () => { + const { host } = mount(meta, { title: 'T', dataKind: { items: [{ kind: 'a' }, { kind: 'b' }] } }) + const edited = { title: 'T', dataKind: { items: [{ kind: 'b-edited' }, { kind: 'c' }] } } + + type('dataKind.items[1].kind', 'b-edited') + type('dataKind.items[2].kind', 'c') + await click('Add') + await click('Remove', 0) + + expect(field('dataKind.items[0].kind')).toHaveValue('b-edited') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) + + it('keeps an edit to the row it was made in, as a row that reads the same moves into its place', async () => { + const { host } = mount(meta, { title: 'T', dataKind: { items: [{ kind: 'x' }, { kind: 'a' }, { kind: 'a' }] } }) + const edited = { title: 'T', dataKind: { items: [{ kind: 'a1' }, { kind: 'a' }] } } + + type('dataKind.items[1].kind', 'a1') + await click('Remove', 0) + + expect(field('dataKind.items[0].kind')).toHaveValue('a1') + expect(host.reader()).toEqual(edited) + await click('Submit') + expect(host.submitted).toEqual([edited]) + }) +}) + +// A row an Add or a Remove leaves where it was, and as it was, goes back into the data they rebuild as its form started +// from it, so that its form keeps what it holds (`dataKindPush.ts`), as it did before the edits were written. Written +// into that data with the other rows' edits, its edit started its form again from it. Measured while this was made: the +// row was no longer dirty, and an error a field of it showed was gone from the field and from what the host was told, +// until the field was touched again. +describe('a `useForm` row an Add or a Remove leaves where it was and as it was', () => { + const meta = { + view: 'Table', + name: 'dataKind.items', + headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }], + renderItemCells: { + view: 'Data', kind: 'items', embedded: true, useForm: true, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text', validate: 'required', required: true }, + { view: 'Button', children: 'Remove', onClick: 'removeData' }, + ], + }, + }, + renderExtraItem: { + view: 'Data', kind: 'items', embedded: true, initialValues: {}, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text', label: 'New kind' }, + { view: 'Button', children: 'Add', onClick: 'addData' }, + ], + }, + }, + } + /** The form of the row that holds the field `name`. */ + const formOf = name => { + let holder + formsStorage.forEach(({ form }) => { if (form.getFieldState(name)) holder = form }) + return holder + } + + it('keeps its form through an Add: its edit, and the error a field of it shows and the host is told of', async () => { + const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'x' }] } }, { hearErrors: true }) + type('dataKind.items[0].kind', 'typed') + type('dataKind.items[1].kind', '') + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) + expect(shown('dataKind.items[1].kind')).toBe('Required') + expect(host.errors[host.errors.length - 1]).toEqual(['dataKind.items[1].kind']) + + type('dataKind.items[2].kind', 'b') + await click('Add') + + expect(field('dataKind.items[0].kind')).toHaveValue('typed') + expect(formOf('dataKind.items[0].kind').getState().pristine).toBe(false) + expect(shown('dataKind.items[1].kind')).toBe('Required') + expect(host.errors[host.errors.length - 1]).toEqual(['dataKind.items[1].kind']) + expect(field('dataKind.items[2].kind')).toHaveValue('b') + // The data holds the row as it started, so its form gives its edit, from what it started with. + expect(host.reader().dataKind.items[0]).toEqual({ kind: 'typed' }) + type('dataKind.items[0].kind', 'a') + expect(host.reader().dataKind.items[0]).toEqual({ kind: 'a' }) + }) + + it('keeps its form through a Remove of a row after it', async () => { + const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'b' }, { kind: 'c' }] } }) + type('dataKind.items[0].kind', 'a-edited') + + await click('Remove', 2) + + expect(field('dataKind.items[0].kind')).toHaveValue('a-edited') + expect(formOf('dataKind.items[0].kind').getState().pristine).toBe(false) + expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'a-edited' }, { kind: 'b' }] } }) + }) +}) + +// A row whose form starts from the whole document, not from its row (`engine/Data.tsx`): a `null` row, for which the +// `Data` view is handed the document, and a row whose cells read the document (`relativeData: false`). An Add or a Remove +// changes the document, so such a form starts again from the data they rebuild, whatever they did to its row: its values +// must be in that data. Found in review: put back, an outer row took a `null` line's value out of it, and a row of the +// second kind went back as it started. +describe('a `useForm` row whose form starts from the whole document', () => { + it('keeps what was typed in an empty line, in a row put back as it was, as a line is added below another row', async () => { + const { host } = mount(phasesWithLines(true, true), { + dataKind: { + phases: [ + { title: 'Design', dataKind: { lineItems: [{ sku: 'D-1' }, null] } }, + { title: 'Build', dataKind: { lineItems: [] } }, + ], + }, + }) + + type('dataKind.phases[0].title', 'Design 2') + type('dataKind.phases.0.dataKind.lineItems[1].sku', 'D-2') + type('dataKind.phases.1.dataKind.lineItems[0].sku', 'B-1') + await click('Add line', 1) + + expect(field('dataKind.phases[0].title')).toHaveValue('Design 2') + expect(field('dataKind.phases.0.dataKind.lineItems[1].sku')).toHaveValue('D-2') + expect(host.reader()).toEqual({ + dataKind: { + phases: [ + { title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-1' }, { sku: 'D-2' }] } }, + { title: 'Build', dataKind: { lineItems: [{ sku: 'B-1' }] } }, + ], + }, + }) + }) + + it('keeps what was typed in a row whose cells read the document, as a row is added', async () => { + const meta = { + view: 'Table', + name: 'dataKind.items', + headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }], + renderItemCells: { + view: 'Data', kind: 'items', embedded: true, useForm: true, relativeData: false, + meta: { view: 'TableCells', items: [{ view: 'Input', name: 'kind', type: 'text' }] }, + }, + renderExtraItem: { + view: 'Data', kind: 'items', embedded: true, initialValues: {}, + meta: { + view: 'TableCells', + items: [ + { view: 'Input', name: 'kind', type: 'text', label: 'New kind' }, + { view: 'Button', children: 'Add', onClick: 'addData' }, + ], + }, + }, + } + const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'b' }] } }) + + type('dataKind.items[0].kind', 'typed') + type('dataKind.items[2].kind', 'c') + await click('Add') + + expect(field('dataKind.items[0].kind')).toHaveValue('typed') + expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'typed' }, { kind: 'b' }, { kind: 'c' }] } }) + }) +}) diff --git a/src/core/engine/dataKindPush.ts b/src/core/engine/dataKindPush.ts index be4659a2..3e96d28d 100644 --- a/src/core/engine/dataKindPush.ts +++ b/src/core/engine/dataKindPush.ts @@ -2,6 +2,9 @@ import { get } from '../utils' import { cloneDeep, set } from '../utils/object' import { phraseMessage } from '../utils/phrases' import type { PhraseMessage } from '../utils/phrases' +import { dataFormsOf } from '../state/formRegistry' +import { changedRowsFor, settleWrittenRows } from './formData' +import type { ChangedRow, WrittenForm } from './formData' /** A row, or a tree of rows, as final-form holds them. */ type Values = Record @@ -10,6 +13,8 @@ type Values = Record export type DataKindForm = { getState: () => { values?: unknown } reset: (values?: unknown) => void + change: (name: string, value: unknown) => void + batch: (changes: () => void) => void mutators?: { push?: (path: string, value: unknown) => void remove?: (path: string, index: number) => unknown @@ -20,6 +25,8 @@ export type DataKindForm = { export type DataKindParent = { props?: { instance?: { form?: DataKindForm } } setState?: (update: (prev: { data?: object }) => object, callback?: () => void) => void + /** Its document tree, whose table rows with a form of their own have their edits kept (`keepRowEdits`) */ + formTree?: object } /** A `Data` block's meta, as far as its rows are located. */ @@ -200,6 +207,26 @@ export function dataKindPathFor (meta: DataKindMeta, kind: string | undefined, f return basePath ? `${basePath}.dataKind` : 'dataKind' } +/** + * Writes the rows whose own forms hold what the parent form does not into the parent form, as `changedRowsFor` gives + * them, before a row is added to it or removed from it (2026-10-27). An Add and a Remove rebuild the document's data + * from that form's values, which held none of what such a row's fields hold, and a row whose data that changed started + * again from it (`WithForm`), what was typed in its cells gone: a row whose expanded table gained or lost a line, or + * whose expanded content had an edit, and the rows after a removed one, which move up a row. Written while each row is + * at the place its form has it, so a removal moves an edit with its row. In one batch: the form's listeners hear once. + * Once the row is added or removed, `settleWrittenRows` puts back what that left as it was. + * + * @param {Object} parentForm - the form the row is added to or removed from + * @param {Map} forms - the forms of its document tree, `dataFormsOf` it; none, nothing is written + * @returns {Object} the rows written, and the form with its values once they were, for `settleWrittenRows` + */ +function keepRowEdits (parentForm: DataKindForm, forms: ReturnType): { rows: ChangedRow[], written: WrittenForm } { + const rows = changedRowsFor(parentForm, forms) + if (rows.length) parentForm.batch(() => rows.forEach(({ place, row }) => parentForm.change(place, row))) + // A cast, not a guard: the form's values are an object, which the caller has checked. + return { rows, written: { form: parentForm, values: parentForm.getState().values as Values } } +} + /** * Append one plain row object via the parent final-form array mutator, then sync `instance.state.data.json` * from form values (same pattern as REMOVE_DATA). Do not also splice `data.json` manually — when that array @@ -251,8 +278,11 @@ export function pushDataKindRow ({ parentUIRender, meta, kind, rowObject, fallba console.warn(`ADD_DATA: dataKind not found at "${dataKindPath}" (kind="${kind}", relativePath="${rel}")`) return false } + const forms = dataFormsOf(parentUIRender.formTree) + const { rows, written } = keepRowEdits(parentForm, forms) parentForm.mutators.push(arrayPath, rowObject) const nextJson = cloneDeep(parentForm.getState().values) + settleWrittenRows(nextJson, rows, written, forms) parentUIRender.setState( (prev) => ({ data: { @@ -298,7 +328,7 @@ export function pushDataKindRow ({ parentUIRender, meta, kind, rowObject, fallba */ /** What `removeDataKindRow` is given: see the parameters above. */ export type RemoveDataKindRowArgs = { - parentUIRender: Required> + parentUIRender: Required> & Pick parentForm?: DataKindForm | null meta?: DataKindMeta kind: string @@ -312,8 +342,11 @@ export function removeDataKindRow ({ parentUIRender, parentForm, meta, kind, ind console.warn('REMOVE_DATA: parent form or mutators.remove is not available') return false } + const forms = dataFormsOf(parentUIRender.formTree) + const { rows, written } = keepRowEdits(parentForm, forms) parentForm.mutators.remove(arrayPath, Number(index)) const nextJson = compactDataKindArrays(cloneDeep(parentForm.getState().values)) + settleWrittenRows(nextJson, rows, written, forms) parentUIRender.setState((prev) => ({ data: { ...prev.data, diff --git a/src/core/engine/formData.ts b/src/core/engine/formData.ts index 12150c0b..cdd6fbdf 100644 --- a/src/core/engine/formData.ts +++ b/src/core/engine/formData.ts @@ -7,7 +7,7 @@ */ import { getIn, setIn } from 'final-form' import type { FormApi, FormState } from 'final-form' -import { get, merge, isEqual, isObject, hasObjectValue, set } from '../utils/object' +import { get, merge, isEqual, isEqualJSON, isObject, hasObjectValue, set } from '../utils/object' import type { RegisteredForm } from '../state/formRegistry' import { FIELD } from '../modules' import { fieldNamesOf } from '../modules/form/batchedForm' @@ -445,6 +445,130 @@ function mergeRows (at: string, arrays: unknown[][], owned: Owned[]): object[] { return rows } +/** + * A row `changedRowsFor` gives: where it is, the row as it is to be written there, and, where the row may go back into + * the data as its form's `initialValues` hold it, that row (`initial`). + */ +export type ChangedRow = { place: string, row: unknown, initial?: object } + +/** The document's form the rows were written into, and its values once they were, as `settleWrittenRows` reads them. */ +export type WrittenForm = { form: { getState: unknown }, values: Values } + +/** + * THE ROWS WHOSE OWN FORMS HOLD WHAT `form` DOES NOT, AS `form` IS TO HOLD THEM (2026-10-27): for each table row with a + * form of its own, its place and the row as `form`'s values have it, with the values of the fields the row's form + * changed of its start (`changedInside`, `startOf`) written at their names (`writeField`), as `mergeForms` writes them, + * where that makes the row another. An Add and a Remove write them into the document's form before they add or remove + * a row there (`dataKindPush.ts`): the data they rebuild is that form's values. + * + * - Left out: a row its array in `form`'s values does not hold, as the live merge leaves it out (one just removed, or a + * hole in the array a row's own form holds), a row whose place there has another shape, which final-form throws + * for, and `form` itself, whose values hold its fields. + * - Shallowest first, each written over the rows before it, as `mergeForms` writes them: a row with a form of its own + * inside another such row comes after it, and is in the outer row as given. + * - `initial` is the row the row's form's `initialValues` hold, where putting it back leaves that form as it is: they + * hold just that row at its place, as a `useForm` row's do (`engine/Data.tsx`), `form` holds the row just so, and + * each row given inside it has an `initial` too. An Add or a Remove that leaves the row where it was, and as it was, + * puts it back into the data it rebuilds. A form that starts from the whole document, a `null` row's or one whose + * cells read the document (`relativeData: false`), starts again from that data whatever they did to its row: its + * values must be in it, and so in any row around it. Found in review: put back, an outer row took a `null` line's + * value away. + * + * @param {Object} form - the form the rows are to be written into, as far as its values are read + * @param {Map} forms - the forms of its document tree, `dataFormsOf` + * @returns {Array} the rows to write, each at its place, shallowest first + */ +export function changedRowsFor (form: { getState: () => { values?: unknown } }, forms: Forms): ChangedRow[] { + const { values } = form.getState() + const owned: Array<{ place: string, names: string[], values: Values, initialValues: unknown }> = [] + forms.forEach(entry => { + // A cast, not a guard: what a form registers as its meta is its document's meta. + const meta = entry.meta as MetaNode | undefined + const place = rowPlaceOf(meta) + if (!place || entry.form.getState === form.getState) return + const rows = get(values, meta!.relativePath!) // not undefined: `rowPlaceOf` read both + if (!Array.isArray(rows) || !(Number(meta!.relativeIndex) in rows)) return + const state = entry.form.getState() + const names = changedInside(fieldsOf(entry.form), place, state.values, startOf(entry.form, state.initialValues)) + if (names.length) owned.push({ place, names, values: state.values, initialValues: state.initialValues }) + }) + // A cast, not a guard: the values hold the rows' arrays, read above. + let next = values as object + const changed: Array<{ place: string, initial?: object }> = [] + owned.sort((a, b) => depthOf(a.place) - depthOf(b.place)).forEach(({ place, names, values: own, initialValues }) => { + const row = get(next, place) + const from = dotted(place).length + 1 + const written = names.reduce((target: unknown, name) => writeField(target, name.slice(from), getIn(own, name)), row) + if (isEqual(written, row)) return + try { + next = setIn(next, place, written) + } catch (anotherShape) { + return + } + const initial = get(initialValues, place) + const placed = isObject(initial) && isEqual(get(values, place), initial) && + isEqualJSON(initialValues, setIn({}, place, initial)) + changed.push({ place, initial: placed ? initial : undefined }) + }) + changed.forEach(outer => { + const inside = `${dotted(outer.place)}.` + if (changed.some(({ place, initial }) => initial === undefined && dotted(place).startsWith(inside))) { + outer.initial = undefined + } + }) + return changed.map(({ place, initial }) => ({ place, row: get(next, place), initial })) +} + +/** + * WHAT AN ADD OR A REMOVE LEAVES OF THE ROWS `changedRowsFor` GAVE, ONCE IT HAS ADDED OR REMOVED ITS ROW (2026-10-27). + * + * - A row the operation left where it was, and as it was, goes back into `json` as its form started from it + * (`initial`), shallowest first, so that an outer row takes the rows inside it along. Its form is handed what it + * started from, and keeps what it holds as it did before: its edits, its touched fields, and the errors it shows and + * tells the host. Written into the data, they started the form again from it, as one the operation moved or changed + * starts. + * - The form of every other row written measures its changes from what the document's form held of its fields once + * they were written (`restartWrittenRows`), as after a submit: the data holds its edits now. Read as they were + * written, before the row was added or removed: where each row still was. Most such forms start again from the data + * anyway, as their row moved or changed. One handed a moved row that reads as the one it started from does not, and + * wrote its edits over that row. A row put back keeps its start, as does each row inside it: the data holds none of + * their edits. + * + * @param {Object} json - the data the operation rebuilt from the document's form, which this changes + * @param {Array} rows - what `changedRowsFor` gave, shallowest first + * @param {Object} written - the document's form the rows were written into, and its values once they were + * @param {Map} forms - the forms of its document tree, `dataFormsOf` + */ +export function settleWrittenRows (json: unknown, rows: ChangedRow[], written: WrittenForm, forms: Forms): void { + // The places of the rows put back, each as the start of the places inside it. + const back: string[] = [] + rows.forEach(({ place, row, initial }) => { + if (initial === undefined || !isEqualJSON(get(json, place), row)) return + set(json, place, cloneDeep(initial)) + back.push(`${dotted(place)}.`) + }) + const kept = new Set(rows.map(({ place }) => `${dotted(place)}.`) + .filter(place => !back.some(outer => place.startsWith(outer)))) + // The rows kept written, and the forms that hold the document from its root, which `restartWrittenRows` reads: the + // one written as it was then. + const restarted = new Map() + forms.forEach((entry, key) => { + // A cast, not a guard: what a form registers as its meta is its document's meta. + const meta = entry.meta as MetaNode | undefined + const place = rowPlaceOf(meta) + if (place) { + if (kept.has(`${dotted(place)}.`)) restarted.set(key, entry) + } else if (mergedFromTheRoot(meta)) { + // A cast, not a guard: `restartWrittenRows` reads this one for its values (`documentFormsOf`). It has no fields and + // no start, so it writes no field over the others (`mergeDocuments`) and its start never moves: the operation + // starts the form written again from the data it rebuilt. + const then = { getState: () => ({ values: written.values }) } as unknown as FormApi + restarted.set(key, entry.form.getState === written.form.getState ? { ...entry, form: then } : entry) + } + }) + restartWrittenRows(restarted) +} + /** * Get raw form values without Select reordering. * Used for showIf lookups where array indices must match the original data order. diff --git a/src/demo/markdowns/changelog.md b/src/demo/markdowns/changelog.md index 9248d8b7..d47b349f 100644 --- a/src/demo/markdowns/changelog.md +++ b/src/demo/markdowns/changelog.md @@ -14,9 +14,17 @@ document's copy of the row. A change is measured from what the document's form holds of the row, so an edit set back after a submit is one too. The rest of the row stays as the document has it, so an edit made in the row's expanded content, or in a table there, is kept, of a field the row's cells show as well. A submit sends the edits of - such a row in a table in another table's expanded content too, which 0.35.0 sent as they were. An Add or a Remove - still loses what was typed in the cells of a row whose data it changes, such as a row whose expanded table gains or - loses a line, or the rows after a removed one, which move up a row: they start again from the document's copy. + such a row in a table in another table's expanded content too, which 0.35.0 sent as they were. +- **An Add or a Remove keeps what was typed in the cells of a table row that `useForm` gives a form of its own.** Both + rebuild the document's data from its form, which held none of what such a row's cells hold, and a row whose data + that changed started again from the document's copy: a row whose expanded table gained or lost a line, a row whose + expanded content had an edit, and every row after a removed one, which moves up a row. An empty (`null`) row, and a + row whose cells read the document (`relativeData: false`), whose forms start from the whole document, lost theirs on + any Add or Remove. The values of the fields each such row's form changed are now written into the document's form + before a row is added or removed, so the rebuilt data holds them, and such a row starts again from it with its + edits, as the document's form does. A row the Add or the Remove leaves where it was, and as it was, keeps its form + as it was: its edits, its touched fields, and the errors it shows and the host is told of. A row that shares the + document's form kept its edits, and still does. - **An edit in a `useForm` block that holds the whole document is in what the document reads.** A `Data` view with `useForm` whose `name` or `data` selects no values starts its form from the document's values, and the document's own form holds them too. An edit made in such a block of a value the document had was missing from `getFormData`, a