From fab9919a77760f318f23c5e683a400cba1a8eba6 Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Tue, 27 Oct 2026 15:06:20 +0100 Subject: [PATCH 1/2] EPBDS-17018: an Add or a Remove keeps what was typed in the cells of a `useForm` table row An Add and a Remove rebuild the document's data from its form, which held none of what a `useForm` row's fields hold, and a row whose data that changed started again from the document's copy, its cells' edits gone: a row whose expanded table gained or lost a line, a row whose expanded content had an edit, every row after a removed one, which moves up a row, and a row whose form starts from the whole document, an empty row's or one whose cells read the document, on any Add or Remove. Rows that share the document's form kept their edits. The fields each such row's form changed are now written into the document's form before the row is added or removed, while each row is still where its form has it (`changedRowsFor`, `keepRowEdits`). A row the operation leaves where it was, and as it was, goes back into the rebuilt data as its form started from it, so that its form keeps its edits, its touched fields and its errors; every other row written measures its changes from what the document's form held of it once written (`settleWrittenRows`, `restartWrittenRows`). --- CLAUDE.md | 7 +- src/core/engine/__tests__/formData.test.js | 191 +++++++++ .../__tests__/rules.row-form-values.test.js | 394 +++++++++++++++++- src/core/engine/dataKindPush.ts | 35 +- src/core/engine/formData.ts | 124 +++++- src/demo/markdowns/changelog.md | 14 +- 6 files changed, 754 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1015423..88ec702b 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. diff --git a/src/core/engine/__tests__/formData.test.js b/src/core/engine/__tests__/formData.test.js index 3a95476b..d5974ad6 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('getFormsData', () => { it('returns master form values when only one form', () => { const forms = new Map([ 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..58db6ea2 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,385 @@ 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 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 7e4c982f..874f7297 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 } 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' @@ -340,6 +340,128 @@ 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 (!(meta && meta.relativePath)) { + // A cast, not a guard: `restartWrittenRows` reads a document's form for its values only (`documentValuesOf`). + 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 20260650..bbc760e6 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. - **A `showIf` that reads the form merges the document's forms once, not once per node.** A node whose `showIf` reads a field decides by every form of its document merged, and each node merged them as it decided, so a table whose rows each hold such a node copied its forms once per row on every render of the rows. Measured on React 18.3 before From a5e1a06237292268f32998bdf53b1b506c966b3a Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Tue, 27 Oct 2026 17:44:10 +0100 Subject: [PATCH 2/2] EPBDS-17018: an Add and a Remove beside a `useForm` block that holds the whole document Since #254 the merges take, from each form that holds the whole document, the fields it changed (`mergeDocuments`), and `restartWrittenRows` reads such forms that way. Two tests, each on rows with forms of their own and on rows that share the document's form, in a table beside such a block: a row's edit through an Add and then a Remove of the row before it, and an edit kept in its row as a row that reads the same moves into its place, in the field, `getFormData` and a submit. Measured on this branch: without the edits written, both fail on rows with forms of their own; without the starts moved, the second fails; with the starts of the rows put back moved too, the first fails. --- .../__tests__/rules.row-form-values.test.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 58db6ea2..14c3b0dc 100644 --- a/src/core/engine/__tests__/rules.row-form-values.test.js +++ b/src/core/engine/__tests__/rules.row-form-values.test.js @@ -1139,6 +1139,73 @@ describe.each(rowsOfBothKinds)('an Add in a table whose cells %s, one of them sh }) }) +// 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