Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/renderer/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<select multiple>` takes an array of values.

## Methods

### `render(formData)`
Expand Down Expand Up @@ -351,6 +353,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 `<select multiple>` takes an array of values.

### Handling Different Field Types

#### Single Input Fields
Expand Down
4 changes: 2 additions & 2 deletions src/lib/js/common/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}

Expand Down
45 changes: 45 additions & 0 deletions src/lib/js/renderer/__fixtures__/formbuilder2formeo.mjs
Original file line number Diff line number Diff line change
@@ -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}`) } },
})
41 changes: 33 additions & 8 deletions src/lib/js/renderer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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(', ')}`)
}
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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 =>
Expand All @@ -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)
Expand Down
161 changes: 160 additions & 1 deletion src/lib/js/renderer/renderer.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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'])
})
})
})
36 changes: 36 additions & 0 deletions tests/renderer-user-data.spec.js
Original file line number Diff line number Diff line change
@@ -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',
])
})
})