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/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 3a679048..b9185cef 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -125,10 +125,23 @@ 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 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 keys) { + const fields = form.elements.namedItem(key) ?? form.elements.namedItem(`${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 +165,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(', ')}`) + } } /** @@ -337,7 +361,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 @@ -350,11 +374,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 => @@ -376,7 +401,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 d7c4878f..f3d25372 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, test } from 'node:test' +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', () => { @@ -1024,6 +1025,111 @@ 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('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 { + mounted().userData = { txt: 'a' } + assert.equal(warn.mock.callCount(), 0) + } finally { + warn.mock.restore() + } + }) + + 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() + } + }) + + 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 = [] @@ -1083,4 +1189,57 @@ 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 + const columns = [...form.querySelectorAll('[style]')] + assert.equal(columns.length, 3) + for (const column of columns) { + 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', className: 'form-control' }, + options: [ + { label: 'S', value: 's' }, + { label: 'M', value: 'm' }, + ], + } + 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', () => { + const data = convertedForm() + delete data.rows['row-fb-text'].children + delete data.columns['col-fb-select'].children + assert.ok(renderForm(data).elements['hidden-1532560563828']) + }) + }) }) 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', + ]) + }) +})