diff --git a/docs/options/actions/README.md b/docs/options/actions/README.md index 5801be0d..14ad69a7 100644 --- a/docs/options/actions/README.md +++ b/docs/options/actions/README.md @@ -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 diff --git a/docs/options/config/README.md b/docs/options/config/README.md index a7401b82..de344fbf 100644 --- a/docs/options/config/README.md +++ b/docs/options/config/README.md @@ -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..attrs` and `columns..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..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. diff --git a/docs/renderer/renderer.md b/docs/renderer/renderer.md index ae183e4b..1ea7e61e 100644 --- a/docs/renderer/renderer.md +++ b/docs/renderer/renderer.md @@ -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-`. 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
+``` + +- `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: diff --git a/src/lib/js/components/columns/column.js b/src/lib/js/components/columns/column.js index d6bf2e96..514ad484 100644 --- a/src/lib/js/components/columns/column.js +++ b/src/lib/js/components/columns/column.js @@ -23,9 +23,6 @@ const DOM_CONFIGS = { }, content: [dom.icon('triangle-down'), dom.icon('triangle-up')], }), - editWindow: () => ({ - className: 'column-edit group-config', - }), } /** @@ -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({ @@ -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, ], @@ -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 @@ -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() } } diff --git a/src/lib/js/components/columns/column.test.js b/src/lib/js/components/columns/column.test.js new file mode 100644 index 00000000..6f166076 --- /dev/null +++ b/src/lib/js/components/columns/column.test.js @@ -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')) + }) + }) +}) diff --git a/src/lib/js/components/columns/index.js b/src/lib/js/components/columns/index.js index 234a1c06..aa8a4828 100644 --- a/src/lib/js/components/columns/index.js +++ b/src/lib/js/components/columns/index.js @@ -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..attrs (#112) + order: ['attrs'], + attrs: { + // id: conditions and the renderer find a column by #f-; tag: would swap the element + disabled: ['id', 'tag'], + hideDisabled: true, + locked: [], + }, + }, } export class Columns extends ComponentData { diff --git a/src/lib/js/components/component-panels.test.js b/src/lib/js/components/component-panels.test.js new file mode 100644 index 00000000..ddab5e95 --- /dev/null +++ b/src/lib/js/components/component-panels.test.js @@ -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) + }) +}) diff --git a/src/lib/js/components/component.js b/src/lib/js/components/component.js index 06afcf59..e5745c42 100644 --- a/src/lib/js/components/component.js +++ b/src/lib/js/components/component.js @@ -1089,6 +1089,13 @@ export default class Component extends Data { return false } + /** + * Whether a panel shows its add button: `panels..add: false` hides it (#117) + * @param {String} panelName e.g. 'attrs', 'options', 'conditions', 'config' + * @return {Boolean} + */ + isAddEnabled = panelName => this.config?.panels?.[panelName]?.add !== false + /** * Generate the markup for field edit mode * @return {Object} fieldEdit element config @@ -1110,12 +1117,16 @@ export default class Component extends Data { editWindow.action = { onRender: () => { if (editPanelLength === 0) { - // If this element has no edit panels, remove the edit toggle - const editToggle = this.dom.querySelector('.edit-toggle') - const fieldActions = this.dom.querySelector(`.${this.name}-actions`) - const actionButtons = fieldActions.getElementsByTagName('button') - fieldActions.style.maxWidth = `${actionButtons.length * actionButtons[0].clientWidth}px` - dom.remove(editToggle) + // If this element has no edit panels, remove its own edit toggle (not a nested component's) + const actions = this.dom.querySelector(`:scope > .${this.name}-actions`) + const actionButtons = actions?.getElementsByTagName('button') ?? [] + if (actionButtons.length) { + actions.style.maxWidth = `${actionButtons.length * actionButtons[0].clientWidth}px` + } + const editToggle = actions?.querySelector('.edit-toggle') + if (editToggle) { + dom.remove(editToggle) + } } else { this.resizePanelWrap() } @@ -1125,28 +1136,53 @@ export default class Component extends Data { return dom.create(editWindow) } + /** + * Panels a component builds itself instead of from its data, keyed by the name `panels.order` uses for them, + * e.g. a row's Settings (#112). Only names in `panels.order` are shown. + * @return {Object} + */ + get customPanels() { + return {} + } + + /** + * What a panel is built from while its data key is unset, e.g. `{ attrs: {} }` so a row shows an empty Attributes + * panel (#112). Fields and stages have none: a control without attrs gets no Attributes panel. + * @return {Object} + */ + get defaultPanelData() { + return {} + } + updateEditPanels = () => { if (!this.config) { return null } const editable = new Set(['object', 'array']) const hasConfigOptions = configOptionsOf(this.config).size > 0 + const { customPanels, defaultPanelData } = this // declared config keys get a Config panel even before the component has any config (a new stage) const panelOrder = unique([ ...this.config.panels.order, ...Object.keys(this.data), ...(hasConfigOptions ? ['config'] : []), ]) - const noPanels = new Set(['children', 'meta', 'action', 'events', ...this.config.panels.disabled]) + // a row's or column's `className` is its internal class list, never a panel + const noPanels = new Set(['children', 'meta', 'action', 'events', 'className', ...this.config.panels.disabled]) const allowedPanels = panelOrder.filter(panelName => !noPanels.has(panelName)) for (const panelName of allowedPanels) { + if (customPanels[panelName]) { + this.editPanels.set(panelName, customPanels[panelName]) + continue + } // a Config panel without declared keys would only offer keys that mean nothing for this component if (panelName === 'config' && !hasConfigOptions) { this.editPanels.delete(panelName) continue } - const panelData = panelName === 'config' ? this.get(panelName) || {} : this.get(panelName) + const panelData = + panelName === 'config' ? this.get(panelName) || {} : (this.get(panelName) ?? defaultPanelData[panelName]) const propType = dom.childType(panelData) if (editable.has(propType)) { const editPanel = new EditPanel(panelData, panelName, this) @@ -1154,18 +1190,27 @@ export default class Component extends Data { } } + this.panels?.destroy() + if (!this.editPanels.size) { + // nothing to edit: Panels needs at least one panel, and editWindow drops the edit button + this.panels = null + return + } + const panelsData = { panels: Array.from(this.editPanels.values()).map(({ panelConfig }) => panelConfig), id: this.id, displayType: 'auto', } - this.panels?.destroy() this.panels = new Panels(panelsData) - if (this.dom) { - this.dom.querySelector('.panel-nav').replaceWith(this.panels.panelNav) - this.dom.querySelector('.panels').replaceWith(this.panels.panelsWrap) + // only this component's own edit window: a nested component's nav is not ours to replace + const editWindow = this.dom?.querySelector(`:scope > .${this.name}-edit`) + const ownNav = editWindow?.querySelector(':scope > .panel-nav') + if (ownNav) { + ownNav.replaceWith(this.panels.panelNav) + editWindow.querySelector(':scope > .panels').replaceWith(this.panels.panelsWrap) } } } diff --git a/src/lib/js/components/control-sets.test.js b/src/lib/js/components/control-sets.test.js index ce7209c0..6946cc5e 100644 --- a/src/lib/js/components/control-sets.test.js +++ b/src/lib/js/components/control-sets.test.js @@ -198,6 +198,18 @@ describe('clicking a control set (#227)', () => { assert.equal(second[0].get('config.label'), 'Street') }) + it("a set's row attrs land on the new row (#112)", async () => { + const taggedSet = { + ...addressSet(), + meta: { group: 'common', id: 'tagged-set', icon: 'rows' }, + controlSet: { ...addressSet().controlSet, row: { attrs: { 'data-section': 'address' } } }, + } + const { controls, stage } = await setup({}, { elements: [taggedSet] }) + controlElement(controls, 'tagged-set').querySelector('button').click() + await tick() + assert.deepEqual(stage.children[2].get('attrs'), { 'data-section': 'address' }) + }) + it('controls.addElement adds a set without asking onBeforeAdd', async () => { const onBeforeAdd = mock.fn() const { controls, stage } = await setup({ onBeforeAdd }) diff --git a/src/lib/js/components/edit-panel/edit-panel.js b/src/lib/js/components/edit-panel/edit-panel.js index ccb1d5eb..5f3391af 100644 --- a/src/lib/js/components/edit-panel/edit-panel.js +++ b/src/lib/js/components/edit-panel/edit-panel.js @@ -2,7 +2,7 @@ import i18n from '@draggable/i18n' import Sortable from 'sortablejs' import dom from '../../common/dom.js' import { capitalize, safeAttrName } from '../../common/helpers.mjs' -import { slugify, toTitleCase } from '../../common/utils/string.mjs' +import { slugify, slugifyAddress, toTitleCase } from '../../common/utils/string.mjs' import { PANEL_CLASSNAME } from '../../constants.js' import Dialog from '../dialog.js' import { configOptionsOf } from './config-options.mjs' @@ -219,11 +219,12 @@ export default class EditPanel { } if (type === 'config') { - // kept so it can hide once every declared key is set + // kept so it can hide once every declared key is set, or when panels.config.add is false this.addConfigButton = dom.create(addBtn) this.syncAddConfigButton() editPanelButtons.push(this.addConfigButton) - } else { + } else if (this.component.isAddEnabled(type)) { + // panels..add: false leaves the button out (#117) editPanelButtons.push(addBtn) } @@ -261,9 +262,10 @@ export default class EditPanel { this.component.set(`attrs.${attr}`, val) addAttributeActions[safeAttr]?.(val, this.component) - // classList, not a selector: a namespaced name like `xlink:href` isn't a valid class selector - const rowClass = `${this.component.name}-attrs-${safeAttr}` - const existingAttr = Array.from(this.props.children).find(row => row.classList.contains(rowClass)) + // EditPanelItem names every item `field-`, whatever the component (a row's too, #112). classList, not a + // selector: a namespaced name like `xlink:href` isn't a valid class selector + const itemClass = `field-${slugifyAddress(itemKey)}` + const existingAttr = Array.from(this.props.children).find(item => item.classList.contains(itemClass)) const newAttr = new EditPanelItem({ key: itemKey, data: { [safeAttr]: val }, @@ -337,11 +339,11 @@ export default class EditPanel { } /** - * The "Add config" button shows only while there is something left to add + * The "Add config" button shows only while there is something left to add and panels.config.add isn't false */ syncAddConfigButton() { if (this.addConfigButton) { - this.addConfigButton.hidden = !this.addableConfigOptions().size + this.addConfigButton.hidden = !this.component.isAddEnabled('config') || !this.addableConfigOptions().size } } diff --git a/src/lib/js/components/edit-panel/edit-panel.test.js b/src/lib/js/components/edit-panel/edit-panel.test.js index b67c8822..efc79e9c 100644 --- a/src/lib/js/components/edit-panel/edit-panel.test.js +++ b/src/lib/js/components/edit-panel/edit-panel.test.js @@ -386,3 +386,81 @@ describe('Config panel review fixes', () => { } }) }) + +describe('panels..add hides the add button (#117)', () => { + before(() => { + i18n.current ??= {} + }) + + const addButton = (component, panel) => component.dom.querySelector(`.${panel}-panel .add-${panel}`) + const selectFor = editorComponents => + new Field( + { + id: 'f-sel', + tag: 'select', + attrs: { type: 'select' }, + config: { label: 'Choices', controlId: 'select' }, + options: [{ label: 'One', value: 'one', selected: false }], + }, + editorComponents + ) + + it('shows the button by default', () => { + assert.ok(addButton(textField(editorWith()), 'attrs')) + }) + + it('fields.all attrs add: false hides "+ Attribute"', () => { + const field = textField(editorWith({ fields: { all: { panels: { attrs: { add: false } } } } })) + assert.equal(addButton(field, 'attrs'), null) + }) + + it('a control id scope hides it for that control only', () => { + const editorComponents = editorWith({ fields: { 'text-input': { panels: { attrs: { add: false } } } } }) + assert.equal(addButton(textField(editorComponents), 'attrs'), null) + assert.ok(addButton(selectFor(editorComponents), 'attrs')) + }) + + it('a component id scope can bring it back', () => { + const field = textField( + editorWith({ + fields: { all: { panels: { attrs: { add: false } } }, 'f-decl': { panels: { attrs: { add: true } } } }, + }) + ) + assert.ok(addButton(field, 'attrs')) + }) + + it('options add: false hides "+ Option"', () => { + const field = selectFor(editorWith({ fields: { all: { panels: { options: { add: false } } } } })) + assert.equal(addButton(field, 'options'), null) + assert.ok(addButton(field, 'attrs')) + }) + + it('stage conditions add: false hides "+ Condition" and keeps Clear All', () => { + const stage = editorWith({ stages: { all: { panels: { conditions: { add: false } } } } }).stages.get('s-1') + assert.equal(addButton(stage, 'conditions'), null) + assert.ok(stage.dom.querySelector('.conditions-panel .clear-all-conditions')) + }) + + it('config add: false keeps the Add config button hidden while keys are left to add', () => { + const field = textField(editorWith({ fields: { all: { panels: { config: { add: false } } } } })) + const panel = field.editPanels.get('config') + assert.ok(panel.addableConfigOptions().size > 0) + assert.equal(panel.addConfigButton.hidden, true) + }) + + it('existing attributes stay editable and removable', () => { + const field = new Field( + { + id: 'f-x', + tag: 'input', + attrs: { type: 'text', 'data-x': '1' }, + config: { label: 'Name', controlId: 'text-input' }, + }, + editorWith({ fields: { all: { panels: { attrs: { add: false } } } } }) + ) + const item = field.editPanels.get('attrs').editPanelItems.find(({ itemKey }) => itemKey === 'attrs.data-x') + assert.ok(item.dom.querySelector('.prop-remove')) + item.removeItem() + assert.equal(field.get('attrs.data-x'), undefined) + }) +}) diff --git a/src/lib/js/components/rows/index.js b/src/lib/js/components/rows/index.js index e12485ae..030a663b 100644 --- a/src/lib/js/components/rows/index.js +++ b/src/lib/js/components/rows/index.js @@ -6,6 +6,18 @@ const DEFAULT_CONFIG = { buttons: ['move', 'edit', 'clone', 'remove'], disabled: [], }, + panels: { + disabled: [], + // Settings is the row's own panel (Row#customPanels); Attributes edits rows..attrs (#112) + order: ['settings', 'attrs'], + attrs: { + // id: conditions and the renderer find a row by #f-; tag: would swap the element; + // data-clone-of: written by the renderer's input-group clones + disabled: ['id', 'tag', 'data-clone-of'], + hideDisabled: true, + locked: [], + }, + }, } export class Rows extends ComponentData { diff --git a/src/lib/js/components/rows/row.js b/src/lib/js/components/rows/row.js index 4f7a258e..c9e75060 100644 --- a/src/lib/js/components/rows/row.js +++ b/src/lib/js/components/rows/row.js @@ -9,6 +9,7 @@ import { COLUMN_PRESET_CLASSNAME, COLUMN_TEMPLATES, CUSTOM_COLUMN_OPTION_CLASSNAME, + PANEL_CLASSNAME, ROW_CLASSNAME, } from '../../constants.js' import Component from '../component.js' @@ -37,6 +38,9 @@ export default class Row extends Component { constructor(rowData, components) { super('row', { ...DEFAULT_DATA(), ...rowData }, components) + // Settings and Attributes panels (#112), built before the DOM that holds them + this.updateEditPanels() + const children = this.createChildWrap() this.dom = dom.create({ @@ -72,10 +76,28 @@ export default class Row extends Component { } /** - * Edit window for Row - * @return {Object} edit window dom config for Row + * A row's Settings panel sits beside its Attributes panel (#112). Built once, so the column layout select it + * holds (`columnPresetControl`) exists even when `panels.disabled` hides the panel. + * @return {Object} */ - get editWindow() { + get customPanels() { + this.settingsPanel ??= { panelConfig: this.settingsPanelConfig() } + return { settings: this.settingsPanel } + } + + /** + * A row shows an empty Attributes panel before it has any attributes (#112) + * @return {Object} + */ + get defaultPanelData() { + return { attrs: {} } + } + + /** + * Settings panel for Row: input group, fieldset and legend, and column widths + * @return {Object} panel config for Panels + */ + settingsPanelConfig() { const fieldsetInput = { tag: 'input', id: `${this.id}-fieldset`, @@ -162,18 +184,16 @@ export default class Row extends Component { const columnSettingsPreset = dom.formGroup([columnSettingsPresetLabel, columnSettingsPresetSelect], 'row') const editWindowContents = [inputGroupInput, 'hr', fieldSetControls, 'hr', columnSettingsPreset] - const editWindow = dom.create({ - className: `${this.name}-edit group-config`, + return { + config: { label: i18n.get('settings') || 'Settings' }, + attrs: { className: `${PANEL_CLASSNAME} settings-panel` }, action: { - onRender: editWindow => { - // Edit window contents are dependent on columns which have not rendered at the time of row creation - const elements = editWindowContents.map(elem => dom.create(elem)) - editWindow.append(...elements) + // the contents depend on columns, which have not rendered when the row is created + onRender: panel => { + panel.append(...editWindowContents.map(elem => dom.create(elem))) }, }, - }) - - return editWindow + } } onAdd(evt) { diff --git a/src/lib/js/components/rows/row.test.js b/src/lib/js/components/rows/row.test.js new file mode 100644 index 00000000..54b06d8f --- /dev/null +++ b/src/lib/js/components/rows/row.test.js @@ -0,0 +1,204 @@ +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 { COLUMN_PRESET_CLASSNAME } from '../../constants.js' +import { Components } from '../index.js' + +const formWithRow = (row = {}) => ({ + id: 'form-row', + stages: { 's-1': { id: 's-1', children: ['r-1'] } }, + rows: { 'r-1': { id: 'r-1', config: {}, children: ['c-1'], ...row } }, + columns: { 'c-1': { id: 'c-1', config: { width: '100%' }, children: [] } }, + fields: {}, +}) + +const mounted = [] + +/** + * One editor's Components loaded with formWithRow(row), its stage mounted so update callbacks run + * @param {Object} [opts] { config, row, callbacks, actions } + */ +const editorWith = ({ config = {}, row, callbacks = {}, actions = {} } = {}) => { + const events = new Events().init(callbacks) + const editorComponents = new Components({ events, actions: new Actions(events).init(actions) }) + editorComponents.config = config + editorComponents.load(formWithRow(row)) + const stageDom = editorComponents.stages.get('s-1').dom + document.body.appendChild(stageDom) + mounted.push(stageDom) + return editorComponents +} + +const rowOf = editorComponents => editorComponents.rows.get('r-1') +const attrKeys = row => row.editPanels.get('attrs').editPanelItems.map(({ itemKey }) => itemKey) +const nextFrames = () => new Promise(resolve => setTimeout(resolve, 50)) + +describe('Row edit panels (#112)', () => { + // addAttribute writes a label into the current language, which the editor loads before this runs + before(() => { + i18n.current ??= {} + }) + + afterEach(() => { + for (const node of mounted.splice(0)) { + node.remove() + } + }) + + it('has a Settings panel, then an Attributes panel, in its row-edit window', () => { + const row = rowOf(editorWith()) + assert.deepEqual([...row.editPanels.keys()], ['settings', 'attrs']) + assert.ok(row.dom.querySelector('.row-edit .settings-panel')) + assert.ok(row.dom.querySelector('.row-edit .attrs-panel')) + }) + + it('the Settings panel holds the input group, fieldset and column layout controls', async () => { + const row = rowOf(editorWith()) + await nextFrames() + const settings = row.dom.querySelector('.row-edit .settings-panel') + assert.ok(settings.querySelector('#r-1-inputGroup')) + assert.ok(settings.querySelector('#r-1-fieldset')) + assert.ok(settings.querySelector(`.${COLUMN_PRESET_CLASSNAME}`)) + }) + + it('panels.disabled settings leaves only Attributes, and column widths still update', () => { + const row = rowOf(editorWith({ config: { rows: { all: { panels: { disabled: ['settings'] } } } } })) + assert.deepEqual([...row.editPanels.keys()], ['attrs']) + assert.doesNotThrow(() => row.autoColumnWidths()) + }) + + it('with every panel disabled the row still builds and resizes', () => { + const row = rowOf(editorWith({ config: { rows: { all: { panels: { disabled: ['settings', 'attrs'] } } } } })) + assert.equal(row.editPanels.size, 0) + assert.doesNotThrow(() => row.autoColumnWidths()) + }) + + it('adding an attribute saves it on the row and fires onUpdateRow', () => { + const seen = [] + const editorComponents = editorWith({ callbacks: { onUpdateRow: ({ detail }) => seen.push(detail.changePath) } }) + rowOf(editorComponents).editPanels.get('attrs').addAttribute('data-section', 'contact') + assert.equal(editorComponents.formData.rows['r-1'].attrs['data-section'], 'contact') + assert.ok(seen.includes('rows.r-1.attrs.data-section'), JSON.stringify(seen)) + }) + + it('adding the same attribute again replaces its item', () => { + const row = rowOf(editorWith()) + const panel = row.editPanels.get('attrs') + panel.addAttribute('data-section', 'contact') + panel.addAttribute('data-section', 'billing') + assert.equal(row.get('attrs.data-section'), 'billing') + assert.equal(panel.props.querySelectorAll('.field-attrs-data-section').length, 1) + }) + + it('id, tag and data-clone-of are reserved', () => { + const row = rowOf(editorWith()) + const panel = row.editPanels.get('attrs') + for (const name of ['id', 'tag', 'data-clone-of']) { + assert.equal(row.isDisabledProp(`attrs.${name}`), true, name) + panel.addAttribute(name, 'x') + assert.equal(row.get(`attrs.${name}`), undefined, name) + } + }) + + it('the add-attribute event reports reserved names as disabled, so the dialog refuses them', () => { + let addEvt + const editorComponents = editorWith({ + actions: { + add: { + attr: evt => { + addEvt = evt + }, + }, + }, + }) + rowOf(editorComponents).dom.querySelector('.row-edit .add-attrs').click() + assert.equal(addEvt.isDisabled('attrs.id'), true) + assert.equal(addEvt.isDisabled('attrs.data-section'), false) + }) + + it('an integrator can reserve more names but not free the built-in ones', () => { + const row = rowOf(editorWith({ config: { rows: { all: { panels: { attrs: { disabled: ['onclick'] } } } } } })) + assert.equal(row.isDisabledProp('attrs.onclick'), true) + assert.equal(row.isDisabledProp('attrs.id'), true) + }) + + it('a loaded row keeps a reserved attribute in its data but does not show it', () => { + const editorComponents = editorWith({ row: { attrs: { id: 'legacy', 'data-section': 'contact' } } }) + assert.deepEqual(attrKeys(rowOf(editorComponents)), ['attrs.data-section']) + assert.equal(editorComponents.formData.rows['r-1'].attrs.id, 'legacy') + }) + + it('row attributes are not applied to the editor canvas', () => { + const row = rowOf(editorWith({ row: { attrs: { 'data-section': 'contact', className: 'd-none' } } })) + assert.equal(row.dom.hasAttribute('data-section'), false) + assert.equal(row.dom.classList.contains('d-none'), false) + }) + + it('a row without attributes saves no attrs key', () => { + const editorComponents = editorWith() + assert.equal(Object.hasOwn(editorComponents.formData.rows['r-1'], 'attrs'), false) + }) + + it('a row loaded with attrs: null shows an empty panel and can take an attribute', () => { + const row = rowOf(editorWith({ row: { attrs: null } })) + assert.deepEqual(attrKeys(row), []) + row.editPanels.get('attrs').addAttribute('data-section', 'contact') + assert.equal(row.get('attrs.data-section'), 'contact') + }) + + it('a cloned row gets its own copy of the attributes', () => { + const editorComponents = editorWith({ row: { attrs: { 'data-section': 'contact' } } }) + const copy = rowOf(editorComponents).clone() + assert.equal(copy.get('attrs.data-section'), 'contact') + copy.set('attrs.data-section', 'billing') + assert.equal(rowOf(editorComponents).get('attrs.data-section'), 'contact') + }) + + it('rows.all attrs add: false hides "+ Attribute" on rows (#117)', () => { + const row = rowOf(editorWith({ config: { rows: { all: { panels: { attrs: { add: false } } } } } })) + assert.equal(row.dom.querySelector('.row-edit .add-attrs'), null) + }) + + it("a row with its edit button disabled leaves its column's edit button alone", async () => { + const config = { + rows: { all: { actionButtons: { disabled: ['edit'] }, panels: { disabled: ['settings', 'attrs'] } } }, + } + const row = rowOf(editorWith({ config })) + await nextFrames() + assert.equal(row.dom.querySelector('.row-actions .edit-toggle'), null) + assert.ok(row.dom.querySelector('.column-actions .edit-toggle')) + }) + + it('a row with every action button and every panel disabled renders without throwing', async () => { + const config = { + rows: { + all: { + actionButtons: { disabled: ['move', 'edit', 'clone', 'remove'] }, + panels: { disabled: ['settings', 'attrs'] }, + }, + }, + } + const row = rowOf(editorWith({ config })) + await nextFrames() + assert.ok(row.dom) + }) + + it("re-running updateEditPanels leaves the row's columns' panel navs alone", async () => { + const editorComponents = editorWith() + const row = rowOf(editorComponents) + const columnNav = editorComponents.columns.get('c-1').dom.querySelector('.panel-nav') + row.updateEditPanels() + assert.equal(editorComponents.columns.get('c-1').dom.querySelector('.panel-nav'), columnNav) + assert.equal(row.dom.querySelectorAll(':scope > .row-edit > .panel-nav').length, 1) + }) + + it("a row without panels is not given a descendant's nav on re-run", async () => { + const config = { rows: { all: { panels: { disabled: ['settings', 'attrs'] } } } } + const editorComponents = editorWith({ config }) + const columnNav = editorComponents.columns.get('c-1').dom.querySelector('.panel-nav') + rowOf(editorComponents).updateEditPanels() + assert.equal(editorComponents.columns.get('c-1').dom.querySelector('.panel-nav'), columnNav) + }) +}) diff --git a/src/lib/js/formdata-schema.test.mjs b/src/lib/js/formdata-schema.test.mjs index 6d019d53..a0d96d17 100644 --- a/src/lib/js/formdata-schema.test.mjs +++ b/src/lib/js/formdata-schema.test.mjs @@ -14,6 +14,16 @@ suite('formData schema', () => { t.assert.ok(result.success, JSON.stringify(result.error?.issues)) }) + test('accepts attrs on rows and columns (#112)', t => { + const data = structuredClone(conditionalFields) + const [rowId] = Object.keys(data.rows) + const [columnId] = Object.keys(data.columns) + data.rows[rowId].attrs = { 'data-section': 'contact', className: 'my-row' } + data.columns[columnId].attrs = { 'aria-label': 'Left', style: 'padding: 4px' } + const result = formDataSchema.safeParse(data) + t.assert.ok(result.success, JSON.stringify(result.error?.issues)) + }) + test('accepts the logical operator between if clauses and rejects unknown ones', t => { t.assert.ok(formDataSchema.safeParse(withSecondClause('&&')).success) t.assert.ok(formDataSchema.safeParse(withSecondClause('||')).success) diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index ad2da76e..9c0d023d 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -38,6 +38,51 @@ const SKIPPED_PAGE_READS = { isNotVisible: true, } +/** + * Class names from a class value: a function is called first, then strings and arrays are flattened + * @param {String|Array|Function} [value] + * @return {String[]} + */ +const classNames = value => { + const resolved = typeof value === 'function' ? value() : value + return [resolved].flat(Infinity).filter(name => typeof name === 'string' && name.trim()) +} + +/** + * A row's or column's own attributes, ready to render (#112). `id` and `tag` are Formeo's: the element is found by + * `#f-` (conditions use it) and is always a div. Formeo's own class list (the component's top-level `className`), + * `attrs.className` and `attrs.class` become one array on `attrs.className`, so the caller drops the top-level + * `className`: dom.create would overwrite a string `attrs.className` with a string top-level one. + * @param {Object|null} [attrs] + * @param {String|String[]} [ownClassName] the component's top-level `className` + * @return {Object} + */ +const layoutAttrs = (attrs, ownClassName) => { + const { id: _id, tag: _tag, class: classAttr, className, ...rest } = attrs ?? {} + const classes = [...new Set([...classNames(ownClassName), ...classNames(className), ...classNames(classAttr)])] + if (classes.length) { + rest.className = classes + } + return rest +} + +// a declaration list split on `;`, except inside parentheses, e.g. url("data:image/png;base64,...") +const STYLE_DECLARATION_SEPARATOR = /;(?![^(]*\))/ +const WIDTH_DECLARATION = /^width\s*:/i + +/** + * A column's own style plus its width. Width declarations in the style are dropped, so `config.width` wins even + * over `width: 10px !important`. + * @param {String} [style] + * @param {String} width e.g. '50%' + * @return {String} + */ +const columnStyle = (style, width) => { + const declarations = typeof style === 'string' ? style.split(STYLE_DECLARATION_SEPARATOR) : [] + const kept = declarations.map(declaration => declaration.trim()).filter(d => d && !WIDTH_DECLARATION.test(d)) + return [...kept, `width: ${width}`].join('; ') +} + export default class FormeoRenderer { constructor(opts = {}, formDataArg) { const { renderContainer: container, elements, formData, config, events, pagination } = processOptions(opts) @@ -424,13 +469,18 @@ export default class FormeoRenderer { * @param {Object} columnData * @return {Object} processed column data */ - processColumn = ({ id, config = {}, ...columnData }) => ({ - ...columnData, - config, - id: this.prefixId(id), - children: this.processFields(columnData.children), - style: `width: ${config.width || '100%'}`, - }) + processColumn = ({ id, config = {}, attrs, className, ...columnData }) => { + const { style, ...columnAttrs } = layoutAttrs(attrs, className) + return { + ...columnData, + attrs: columnAttrs, + config, + id: this.prefixId(id), + children: this.processFields(columnData.children), + // the column's own style, minus any width, so config.width always wins (#112) + style: columnStyle(typeof style === 'function' ? style() : style, config.width || '100%'), + } + } processRows = stageId => this.orderChildren('rows', this.form.stages[stageId].children).reduce((acc, row) => { @@ -452,8 +502,15 @@ export default class FormeoRenderer { */ processRow = (data, type = 'row') => { const { config = {}, id } = data + // Formeo's own class list joins attrs.className (see layoutAttrs) + const { className: ownClassName, ...rowProps } = data const className = [`formeo-${type}-wrap`] - const rowData = { ...data, children: this.processColumns(data.id), id: this.prefixId(id) } + const rowData = { + ...rowProps, + attrs: layoutAttrs(data.attrs, ownClassName), + children: this.processColumns(data.id), + id: this.prefixId(id), + } this.cacheComponent(rowData) const configConditions = [ diff --git a/src/lib/js/renderer/layout-attrs.test.js b/src/lib/js/renderer/layout-attrs.test.js new file mode 100644 index 00000000..15d64de3 --- /dev/null +++ b/src/lib/js/renderer/layout-attrs.test.js @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, test } from 'node:test' +import { JSDOM } from 'jsdom' +import FormeoRenderer from './index.js' + +const formWith = ({ row = {}, column = {}, rowConfig = {} } = {}) => ({ + id: 'form-la', + stages: { 'stage-1': { id: 'stage-1', children: ['row-1'] } }, + rows: { 'row-1': { id: 'row-1', className: ['formeo-row'], config: rowConfig, children: ['col-1'], ...row } }, + columns: { + 'col-1': { id: 'col-1', className: ['formeo-column'], config: { width: '50%' }, children: ['field-1'], ...column }, + }, + fields: { + 'field-1': { id: 'field-1', tag: 'input', attrs: { type: 'text', name: 'name' }, config: { label: 'Name' } }, + }, +}) + +// a source field in its own row whose value "hide" hides row-1 +const withSourceRow = formData => { + formData.stages['stage-1'].children.unshift('row-src') + formData.rows['row-src'] = { id: 'row-src', config: {}, children: ['col-src'] } + formData.columns['col-src'] = { id: 'col-src', config: { width: '100%' }, children: ['src'] } + formData.fields.src = { + id: 'src', + tag: 'input', + attrs: { type: 'text' }, + config: { label: 'Source' }, + conditions: [ + { + if: [{ source: 'fields.src', sourceProperty: 'value', comparison: 'equals', target: 'hide' }], + then: [{ target: 'rows.row-1', targetProperty: 'isNotVisible', assignment: '', value: '' }], + }, + ], + } + return formData +} + +describe('row and column attributes in the renderer (#112)', () => { + let dom + let container + const nativeEvent = global.Event + + beforeEach(() => { + dom = new JSDOM('
', { + url: 'http://localhost', + pretendToBeVisual: true, + }) + global.document = dom.window.document + global.window = dom.window + global.Element = dom.window.Element + global.HTMLElement = dom.window.HTMLElement + global.HTMLFormElement = dom.window.HTMLFormElement + global.Node = dom.window.Node + global.FormData = dom.window.FormData + global.Event = dom.window.Event + container = dom.window.document.getElementById('container') + }) + + afterEach(() => { + for (const key of ['document', 'window', 'Element', 'HTMLElement', 'HTMLFormElement', 'Node', 'FormData']) { + delete global[key] + } + global.Event = nativeEvent + }) + + const render = formData => { + new FormeoRenderer({ renderContainer: container }).render(formData) + return container + } + + test('row attributes land on the row element conditions target, not its wrapper', () => { + render(formWith({ row: { attrs: { 'data-section': 'contact', className: 'my-row' } } })) + const row = container.querySelector('#f-row-1') + assert.equal(row.getAttribute('data-section'), 'contact') + assert.ok(row.classList.contains('formeo-row')) + assert.ok(row.classList.contains('my-row')) + assert.ok(row.parentElement.classList.contains('formeo-row-wrap')) + assert.equal(row.parentElement.hasAttribute('data-section'), false) + }) + + test('a fieldset row keeps attributes on the inner row element', () => { + render(formWith({ rowConfig: { fieldset: true, legend: 'Contact' }, row: { attrs: { 'data-x': '1' } } })) + const row = container.querySelector('#f-row-1') + assert.equal(row.parentElement.tagName, 'FIELDSET') + assert.equal(row.parentElement.hasAttribute('data-x'), false) + assert.equal(row.getAttribute('data-x'), '1') + }) + + test('class joins the class list instead of being lost', () => { + render(formWith({ row: { attrs: { class: 'plain', className: 'named' } } })) + const { classList } = container.querySelector('#f-row-1') + assert.ok(classList.contains('plain')) + assert.ok(classList.contains('named')) + assert.ok(classList.contains('formeo-row')) + }) + + test('a string className is kept beside a string Formeo class', () => { + render( + formWith({ + row: { className: 'formeo-row', attrs: { className: 'my-row' } }, + column: { className: 'formeo-column', attrs: { className: 'my-col' } }, + }) + ) + const row = container.querySelector('#f-row-1') + assert.deepEqual([...row.classList], ['formeo-row', 'my-row']) + const column = container.querySelector('#f-col-1') + assert.deepEqual([...column.classList], ['formeo-column', 'my-col']) + }) + + test('function class values are called, not rendered as source text', () => { + render(formWith({ row: { attrs: { class: () => 'from-fn', className: () => ['named-fn'] } } })) + const row = container.querySelector('#f-row-1') + assert.deepEqual([...row.classList], ['formeo-row', 'named-fn', 'from-fn']) + }) + + test('a row without any class renders no class attribute', () => { + render(formWith({ row: { className: undefined, attrs: { 'data-x': '1' } } })) + assert.equal(container.querySelector('#f-row-1').hasAttribute('class'), false) + }) + + test('id and tag are ignored', () => { + render(formWith({ row: { attrs: { id: 'hijack', tag: 'section' } }, column: { attrs: { id: 'x', tag: 'aside' } } })) + assert.equal(container.querySelector('#hijack'), null) + assert.equal(container.querySelector('#f-row-1').tagName, 'DIV') + assert.equal(container.querySelector('#f-col-1').tagName, 'DIV') + }) + + test('column attributes render, and its own style merges with the width, width last', () => { + render(formWith({ column: { attrs: { 'aria-label': 'Left', style: 'padding: 4px; width: 10px;' } } })) + const column = container.querySelector('#f-col-1') + assert.equal(column.getAttribute('aria-label'), 'Left') + assert.equal(column.style.padding, '4px') + assert.equal(column.style.width, '50%') + }) + + test('a column without a style keeps its width', () => { + render(formWith()) + assert.equal(container.querySelector('#f-col-1').style.cssText, 'width: 50%;') + }) + + test('an !important width in the column style does not beat config.width', () => { + render(formWith({ column: { attrs: { style: 'WIDTH: 10px !important; min-width: 5px' } } })) + const column = container.querySelector('#f-col-1') + assert.equal(column.style.width, '50%') + assert.equal(column.style.getPropertyPriority('width'), '') + assert.equal(column.style.minWidth, '5px') + }) + + test("a semicolon inside a column style's url() is kept", () => { + render(formWith({ column: { attrs: { style: 'background-image: url("data:image/png;base64,AAAA")' } } })) + const { style } = container.querySelector('#f-col-1') + assert.equal(style.backgroundImage, 'url("data:image/png;base64,AAAA")') + assert.equal(style.width, '50%') + }) + + test('attrs: null renders like no attributes', () => { + assert.doesNotThrow(() => render(formWith({ row: { attrs: null }, column: { attrs: null } }))) + assert.ok(container.querySelector('#f-row-1')) + }) + + test('a row with attributes is still hidden by a rows. condition (#277)', () => { + render(withSourceRow(formWith({ row: { attrs: { 'data-section': 'contact' } } }))) + const source = container.querySelector('#f-src') + source.value = 'hide' + source.dispatchEvent(new dom.window.Event('input', { bubbles: true })) + assert.equal(container.querySelector('#f-row-1').parentElement.hasAttribute('hidden'), true) + }) + + test('an input-group clone keeps the attributes and gets its own ids', () => { + // clone lookup goes through baseId(), which only recognises editor-style hex ids + render({ + id: 'form-la', + stages: { '0a0a0a0a': { id: '0a0a0a0a', children: ['1b1b1b1b'] } }, + rows: { + '1b1b1b1b': { + id: '1b1b1b1b', + config: { inputGroup: true }, + attrs: { 'data-section': 'contact' }, + children: ['2c2c2c2c'], + }, + }, + columns: { '2c2c2c2c': { id: '2c2c2c2c', config: { width: '100%' }, children: ['3d3d3d3d'] } }, + fields: { + '3d3d3d3d': { id: '3d3d3d3d', tag: 'input', attrs: { type: 'text', name: 'name' }, config: { label: 'Name' } }, + }, + }) + container.querySelector('.add-input-group').click() + const rows = container.querySelectorAll('[data-section="contact"]') + assert.equal(rows.length, 2) + assert.ok(rows[1].getAttribute('data-clone-of')) + const ids = [...container.querySelectorAll('[id]')].map(elem => elem.id) + assert.equal(new Set(ids).size, ids.length, 'no duplicate ids') + }) +}) diff --git a/src/types/formeo.d.ts b/src/types/formeo.d.ts index 38f7acb0..14e2381c 100644 --- a/src/types/formeo.d.ts +++ b/src/types/formeo.d.ts @@ -94,6 +94,10 @@ export interface StageData extends ComponentDataBase { /** A row's data without its id and children, e.g. a control set's `row`. */ export interface RowProps extends ComponentProps { + /** + * Formeo's own class list (`formeo-row`). Put your classes in `attrs.className` (or `attrs.class`); the renderer + * merges them. `attrs.id` and `attrs.tag` are reserved and ignored by the renderer. + */ className?: string | string[] config?: { fieldset?: boolean; legend?: string; inputGroup?: boolean; [key: string]: unknown } } @@ -105,6 +109,11 @@ export interface RowData extends RowProps { export interface ColumnData extends ComponentDataBase { children: ComponentId[] + /** + * Formeo's own class list (`formeo-column`). Put your classes in `attrs.className` (or `attrs.class`). A column's + * `attrs.style` is kept minus any `width`, which always comes from `config.width`. `attrs.id` and `attrs.tag` are + * reserved. + */ className?: string | string[] config?: { width?: string; [key: string]: unknown } } @@ -357,12 +366,24 @@ export interface ComponentConfig { /** Attribute names that can't be removed or changed. Combined with control `lockedAttrs`. */ locked?: string[] hideDisabled?: boolean + /** `false` hides the "+ Attribute" button. Attributes already set stay editable unless locked. */ + add?: boolean + } + options?: { + /** `false` hides the "+ Option" button. */ + add?: boolean + } + conditions?: { + /** `false` hides the "+ Condition" button. "Clear All" stays. */ + add?: boolean } config?: { /** `config` keys the Configuration panel and its "Add config" dialog offer, merged per `all`, control id, then id. */ options?: Record /** `config` keys hidden from the panel and the dialog, at whichever level they were declared. */ disabled?: string[] + /** `false` hides the "+ Configuration" button. */ + add?: boolean } [panel: string]: unknown } diff --git a/src/types/formeo.test-d.ts b/src/types/formeo.test-d.ts index b1dcf381..fa795fdb 100644 --- a/src/types/formeo.test-d.ts +++ b/src/types/formeo.test-d.ts @@ -333,8 +333,95 @@ const unnarrowedAddChild = (evt: ComponentEventData | { parent: FormeoComponent; // @ts-expect-error `other` switches the Other choice on or off; it is a boolean const badOther: FieldData = { id: 'x', tag: 'input', config: { other: 'yes' } } +// #112 / #117: row and column attributes, and hiding a panel's add button +const lockedDown: FormeoEditorOptions = { + config: { + fields: { + all: { panels: { attrs: { add: false }, options: { add: false }, config: { add: false } } }, + a1b2c3d4: { panels: { attrs: { add: true } } }, + }, + rows: { + all: { + panels: { order: ['settings', 'attrs'], disabled: ['settings'], attrs: { add: false, disabled: ['onclick'] } }, + }, + }, + columns: { all: { actionButtons: { disabled: ['edit'] }, panels: { attrs: { add: false } } } }, + stages: { all: { panels: { conditions: { add: false } } } }, + }, +} + +const withLayoutAttrs: FormeoFormData = { + ...formData, + rows: { c5060f33: { ...formData.rows.c5060f33, attrs: { 'data-section': 'contact', className: 'my-row' } } }, + columns: { '2474fbd2': { ...formData.columns['2474fbd2'], attrs: { 'aria-label': 'Left', style: 'padding: 4px' } } }, +} + +const badAdd: FormeoEditorOptions = { + config: { + fields: { + all: { + panels: { + attrs: { + // @ts-expect-error add is a boolean + add: 'no', + }, + }, + }, + }, + }, +} + +const badAddOptions: FormeoEditorOptions = { + config: { + fields: { + all: { + panels: { + options: { + // @ts-expect-error add is a boolean + add: 'no', + }, + }, + }, + }, + }, +} + +const badAddConditions: FormeoEditorOptions = { + config: { + fields: { + all: { + panels: { + conditions: { + // @ts-expect-error add is a boolean + add: 'no', + }, + }, + }, + }, + }, +} + +const badAddConfig: FormeoEditorOptions = { + config: { + fields: { + all: { + panels: { + config: { + // @ts-expect-error add is a boolean + add: 'no', + }, + }, + }, + }, + }, +} + export { and, + badAdd, + badAddConditions, + badAddConfig, + badAddOptions, badConfigOption, badFormActions, badHook, @@ -352,6 +439,7 @@ export { html, jquery, json, + lockedDown, newPage, otherChoiceForm, queried, @@ -362,4 +450,5 @@ export { unnarrowedAddChild, unnarrowedRender, values, + withLayoutAttrs, } diff --git a/tests/row-column-attributes.spec.js b/tests/row-column-attributes.spec.js new file mode 100644 index 00000000..67f4cf48 --- /dev/null +++ b/tests/row-column-attributes.spec.js @@ -0,0 +1,142 @@ +// @ts-check +import { expect, test } from '@playwright/test' +import { formFor } from './helpers/multi-editor.js' + +/** + * Mounts an editor in #e2e-rc (above the demo editor), with formFor('rc', 'Name') unless formData is given: + * row `row-rc`, column `col-rc`, field `field-rc`. The editor is window.__editor. + * @param {import('@playwright/test').Page} page + * @param {{ config?: Object, formData?: Object }} [options] + */ +const mountEditor = async (page, { config = {}, formData = formFor('rc', 'Name') } = {}) => { + await page.goto('/') + await expect(page.locator('.formeo-editor').first()).toBeVisible() + await page.evaluate( + async ({ formData, config }) => { + const container = document.createElement('div') + container.id = 'e2e-rc' + document.body.prepend(container) + window.__editor = new window.FormeoEditor( + { editorContainer: container, sessionStorage: false, style: null, config }, + formData + ) + await window.__editor.whenReady() + }, + { formData, config } + ) + return page.locator('#e2e-rc') +} + +/** + * Opens a row's, column's or field's edit window with its edit button + * @param {import('@playwright/test').Locator} component + * @param {'row' | 'column' | 'field'} type + */ +const openEdit = async (component, type) => { + await component.hover() + await component.locator(`.${type}-actions`).first().hover() + await component.locator(`.${type}-actions .edit-toggle`).first().click() + const editWindow = component.locator(`.${type}-edit`).first() + await expect(editWindow).toBeVisible() + return editWindow +} + +/** + * Fills and submits the add-attribute dialog from an edit window's "+ Attribute" button + * @param {import('@playwright/test').Page} page + * @param {import('@playwright/test').Locator} editWindow + */ +const addAttribute = async (page, editWindow, name, value) => { + await editWindow.locator('.add-attrs').click() + const dialog = page.locator('.formeo-dialog.add-attribute-dialog') + await expect(dialog).toBeVisible() + await dialog.locator('[name="attrName"]').fill(name) + await dialog.locator('[name="attrValue"]').fill(value) + await dialog.locator('button[type="submit"]').click() + return dialog +} + +const formDataOf = page => page.evaluate(() => window.__editor.formData) + +const renderEditorForm = page => + page.evaluate(() => { + const container = Object.assign(document.createElement('div'), { id: 'e2e-rc-render' }) + document.body.appendChild(container) + new window.FormeoRenderer({ renderContainer: container }).render(window.__editor.formData) + }) + +test.describe('row and column attributes (#112)', () => { + test('a row attribute added in the editor renders on the row', async ({ page }) => { + const editor = await mountEditor(page) + const editWindow = await openEdit(editor.locator('.formeo-row').first(), 'row') + await editWindow.getByRole('heading', { name: 'Attributes' }).click() + const dialog = await addAttribute(page, editWindow, 'data-section', 'contact') + await expect(dialog).toHaveCount(0) + await expect.poll(async () => (await formDataOf(page)).rows['row-rc'].attrs?.['data-section']).toBe('contact') + await renderEditorForm(page) + await expect(page.locator('#e2e-rc-render #f-row-rc')).toHaveAttribute('data-section', 'contact') + }) + + test('a column attribute added from its new edit button renders on the column', async ({ page }) => { + const editor = await mountEditor(page) + const editWindow = await openEdit(editor.locator('.formeo-column').first(), 'column') + await addAttribute(page, editWindow, 'aria-label', 'Left') + await expect.poll(async () => (await formDataOf(page)).columns['col-rc'].attrs?.['aria-label']).toBe('Left') + await renderEditorForm(page) + await expect(page.locator('#e2e-rc-render #f-col-rc')).toHaveAttribute('aria-label', 'Left') + }) + + test('a reserved name is refused and the dialog stays open', async ({ page }) => { + const editor = await mountEditor(page) + const editWindow = await openEdit(editor.locator('.formeo-row').first(), 'row') + await editWindow.getByRole('heading', { name: 'Attributes' }).click() + const dialog = await addAttribute(page, editWindow, 'id', 'mine') + await expect(dialog).toBeVisible() + expect(await dialog.locator('[name="attrName"]').evaluate(el => el.validity.valid)).toBe(false) + }) + + test('the row Settings tab still toggles the fieldset and shows the column layout', async ({ page }) => { + const formData = formFor('rc', 'Name') + formData.rows['row-rc'].config = { fieldset: false } + const editor = await mountEditor(page, { formData }) + const editWindow = await openEdit(editor.locator('.formeo-row').first(), 'row') + await editWindow.getByRole('heading', { name: 'Settings' }).click() + await editWindow.locator('#row-rc-fieldset').check() + await expect.poll(async () => (await formDataOf(page)).rows['row-rc'].config.fieldset).toBe(true) + await expect(editWindow.locator('.column-preset')).toBeVisible() + }) +}) + +test.describe('hiding the add button (#117)', () => { + test('fields.all attrs add: false hides + Attribute; existing attributes stay editable', async ({ page }) => { + const formData = formFor('rc', 'Name') + formData.fields['field-rc'].attrs.placeholder = 'Your name' + const editor = await mountEditor(page, { + formData, + config: { fields: { all: { panels: { attrs: { add: false } } } } }, + }) + const editWindow = await openEdit(editor.locator('.formeo-field').first(), 'field') + await editWindow.getByRole('heading', { name: 'Attributes' }).click() + await expect(editWindow.locator('.add-attrs')).toHaveCount(0) + await editWindow.locator('.field-attrs-placeholder input').fill('Full name') + await expect.poll(async () => (await formDataOf(page)).fields['field-rc'].attrs.placeholder).toBe('Full name') + }) + + test('rows.all attrs add: false hides + Attribute on rows', async ({ page }) => { + const editor = await mountEditor(page, { config: { rows: { all: { panels: { attrs: { add: false } } } } } }) + const editWindow = await openEdit(editor.locator('.formeo-row').first(), 'row') + await editWindow.getByRole('heading', { name: 'Attributes' }).click() + await expect(editWindow.locator('.add-attrs')).toHaveCount(0) + }) + + test('a field id can bring the button back', async ({ page }) => { + const editor = await mountEditor(page, { + config: { + fields: { all: { panels: { attrs: { add: false } } }, 'field-rc': { panels: { attrs: { add: true } } } }, + }, + }) + const editWindow = await openEdit(editor.locator('.formeo-field').first(), 'field') + await editWindow.getByRole('heading', { name: 'Attributes' }).click() + await expect(editWindow.locator('.add-attrs')).toBeVisible() + }) +})