diff --git a/docs/controls/custom-attribute-types.md b/docs/controls/custom-attribute-types.md index 1e086f94..d9c0da09 100644 --- a/docs/controls/custom-attribute-types.md +++ b/docs/controls/custom-attribute-types.md @@ -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..attrs.` is never disabled. If an attribute is both disabled and locked, it is hidden. diff --git a/docs/options/config/README.md b/docs/options/config/README.md index 9dc7283a..863090c3 100644 --- a/docs/options/config/README.md +++ b/docs/options/config/README.md @@ -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. diff --git a/docs/options/controls/README.md b/docs/options/controls/README.md index be16bda7..068e5b79 100644 --- a/docs/options/controls/README.md +++ b/docs/options/controls/README.md @@ -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', diff --git a/docs/options/i18n/README.md b/docs/options/i18n/README.md index 65790121..1713e514 100644 --- a/docs/options/i18n/README.md +++ b/docs/options/i18n/README.md @@ -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 diff --git a/src/lib/js/common/actions.js b/src/lib/js/common/actions.js index a750a2ef..c0d2c801 100644 --- a/src/lib/js/common/actions.js +++ b/src/lib/js/common/actions.js @@ -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 '' diff --git a/src/lib/js/common/actions.test.js b/src/lib/js/common/actions.test.js index b0ec2b20..12315d55 100644 --- a/src/lib/js/common/actions.test.js +++ b/src/lib/js/common/actions.test.js @@ -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(), }) @@ -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) diff --git a/src/lib/js/components/edit-panel/duplicate-name-hint.js b/src/lib/js/components/edit-panel/duplicate-name-hint.js new file mode 100644 index 00000000..01fd11d8 --- /dev/null +++ b/src/lib/js/components/edit-panel/duplicate-name-hint.js @@ -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)) +} diff --git a/src/lib/js/components/edit-panel/duplicate-name-hint.test.js b/src/lib/js/components/edit-panel/duplicate-name-hint.test.js new file mode 100644 index 00000000..89ceb8e4 --- /dev/null +++ b/src/lib/js/components/edit-panel/duplicate-name-hint.test.js @@ -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) + }) +}) diff --git a/src/lib/js/components/edit-panel/edit-panel-item.mjs b/src/lib/js/components/edit-panel/edit-panel-item.mjs index baf18962..0dfe5269 100644 --- a/src/lib/js/components/edit-panel/edit-panel-item.mjs +++ b/src/lib/js/components/edit-panel/edit-panel-item.mjs @@ -13,6 +13,7 @@ import { REVERSED_CHECKED_TYPES, } from '../../constants.js' import { Condition } from './condition.mjs' +import { scheduleDuplicateNameHints, watchFieldName } from './duplicate-name-hint.js' import { INPUT_TYPE_ACTION, ITEM_INPUT_TYPE_MAP, labelHelper } from './helpers.mjs' const panelDataKeyMap = new Map([ @@ -83,6 +84,12 @@ export default class EditPanelItem { className: liClassList, children: { className: 'component-prop', children: [this.itemInputs(), this.itemControls] }, }) + + // a new or rebuilt field name row needs its duplicate-name hint (#331) + if (this.itemKey === 'attrs.name' && field.name === 'field') { + watchFieldName(this.field) + scheduleDuplicateNameHints(this.field.components) + } } get itemValues() { @@ -181,6 +188,10 @@ export default class EditPanelItem { this.dom.remove() this.panel.updateProps() this.field.debouncedUpdatePreview?.() + // removing a path fires no component event, so tell the other fields their name may be unique now + if (this.itemKey === 'attrs.name' && this.field.name === 'field') { + scheduleDuplicateNameHints(this.field.components) + } } get itemControls() { @@ -284,8 +295,13 @@ export default class EditPanelItem { name: baseConfig.attrs.type === 'checkbox' ? `${name}[]` : name, } - attrs.disabled = this.isDisabled - attrs.readonly = this.isLocked + // readonly does nothing on checkboxes, radios and selects, so a locked one is disabled instead + if (this.isLocked) { + const isTextControl = + baseConfig.tag === 'textarea' || + (baseConfig.tag === 'input' && !['checkbox', 'radio'].includes(baseConfig.attrs?.type)) + attrs[isTextControl ? 'readonly' : 'disabled'] = true + } const itemInputAction = itemInputActions.get(this.itemSlug)?.(this) diff --git a/src/lib/js/components/edit-panel/edit-panel.js b/src/lib/js/components/edit-panel/edit-panel.js index fc150bb7..de2e67e5 100644 --- a/src/lib/js/components/edit-panel/edit-panel.js +++ b/src/lib/js/components/edit-panel/edit-panel.js @@ -263,6 +263,11 @@ export default class EditPanel { * @param {String|Array} val */ addAttribute = (attr, valArg) => { + // locked and disabled attributes can't be added or overwritten, whoever calls this + if (this.component.isLockedProp(`attrs.${attr}`) || this.component.isDisabledProp(`attrs.${attr}`)) { + return + } + let val = valArg const safeAttr = safeAttrName(attr) const itemKey = `attrs.${safeAttr}` diff --git a/src/lib/js/components/edit-panel/locked-attrs.test.js b/src/lib/js/components/edit-panel/locked-attrs.test.js index e6795d4e..9c567f50 100644 --- a/src/lib/js/components/edit-panel/locked-attrs.test.js +++ b/src/lib/js/components/edit-panel/locked-attrs.test.js @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert' -import { after, describe, it } from 'node:test' +import { after, before, describe, it } from 'node:test' +import i18n from '@draggable/i18n' import Field from '../fields/field.js' import components from '../index.js' @@ -11,6 +12,11 @@ describe('locking attributes through config (#116)', () => { // that doesn't replace but merges into the existing configVal. const previousConfig = Fields.configVal + // addAttribute writes a label into the current language, which the editor loads before this runs + before(() => { + i18n.current ??= {} + }) + after(() => { // Restore by assigning directly to configVal to bypass the merging setter Fields.configVal = previousConfig @@ -26,4 +32,63 @@ describe('locking attributes through config (#116)', () => { assert.equal(item.isLocked, true) assert.equal(item.dom.querySelector('.prop-remove'), null) }) + + it('a locked boolean attribute renders a disabled checkbox, not readonly', () => { + Fields.config = { all: { panels: { attrs: { locked: ['required'] } } } } + const field = new Field( + { tag: 'input', attrs: { type: 'text', required: true }, config: { label: 'Name' } }, + components + ) + const item = field.editPanels.get('attrs').editPanelItems.find(i => i.itemKey === 'attrs.required') + const checkbox = item.dom.querySelector('input[type="checkbox"]') + assert.ok(checkbox, 'checkbox exists') + assert.equal(checkbox.disabled, true) + assert.equal(checkbox.hasAttribute('readonly'), false) + }) + + it('a locked string attribute renders a readonly text input, not disabled', () => { + Fields.config = { all: { panels: { attrs: { locked: ['className'] } } } } + const field = new Field( + { tag: 'input', attrs: { type: 'text', className: 'x' }, config: { label: 'Name' } }, + components + ) + const item = field.editPanels.get('attrs').editPanelItems.find(i => i.itemKey === 'attrs.className') + const textInput = item.dom.querySelector('input[type="text"]') + assert.ok(textInput, 'text input exists') + assert.equal(textInput.readOnly, true) + assert.equal(textInput.disabled, false) + }) + + it('a locked array (picklist) attribute renders a disabled select', () => { + Fields.config = { + all: { panels: { attrs: { locked: ['type'] } } }, + 'text-input': { + attrs: { + type: [ + { label: 'text', value: 'text' }, + { label: 'phone', value: 'phone' }, + ], + }, + }, + } + const field = new Field( + { tag: 'input', attrs: { type: 'text' }, config: { label: 'Name', controlId: 'text-input' } }, + components + ) + const item = field.editPanels.get('attrs').editPanelItems.find(i => i.itemKey === 'attrs.type') + const select = item.dom.querySelector('select') + assert.ok(select, 'select exists') + assert.equal(select.disabled, true) + }) + + it("EditPanel#addAttribute won't overwrite a locked attribute", () => { + Fields.config = { all: { panels: { attrs: { locked: ['required'] } } } } + const field = new Field( + { tag: 'input', attrs: { type: 'text', required: true }, config: { label: 'Name' } }, + components + ) + const panel = field.editPanels.get('attrs') + panel.addAttribute('required', false) + assert.equal(field.get('attrs.required'), true) + }) }) diff --git a/src/lib/js/components/fields/duplicate-names.mjs b/src/lib/js/components/fields/duplicate-names.mjs new file mode 100644 index 00000000..f16e39ba --- /dev/null +++ b/src/lib/js/components/fields/duplicate-names.mjs @@ -0,0 +1,15 @@ +// Only an explicit attrs.name can collide: generated names include the field id. +// The renderer submits attrs.name verbatim and userData only drops a checkbox group's [] suffix, +// so the key is compared the same way, without trimming. +export const fieldNameKey = name => String(name ?? '').replace(/\[\]$/, '') + +export const duplicateNameIds = entries => { + const idsByName = new Map() + for (const [id, name] of entries) { + const key = fieldNameKey(name) + if (key) { + idsByName.set(key, [...(idsByName.get(key) ?? []), id]) + } + } + return new Set([...idsByName.values()].filter(ids => ids.length > 1).flat()) +} diff --git a/src/lib/js/components/fields/duplicate-names.test.mjs b/src/lib/js/components/fields/duplicate-names.test.mjs new file mode 100644 index 00000000..9eeab322 --- /dev/null +++ b/src/lib/js/components/fields/duplicate-names.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { duplicateNameIds, fieldNameKey } from './duplicate-names.mjs' + +describe('fieldNameKey', () => { + test('drops a trailing [], as userData does', () => { + assert.equal(fieldNameKey('hobbies[]'), 'hobbies') + }) + test('keeps whitespace, since the renderer submits the name verbatim', () => { + assert.equal(fieldNameKey(' email '), ' email ') + }) + test('is empty for missing names', () => { + for (const name of [undefined, null, '', '[]']) { + assert.equal(fieldNameKey(name), '') + } + }) +}) + +describe('duplicateNameIds', () => { + test('returns every field sharing a name', () => { + const ids = duplicateNameIds([ + ['a', 'email'], + ['b', 'email'], + ['c', 'phone'], + ['d', 'email[]'], + ]) + assert.deepEqual([...ids].sort(), ['a', 'b', 'd']) + }) + test('compares names verbatim, so whitespace makes a different key', () => { + const ids = duplicateNameIds([ + ['a', 'email'], + ['b', ' email '], + ['c', ' '], + ['d', ' '], + ]) + assert.deepEqual([...ids].sort(), ['c', 'd']) + }) + test('ignores fields without a name', () => { + assert.equal( + duplicateNameIds([ + ['a', ''], + ['b', undefined], + ['c', ''], + ]).size, + 0 + ) + }) +}) diff --git a/src/lib/js/editor.js b/src/lib/js/editor.js index 8200d55e..fa3a0402 100644 --- a/src/lib/js/editor.js +++ b/src/lib/js/editor.js @@ -160,13 +160,15 @@ export class FormeoEditor { } this.#initState = INIT_STATES.LOADING_RESOURCES + const storedLocale = globalThis.sessionStorage?.getItem(SESSION_LOCALE_KEY) const promises = [ fetchIcons(this.opts.svgSprite), fetchFormeoStyle(this.opts.style), i18n.init({ preloaded: { 'en-US': enUS }, ...this.opts.i18n, - locale: globalThis.sessionStorage?.getItem(SESSION_LOCALE_KEY), + // a locale picked with setLang() wins; otherwise keep the configured one (a null would reset it to en-US) + ...(storedLocale && { locale: storedLocale }), }), ].filter(Boolean) diff --git a/tests/control-attr-rules.spec.js b/tests/control-attr-rules.spec.js index b1d04b29..aa3ee30d 100644 --- a/tests/control-attr-rules.spec.js +++ b/tests/control-attr-rules.spec.js @@ -43,4 +43,40 @@ test.describe('Control-level lockedAttrs / disabledAttrs', () => { expect(await name.evaluate(el => el.validity.valid)).toBe(false) await expect(field.locator('.attrs-panel .field-attrs-type')).toHaveCount(0) }) + + test('a locked attr cannot be re-added with + Attribute', async ({ page }) => { + const { field } = await addFieldAndEdit(page, '@ Email', 'Attributes') + await field.locator('.attrs-panel .add-attrs').click() + const dialog = page.locator('.formeo-dialog.add-attribute-dialog') + await expect(dialog).toBeVisible() + const name = dialog.locator('[name="attrName"]') + await name.fill('required') + await dialog.locator('button[type="submit"]').click() + await expect(dialog).toBeVisible() + expect(await name.evaluate(el => el.validity.valid)).toBe(false) + + const fieldId = await field.getAttribute('id') + const required = await page.evaluate( + id => window.frameworkLoader.currentDemo.editor.formData.fields[id].attrs.required, + fieldId + ) + expect(required).toBe(true) + }) + + test('the keyboard cannot toggle a locked checkbox', async ({ page }) => { + const { field } = await addFieldAndEdit(page, '@ Email', 'Attributes') + const checkbox = field.locator('.field-attrs-required input[type="checkbox"]') + await expect(checkbox).toBeVisible() + await expect(checkbox).toBeDisabled() + + await checkbox.focus() + await page.keyboard.press('Space') + + const fieldId = await field.getAttribute('id') + const required = await page.evaluate( + id => window.frameworkLoader.currentDemo.editor.formData.fields[id].attrs.required, + fieldId + ) + expect(required).toBe(true) + }) }) diff --git a/tests/duplicate-field-names.spec.js b/tests/duplicate-field-names.spec.js new file mode 100644 index 00000000..17e4742b --- /dev/null +++ b/tests/duplicate-field-names.spec.js @@ -0,0 +1,80 @@ +// @ts-check +import { expect, test } from '@playwright/test' +import { addFieldAndEdit, gotoEditor } from './helpers/editor.js' + +/** + * Hover a field's action bar and click one of its buttons + * @param {import('@playwright/test').Locator} field + * @param {'clone' | 'remove' | 'edit-toggle'} action + */ +const fieldAction = async (field, action) => { + await field.locator('.field-actions').hover() + await field.locator(`.field-actions .${action === 'edit-toggle' ? action : `item-${action}`}`).click() +} + +/** + * Open a field's Attributes panel unless it is already showing + * @param {import('@playwright/test').Locator} field + */ +const openAttributes = async field => { + const editPanel = field.locator('.field-edit') + if (!(await editPanel.isVisible())) { + await fieldAction(field, 'edit-toggle') + await editPanel.waitFor({ state: 'visible' }) + } + await editPanel.getByRole('heading', { name: 'Attributes' }).click() + await expect(field.locator('.attrs-panel')).toBeVisible() +} + +test.describe('Duplicate field name hint (#331)', () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page) + }) + + test('warns on both fields after a clone, and clears on rename or removal', async ({ page }) => { + const { field, editPanel } = await addFieldAndEdit(page, 'Text Input', 'Attributes') + const originalId = await field.getAttribute('id') + // `field` is "the last field", which a clone changes, so pin the original by id + const original = page.locator(`.formeo-field[id="${originalId}"]`) + + await editPanel.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('email') + await dialog.locator('button[type="submit"]').click() + await expect(dialog).toHaveCount(0) + await expect(original.locator('.field-attrs-name')).toHaveCount(1) + // a unique name gets no warning + await expect(original.locator('.duplicate-name-hint')).toBeHidden() + + await fieldAction(original, 'clone') + const fields = page.locator('.formeo-field') + await expect(fields).toHaveCount(2) + const copy = page.locator(`.formeo-field:not([id="${originalId}"])`) + + for (const each of [original, copy]) { + await openAttributes(each) + const hint = each.locator('.field-attrs-name .duplicate-name-hint') + await expect(hint).toBeVisible() + await expect(hint).toContainText('email') + await expect(hint).toHaveAttribute('role', 'status') + const hintId = await hint.getAttribute('id') + await expect(each.locator('.field-attrs-name input')).toHaveAttribute('aria-describedby', hintId ?? '') + } + + await copy.locator('.field-attrs-name input').fill('phone') + await expect(original.locator('.duplicate-name-hint')).toBeHidden() + await expect(copy.locator('.duplicate-name-hint')).toBeHidden() + + // clone the original again, then remove that clone + await fieldAction(original, 'clone') + await expect(fields).toHaveCount(3) + await expect(original.locator('.duplicate-name-hint')).toBeVisible() + const secondCopy = original.locator('xpath=following-sibling::li[contains(@class, "formeo-field")][1]') + await expect(secondCopy).not.toHaveAttribute('id', originalId ?? '') + await fieldAction(secondCopy, 'remove') + await expect(fields).toHaveCount(2) + await expect(original.locator('.duplicate-name-hint')).toBeHidden() + }) +}) diff --git a/tests/editor-i18n-locale.spec.js b/tests/editor-i18n-locale.spec.js new file mode 100644 index 00000000..ecf05673 --- /dev/null +++ b/tests/editor-i18n-locale.spec.js @@ -0,0 +1,44 @@ +// @ts-check +import { expect, test } from '@playwright/test' + +// a made-up locale registered through i18n.override, so nothing is fetched. +// Only the "save" key is overridden - @draggable/i18n falls back to en-US +// for any other key that a fresh, empty editor renders. +const LOCALE = 'xx-XX' +const MARKER = 'XX-MARKER' + +const mountEditor = (page, { stored } = {}) => + page.evaluate( + async ({ locale, marker, stored }) => { + sessionStorage.removeItem('formeo-locale') + if (stored) { + sessionStorage.setItem('formeo-locale', stored) + } + const container = Object.assign(document.createElement('div'), { id: 'i18n-editor' }) + document.body.appendChild(container) + const editor = new window.FormeoEditor({ + editorContainer: container, + sessionStorage: false, + i18n: { locale, override: { [locale]: { save: marker } } }, + }) + await editor.whenReady() + }, + { locale: LOCALE, marker: MARKER, stored } + ) + +test.describe('i18n.locale option', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await expect(page.locator('.formeo-editor').first()).toBeVisible() + }) + + test('the configured locale is used when nothing is stored', async ({ page }) => { + await mountEditor(page) + await expect(page.locator('#i18n-editor')).toContainText(MARKER) + }) + + test('a locale stored by setLang still wins over the config', async ({ page }) => { + await mountEditor(page, { stored: 'en-US' }) + await expect(page.locator('#i18n-editor')).not.toContainText(MARKER) + }) +})