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
3 changes: 3 additions & 0 deletions docs/options/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

With Actions you can modify or completely replace some editor functions. By default, adding an attribute opens a small dialog in the editor. Define `add.attr` to use your own UI or extra validation.

`add.attr` replaces the dialog; it doesn't hide the **+ Attribute** button. To stop users adding attributes, set
`panels.attrs.add: false` instead (see [Hide the add buttons](../config/README.md#hide-the-add-buttons)).

## Full Example

```javascript
Expand Down
59 changes: 59 additions & 0 deletions docs/options/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,62 @@ new FormeoEditor({
```

A locked attribute can't be removed, re-added with **+ Attribute**, or changed; locked checkboxes and selects are disabled. If one was removed before you locked it, remove the `locked` config entry, add it back with **+ Attribute** (name `required`, value `true`), then restore the lock.

### Row and column attributes

Rows and columns have an **Attributes** panel, like fields. Open a row with its edit button: the **Settings** tab
holds the input group, fieldset and column layout controls, and the **Attributes** tab adds attributes with
**+ Attribute**. Columns have an edit button that opens their Attributes panel.

Attributes are saved in `rows.<id>.attrs` and `columns.<id>.attrs` and rendered on the `.formeo-row` /
`.formeo-column` element (see [Row and column attributes](../../renderer/renderer.md#row-and-column-attributes)).
They are not applied in the editor, so a class like `d-none` can't hide a row while you build.

- Put classes in `className`. Formeo keeps its own `formeo-row` / `formeo-column` class separately and merges the two
when rendering.
- `id` and `tag` are reserved on rows and columns, and `data-clone-of` on rows. The dialog refuses them. You can
reserve more names with `panels.attrs.disabled`, but you can't free these.

```javascript
new FormeoEditor({
editorContainer: '#formeo-editor',
config: {
rows: {
all: {
panels: {
attrs: { disabled: ['onclick'] }, // reserve another name
// disabled: ['settings'], // hide the row Settings tab
},
},
},
columns: {
all: { actionButtons: { disabled: ['edit'] } }, // no column edit button, as before
},
},
})
```

### Hide the add buttons

`panels.<panel>.add: false` hides that panel's add button: `attrs` (**+ Attribute**), `options` (**+ Option**),
`conditions` (**+ Condition**) and `config` (**+ Configuration**). It works in every scope, so a control id or
component id can set it back to `true`.

```javascript
new FormeoEditor({
editorContainer: '#formeo-editor',
config: {
fields: {
all: { panels: { attrs: { add: false } } }, // no + Attribute on any field
select: { panels: { options: { add: false } } }, // no + Option on selects
'a33bcc32-c54c-46ed-9609-7cdb5b3dc511': { panels: { attrs: { add: true } } }, // except this field
},
rows: { all: { panels: { attrs: { add: false } } } },
columns: { all: { panels: { attrs: { add: false } } } },
stages: { all: { panels: { conditions: { add: false } } } }, // conditions live on the stage
},
})
```

Attributes, options and conditions that are already there stay editable and removable. Use `locked` to stop that,
and `disabled` to hide them. The setting only changes the editor UI: your own code can still add attributes.
25 changes: 25 additions & 0 deletions docs/renderer/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,31 @@ The renderer supports dynamic input groups that allow users to add/remove field
// Each cloned group gets a unique ID and a remove button
```

### Row and column attributes

A row's `attrs` and a column's `attrs` render on the `.formeo-row` / `.formeo-column` element, the one with id
`f-<id>`. The fieldset or wrapper around a row gets none of them. Conditions find their target by that id, but
show and hide its parent element (for a row, the `formeo-row-wrap` div or fieldset), not the element itself.

```javascript
rows: {
'row-1': {
id: 'row-1',
className: ['formeo-row'], // saved by the editor
config: {},
children: ['col-1'],
attrs: { 'data-section': 'contact', className: 'contact' },
},
},
// renders <div class="formeo-row contact" data-section="contact" id="f-row-1">
```

- `className` and `class` are both merged with the component's own class list, `formeo-row` / `formeo-column` for
editor-built forms.
- A column's `style` is kept without any `width` declaration, even an `!important` one, and the width from
`config.width` is added after it, so `config.width` always wins.
- `id` and `tag` are ignored: Formeo needs the element's id and always renders a `div`.

### Accessing Components

The renderer caches all rendered components internally:
Expand Down
24 changes: 18 additions & 6 deletions src/lib/js/components/columns/column.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ const DOM_CONFIGS = {
},
content: [dom.icon('triangle-down'), dom.icon('triangle-up')],
}),
editWindow: () => ({
className: 'column-edit group-config',
}),
}

/**
Expand All @@ -40,6 +37,13 @@ export default class Column extends Component {
constructor(columnData, components) {
super('column', { ...DEFAULT_DATA(), ...columnData }, components)

const actionButtons = this.getActionButtons()
// its Attributes panel (#112), built before the DOM that holds it. Without an edit button nothing opens it, so
// skip the panels and their ResizeObserver (actionButtons.disabled: ['edit'])
if (this.actionButtons.some(button => button.meta?.id === 'edit')) {
this.updateEditPanels()
}

const childWrap = this.createChildWrap()

this.dom = dom.create({
Expand All @@ -51,8 +55,8 @@ export default class Column extends Component {
id: this.id,
content: [
this.getComponentTag(),
this.getActionButtons(),
DOM_CONFIGS.editWindow(),
actionButtons,
this.editWindow,
DOM_CONFIGS.resizeHandle(new ResizeColumn(this.components)),
childWrap,
],
Expand Down Expand Up @@ -86,6 +90,14 @@ export default class Column extends Component {
})
}

/**
* A column shows an empty Attributes panel before it has any attributes (#112)
* @return {Object}
*/
get defaultPanelData() {
return { attrs: {} }
}

/**
* Process column configuration data
* @param {Object} column
Expand All @@ -100,7 +112,7 @@ export default class Column extends Component {
// loops through children and refresh their edit panels
refreshFieldPanels = () => {
for (const field of this.children) {
field.panels.nav.refresh()
field.panels?.nav.refresh()
}
}

Expand Down
114 changes: 114 additions & 0 deletions src/lib/js/components/columns/column.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { strict as assert } from 'node:assert'
import { afterEach, before, describe, it } from 'node:test'
import i18n from '@draggable/i18n'
import { Actions } from '../../common/actions.js'
import { Events } from '../../common/events.js'
import { Components } from '../index.js'

const formWithColumn = (column = {}) => ({
id: 'form-col',
stages: { 's-1': { id: 's-1', children: ['r-1'] } },
rows: { 'r-1': { id: 'r-1', config: {}, children: ['c-1'] } },
columns: { 'c-1': { id: 'c-1', config: { width: '100%' }, children: [], ...column } },
fields: {},
})

const mounted = []
const nextFrames = () => new Promise(resolve => setTimeout(resolve, 50))

const editorWith = ({ config = {}, column, form } = {}) => {
const events = new Events().init({})
const editorComponents = new Components({ events, actions: new Actions(events).init({}) })
editorComponents.config = config
editorComponents.load(form ?? formWithColumn(column))
return editorComponents
}

const columnOf = editorComponents => editorComponents.columns.get('c-1')

describe('Column edit panel (#112)', () => {
before(() => {
i18n.current ??= {}
})

it('has an edit button and an Attributes panel in its column-edit window', () => {
const column = columnOf(editorWith())
assert.ok(column.dom.querySelector('.column-actions .edit-toggle'))
assert.deepEqual([...column.editPanels.keys()], ['attrs'])
assert.ok(column.dom.querySelector('.column-edit .attrs-panel'))
})

it('actionButtons.disabled edit restores the old column buttons', () => {
const column = columnOf(editorWith({ config: { columns: { all: { actionButtons: { disabled: ['edit'] } } } } }))
assert.equal(column.dom.querySelector('.column-actions .edit-toggle'), null)
})

it('without an edit button the column builds no panels', () => {
const column = columnOf(editorWith({ config: { columns: { all: { actionButtons: { disabled: ['edit'] } } } } }))
assert.equal(column.editPanels.size, 0)
assert.equal(column.panels, undefined)
assert.equal(column.dom.querySelector('.column-edit .attrs-panel'), null)
})

it('the edit button opens the column edit window', () => {
const column = columnOf(editorWith())
column.toggleEdit(true)
assert.ok(column.dom.classList.contains('editing-column'))
})

it('adding an attribute saves it on the column', () => {
const editorComponents = editorWith()
columnOf(editorComponents).editPanels.get('attrs').addAttribute('aria-label', 'Left')
assert.equal(editorComponents.formData.columns['c-1'].attrs['aria-label'], 'Left')
})

it('id and tag are reserved; style is not', () => {
const column = columnOf(editorWith())
const panel = column.editPanels.get('attrs')
for (const name of ['id', 'tag']) {
assert.equal(column.isDisabledProp(`attrs.${name}`), true, name)
panel.addAttribute(name, 'x')
assert.equal(column.get(`attrs.${name}`), undefined, name)
}
assert.equal(column.isDisabledProp('attrs.style'), false)
})

it('a column without attributes saves no attrs key', () => {
assert.equal(Object.hasOwn(editorWith().formData.columns['c-1'], 'attrs'), false)
})

it('a column keeps its width', () => {
const column = columnOf(editorWith({ column: { config: { width: '40%' } } }))
assert.equal(column.dom.style.width, '40%')
})

describe('with its edit button disabled', () => {
afterEach(() => {
for (const node of mounted.splice(0)) {
node.remove()
}
})

it("leaves its field's edit button alone", async () => {
const form = formWithColumn({ children: ['f-1'] })
form.fields['f-1'] = {
id: 'f-1',
tag: 'input',
attrs: { type: 'text' },
config: { label: 'Name', controlId: 'text-input' },
meta: { id: 'text-input' },
}
const config = {
columns: { all: { actionButtons: { disabled: ['edit'] }, panels: { disabled: ['attrs'] } } },
}
const editorComponents = editorWith({ config, form })
const stageDom = editorComponents.stages.get('s-1').dom
document.body.appendChild(stageDom)
mounted.push(stageDom)
await nextFrames()
const column = columnOf(editorComponents)
assert.equal(column.dom.querySelector('.column-actions .edit-toggle'), null)
assert.ok(column.dom.querySelector('.field-actions .edit-toggle'))
})
})
})
13 changes: 12 additions & 1 deletion src/lib/js/components/columns/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@ import Column from './column.js'

const DEFAULT_CONFIG = {
actionButtons: {
buttons: ['clone', 'move', 'remove'],
buttons: ['clone', 'move', 'edit', 'remove'],
disabled: [],
},
panels: {
disabled: [],
// Attributes edits columns.<id>.attrs (#112)
order: ['attrs'],
attrs: {
// id: conditions and the renderer find a column by #f-<id>; tag: would swap the element
disabled: ['id', 'tag'],
hideDisabled: true,
locked: [],
},
},
}

export class Columns extends ComponentData {
Expand Down
89 changes: 89 additions & 0 deletions src/lib/js/components/component-panels.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { strict as assert } from 'node:assert'
import { describe, it } from 'node:test'
import { Actions } from '../common/actions.js'
import { Events } from '../common/events.js'
import Field from './fields/field.js'
import { Components } from './index.js'
import Stage from './stages/stage.js'

// an editor's Components with the given `config` option, loaded with one empty stage
const editorWith = (config = {}) => {
const events = new Events().init({})
const editorComponents = new Components({ events, actions: new Actions(events).init({}) })
editorComponents.config = config
editorComponents.load({ id: 'form-cp', stages: { 's-1': { id: 's-1', config: {}, children: [] } } })
return editorComponents
}

// a stage that declares a custom Settings panel and an empty Attributes panel, the way rows do (#112)
class PanelledStage extends Stage {
get customPanels() {
return {
settings: {
panelConfig: {
config: { label: 'Settings' },
attrs: { className: 'f-panel settings-panel' },
children: 'Settings go here',
},
},
}
}

get defaultPanelData() {
return { attrs: {} }
}
}

const withSettings = { stages: { all: { panels: { order: ['settings'] } } } }

describe('Component#updateEditPanels custom and default panels (#112)', () => {
it('adds a custom panel that panels.order names, in that order', () => {
const stage = new PanelledStage({ id: 's-p', children: [] }, editorWith(withSettings))
assert.deepEqual([...stage.editPanels.keys()], ['attrs', 'conditions', 'settings'])
assert.ok(stage.dom.querySelector('.settings-panel'))
})

it('leaves out a custom panel that panels.order does not name', () => {
const stage = new PanelledStage({ id: 's-p', children: [] }, editorWith())
assert.equal(stage.editPanels.has('settings'), false)
})

it('panels.disabled removes a custom panel', () => {
const stage = new PanelledStage(
{ id: 's-p', children: [] },
editorWith({ stages: { all: { panels: { order: ['settings'], disabled: ['settings'] } } } })
)
assert.equal(stage.editPanels.has('settings'), false)
})

it('defaultPanelData shows an empty panel without writing the key', () => {
const stage = new PanelledStage({ id: 's-p', children: [] }, editorWith())
assert.equal(stage.editPanels.get('attrs').editPanelItems.length, 0)
assert.equal(stage.get('attrs'), undefined)
})

it('defaultPanelData also covers a null value', () => {
const stage = new PanelledStage({ id: 's-p', children: [], attrs: null }, editorWith())
assert.ok(stage.editPanels.has('attrs'))
})

it('without defaultPanelData a missing key still gets no panel', () => {
assert.equal(new Stage({ id: 's-q', children: [] }, editorWith()).editPanels.has('attrs'), false)
const rule = new Field({ id: 'f-hr', tag: 'hr', config: { label: 'Rule' } }, editorWith())
assert.equal(rule.editPanels.has('attrs'), false)
})

it('className is never a panel', () => {
const stage = new Stage({ id: 's-c', children: [], className: ['formeo-stage'] }, editorWith())
assert.equal(stage.editPanels.has('className'), false)
})

it('a component with every panel disabled builds without panels', () => {
const editorComponents = editorWith({
stages: { all: { panels: { disabled: ['attrs', 'options', 'conditions'] } } },
})
const stage = editorComponents.stages.get('s-1')
assert.equal(stage.editPanels.size, 0)
assert.equal(stage.panels, null)
})
})
Loading
Loading