diff --git a/docs/controls/custom-controls.md b/docs/controls/custom-controls.md
index e8ba1ac2..13c2c02c 100644
--- a/docs/controls/custom-controls.md
+++ b/docs/controls/custom-controls.md
@@ -143,9 +143,68 @@ 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 = {
+ 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: {
+ 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`). 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.
+
+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; `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
- [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..f6f01b10 100644
--- a/docs/options/controls/README.md
+++ b/docs/options/controls/README.md
@@ -94,6 +94,9 @@ 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
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/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
new file mode 100644
index 00000000..ce7209c0
--- /dev/null
+++ b/src/lib/js/components/control-sets.test.js
@@ -0,0 +1,419 @@
+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 { controlSetDropTarget } from './controls/control-set.mjs'
+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)
+ })
+})
+
+/**
+ * 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("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')
+ 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
new file mode 100644
index 00000000..129a29e2
--- /dev/null
+++ b/src/lib/js/components/controls/control-set.mjs
@@ -0,0 +1,101 @@
+import mergeWith from 'lodash/mergeWith.js'
+import { indexOfNode } from '../../common/helpers.mjs'
+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