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
2 changes: 1 addition & 1 deletion docs/controls/custom-attribute-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ const customControl = {
Lists are merged, so a later source cannot unlock or re-enable an attribute. Two details:

- **Disabled** attributes are not shown in the edit panel and cannot be added back with **+ Attribute**. The value in the field's data is still rendered.
- **Locked** attributes are shown read-only and greyed out, with no remove button.
- **Locked** attributes are shown greyed out, with no remove button. A locked attribute can't be removed, re-added with **+ Attribute**, or changed; locked checkboxes and selects are disabled, and locked text inputs are read-only.

An attribute that you give dropdown options in `config.fields.<scope>.attrs.<name>` is never disabled. If an attribute is both disabled and locked, it is hidden.

Expand Down
2 changes: 1 addition & 1 deletion docs/options/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,4 @@ new FormeoEditor({
})
```

Locked attributes can't be removed from the edit panel. If one was removed before you locked it, add it back with **+ Attribute** (name `required`, value `true`).
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.
2 changes: 1 addition & 1 deletion docs/options/controls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const controlOptions = {
config: {
label: 'Email',
disabledAttrs: ['type'], // hidden from the edit panel and cannot be re-added
lockedAttrs: [], // shown, but cannot be removed
lockedAttrs: [], // shown, but can't be removed, re-added with + Attribute, or changed; locked checkboxes and selects are disabled
},
meta: {
group: 'common',
Expand Down
2 changes: 2 additions & 0 deletions docs/options/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Configure Formeo to use any language.
| `langs` | Array | array of available locales | `['en-US', 'pt-PT']` | `['en-US']` |
| `override` | Object | entire dictionary or overrides | `{'en-US': {textInput: 'Short Text'}}` | `{en-US: {...}}` |

`locale` is used on first load. A language picked later with `editor.i18n.setLang()` is kept in sessionStorage for the tab and wins over this option.

Full Example

```javascript
Expand Down
2 changes: 1 addition & 1 deletion src/lib/js/common/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const attributeProblem = (rawValue, evt) => {
// relying on `required`.
return rawValue ? i18n.get('attributeNameRequired') || 'Enter an attribute name' : ''
}
if (!ATTRIBUTE_NAME.test(attr) || evt.isDisabled(`attrs.${attr}`)) {
if (!ATTRIBUTE_NAME.test(attr) || evt.isDisabled(`attrs.${attr}`) || evt.isLocked?.(`attrs.${attr}`)) {
return i18n.get('attributeNotPermitted', { attribute: attr }) || `Attribute "${attr}" is not permitted`
}
return ''
Expand Down
22 changes: 21 additions & 1 deletion src/lib/js/common/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ before(() => {
global.FormData = window.FormData
})

const addAttrEvt = () => ({
const addAttrEvt = ({ isLocked = () => false } = {}) => ({
message: { attr: 'What attribute would you like to add?', value: 'Default Value' },
isDisabled: mock.fn(propName => propName === 'attrs.type'),
isLocked: mock.fn(isLocked),
addAction: mock.fn(),
})

Expand Down Expand Up @@ -81,6 +82,25 @@ describe('actions.add.attrs default (#233)', () => {
assert.deepEqual(evt.isDisabled.mock.calls.at(-1).arguments, ['attrs.type'])
})

it('rejects a locked attribute name', () => {
const evt = addAttrEvt({ isLocked: name => name === 'attrs.required' })
actions.add.attrs(evt)
const input = submit('required')
assert.equal(evt.addAction.mock.callCount(), 0)
assert.ok(dialog(), 'still open')
assert.equal(input.validity.valid, false)
assert.deepEqual(evt.isLocked.mock.calls.at(-1).arguments, ['attrs.required'])
})

it('accepts a name when the event has no isLocked', () => {
const evt = addAttrEvt()
delete evt.isLocked
actions.add.attrs(evt)
assert.doesNotThrow(() => submit('data-x', '1'))
assert.deepEqual(evt.addAction.mock.calls[0].arguments, ['data-x', '1'])
assert.equal(dialog(), null)
})

it('rejects names that are not valid attribute names', () => {
const evt = addAttrEvt()
actions.add.attrs(evt)
Expand Down
110 changes: 110 additions & 0 deletions src/lib/js/components/edit-panel/duplicate-name-hint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import i18n from '@draggable/i18n'
import dom from '../../common/dom.js'
import { duplicateNameIds, fieldNameKey } from '../fields/duplicate-names.mjs'

const HINT_CLASSNAME = 'duplicate-name-hint'
const pending = new Set()
const watchedFields = new WeakSet()

const hintText = name =>
i18n.get('duplicateFieldName', { name }) ||
`Another field is also named "${name}", so their answers will share one key.`

/**
* The fields placed in one editor's form. `field.components` is that editor's own Components (#152),
* so another editor's fields never count.
* @param {Components} components
* @return {Field[]}
*/
const formFields = components => {
if (!components?.data?.stages) {
return []
}
return Object.entries(components.flatList())
.filter(([key]) => key.startsWith('fields.'))
.map(([, field]) => field)
}

/**
* The hint lives in the name row as a status region that stays in place and is emptied when the name
* is unique, so screen readers announce it when it fills.
* @param {Field} field
* @param {HTMLElement} row the field's `.field-attrs-name` row
* @param {String} [name] the shared name, or nothing when this field's name is unique
*/
const renderHint = (field, row, name) => {
let hint = row.querySelector(`.${HINT_CLASSNAME}`)
if (!hint) {
hint = dom.create({
...dom.helpText(''),
className: ['f-help-text', 'text-warning', HINT_CLASSNAME],
attrs: { id: `${field.id}-${HINT_CLASSNAME}`, role: 'status' },
})
row.appendChild(hint)
}
// rewriting the same text would make the status region announce it again
const text = name ? hintText(name) : ''
if (hint.textContent !== text) {
hint.textContent = text
}

// an integrator can give `name` a picklist, which renders a select
const input = row.querySelector('.prop-inputs input, .prop-inputs select')
if (name) {
input?.setAttribute('aria-describedby', hint.id)
} else {
input?.removeAttribute('aria-describedby')
}
}

/**
* Show or clear the duplicate-name hint on every field in one editor whose attrs panel is built
* @param {Components} components the editor to scan
*/
export const refreshDuplicateNameHints = components => {
const fields = formFields(components)
const duplicates = duplicateNameIds(fields.map(field => [field.id, field.get('attrs.name')]))

for (const field of fields) {
// a field without a name row can still be given a name in code
watchFieldName(field)
const row = field.dom?.querySelector('.field-attrs-name')
if (row) {
renderHint(field, row, duplicates.has(field.id) ? fieldNameKey(field.get('attrs.name')) : '')
}
}
}

/**
* Refresh once the current task is done, so loading or cloning many fields costs one scan per editor
* @param {Components} components
*/
export const scheduleDuplicateNameHints = components => {
if (!components || pending.has(components)) {
return
}
pending.add(components)
queueMicrotask(() => {
pending.delete(components)
refreshDuplicateNameHints(components)
})
}

const nameChanged = ({ path }) => {
const changed = Array.isArray(path) ? path.join('.') : String(path)
return changed === 'attrs' || changed === 'attrs.name'
}

/**
* Refresh an editor's hints whenever this field's name changes or the field is removed.
* Uses the field's own component events; registered once per field.
* @param {Field} field
*/
export const watchFieldName = field => {
if (watchedFields.has(field)) {
return
}
watchedFields.add(field)
field.addEventListener('onUpdate', evt => nameChanged(evt) && scheduleDuplicateNameHints(field.components))
field.addEventListener('onRemove', () => scheduleDuplicateNameHints(field.components))
}
162 changes: 162 additions & 0 deletions src/lib/js/components/edit-panel/duplicate-name-hint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { strict as assert } from 'node:assert'
import { 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 textField = (id, attrs = {}) => ({ id, tag: 'input', attrs: { type: 'text', ...attrs }, config: { label: id } })

const formWith = (key, fields) => ({
id: `form-${key}`,
stages: { [`stage-${key}`]: { id: `stage-${key}`, children: [`row-${key}`] } },
rows: { [`row-${key}`]: { id: `row-${key}`, config: {}, children: [`col-${key}`] } },
columns: { [`col-${key}`]: { id: `col-${key}`, config: { width: '100%' }, children: fields.map(({ id }) => id) } },
fields: Object.fromEntries(fields.map(field => [field.id, field])),
})

const editorWith = (key, fields, fieldsConfig) => {
const events = new Events().init({})
const components = new Components({ events, actions: new Actions(events).init({}) })
if (fieldsConfig) {
components.fields.config = fieldsConfig
}
components.load(formWith(key, fields))
return components
}

// hints are refreshed in a microtask so a whole load or clone costs one scan
const flush = () => new Promise(resolve => setImmediate(resolve))

const nameRow = field => field.dom.querySelector('.field-attrs-name')
const nameInput = field => nameRow(field).querySelector('input')
const visibleHint = field => {
const hint = nameRow(field)?.querySelector('.duplicate-name-hint')
return hint?.textContent.trim() ? hint : null
}

describe('duplicate field name hint (#331)', () => {
before(() => {
i18n.current ??= {}
})

it('shows a warning under the name of every field sharing it', async () => {
const editor = editorWith('dup', [textField('a', { name: 'email' }), textField('b', { name: 'email' })])
await flush()

for (const id of ['a', 'b']) {
const field = editor.fields.get(id)
const hint = visibleHint(field)
assert.ok(hint, `field ${id} shows the hint`)
assert.match(hint.textContent, /email/)
assert.equal(hint.tagName, 'SMALL')
assert.ok(hint.classList.contains('f-help-text'))
assert.ok(hint.classList.contains('text-warning'))
assert.equal(hint.getAttribute('role'), 'status')
assert.equal(nameInput(field).getAttribute('aria-describedby'), hint.id)
}
})

it('clears both hints once one field is renamed', async () => {
const editor = editorWith('rename', [textField('a', { name: 'email' }), textField('b', { name: 'email' })])
await flush()

editor.fields.get('b').set('attrs.name', 'phone')
await flush()

for (const id of ['a', 'b']) {
const field = editor.fields.get(id)
assert.equal(visibleHint(field), null, `field ${id} hint is hidden`)
assert.equal(nameInput(field).hasAttribute('aria-describedby'), false)
}
})

it('shows nothing for a field whose name is unique', async () => {
const editor = editorWith('single', [textField('a', { name: 'email' }), textField('b')])
await flush()

assert.equal(visibleHint(editor.fields.get('a')), null)
})

it('ignores fields in another editor', async () => {
const one = editorWith('one', [textField('one-a', { name: 'email' })])
const two = editorWith('two', [textField('two-a', { name: 'email' })])
await flush()

assert.equal(visibleHint(one.fields.get('one-a')), null)
assert.equal(visibleHint(two.fields.get('two-a')), null)
})

it('warns about a clone, and stops once the clone is removed', async () => {
const editor = editorWith('clone', [textField('a', { name: 'email' })])
await flush()
const original = editor.fields.get('a')

const copy = original.clone()
await flush()
assert.ok(visibleHint(original), 'original shows the hint')
assert.ok(visibleHint(copy), 'clone shows the hint')

copy.remove()
await flush()
assert.equal(visibleHint(original), null)
})

it('leaves an unchanged hint alone while another field is renamed', async () => {
const editor = editorWith('quiet', [
textField('a', { name: 'email' }),
textField('b', { name: 'email' }),
textField('c', { name: 'phone' }),
])
await flush()
const textNode = visibleHint(editor.fields.get('a')).firstChild

editor.fields.get('c').set('attrs.name', 'mobile')
await flush()

// rewriting the same text would make the status region announce it again
assert.strictEqual(visibleHint(editor.fields.get('a')).firstChild, textNode)
})

it('warns when a field without a name row is given a duplicate name in code', async () => {
const editor = editorWith('api', [textField('a', { name: 'email' }), textField('b')])
await flush()
assert.equal(nameRow(editor.fields.get('b')), null, 'b has no name row')

editor.fields.get('b').set('attrs.name', 'email')
await flush()

assert.ok(visibleHint(editor.fields.get('a')), 'a shows the hint')
})

it('describes a picklist name control too', async () => {
const picklist = [
{ label: 'email', value: 'email' },
{ label: 'phone', value: 'phone' },
]
const withControl = (id, name) => ({ ...textField(id, { name }), config: { label: id, controlId: 'text-input' } })
const editor = editorWith('picklist', [withControl('a', 'email'), withControl('b', 'email')], {
'text-input': { attrs: { name: picklist } },
})
await flush()

const field = editor.fields.get('a')
const select = nameRow(field).querySelector('select')
assert.ok(select, 'name renders as a select')
assert.equal(select.getAttribute('aria-describedby'), visibleHint(field).id)
})

it('stops warning when the other field loses its name attribute', async () => {
const editor = editorWith('attr', [textField('a', { name: 'email' }), textField('b', { name: 'email' })])
await flush()

const nameItem = editor.fields
.get('b')
.editPanels.get('attrs')
.editPanelItems.find(item => item.itemKey === 'attrs.name')
nameItem.removeItem()
await flush()

assert.equal(visibleHint(editor.fields.get('a')), null)
})
})
Loading
Loading