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
59 changes: 59 additions & 0 deletions docs/controls/custom-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions docs/options/controls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions docs/options/events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/demo/js/options/controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
23 changes: 21 additions & 2 deletions src/lib/js/components/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading