From 8fae4c0f98b67209c966c714a09c36657a413ae3 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 13:30:21 +0100 Subject: [PATCH 1/9] feat(controls): expand a control set definition into its row and fields A control with controlSet.fields describes one row of fields. Each member can name a registered control and override its data (arrays such as options are replaced), or give field data as it is. Every expansion is a fresh copy; unknown controls are skipped with a warning. Refs #227 --- .../js/components/controls/control-set.mjs | 54 ++++++++ .../components/controls/control-set.test.js | 115 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/lib/js/components/controls/control-set.mjs create mode 100644 src/lib/js/components/controls/control-set.test.js diff --git a/src/lib/js/components/controls/control-set.mjs b/src/lib/js/components/controls/control-set.mjs new file mode 100644 index 00000000..b61342a5 --- /dev/null +++ b/src/lib/js/components/controls/control-set.mjs @@ -0,0 +1,54 @@ +import mergeWith from 'lodash/mergeWith.js' +import { clone } from '../../common/utils/index.mjs' + +/** + * The componentType a control set reports to onBeforeAdd (#227) + */ +export const CONTROL_SET = 'controlSet' + +/** + * A control set adds one row holding several fields, like formBuilder's inputSets (#227) + * @param {Object} controlData a control definition + * @return {Boolean} + */ +export const isControlSet = controlData => Array.isArray(controlData?.controlSet?.fields) + +// member overrides replace arrays (e.g. a select's options) instead of appending to them +const replaceArrays = (_value, override) => (Array.isArray(override) ? clone(override) : undefined) + +/** + * What a control set adds, as fresh copies: its layout, its row's data and each member's field data. + * A member naming a `control` starts from that control's data and merges its other keys over it; a member + * without one is used as it is. Unknown controls are skipped with a warning. + * @param {Object} controlData a control definition with controlSet + * @param {Function} lookupControl controlId => a copy of that field control's data without meta, or undefined + * @return {{layout: String, row: Object, fields: Array}} + */ +export const expandControlSet = (controlData, lookupControl) => { + const { controlSet, meta } = controlData + const warn = message => console.warn(`formeo: control set "${meta?.id}" ${message}`) + const fields = [] + for (const { control, id: _id, meta: memberMeta, ...member } of controlSet.fields) { + if (!control) { + const data = clone(member) + if (memberMeta?.id) { + data.config = { ...data.config, controlId: memberMeta.id } + } + fields.push(data) + continue + } + const base = lookupControl(control) + if (!base) { + warn(`skips a member: "${control}" is not a field control.`) + continue + } + const data = mergeWith(base, clone(member), replaceArrays) + data.config = { ...data.config, controlId: control } + fields.push(data) + } + if (!fields.length) { + warn('has no fields, so it adds nothing.') + } + const { id: _rowId, ...row } = clone(controlSet.row || {}) + return { layout: controlSet.layout === 'columns' ? 'columns' : 'stacked', row, fields } +} diff --git a/src/lib/js/components/controls/control-set.test.js b/src/lib/js/components/controls/control-set.test.js new file mode 100644 index 00000000..c116fd74 --- /dev/null +++ b/src/lib/js/components/controls/control-set.test.js @@ -0,0 +1,115 @@ +import { strict as assert } from 'node:assert' +import { afterEach, describe, it, mock } from 'node:test' +import { expandControlSet, isControlSet } from './control-set.mjs' + +const controls = { + 'text-input': { + tag: 'input', + attrs: { type: 'text', required: false, className: '' }, + config: { label: 'Text Input' }, + }, + select: { + tag: 'select', + attrs: { required: false }, + config: { label: 'Select' }, + options: [ + { label: 'Option 1', value: 'option-1', selected: false }, + { label: 'Option 2', value: 'option-2', selected: false }, + ], + }, +} +const lookup = controlId => (controls[controlId] ? structuredClone(controls[controlId]) : undefined) + +/** + * A set definition around the given members + * @param {Array} fields controlSet.fields + * @param {Object} [controlSet] more controlSet keys (layout, row) + * @return {Object} control definition + */ +const setOf = (fields, controlSet = {}) => ({ + meta: { group: 'common', id: 'test-set', icon: 'rows' }, + config: { label: 'Test set' }, + controlSet: { fields, ...controlSet }, +}) + +afterEach(() => mock.restoreAll()) + +describe('isControlSet (#227)', () => { + it('is true only for a definition with controlSet.fields', () => { + assert.equal(isControlSet(setOf([])), true) + assert.equal(isControlSet(controls['text-input']), false) + assert.equal(isControlSet({ controlSet: {} }), false) + assert.equal(isControlSet(undefined), false) + }) +}) + +describe('expandControlSet (#227)', () => { + it('starts a member from its control and merges the overrides', () => { + const member = { control: 'text-input', attrs: { name: 'street' }, config: { label: 'Street' } } + const { fields } = expandControlSet(setOf([member]), lookup) + assert.deepEqual(fields, [ + { + tag: 'input', + attrs: { type: 'text', required: false, className: '', name: 'street' }, + config: { label: 'Street', controlId: 'text-input' }, + }, + ]) + }) + + it('replaces arrays instead of appending to them', () => { + const options = [{ label: 'Canada', value: 'ca', selected: false }] + const { fields } = expandControlSet(setOf([{ control: 'select', options }]), lookup) + assert.deepEqual(fields[0].options, options) + assert.notEqual(fields[0].options, options, 'a copy') + }) + + it('uses a member without a control as it is, minus its id; its meta.id becomes config.controlId', () => { + const member = { id: 'x-1', tag: 'input', attrs: { type: 'text' }, config: { label: 'Note' }, meta: { id: 'note' } } + const { fields } = expandControlSet(setOf([member]), lookup) + assert.deepEqual(fields, [{ tag: 'input', attrs: { type: 'text' }, config: { label: 'Note', controlId: 'note' } }]) + }) + + it('returns fresh copies each time and never changes the definition', () => { + const definition = setOf([{ control: 'text-input', config: { label: 'Street' } }], { + row: { config: { legend: 'Address' } }, + }) + const original = structuredClone(definition) + const first = expandControlSet(definition, lookup) + const second = expandControlSet(definition, lookup) + first.fields[0].config.label = 'changed' + first.row.config.legend = 'changed' + assert.equal(second.fields[0].config.label, 'Street') + assert.equal(second.row.config.legend, 'Address') + assert.deepEqual(definition, original) + }) + + it('skips and warns about a member whose control is unknown', () => { + const warn = mock.method(console, 'warn', () => {}) + const { fields } = expandControlSet(setOf([{ control: 'nope' }, { control: 'text-input' }]), lookup) + assert.equal(fields.length, 1) + assert.equal(warn.mock.callCount(), 1) + const [message] = warn.mock.calls[0].arguments + assert.match(message, /^formeo: control set "test-set"/) + assert.match(message, /"nope"/) + }) + + it('warns when a set ends up with no fields', () => { + const warn = mock.method(console, 'warn', () => {}) + const { fields } = expandControlSet(setOf([{ control: 'nope' }]), lookup) + assert.deepEqual(fields, []) + assert.equal(warn.mock.callCount(), 2) + assert.match(warn.mock.calls[1].arguments[0], /^formeo: control set "test-set" has no fields/) + }) + + it('defaults to a stacked layout and an empty row, and copies the row without its id', () => { + assert.deepEqual(expandControlSet(setOf([{ control: 'text-input' }]), lookup).row, {}) + assert.equal(expandControlSet(setOf([{ control: 'text-input' }]), lookup).layout, 'stacked') + const columns = expandControlSet( + setOf([{ control: 'text-input' }], { layout: 'columns', row: { id: 'r-1', config: { legend: 'A' } } }), + lookup + ) + assert.equal(columns.layout, 'columns') + assert.deepEqual(columns.row, { config: { legend: 'A' } }) + assert.equal(expandControlSet(setOf([{ control: 'text-input' }], { layout: 'grid' }), lookup).layout, 'stacked') + }) +}) From f6a0527783e6e19396900315a9864e89d7de4655 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 13:38:58 +0100 Subject: [PATCH 2/9] feat(controls): add a control set's row and fields when its control is clicked Clicking a control set runs onBeforeAdd once with componentType 'controlSet' and the expanded { layout, row, fields }, then adds one new row holding every field (one column, or one column per field with layout 'columns'). controls.addElement adds a set too. A set's drag keeps its button instead of a ghost preview. Refs #227 --- src/lib/js/components/control-sets.test.js | 243 ++++++++++++++++++ .../js/components/controls/control-set.mjs | 23 ++ src/lib/js/components/controls/index.js | 75 ++++-- 3 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 src/lib/js/components/control-sets.test.js diff --git a/src/lib/js/components/control-sets.test.js b/src/lib/js/components/control-sets.test.js new file mode 100644 index 00000000..45c21cd5 --- /dev/null +++ b/src/lib/js/components/control-sets.test.js @@ -0,0 +1,243 @@ +import { strict as assert } from 'node:assert' +import { after, afterEach, before, describe, it, mock } from 'node:test' +import Sortable from 'sortablejs' +import { Actions } from '../common/actions.js' +import { Events } from '../common/events.js' +import { loaded } from '../common/loaders.js' +import { CONTROL_GROUP_CLASSNAME } from '../constants.js' +import TinyMCEControl from './controls/html/tinymce.js' +import { Components, Controls } from './index.js' + +/** + * An Address set: two text inputs and a select, in a fieldset row + * @param {String} [layout] 'stacked' or 'columns' + * @return {Object} control definition + */ +const addressSet = (layout = 'stacked') => ({ + meta: { group: 'common', id: `address-${layout}`, icon: 'rows' }, + config: { label: `Address ${layout}` }, + controlSet: { + layout, + row: { config: { fieldset: true, legend: 'Address' } }, + fields: [ + { control: 'text-input', attrs: { name: 'street' }, config: { label: 'Street' } }, + { control: 'text-input', attrs: { name: 'city' }, config: { label: 'City' } }, + { control: 'select', attrs: { name: 'country' }, options: [{ label: 'Canada', value: 'ca', selected: false }] }, + ], + }, +}) + +const emptySet = { + meta: { group: 'common', id: 'empty-set', icon: 'rows' }, + config: { label: 'Empty set' }, + controlSet: { fields: [{ control: 'nope' }] }, +} + +/** + * Two rows of one field each, with ids unique to this file + * @return {Object} formData + */ +const twoRows = () => ({ + id: 'form-s', + stages: { 'stage-s': { id: 'stage-s', children: ['row-s1', 'row-s2'] } }, + rows: { + 'row-s1': { id: 'row-s1', config: {}, children: ['col-s1'] }, + 'row-s2': { id: 'row-s2', config: {}, children: ['col-s2'] }, + }, + columns: { + 'col-s1': { id: 'col-s1', config: { width: '100%' }, children: ['field-s1'] }, + 'col-s2': { id: 'col-s2', config: { width: '100%' }, children: ['field-s2'] }, + }, + fields: { + 'field-s1': { + id: 'field-s1', + tag: 'input', + attrs: { type: 'text' }, + config: { label: 'One', controlId: 'text-input' }, + }, + 'field-s2': { + id: 'field-s2', + tag: 'input', + attrs: { type: 'text' }, + config: { label: 'Two', controlId: 'text-input' }, + }, + }, +}) + +const mounted = [] +let localStorage +before(() => { + // jsdom never loads the TinyMCE script, so Controls#init would wait for it forever + loaded.js.add(new TinyMCEControl().dependencies.js) + localStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') + const store = { getItem: () => null, setItem: () => {}, removeItem: () => {} } + Object.defineProperty(globalThis, 'localStorage', { value: store, configurable: true }) +}) +after(() => { + if (localStorage) { + Object.defineProperty(globalThis, 'localStorage', localStorage) + } else { + delete globalThis.localStorage + } +}) +afterEach(() => { + for (const element of mounted.splice(0)) { + element.remove() + } + mock.restoreAll() +}) + +/** + * One editor's Components loaded with twoRows(), and its Controls with the test sets, mounted in the document + * @param {Object} [callbacks] events option callbacks + * @param {Object} [controlOptions] more Controls options + */ +const setup = async (callbacks = {}, controlOptions = {}) => { + const events = new Events().init(callbacks) + const components = new Components({ events, actions: new Actions(events).init({}) }) + components.load(twoRows()) + const stage = components.stages.get('stage-s') + document.body.appendChild(stage.dom) + mounted.push(stage.dom) + const controls = await new Controls(components).init( + { elements: [addressSet(), addressSet('columns'), emptySet], ...controlOptions }, + false + ) + components.controls = controls + document.body.appendChild(controls.dom) + mounted.push(controls.dom) + return { components, controls, stage } +} + +const controlElement = (controls, setId) => controls.dom.querySelector(`.${setId}-control`) +const fieldsOf = row => row.children.flatMap(column => column.children) +const fieldCount = components => Object.keys(components.fields.data).length +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('clicking a control set (#227)', () => { + it('describeControl expands a set into its layout, row and fields', async () => { + const { controls } = await setup() + const described = controls.describeControl(controlElement(controls, 'address-stacked').id) + assert.equal(described.componentType, 'controlSet') + assert.equal(described.controlId, 'address-stacked') + assert.equal(described.data.layout, 'stacked') + assert.equal(described.data.row.config.legend, 'Address') + assert.deepEqual( + described.data.fields.map(field => [field.tag, field.attrs.name, field.config.controlId]), + [ + ['input', 'street', 'text-input'], + ['input', 'city', 'text-input'], + ['select', 'country', 'select'], + ] + ) + assert.deepEqual(described.data.fields[2].options, [{ label: 'Canada', value: 'ca', selected: false }]) + }) + + it('asks onBeforeAdd once, then adds one row holding the fields', async () => { + const seen = [] + const onAddField = mock.fn() + const { components, controls, stage } = await setup({ + onBeforeAdd: ({ detail }) => { + seen.push(detail) + }, + onAddField, + }) + controlElement(controls, 'address-stacked').querySelector('button').click() + await tick() + assert.equal(seen.length, 1) + assert.equal(seen[0].componentType, 'controlSet') + assert.equal(seen[0].controlId, 'address-stacked') + assert.equal(seen[0].parent, stage) + assert.equal(seen[0].index, 2) + assert.equal(seen[0].addedVia, 'click') + assert.equal(seen[0].data.fields.length, 3) + assert.equal(stage.children.length, 3) + const row = stage.children[2] + assert.equal(row.get('config.fieldset'), true) + assert.equal(row.get('config.legend'), 'Address') + assert.equal(row.children.length, 1, 'stacked: one column') + assert.deepEqual( + fieldsOf(row).map(field => field.get('attrs.name')), + ['street', 'city', 'country'] + ) + assert.equal(fieldCount(components), 5) + assert.equal(onAddField.mock.callCount(), 3, 'the usual after-events, once per field') + }) + + it('false from onBeforeAdd adds nothing', async () => { + const { components, controls, stage } = await setup({ onBeforeAdd: () => false }) + controlElement(controls, 'address-stacked').querySelector('button').click() + assert.equal(stage.children.length, 2) + assert.equal(fieldCount(components), 2) + }) + + it("layout: 'columns' puts each field in its own column, with equal widths", async () => { + const { controls, stage } = await setup() + controlElement(controls, 'address-columns').querySelector('button').click() + const row = stage.children[2] + assert.equal(row.children.length, 3) + assert.deepEqual( + row.children.map(column => column.children.length), + [1, 1, 1] + ) + assert.deepEqual( + row.children.map(column => column.get('config.width')), + ['33.3%', '33.3%', '33.3%'] + ) + }) + + it('adding a set twice creates independent fields', async () => { + const { controls, stage } = await setup() + const id = controlElement(controls, 'address-stacked').id + controls.addElement(id) + controls.addElement(id) + const [first, second] = [fieldsOf(stage.children[2]), fieldsOf(stage.children[3])] + assert.notEqual(first[0].id, second[0].id) + first[0].set('config.label', 'Street line 1') + assert.equal(second[0].get('config.label'), 'Street') + }) + + it('controls.addElement adds a set without asking onBeforeAdd', async () => { + const onBeforeAdd = mock.fn() + const { controls, stage } = await setup({ onBeforeAdd }) + const row = controls.addElement(controlElement(controls, 'address-stacked').id) + assert.equal(onBeforeAdd.mock.callCount(), 0) + assert.equal(row, stage.children[2]) + }) + + it('a set with no fields adds nothing and asks nothing', async () => { + mock.method(console, 'warn', () => {}) + const onBeforeAdd = mock.fn() + const { components, controls, stage } = await setup({ onBeforeAdd }) + controlElement(controls, 'empty-set').querySelector('button').click() + assert.equal(controls.addElement(controlElement(controls, 'empty-set').id), undefined) + assert.equal(onBeforeAdd.mock.callCount(), 0) + assert.equal(stage.children.length, 2) + assert.equal(fieldCount(components), 2) + }) + + it('a member naming another set or a layout control is skipped with a warning', async () => { + const warn = mock.method(console, 'warn', () => {}) + const nested = { + meta: { group: 'common', id: 'nested-set', icon: 'rows' }, + config: { label: 'Nested' }, + controlSet: { fields: [{ control: 'address-stacked' }, { control: 'layout-row' }, { control: 'text-input' }] }, + } + const { controls } = await setup({}, { elements: [addressSet(), nested] }) + const described = controls.describeControl(controlElement(controls, 'nested-set').id) + assert.equal(described.data.fields.length, 1) + assert.equal(warn.mock.callCount(), 2) + }) + + it('dragging a set with ghostPreview on keeps its own button instead of a field preview', async () => { + const { controls } = await setup({}, { ghostPreview: true }) + const item = controlElement(controls, 'address-stacked') + const sortable = Sortable.get(item.closest(`.${CONTROL_GROUP_CLASSNAME}`)) + const clone = item.cloneNode(true) + sortable.options.onClone({ clone, item }) + await tick() + await tick() + assert.ok(clone.querySelector('button'), 'still the control button') + assert.equal(clone.querySelector('.formeo-field'), null) + }) +}) diff --git a/src/lib/js/components/controls/control-set.mjs b/src/lib/js/components/controls/control-set.mjs index b61342a5..8f518809 100644 --- a/src/lib/js/components/controls/control-set.mjs +++ b/src/lib/js/components/controls/control-set.mjs @@ -52,3 +52,26 @@ export const expandControlSet = (controlData, lookupControl) => { const { id: _rowId, ...row } = clone(controlSet.row || {}) return { layout: controlSet.layout === 'columns' ? 'columns' : 'stacked', row, fields } } + +/** + * Adds a control set to a stage as one new row (#227) + * @param {Stage} stage + * @param {{layout: String, row: Object, fields: Array}} expanded what expandControlSet returned + * @param {Number} [index] the new row's position in the stage; the end by default + * @return {Row} the new row + */ +export const insertControlSet = (stage, { layout, row, fields }, index) => { + const newRow = stage.addChild(clone(row), index) + if (layout === 'columns') { + for (const fieldData of fields) { + newRow.addChild().addChild(clone(fieldData)) + } + newRow.autoColumnWidths() + return newRow + } + const column = newRow.addChild() + for (const fieldData of fields) { + column.addChild(clone(fieldData)) + } + return newRow +} diff --git a/src/lib/js/components/controls/index.js b/src/lib/js/components/controls/index.js index 45dbfb23..f62f5604 100644 --- a/src/lib/js/components/controls/index.js +++ b/src/lib/js/components/controls/index.js @@ -8,6 +8,7 @@ import { get, set } from '../../common/utils/object.mjs' import { CONTROL_GROUP_CLASSNAME, PANEL_CLASSNAME } from '../../constants.js' import Panels from '../panels.js' import Control from './control.js' +import { CONTROL_SET, expandControlSet, insertControlSet, isControlSet } from './control-set.mjs' import defaultOptions from './options.js' /** @@ -301,8 +302,9 @@ export class Controls { // Copy the item's id to the clone so we can identify what control it represents clone.id = item.id - if (this.options.ghostPreview) { - const { controlData } = this.get(item.id) + const { controlData } = this.get(item.id) + // a control set's drag keeps its own button: a single field's preview would mislead (#227) + if (this.options.ghostPreview && !isControlSet(controlData)) { // Dynamically import Field to avoid circular dependency import('../fields/field.js').then(({ default: Field }) => { clone.innerHTML = '' @@ -353,18 +355,37 @@ export class Controls { field: (controlData, stage) => this.layoutTypes.column(stage).addChild(controlData), } + /** + * A field control's data for a control set member, or undefined when controlId is unknown, a layout control or + * another set (#227) + * @param {String} controlId a control's meta.id + * @return {Object|undefined} a copy without meta + */ + lookupMemberControl = controlId => { + const controlData = this.data.get(controlId) + if (!controlData?.meta || controlId.startsWith('layout-') || isControlSet(controlData)) { + return undefined + } + const { meta: _meta, ...fieldData } = clone(controlData) + return fieldData + } + /** * What a control creates * @param {String} id control id (its element's id) - * @return {{componentType: String, controlId: String, data: Object}} componentType is 'row' or 'column' for a - * layout control, else 'field'; data is what a new field starts from ({} for layout controls, whose rows and - * columns start from their own defaults) + * @return {{componentType: String, controlId: String, data: Object}} componentType is 'controlSet' for a control + * set (data: its layout, row and fields), 'row' or 'column' for a layout control ({} data: rows and columns start + * from their own defaults), else 'field' (data: what the new field starts from) */ describeControl = id => { + const controlData = get(this.get(id), 'controlData') const { meta: { id: controlId }, ...elementData - } = get(this.get(id), 'controlData') + } = controlData + if (isControlSet(controlData)) { + return { componentType: CONTROL_SET, controlId, data: expandControlSet(controlData, this.lookupMemberControl) } + } set(elementData, 'config.controlId', controlId) const layoutType = controlId.replace(/^layout-/, '') const isLayout = @@ -373,13 +394,15 @@ export class Controls { } /** - * Append an element to a stage - * @param {String} id control id - * @param {Stage} [stage] the active stage by default - * @return {Component} the new row, column or field + * Adds what describeControl described to a stage + * @param {{componentType: String, data: Object}} described + * @param {Stage} stage + * @return {Component|undefined} the new row, column or field; undefined for a control set with no fields */ - addElement = (id, stage = this.components.stages.active) => { - const { componentType, data } = this.describeControl(id) + addDescribed = ({ componentType, data }, stage) => { + if (componentType === CONTROL_SET) { + return data.fields.length ? insertControlSet(stage, data) : undefined + } if (componentType === 'field') { return this.layoutTypes.field(data, stage) } @@ -387,16 +410,34 @@ export class Controls { } /** - * A control's click: onBeforeAdd decides whether and when it is added to the active stage (#281) + * Append an element to a stage + * @param {String} id control id + * @param {Stage} [stage] the active stage by default + * @return {Component|undefined} the new row, column or field + */ + addElement = (id, stage = this.components.stages.active) => this.addDescribed(this.describeControl(id), stage) + + /** + * A control's click: onBeforeAdd decides whether and when it is added to the active stage (#281). A control set + * with no fields adds nothing and runs no hook (#227). * @param {String} id control id * @return {Boolean|Promise} see Events#before */ requestAddElement = id => { const stage = this.components.stages.active - const detail = { ...this.describeControl(id), parent: stage, index: stage.children.length, addedVia: 'click' } - return this.components.events.before('add', detail, () => stage.isRegistered && this.addElement(id, stage), { - src: this.dom, - }) + const described = this.describeControl(id) + if (described.componentType === CONTROL_SET && !described.data.fields.length) { + return false + } + const detail = { ...described, parent: stage, index: stage.children.length, addedVia: 'click' } + return this.components.events.before( + 'add', + detail, + () => stage.isRegistered && this.addDescribed(described, stage), + { + src: this.dom, + } + ) } /** From 06d72b891391e10c1e17283f1a71a11dfce1984f Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 13:54:01 +0100 Subject: [PATCH 3/9] feat(controls): drop a control set onto a stage, row or column A dropped control set becomes one new row: at the drop index on a stage, or right after the row it was dropped in. onBeforeAdd reports that stage and index. A held drop is added once allowed, unless its target was removed meanwhile; a set with no fields adds nothing. Also fixes Stage#onAdd swallowing the return value of super.onAdd, which the new "onAdd returns the new row" drop test exposed for a stage-level drop. Refs #227 --- src/lib/js/components/component.js | 23 ++- src/lib/js/components/control-sets.test.js | 140 ++++++++++++++++++ .../js/components/controls/control-set.mjs | 19 +++ src/lib/js/components/stages/stage.js | 1 + 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/lib/js/components/component.js b/src/lib/js/components/component.js index 01842f29..f84d7b8a 100644 --- a/src/lib/js/components/component.js +++ b/src/lib/js/components/component.js @@ -21,6 +21,7 @@ import { PARENT_TYPE_MAP, PROPERTY_OPTIONS, } from '../constants.js' +import { CONTROL_SET, controlSetDropTarget, insertControlSet } from './controls/control-set.mjs' import Data from './data.js' import EditPanel from './edit-panel/edit-panel.js' import Panels from './panels.js' @@ -768,13 +769,31 @@ export default class Component extends Data { // either way; addChild appends when newIndex is past the end, so a smaller list by then is fine. const control = this.components.controls.describeControl(item.id) dom.remove(item) + const isControlSet = control.componentType === CONTROL_SET + if (isControlSet && !control.data.fields.length) { + // an empty control set adds nothing and runs no hook (#227) + this.emptyClass() + return undefined + } let added - const proceed = () => { + let proceed = () => { if (this.isRegistered) { added = finish(onAddConditions.controls(control)) } } - const detail = { ...control, parent: this, index: newIndex, addedVia: 'dragDrop' } + let detail = { ...control, parent: this, index: newIndex, addedVia: 'dragDrop' } + if (isControlSet) { + // a control set is always a new row of the stage: at the drop index, or right after the row it was dropped in + // (#227); the component that received the drop keeps its own children, so its empty state is recomputed + const target = controlSetDropTarget(this, newIndex) + detail = { ...control, parent: target.stage, index: target.index, addedVia: 'dragDrop' } + proceed = () => { + if (this.isRegistered && target.stage.isRegistered) { + added = finish(insertControlSet(target.stage, control.data, target.index)) + this.emptyClass() + } + } + } const result = this.components.events.before('add', detail, proceed, { src: this.dom }) const restoreIfCancelled = proceeded => proceeded || (this.isRegistered && this.emptyClass()) if (result instanceof Promise) { diff --git a/src/lib/js/components/control-sets.test.js b/src/lib/js/components/control-sets.test.js index 45c21cd5..c231db9a 100644 --- a/src/lib/js/components/control-sets.test.js +++ b/src/lib/js/components/control-sets.test.js @@ -5,6 +5,7 @@ import { Actions } from '../common/actions.js' import { Events } from '../common/events.js' import { loaded } from '../common/loaders.js' import { CONTROL_GROUP_CLASSNAME } from '../constants.js' +import { controlSetDropTarget } from './controls/control-set.mjs' import TinyMCEControl from './controls/html/tinymce.js' import { Components, Controls } from './index.js' @@ -241,3 +242,142 @@ describe('clicking a control set (#227)', () => { assert.equal(clone.querySelector('.formeo-field'), null) }) }) + +/** + * Drops a control's element on a component the way Sortable does: the clone sits at newIndex, then onAdd runs + * @param {Component} target stage, row or column + * @param {Element} controlItem the control's
  • + * @param {Number} newIndex + */ +const dropControl = (target, controlItem, newIndex) => { + const item = document.createElement('li') + item.id = controlItem.id + const from = document.createElement('ul') + from.className = CONTROL_GROUP_CLASSNAME + const to = target.dom.querySelector('.children') + to.insertBefore(item, to.children[newIndex] || null) + return { item, result: target.onAdd({ from, to, item, newIndex }) } +} + +const legendOf = row => row.get('config.legend') + +describe('dropping a control set (#227)', () => { + it('controlSetDropTarget: a stage at the drop index, otherwise right after the target row', async () => { + const { components, stage } = await setup() + assert.deepEqual(controlSetDropTarget(stage, 1), { stage, index: 1 }) + assert.deepEqual(controlSetDropTarget(components.rows.get('row-s1'), 0), { stage, index: 1 }) + assert.deepEqual(controlSetDropTarget(components.columns.get('col-s2'), 0), { stage, index: 2 }) + }) + + it('a set dropped on the stage goes in at the drop index', async () => { + const seen = [] + const { controls, stage } = await setup({ onBeforeAdd: ({ detail }) => seen.push(detail) }) + const { item, result } = dropControl(stage, controlElement(controls, 'address-stacked'), 1) + assert.equal(item.isConnected, false) + assert.deepEqual( + stage.children.map(row => row.id).filter(id => id.startsWith('row-s')), + ['row-s1', 'row-s2'] + ) + assert.equal(stage.children.length, 3) + assert.equal(stage.children[1], result, 'onAdd returns the new row') + assert.equal(legendOf(stage.children[1]), 'Address') + assert.deepEqual( + stage.get('children'), + stage.children.map(row => row.id), + 'child order saved' + ) + assert.equal(seen[0].componentType, 'controlSet') + assert.equal(seen[0].parent, stage) + assert.equal(seen[0].index, 1) + assert.equal(seen[0].addedVia, 'dragDrop') + }) + + it('a set dropped in a column goes in as a new row right after that row; the column keeps its field', async () => { + const seen = [] + const { components, controls, stage } = await setup({ onBeforeAdd: ({ detail }) => seen.push(detail) }) + const column = components.columns.get('col-s1') + dropControl(column, controlElement(controls, 'address-stacked'), 0) + assert.equal(stage.children.length, 3) + assert.equal(stage.children[0].id, 'row-s1') + assert.equal(legendOf(stage.children[1]), 'Address') + assert.equal(stage.children[2].id, 'row-s2') + assert.deepEqual( + column.children.map(field => field.id), + ['field-s1'] + ) + assert.equal(seen[0].parent, stage) + assert.equal(seen[0].index, 1) + }) + + it('a set dropped in a column with no fields leaves that column marked empty', async () => { + const { components, controls } = await setup() + components.fields.get('field-s1').remove() + const column = components.columns.get('col-s1') + dropControl(column, controlElement(controls, 'address-stacked'), 0) + assert.equal(column.children.length, 0) + assert.equal(column.dom.classList.contains('empty'), true) + }) + + it('a cancelled set drop removes the placeholder and adds nothing', async () => { + const { components, controls, stage } = await setup({ onBeforeAdd: () => false }) + const { item } = dropControl(stage, controlElement(controls, 'address-stacked'), 0) + assert.equal(item.isConnected, false) + assert.equal(stage.children.length, 2) + assert.equal(fieldCount(components), 2) + }) + + it('a held set drop is added once allowed, appended if its index no longer fits', async () => { + let resolve + const { components, controls, stage } = await setup({ + onBeforeAdd: () => + new Promise(res => { + resolve = res + }), + }) + dropControl(stage, controlElement(controls, 'address-stacked'), 2) + components.rows.get('row-s2').remove() + resolve(true) + await tick() + assert.equal(stage.children.length, 2) + assert.equal(stage.children[0].id, 'row-s1') + assert.equal(legendOf(stage.children[1]), 'Address') + }) + + it('a held set drop whose target row was removed while waiting adds nothing', async () => { + let resolve + const { components, controls, stage } = await setup({ + onBeforeAdd: () => + new Promise(res => { + resolve = res + }), + }) + dropControl(components.columns.get('col-s1'), controlElement(controls, 'address-stacked'), 0) + components.rows.get('row-s1').remove() + resolve(true) + await tick() + assert.deepEqual( + stage.children.map(row => row.id), + ['row-s2'] + ) + assert.equal(fieldCount(components), 1) + }) + + it('dropping a set with no fields removes the placeholder, adds nothing and asks nothing', async () => { + mock.method(console, 'warn', () => {}) + const onBeforeAdd = mock.fn() + const { controls, stage } = await setup({ onBeforeAdd }) + const { item } = dropControl(stage, controlElement(controls, 'empty-set'), 0) + assert.equal(item.isConnected, false) + assert.equal(onBeforeAdd.mock.callCount(), 0) + assert.equal(stage.children.length, 2) + }) + + it('dropping a field control is unchanged', async () => { + const { components, controls } = await setup() + const column = components.columns.get('col-s1') + const textControl = controls.dom.querySelector('.text-input-control') + const { result } = dropControl(column, textControl, 1) + assert.equal(column.children.length, 2) + assert.equal(column.children[1], result) + }) +}) diff --git a/src/lib/js/components/controls/control-set.mjs b/src/lib/js/components/controls/control-set.mjs index 8f518809..5169c414 100644 --- a/src/lib/js/components/controls/control-set.mjs +++ b/src/lib/js/components/controls/control-set.mjs @@ -1,4 +1,5 @@ import mergeWith from 'lodash/mergeWith.js' +import { indexOfNode } from '../../common/helpers.mjs' import { clone } from '../../common/utils/index.mjs' /** @@ -75,3 +76,21 @@ export const insertControlSet = (stage, { layout, row, fields }, index) => { } return newRow } + +/** + * Where a control set dropped on a component goes: a stage takes it at the drop index; a row, column or field + * sends it to a new row right after its own row (#227) + * @param {Component} component the stage, row, column or field that received the drop + * @param {Number} newIndex the drop index + * @return {{stage: Stage, index: Number}} + */ +export const controlSetDropTarget = (component, newIndex) => { + if (component.name === 'stage') { + return { stage: component, index: newIndex } + } + let row = component + while (row.name !== 'row') { + row = row.parent + } + return { stage: row.parent, index: indexOfNode(row.dom) + 1 } +} diff --git a/src/lib/js/components/stages/stage.js b/src/lib/js/components/stages/stage.js index 5ec78e69..235be044 100644 --- a/src/lib/js/components/stages/stage.js +++ b/src/lib/js/components/stages/stage.js @@ -120,6 +120,7 @@ export default class Stage extends Component { if (component?.name === 'column') { component.parent.autoColumnWidths() } + return component } /** From 7bf17d81e4612d2c13759d7a99139b96b92270d3 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 14:04:58 +0100 Subject: [PATCH 4/9] test(controls): add an Address control set to the demo and cover it end to end Refs #227 --- src/demo/js/options/controls.js | 22 ++++++++++ tests/control-sets.spec.js | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/control-sets.spec.js diff --git a/src/demo/js/options/controls.js b/src/demo/js/options/controls.js index ce0d17f8..131c4dc2 100644 --- a/src/demo/js/options/controls.js +++ b/src/demo/js/options/controls.js @@ -5,6 +5,28 @@ const controls = { // elements: ['button'], }, elements: [ + { + meta: { group: 'common', id: 'address-set', icon: 'rows' }, + config: { label: 'Address' }, + controlSet: { + row: { config: { fieldset: true, legend: 'Address' } }, + fields: [ + { control: 'text-input', attrs: { name: 'street' }, config: { label: 'Street' } }, + { control: 'text-input', attrs: { name: 'city' }, config: { label: 'City' } }, + { control: 'text-input', attrs: { name: 'postcode' }, config: { label: 'Postcode' } }, + { + control: 'select', + attrs: { name: 'country' }, + config: { label: 'Country' }, + options: [ + { label: 'Canada', value: 'ca', selected: false }, + { label: 'United Kingdom', value: 'uk', selected: false }, + { label: 'United States', value: 'us', selected: false }, + ], + }, + ], + }, + }, { tag: 'input', config: { diff --git a/tests/control-sets.spec.js b/tests/control-sets.spec.js new file mode 100644 index 00000000..838c373a --- /dev/null +++ b/tests/control-sets.spec.js @@ -0,0 +1,73 @@ +// @ts-check +import { expect, test } from '@playwright/test' +import { gotoEditor } from './helpers/editor.js' +import { dragControlTo } from './helpers/multi-editor.js' + +let errors +test.beforeEach(({ page }) => { + errors = [] + page.on('pageerror', err => errors.push(err.message)) +}) +test.afterEach(() => { + expect(errors).toEqual([]) +}) + +/** + * The demo form's rows whose legend is 'Address', with their fields' names and control ids + * @param {import('@playwright/test').Page} page + */ +const addressRows = page => + page.evaluate(() => { + const { formData } = window.frameworkLoader.currentDemo.editor + return Object.values(formData.rows) + .filter(row => row.config?.legend === 'Address') + .map(row => { + const fields = row.children.flatMap(id => formData.columns[id].children).map(id => formData.fields[id]) + return { + fieldset: row.config.fieldset, + names: fields.map(field => field.attrs.name), + controlIds: fields.map(field => field.config.controlId), + } + }) + }) + +const addressFields = { + fieldset: true, + names: ['street', 'city', 'postcode', 'country'], + controlIds: ['text-input', 'text-input', 'text-input', 'select'], +} + +test.describe('control sets (#227)', () => { + test('clicking the Address set adds one row with its four fields', async ({ page }) => { + await gotoEditor(page) + const rows = page.locator('.formeo-editor .formeo-row') + const before = await rows.count() + await page.locator('.address-set-control button').click() + await expect(rows).toHaveCount(before + 1) + await expect( + page.locator('.formeo-editor .formeo-row').filter({ hasText: 'Street' }).locator('.formeo-field') + ).toHaveCount(4) + expect(await addressRows(page)).toEqual([addressFields]) + }) + + test('dragging the Address set onto the page adds it there', async ({ page }) => { + await gotoEditor(page) + const stage = page.locator('.formeo-editor .formeo-stage:not([hidden])').first() + await dragControlTo(page, page.locator('.address-set-control'), stage) + await expect.poll(() => addressRows(page)).toEqual([addressFields]) + }) + + test('an onBeforeAdd veto from a document listener adds nothing', async ({ page }) => { + await gotoEditor(page) + await page.evaluate(() => { + document.addEventListener('formeoBeforeAdd', evt => { + if (evt.detail.componentType === 'controlSet') { + evt.preventDefault() + } + }) + }) + await page.locator('.address-set-control button').click() + await page.waitForTimeout(300) + expect(await addressRows(page)).toEqual([]) + }) +}) From ee1ebf20c3010a6fae835aa3b9f11a7b74fe0f12 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 14:05:18 +0100 Subject: [PATCH 5/9] docs(controls): document control sets How to define a set in controls.elements, how members start from a control and override it, where a clicked or dropped set goes, and what onBeforeAdd receives for one. Refs #227 --- docs/controls/custom-controls.md | 52 ++++++++++++++++++++++++++++++++ docs/options/controls/README.md | 2 ++ docs/options/events/README.md | 14 +++++---- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/controls/custom-controls.md b/docs/controls/custom-controls.md index e8ba1ac2..26f2b214 100644 --- a/docs/controls/custom-controls.md +++ b/docs/controls/custom-controls.md @@ -143,9 +143,61 @@ action: { Once that input's value is set, `renderer.userData.annotation` (and `renderer.userFormData`) reflects it like any other field. +## Control sets + +A control set adds several fields at once, like formBuilder's `inputSets`: clicking or dropping it adds one new row +holding all of them. Define it in `controls.elements` with a `controlSet` key instead of `tag`/`attrs`: + +```javascript +const addressSet = { + meta: { group: 'common', id: 'address-set', icon: 'rows' }, + config: { label: 'Address' }, + controlSet: { + layout: 'stacked', // default: one column; 'columns' gives each field its own column + row: { config: { fieldset: true, legend: 'Address' } }, // optional data for the new row + fields: [ + { control: 'text-input', attrs: { name: 'street' }, config: { label: 'Street' } }, + { control: 'text-input', attrs: { name: 'city' }, config: { label: 'City' } }, + { + control: 'select', + attrs: { name: 'country' }, + config: { label: 'Country' }, + options: [ + { label: 'Canada', value: 'ca', selected: false }, + { label: 'United States', value: 'us', selected: false }, + ], + }, + ], + }, +} + +new FormeoEditor({ editorContainer: '.formeo-editor', controls: { elements: [addressSet] } }) +``` + +Each entry in `fields` is one field: + +- With `control`, it starts from that control's data (what clicking the control would add; `control` is its + `meta.id`, e.g. `'text-input'`, `'select'`, `'textarea'` or one of your own) and the entry's other keys override it. + Arrays such as `options` replace the control's, rather than being added to them. +- Without `control`, the entry is the field's data as it is (`tag`, `attrs`, `config`, `options`). +- An unknown `control`, a layout control or another set is skipped with a console warning. A set left with no fields + adds nothing. + +Every add creates new ids, and changing one added field never changes another or the set's definition. `row` takes +the same data as a row's settings, so `config: { inputGroup: true }` makes the set a repeatable input group. + +Where it goes: a click adds the row at the end of the current page. A drop on a page adds it where it was dropped; a +drop on a row or column adds it as a new row right after that row (a set never goes inside an existing column). + +The [`onBeforeAdd`](../options/events/README.md#before-hooks) hook runs once for the whole set, with +`componentType: 'controlSet'`, the set's `controlId`, and `data: { layout, row, fields }`; `parent` and `index` are +the page and position the new row goes to. The usual events (`onAddRow`, `onAddColumn`, `onAddField`) follow for what +is added. `controls.addElement(id)` from your code adds a set without the hook. + ## See Also - [Controls](README.md) - Overview of controls and control groups - [Control Options](../options/controls/README.md) - Configure the control panel, including `elements` - [Custom Attribute Types](custom-attribute-types.md) - Attribute input types for a control's `attrs` - [Renderer: Custom Elements](../renderer/renderer.md#advanced-topics) - The renderer's `elements` option +- [Control sets](#control-sets) - Add several fields at once diff --git a/docs/options/controls/README.md b/docs/options/controls/README.md index 068e5b79..5d11b7a1 100644 --- a/docs/options/controls/README.md +++ b/docs/options/controls/README.md @@ -94,6 +94,8 @@ See [Controlling Attribute Visibility](../../controls/custom-attribute-types.md# See [Custom controls](../../controls/custom-controls.md) for a full editor + renderer example. +An element with a `controlSet` key adds a group of fields at once; see [Control sets](../../controls/custom-controls.md#control-sets). + ## elementOrder Set the element order within a control group. May be overridden if [sortable](#sortable) is set to true diff --git a/docs/options/events/README.md b/docs/options/events/README.md index 408542ae..41fc0288 100644 --- a/docs/options/events/README.md +++ b/docs/options/events/README.md @@ -126,12 +126,14 @@ option callback and a cancelable DOM event. | `onBeforeClone` | `formeoBeforeClone` | a row, column or field is cloned with its clone button | `{ component, componentType, componentId, parent }` | | `onBeforeSave` | `formeoBeforeSave` | the Save button saves (before `actions.click.btn`, `actions.save.form`, the `sessionStorage` copy and `onSave`) | `{ formData }`; an allowed save saves this same formData | -For `onBeforeAdd`, `componentType` is what the control creates (`'field'`, or `'row'`/`'column'` for the layout -controls), `controlId` is the control's id (e.g. `'text-input'`), and `data` is what a new field starts from; treat it as -read-only. `parent` and `index` say where it goes: the page and its row count for a click (every click adds a new row -at the end), or the stage, row or column it was dropped on and the drop position. `addedVia` is `'click'` or -`'dragDrop'`. A field dropped on a page or row also gets a new row or column around it; those don't run hooks of -their own. If the component it was dropped on is removed while the hook waits, nothing is added. +For `onBeforeAdd`, `componentType` is what the control creates (`'field'`, `'row'`/`'column'` for the layout +controls, or `'controlSet'` for a [control set](../../controls/custom-controls.md#control-sets)), `controlId` is the +control's id (e.g. `'text-input'`), and `data` is what a new field starts from, or a set's `{ layout, row, fields }`; +treat it as read-only. `parent` and `index` say where it goes: the page and its row count for a click (every click +adds a new row at the end), or the stage, row or column it was dropped on and the drop position. `addedVia` is +`'click'` or `'dragDrop'`. A field dropped on a page or row also gets a new row or column around it; those don't run +hooks of their own. A control set always becomes a new row: `parent` and `index` are its page and position. If the +component it was dropped on is removed while the hook waits, nothing is added. A callback cancels by returning `false` or calling `evt.preventDefault()`. To make Formeo wait, return a Promise: the change happens when it resolves, and is cancelled if it resolves to `false`. A callback that throws, or a Promise that From 27b3f99a0e5ef535d0eba65c2fe07c26494d2b4e Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 14:22:02 +0100 Subject: [PATCH 6/9] fix(controls): keep a row's column widths when a control set is dropped on it A control set dropped on a row never changes that row's columns (it lands in a new row right after it), so Row#onAdd should not call autoColumnWidths for that drop. It previously always did, silently resetting any custom widths (e.g. 25%/75%) to equal shares and firing columnResized. Same fix covers an empty-set drop on a row. Refs #227 --- src/lib/js/components/control-sets.test.js | 36 ++++++++++++++++++++++ src/lib/js/components/rows/row.js | 12 ++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/lib/js/components/control-sets.test.js b/src/lib/js/components/control-sets.test.js index c231db9a..ce7209c0 100644 --- a/src/lib/js/components/control-sets.test.js +++ b/src/lib/js/components/control-sets.test.js @@ -372,6 +372,42 @@ describe('dropping a control set (#227)', () => { assert.equal(stage.children.length, 2) }) + it("a set dropped on a row keeps that row's column widths; the row itself is unchanged", async () => { + const { components, controls, stage } = await setup() + const row = components.rows.get('row-s1') + row.addChild() + const [col1, col2] = row.children + col1.setWidth('25%') + col2.setWidth('75%') + const { result } = dropControl(row, controlElement(controls, 'address-stacked'), 0) + assert.deepEqual( + row.children.map(column => column.get('config.width')), + ['25%', '75%'] + ) + assert.deepEqual( + row.children.map(column => column.dom.style.width), + ['25%', '75%'] + ) + assert.equal(stage.children[0], row, 'row-s1 unchanged, still first') + assert.equal(legendOf(stage.children[1]), 'Address') + assert.equal(stage.children[1], result, "the row's onAdd returns the new row") + }) + + it("an empty-set drop on a row also keeps that row's column widths", async () => { + mock.method(console, 'warn', () => {}) + const { components, controls } = await setup() + const row = components.rows.get('row-s1') + row.addChild() + const [col1, col2] = row.children + col1.setWidth('25%') + col2.setWidth('75%') + dropControl(row, controlElement(controls, 'empty-set'), 0) + assert.deepEqual( + row.children.map(column => column.get('config.width')), + ['25%', '75%'] + ) + }) + it('dropping a field control is unchanged', async () => { const { components, controls } = await setup() const column = components.columns.get('col-s1') diff --git a/src/lib/js/components/rows/row.js b/src/lib/js/components/rows/row.js index b62c2534..4f7a258e 100644 --- a/src/lib/js/components/rows/row.js +++ b/src/lib/js/components/rows/row.js @@ -12,6 +12,7 @@ import { ROW_CLASSNAME, } from '../../constants.js' import Component from '../component.js' +import { isControlSet } from '../controls/control-set.mjs' const DEFAULT_DATA = () => Object.freeze({ @@ -175,9 +176,14 @@ export default class Row extends Component { return editWindow } - onAdd(...args) { - super.onAdd(...args) - this.autoColumnWidths() + onAdd(evt) { + const component = super.onAdd(evt) + // a control set dropped on this row goes into a new row after it (#227); it never touches this + // row's columns, so their widths should not be reset to equal shares + if (!isControlSet(this.components.controls?.get(evt.item.id)?.controlData)) { + this.autoColumnWidths() + } + return component } onRemove(...args) { From 909c1e902a3564b93fc8c588e0edd08deb1a9cb2 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 14:24:21 +0100 Subject: [PATCH 7/9] fix(controls): skip a control set member that is not an object expandControlSet destructured each controlSet.fields member directly, throwing a TypeError on a null (or otherwise non-object) member. Since describeControl runs before dom.remove(item) in Component#onAdd, a throw here left the dragged control's placeholder stuck on the canvas. Skip a non-object member with a warning, in the same format as an unknown control's. Refs #227 --- src/lib/js/components/controls/control-set.mjs | 7 ++++++- src/lib/js/components/controls/control-set.test.js | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/js/components/controls/control-set.mjs b/src/lib/js/components/controls/control-set.mjs index 5169c414..129a29e2 100644 --- a/src/lib/js/components/controls/control-set.mjs +++ b/src/lib/js/components/controls/control-set.mjs @@ -29,7 +29,12 @@ export const expandControlSet = (controlData, lookupControl) => { const { controlSet, meta } = controlData const warn = message => console.warn(`formeo: control set "${meta?.id}" ${message}`) const fields = [] - for (const { control, id: _id, meta: memberMeta, ...member } of controlSet.fields) { + for (const rawMember of controlSet.fields) { + if (typeof rawMember !== 'object' || rawMember === null) { + warn('skips a member: it is not an object.') + continue + } + const { control, id: _id, meta: memberMeta, ...member } = rawMember if (!control) { const data = clone(member) if (memberMeta?.id) { diff --git a/src/lib/js/components/controls/control-set.test.js b/src/lib/js/components/controls/control-set.test.js index c116fd74..05936242 100644 --- a/src/lib/js/components/controls/control-set.test.js +++ b/src/lib/js/components/controls/control-set.test.js @@ -83,6 +83,16 @@ describe('expandControlSet (#227)', () => { assert.deepEqual(definition, original) }) + it('skips and warns about a null or non-object member instead of throwing', () => { + const warn = mock.method(console, 'warn', () => {}) + const { fields } = expandControlSet(setOf([null, 'text', { control: 'text-input' }]), lookup) + assert.equal(fields.length, 1) + assert.equal(warn.mock.callCount(), 2) + for (const call of warn.mock.calls) { + assert.match(call.arguments[0], /^formeo: control set "test-set" skips a member: it is not an object\.$/) + } + }) + it('skips and warns about a member whose control is unknown', () => { const warn = mock.method(console, 'warn', () => {}) const { fields } = expandControlSet(setOf([{ control: 'nope' }, { control: 'text-input' }]), lookup) From 0f5b0e13e50fbbcf61c09b7284ac622dab255ed5 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 14:28:08 +0100 Subject: [PATCH 8/9] docs(controls): say which id controls.addElement takes, and how a literal member names its control The control sets doc left `controls.addElement(id)`'s `id` ambiguous with `meta.id`; only a top-level `id` on the definition works, and it must be distinct from `meta.id` or a generated uuid is used. Also document that a literal (control-less) member can still name its control via `meta.id`, which becomes its `config.controlId`. Rewrap the 134-column pointer line in docs/options/controls/README.md's "## elements" section to fit 120. Refs #227 --- docs/controls/custom-controls.md | 11 +++++++++-- docs/options/controls/README.md | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/controls/custom-controls.md b/docs/controls/custom-controls.md index 26f2b214..13c2c02c 100644 --- a/docs/controls/custom-controls.md +++ b/docs/controls/custom-controls.md @@ -150,6 +150,8 @@ holding all of them. Define it in `controls.elements` with a `controlSet` key in ```javascript const addressSet = { + id: 'address-set-control', // this control's own element id, used by controls.addElement(id) below; + // give it one distinct from meta.id below, or a generated uuid is used instead meta: { group: 'common', id: 'address-set', icon: 'rows' }, config: { label: 'Address' }, controlSet: { @@ -179,7 +181,9 @@ Each entry in `fields` is one field: - With `control`, it starts from that control's data (what clicking the control would add; `control` is its `meta.id`, e.g. `'text-input'`, `'select'`, `'textarea'` or one of your own) and the entry's other keys override it. Arrays such as `options` replace the control's, rather than being added to them. -- Without `control`, the entry is the field's data as it is (`tag`, `attrs`, `config`, `options`). +- Without `control`, the entry is the field's data as it is (`tag`, `attrs`, `config`, `options`). It can still name + its own control with `meta.id`, which becomes its `config.controlId` — so control-level settings such as locked + attributes and a renderer's `elements` actions apply to it, the same as a field added from that control directly. - An unknown `control`, a layout control or another set is skipped with a console warning. A set left with no fields adds nothing. @@ -192,7 +196,10 @@ drop on a row or column adds it as a new row right after that row (a set never g The [`onBeforeAdd`](../options/events/README.md#before-hooks) hook runs once for the whole set, with `componentType: 'controlSet'`, the set's `controlId`, and `data: { layout, row, fields }`; `parent` and `index` are the page and position the new row goes to. The usual events (`onAddRow`, `onAddColumn`, `onAddField`) follow for what -is added. `controls.addElement(id)` from your code adds a set without the hook. +is added. `controls.addElement(id)` from your code adds a set without the hook; `id` is the control's own `id` (its +element id) — a generated uuid unless the definition sets a top-level `id` distinct from `meta.id`, as `address-set` +does above with `'address-set-control'` — not its `meta.id` itself, which `editor.controls.addElement('address-set')` +would throw on. ## See Also diff --git a/docs/options/controls/README.md b/docs/options/controls/README.md index 5d11b7a1..f6f01b10 100644 --- a/docs/options/controls/README.md +++ b/docs/options/controls/README.md @@ -94,7 +94,8 @@ See [Controlling Attribute Visibility](../../controls/custom-attribute-types.md# See [Custom controls](../../controls/custom-controls.md) for a full editor + renderer example. -An element with a `controlSet` key adds a group of fields at once; see [Control sets](../../controls/custom-controls.md#control-sets). +An element with a `controlSet` key adds a group of fields at once; see +[Control sets](../../controls/custom-controls.md#control-sets). ## elementOrder From b0274fb318b4050a6f5b233685c6bdf284fca860 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 14:31:29 +0100 Subject: [PATCH 9/9] test(controls): pin a dropped set's position and the veto listener running The drag e2e test only checked that the Address set was added, not that it landed where it was dropped; assert its row's position in the stage's own children order instead. The veto test waited a fixed 300ms without confirming the document listener actually ran; have it set a flag and poll for it before asserting no rows were added. Refs #227 --- tests/control-sets.spec.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/control-sets.spec.js b/tests/control-sets.spec.js index 838c373a..d4f45151 100644 --- a/tests/control-sets.spec.js +++ b/tests/control-sets.spec.js @@ -37,6 +37,17 @@ const addressFields = { controlIds: ['text-input', 'text-input', 'text-input', 'select'], } +/** + * The Address row's position among the given stage's own rows (formData.stages[stageId].children), or -1 + * @param {import('@playwright/test').Page} page + * @param {String} stageId + */ +const addressRowPosition = (page, stageId) => + page.evaluate(sid => { + const { formData } = window.frameworkLoader.currentDemo.editor + return formData.stages[sid].children.findIndex(id => formData.rows[id]?.config?.legend === 'Address') + }, stageId) + test.describe('control sets (#227)', () => { test('clicking the Address set adds one row with its four fields', async ({ page }) => { await gotoEditor(page) @@ -53,21 +64,30 @@ test.describe('control sets (#227)', () => { test('dragging the Address set onto the page adds it there', async ({ page }) => { await gotoEditor(page) const stage = page.locator('.formeo-editor .formeo-stage:not([hidden])').first() + const stageId = await stage.getAttribute('id') + const before = await page.evaluate( + sid => window.frameworkLoader.currentDemo.editor.formData.stages[sid].children.length, + stageId + ) + // dragControlTo drops near the bottom of the stage, i.e. after every existing row await dragControlTo(page, page.locator('.address-set-control'), stage) await expect.poll(() => addressRows(page)).toEqual([addressFields]) + expect(await addressRowPosition(page, stageId)).toBe(before) }) test('an onBeforeAdd veto from a document listener adds nothing', async ({ page }) => { await gotoEditor(page) await page.evaluate(() => { + window.__vetoed = false document.addEventListener('formeoBeforeAdd', evt => { if (evt.detail.componentType === 'controlSet') { evt.preventDefault() + window.__vetoed = true } }) }) await page.locator('.address-set-control button').click() - await page.waitForTimeout(300) + await expect.poll(() => page.evaluate(() => window.__vetoed)).toBe(true) expect(await addressRows(page)).toEqual([]) }) })