From 3f4095cc8ef0ca790a3dfc0910e5b86462d019c2 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 09:47:27 +0100 Subject: [PATCH 1/4] fix(renderer): skip unknown keys in the userData setter instead of throwing Assigning saved answers with a key the form no longer has threw a TypeError and dropped every key after it. Skip such keys, log one warning listing them, find the form the same way the getter does, and select every value of an array for a multiple select. Refs #123 Refs #229 --- docs/renderer/renderer.md | 4 ++ src/lib/js/renderer/index.js | 24 ++++++-- src/lib/js/renderer/renderer.test.js | 86 +++++++++++++++++++++++++++- tests/renderer-user-data.spec.js | 36 ++++++++++++ 4 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tests/renderer-user-data.spec.js diff --git a/docs/renderer/renderer.md b/docs/renderer/renderer.md index a334ced1..8dcc6b8c 100644 --- a/docs/renderer/renderer.md +++ b/docs/renderer/renderer.md @@ -182,6 +182,8 @@ renderer.userData = { } ``` +> Keys with no matching field are skipped, and the renderer logs one console warning that lists them. The setter never throws, so answers saved from an older version of a form still restore. A `` takes an array of values. + ### Handling Different Field Types #### Single Input Fields diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index 3a679048..0c3e34d7 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -125,10 +125,15 @@ export default class FormeoRenderer { ) } - set userData(data = {}) { - const form = this.container.querySelector('form') - for (const key of Object.keys(data)) { - const fields = form.elements[key] ?? form.elements[`${key}[]`] + set userData(data) { + const form = this.container?.querySelector('.formeo-render') || this.renderedForm + const unmatched = [] + for (const key of Object.keys(data ?? {})) { + const fields = form?.elements[key] ?? form?.elements[`${key}[]`] + if (!fields) { + unmatched.push(key) + continue + } // a group with a single option resolves to the input itself rather than a RadioNodeList const checkables = checkableInputs(fields) @@ -152,11 +157,22 @@ export default class FormeoRenderer { field.checked = field.value === data[key] } } + // A multiple select takes every value in an array + else if (fields.type === 'select-multiple') { + const values = [data[key]].flat().map(String) + for (const option of fields.options) { + option.selected = values.includes(option.value) + } + } // Handle single inputs else if (fields.type) { fields.value = data[key] } } + // saved answers can outlive the form they came from, so a missing field is a warning, never an error + if (unmatched.length) { + console.warn(`formeo: renderer.userData has no field named: ${unmatched.join(', ')}`) + } } /** diff --git a/src/lib/js/renderer/renderer.test.js b/src/lib/js/renderer/renderer.test.js index d7c4878f..b43096dc 100644 --- a/src/lib/js/renderer/renderer.test.js +++ b/src/lib/js/renderer/renderer.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { afterEach, beforeEach, describe, test } from 'node:test' +import { afterEach, beforeEach, describe, mock, test } from 'node:test' import { JSDOM } from 'jsdom' import FormeoRenderer from './index.js' @@ -1024,6 +1024,90 @@ describe('FormeoRenderer', () => { }) }) + describe('userData setter with keys the form lacks (#123, #229)', () => { + const formData = () => ({ + id: 'ud-form', + stages: { 's-1': { id: 's-1', children: ['r-1'] } }, + rows: { 'r-1': { id: 'r-1', config: {}, children: ['c-1'] } }, + columns: { 'c-1': { id: 'c-1', config: { width: '100%' }, children: ['txt', 'multi'] } }, + fields: { + txt: { id: 'txt', tag: 'input', attrs: { type: 'text', name: 'txt' }, config: { label: 'Text' } }, + multi: { + id: 'multi', + tag: 'select', + attrs: { name: 'multi', multiple: true }, + config: { label: 'Multi' }, + options: [ + { label: 'X', value: 'x' }, + { label: 'Y', value: 'y' }, + { label: 'Z', value: 'z' }, + ], + }, + }, + }) + const mounted = () => { + const renderer = new FormeoRenderer({ renderContainer: document.getElementById('container') }) + renderer.render(formData()) + return renderer + } + + test('skips an unknown key and still applies the keys after it', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + const renderer = mounted() + assert.doesNotThrow(() => { + renderer.userData = { nope: 1, txt: 'later' } + }) + assert.equal(document.querySelector('[name="txt"]').value, 'later') + } finally { + warn.mock.restore() + } + }) + + test('warns once, listing every unmatched key', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + mounted().userData = { nope: 1, txt: 'a', other: 2 } + assert.equal(warn.mock.callCount(), 1) + assert.equal(warn.mock.calls[0].arguments[0], 'formeo: renderer.userData has no field named: nope, other') + } finally { + warn.mock.restore() + } + }) + + test('does not warn when every key matches', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + mounted().userData = { txt: 'a' } + assert.equal(warn.mock.callCount(), 0) + } finally { + warn.mock.restore() + } + }) + + test('never throws before render or for null', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + const renderer = new FormeoRenderer({ renderContainer: document.getElementById('container') }) + assert.doesNotThrow(() => { + renderer.userData = { txt: 'a' } + }) + assert.doesNotThrow(() => { + mounted().userData = null + }) + } finally { + warn.mock.restore() + } + }) + + test('selects every value of an array for a multiple select', () => { + const renderer = mounted() + renderer.userData = { multi: ['x', 'z'] } + const selected = [...document.querySelector('[name="multi"]').selectedOptions].map(option => option.value) + assert.deepEqual(selected, ['x', 'z']) + }) + }) + describe('custom controls (#228)', () => { test('elements[controlId].action.onRender runs for a custom control once it is in the page', async () => { const seen = [] diff --git a/tests/renderer-user-data.spec.js b/tests/renderer-user-data.spec.js new file mode 100644 index 00000000..9cdf4e48 --- /dev/null +++ b/tests/renderer-user-data.spec.js @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test' + +test.describe('renderer.userData setter (#123, #229)', () => { + test('skips keys the form lacks with one warning and no page error', async ({ page }) => { + const errors = [] + const warnings = [] + page.on('pageerror', error => errors.push(error.message)) + page.on('console', message => message.type() === 'warning' && warnings.push(message.text())) + + await page.goto('/') + await expect(page.locator('.formeo-editor')).toBeVisible() + warnings.length = 0 + + await page.evaluate(() => { + const container = Object.assign(document.createElement('div'), { id: 'ud-container' }) + document.body.appendChild(container) + const renderer = new window.FormeoRenderer({ renderContainer: container }) + renderer.render({ + id: 'ud-form', + stages: { 's-1': { id: 's-1', children: ['r-1'] } }, + rows: { 'r-1': { id: 'r-1', config: {}, children: ['c-1'] } }, + columns: { 'c-1': { id: 'c-1', config: { width: '100%' }, children: ['name'] } }, + fields: { + name: { id: 'name', tag: 'input', attrs: { type: 'text', name: 'name' }, config: { label: 'Name' } }, + }, + }) + renderer.userData = { nope: 'x', name: 'Ada' } + }) + + await expect(page.locator('#ud-container [name="name"]')).toHaveValue('Ada') + expect(errors).toEqual([]) + expect(warnings.filter(text => text.includes('renderer.userData'))).toEqual([ + 'formeo: renderer.userData has no field named: nope', + ]) + }) +}) From 8af6896d31e98d2e894b52d8c253abd3aacbb608 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 09:54:46 +0100 Subject: [PATCH 2/4] fix(renderer): render rows, columns and option groups without a config formBuilder2Formeo writes columns with no config, and hand-written formData often leaves it out, so the renderer threw reading width, legend or inline. Default config (and children) where the renderer reads them, and test with data shaped like the converter's output. Refs #212 --- src/lib/js/common/dom.js | 4 +- .../__fixtures__/formbuilder2formeo.mjs | 45 +++++++++++++++++ src/lib/js/renderer/index.js | 9 ++-- src/lib/js/renderer/renderer.test.js | 49 +++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src/lib/js/renderer/__fixtures__/formbuilder2formeo.mjs diff --git a/src/lib/js/common/dom.js b/src/lib/js/common/dom.js index 14b032ae..a30d1e17 100644 --- a/src/lib/js/common/dom.js +++ b/src/lib/js/common/dom.js @@ -570,10 +570,10 @@ class DOM { } if (attrs.className) { - elem.config.inputWrap = attrs.className + elem.config = { ...elem.config, inputWrap: attrs.className } } - if (elem.config.inline) { + if (elem.config?.inline) { inputWrap.className.push(`f-${fieldType}-inline`) } diff --git a/src/lib/js/renderer/__fixtures__/formbuilder2formeo.mjs b/src/lib/js/renderer/__fixtures__/formbuilder2formeo.mjs new file mode 100644 index 00000000..9bf70832 --- /dev/null +++ b/src/lib/js/renderer/__fixtures__/formbuilder2formeo.mjs @@ -0,0 +1,45 @@ +// Shaped like formBuilder2Formeo's convertData() output (Draggable/formBuilder2Formeo src/convert-data.js): +// columns have no `config`, the stage has `settings` rather than `config`, and fields carry no attrs.type. +const field = (id, data) => ({ id, ...data }) +const fields = { + 'fb-text': field('fb-text', { + tag: 'input', + config: { label: 'First Name' }, + attrs: { name: 'text-1532560573320', className: 'red form-control' }, + meta: { id: 'text', icon: 'text', group: 'form' }, + }), + 'fb-hidden': field('fb-hidden', { + tag: 'input', + attrs: { name: 'hidden-1532560563828' }, + meta: { id: 'hidden', icon: 'hidden', group: 'form' }, + }), + 'fb-select': field('fb-select', { + tag: 'select', + config: { label: 'Profession' }, + attrs: { name: 'select-1532560573336', className: 'form-control' }, + options: [ + { label: 'Street Sweeper', value: 'option-2' }, + { label: 'Brain Surgeon', value: 'option-3' }, + ], + meta: { id: 'select', icon: 'select', group: 'form' }, + }), +} +const ids = Object.keys(fields) + +export const convertedForm = () => ({ + id: 'fb-form', + fields: structuredClone(fields), + columns: Object.fromEntries(ids.map(id => [`col-${id}`, { id: `col-${id}`, children: [id] }])), + rows: Object.fromEntries( + ids.map(id => [ + `row-${id}`, + { + id: `row-${id}`, + config: { fieldset: false, legend: '', inputGroup: false }, + attrs: { className: 'f-row' }, + children: [`col-${id}`], + }, + ]) + ), + stages: { 'fb-stage': { id: 'fb-stage', settings: {}, children: ids.map(id => `row-${id}`) } }, +}) diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index 0c3e34d7..cb7cab7e 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -353,7 +353,7 @@ export default class FormeoRenderer { return renderedForm.outerHTML } - orderChildren = (type, order) => + orderChildren = (type, order = []) => order.reduce((acc, cur) => { acc.push(this.form[type][cur]) return acc @@ -366,11 +366,12 @@ export default class FormeoRenderer { * @param {Object} columnData * @return {Object} processed column data */ - processColumn = ({ id, ...columnData }) => ({ + processColumn = ({ id, config = {}, ...columnData }) => ({ ...columnData, + config, id: this.prefixId(id), children: this.processFields(columnData.children), - style: `width: ${columnData.config.width || '100%'}`, + style: `width: ${config.width || '100%'}`, }) processRows = stageId => @@ -392,7 +393,7 @@ export default class FormeoRenderer { * @return {Object} row config object */ processRow = (data, type = 'row') => { - const { config, id } = data + const { config = {}, id } = data const className = [`formeo-${type}-wrap`] const rowData = { ...data, children: this.processColumns(data.id), id: this.prefixId(id) } this.cacheComponent(rowData) diff --git a/src/lib/js/renderer/renderer.test.js b/src/lib/js/renderer/renderer.test.js index b43096dc..1efe0bf2 100644 --- a/src/lib/js/renderer/renderer.test.js +++ b/src/lib/js/renderer/renderer.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, mock, test } from 'node:test' import { JSDOM } from 'jsdom' +import { convertedForm } from './__fixtures__/formbuilder2formeo.mjs' import FormeoRenderer from './index.js' describe('FormeoRenderer', () => { @@ -1167,4 +1168,52 @@ describe('FormeoRenderer', () => { }) }) }) + + describe('formData without config objects (#212)', () => { + const renderForm = data => { + const renderer = new FormeoRenderer({ renderContainer: document.getElementById('container') }) + renderer.render(data) + return document.querySelector('#container form') + } + + test('renders formBuilder2Formeo output, whose columns have no config', () => { + const form = renderForm(convertedForm()) + for (const name of ['text-1532560573320', 'hidden-1532560563828', 'select-1532560573336']) { + assert.ok(form.elements[name], name) + } + // columns have no class of their own; the inline width style is what identifies them + for (const column of form.querySelectorAll('[style]')) { + assert.match(column.getAttribute('style'), /width: 100%/) + } + }) + + test('renders a row with no config', () => { + const data = convertedForm() + for (const row of Object.values(data.rows)) { + delete row.config + } + assert.ok(renderForm(data).elements['text-1532560573320']) + }) + + test('renders an option group with no config', () => { + const data = convertedForm() + data.fields['fb-text'] = { + id: 'fb-text', + tag: 'input', + attrs: { type: 'radio', name: 'size' }, + options: [ + { label: 'S', value: 's' }, + { label: 'M', value: 'm' }, + ], + } + assert.equal(renderForm(data).querySelectorAll('input[type="radio"]').length, 2) + }) + + test('renders a row or column with no children', () => { + const data = convertedForm() + delete data.rows['row-fb-text'].children + delete data.columns['col-fb-select'].children + assert.ok(renderForm(data).elements['hidden-1532560563828']) + }) + }) }) From 75e94cd1eab77f0fa9a42fdf250f02dbe7fdb6be Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 10:29:21 +0100 Subject: [PATCH 3/4] fix(renderer): say when userData is set before render, and cover the option-wrap className The userData setter blamed every key as unmatched when no form was rendered yet; warn once that it was set before render() instead, and return before touching a nonexistent form. Also strengthen two renderer.test.js assertions left too loose by earlier fixes: the option-group-with-no-config test now sets a field className and checks it lands on the group's wrap, and the formBuilder2Formeo column-style loop now asserts it actually iterates 3 columns. Refs #123 Refs #212 --- src/lib/js/renderer/index.js | 10 +++++++++- src/lib/js/renderer/renderer.test.js | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index cb7cab7e..0ed71f5f 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -127,8 +127,16 @@ export default class FormeoRenderer { set userData(data) { const form = this.container?.querySelector('.formeo-render') || this.renderedForm + const keys = Object.keys(data ?? {}) + if (!form) { + // no rendered form to blame missing fields on; the answers just have nowhere to go yet + if (keys.length) { + console.warn('formeo: renderer.userData was set before render(); nothing to fill') + } + return + } const unmatched = [] - for (const key of Object.keys(data ?? {})) { + for (const key of keys) { const fields = form?.elements[key] ?? form?.elements[`${key}[]`] if (!fields) { unmatched.push(key) diff --git a/src/lib/js/renderer/renderer.test.js b/src/lib/js/renderer/renderer.test.js index 1efe0bf2..6cd421b4 100644 --- a/src/lib/js/renderer/renderer.test.js +++ b/src/lib/js/renderer/renderer.test.js @@ -1086,16 +1086,25 @@ describe('FormeoRenderer', () => { } }) - test('never throws before render or for null', () => { + test('never throws before render or for null, and warns once before render()', () => { const warn = mock.method(console, 'warn', () => {}) try { const renderer = new FormeoRenderer({ renderContainer: document.getElementById('container') }) assert.doesNotThrow(() => { renderer.userData = { txt: 'a' } }) + assert.equal(warn.mock.callCount(), 1) + assert.equal( + warn.mock.calls[0].arguments[0], + 'formeo: renderer.userData was set before render(); nothing to fill' + ) + + warn.mock.resetCalls() + assert.doesNotThrow(() => { mounted().userData = null }) + assert.equal(warn.mock.callCount(), 0) } finally { warn.mock.restore() } @@ -1182,7 +1191,9 @@ describe('FormeoRenderer', () => { assert.ok(form.elements[name], name) } // columns have no class of their own; the inline width style is what identifies them - for (const column of form.querySelectorAll('[style]')) { + const columns = [...form.querySelectorAll('[style]')] + assert.equal(columns.length, 3) + for (const column of columns) { assert.match(column.getAttribute('style'), /width: 100%/) } }) @@ -1200,13 +1211,16 @@ describe('FormeoRenderer', () => { data.fields['fb-text'] = { id: 'fb-text', tag: 'input', - attrs: { type: 'radio', name: 'size' }, + attrs: { type: 'radio', name: 'size', className: 'form-control' }, options: [ { label: 'S', value: 's' }, { label: 'M', value: 'm' }, ], } - assert.equal(renderForm(data).querySelectorAll('input[type="radio"]').length, 2) + const form = renderForm(data) + assert.equal(form.querySelectorAll('input[type="radio"]').length, 2) + // a field-level className with no config lands on the option group's wrap, not the inputs + assert.equal(form.querySelector('#f-fb-text').className, 'form-control') }) test('renders a row or column with no children', () => { From e011a362db019abc150bee6bde829f361d4fe7e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:12:05 +0000 Subject: [PATCH 4/4] fix(renderer): ignore form collection property names Co-authored-by: kevinchappell <1457540+kevinchappell@users.noreply.github.com> --- src/lib/js/renderer/index.js | 2 +- src/lib/js/renderer/renderer.test.js | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index 0ed71f5f..b9185cef 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -137,7 +137,7 @@ export default class FormeoRenderer { } const unmatched = [] for (const key of keys) { - const fields = form?.elements[key] ?? form?.elements[`${key}[]`] + const fields = form.elements.namedItem(key) ?? form.elements.namedItem(`${key}[]`) if (!fields) { unmatched.push(key) continue diff --git a/src/lib/js/renderer/renderer.test.js b/src/lib/js/renderer/renderer.test.js index 6cd421b4..f3d25372 100644 --- a/src/lib/js/renderer/renderer.test.js +++ b/src/lib/js/renderer/renderer.test.js @@ -1076,6 +1076,18 @@ describe('FormeoRenderer', () => { } }) + test('treats form control collection property names as unmatched keys', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + mounted().userData = { item: 1, txt: 'a', length: 2 } + assert.equal(document.querySelector('[name="txt"]').value, 'a') + assert.equal(warn.mock.callCount(), 1) + assert.equal(warn.mock.calls[0].arguments[0], 'formeo: renderer.userData has no field named: item, length') + } finally { + warn.mock.restore() + } + }) + test('does not warn when every key matches', () => { const warn = mock.method(console, 'warn', () => {}) try {