UNPKG

@lion/ui

Version:

A package of extendable web components

1,349 lines (1,208 loc) 60.1 kB
import { LitElement } from 'lit'; import { IsNumber, Required, LionField, Validator, FormGroupMixin } from '@lion/ui/form-core.js'; import '@lion/ui/define/lion-field.js'; import '@lion/ui/define/lion-validation-feedback.js'; import { localizeTearDown } from '@lion/ui/localize-test-helpers.js'; import { aTimeout, defineCE, expect, fixture, html, triggerFocusFor, unsafeStatic, } from '@open-wc/testing'; import sinon from 'sinon'; import { getFormControlMembers } from '@lion/ui/form-core-test-helpers.js'; /** * @param {{ tagString?: string, childTagString?:string }} [cfg] */ export function runFormGroupMixinSuite(cfg = {}) { class FormChild extends LionField { // @ts-ignore get slots() { return { ...super.slots, input: () => document.createElement('input'), }; } } const childTagString = cfg.childTagString || defineCE(FormChild); class FormGroup extends FormGroupMixin(LitElement) { constructor() { super(); /** @override from FormRegistrarMixin */ this._isFormOrFieldset = true; /** @type {'child'|'choice-group'|'fieldset'} */ this._repropagationRole = 'fieldset'; // configures FormControlMixin } } const tagString = cfg.tagString || defineCE(FormGroup); const tag = unsafeStatic(tagString); const childTag = unsafeStatic(childTagString); const inputSlots = html` <${childTag} name="gender[]"></${childTag}> <${childTag} name="gender[]"></${childTag}> <${childTag} name="color"></${childTag}> <${childTag} name="hobbies[]"></${childTag}> <${childTag} name="hobbies[]"></${childTag}> `; beforeEach(() => { localizeTearDown(); }); describe('FormGroupMixin', () => { // TODO: Tests below belong to FormControlMixin. Preferably run suite integration test it(`has a fieldName based on the label`, async () => { const el1 = /** @type {FormGroup} */ ( await fixture(html`<${tag} label="foo">${inputSlots}</${tag}>`) ); const { _labelNode: _labelNode1 } = getFormControlMembers(el1); expect(el1.fieldName).to.equal(_labelNode1.textContent); const el2 = /** @type {FormGroup} */ ( await fixture(html`<${tag}><label slot="label">bar</label>${inputSlots}</${tag}>`) ); const { _labelNode: _labelNode2 } = getFormControlMembers(el2); expect(el2.fieldName).to.equal(_labelNode2.textContent); }); it(`has a fieldName based on the name if no label exists`, async () => { const el = /** @type {FormGroup} */ ( await fixture(html`<${tag} name="foo">${inputSlots}</${tag}>`) ); expect(el.fieldName).to.equal(el.name); }); it(`can override fieldName`, async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} label="foo" .fieldName="${'bar'}">${inputSlots}</${tag}> `) ); // @ts-ignore [allow-protected] in test expect(el.__fieldName).to.equal(el.fieldName); }); // TODO: Tests below belong to FormRegistrarMixin. Preferably run suite integration test it(`${tagString} has an up to date list of every form element in .formElements`, async () => { const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`)); // @ts-ignore [allow-protected] in test expect(el.formElements._keys().length).to.equal(3); expect(el.formElements['hobbies[]'].length).to.equal(2); el.removeChild(el.formElements['hobbies[]'][0]); // @ts-ignore [allow-protected] in test expect(el.formElements._keys().length).to.equal(3); expect(el.formElements['hobbies[]'].length).to.equal(1); }); it(`supports in html wrapped form elements`, async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <div> <${childTag} name="foo"></${childTag}> </div> </${tag}> `) ); expect(el.formElements.length).to.equal(1); el.children[0].removeChild(el.formElements.foo); expect(el.formElements.length).to.equal(0); }); it('handles names with ending [] as an array', async () => { const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`)); el.formElements['gender[]'][0].modelValue = { value: 'male' }; el.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; el.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; // @ts-ignore expect(el.formElements._keys().length).to.equal(3); expect(el.formElements['hobbies[]'].length).to.equal(2); expect(el.formElements['hobbies[]'][0].modelValue.value).to.equal('chess'); expect(el.formElements['gender[]'][0].modelValue.value).to.equal('male'); expect(el.modelValue['hobbies[]']).to.deep.equal([ { checked: false, value: 'chess' }, { checked: false, value: 'rugby' }, ]); }); it('throws if name is the same as its parent', async () => { const orig = console.info; console.info = () => {}; let error; const el = /** @type {FormGroup} */ (await fixture(html`<${tag} name="foo"></${tag}>`)); try { // we test the api directly as errors thrown from a web component are in a // different context and we can not catch them here => register fake elements el.addFormElement( /** @type {HTMLElement & import('../../types/FormControlMixinTypes.js').FormControlHost} */ ({ name: 'foo', }), ); } catch (err) { error = err; } expect(error).to.be.instanceOf(TypeError); expect(/** @type {TypeError} */ (error).message).to.equal( 'You can not have the same name "foo" as your parent', ); console.info = orig; // restore original console }); it('throws if same name without ending [] is used', async () => { const orig = console.info; console.info = () => {}; let error; const el = /** @type {FormGroup} */ (await fixture(html`<${tag}></${tag}>`)); try { // we test the api directly as errors thrown from a web component are in a // different context and we can not catch them here => register fake elements el.addFormElement( /** @type {HTMLElement & import('../../types/FormControlMixinTypes.js').FormControlHost} */ ({ name: 'fooBar', }), ); el.addFormElement( /** @type {HTMLElement & import('../../types/FormControlMixinTypes.js').FormControlHost} */ ({ name: 'fooBar', }), ); } catch (err) { error = err; } expect(error).to.be.instanceOf(TypeError); expect(/** @type {TypeError} */ (error).message).to.equal( 'Name "fooBar" is already registered - if you want an array add [] to the end', ); console.info = orig; // restore original console }); /* eslint-enable no-console */ it('can dynamically add/remove elements', async () => { const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`)); const newField = /** @type {FormGroup} */ ( await fixture(html`<${childTag} name="lastName"></${childTag}>`) ); const { _inputNode } = getFormControlMembers(el); // @ts-ignore [allow-protected] in test expect(el.formElements._keys().length).to.equal(3); el.appendChild(newField); // @ts-ignore [allow-protected] in test expect(el.formElements._keys().length).to.equal(4); _inputNode.removeChild(newField); // @ts-ignore [allow-protected] in test expect(el.formElements._keys().length).to.equal(3); }); // TODO: Tests below belong to FormGroupMixin. Preferably run suite integration test it('can read/write all values (of every input) via this.modelValue', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="lastName"></${childTag}> <${tag} name="newfieldset">${inputSlots}</${tag}> </${tag}> `) ); const newFieldset = /** @type {FormGroup} */ (el.querySelector(tagString)); el.formElements.lastName.modelValue = 'Bar'; newFieldset.formElements['hobbies[]'][0].modelValue = { checked: true, value: 'chess' }; newFieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'football' }; newFieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; newFieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; newFieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; expect(el.modelValue).to.deep.equal({ lastName: 'Bar', newfieldset: { 'hobbies[]': [ { checked: true, value: 'chess' }, { checked: false, value: 'football' }, ], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], color: { checked: false, value: 'blue' }, }, }); // make sure values are full settled before changing them await aTimeout(0); el.modelValue = { lastName: 2, newfieldset: { 'hobbies[]': [ { checked: true, value: 'chess' }, { checked: false, value: 'baseball' }, ], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], color: { checked: false, value: 'blue' }, }, }; expect(newFieldset.formElements['hobbies[]'][0].modelValue).to.deep.equal({ checked: true, value: 'chess', }); expect(newFieldset.formElements['hobbies[]'][1].modelValue).to.deep.equal({ checked: false, value: 'baseball', }); expect(el.formElements.lastName.modelValue).to.equal(2); }); it('works with document.createElement', async () => { const el = /** @type {FormGroup} */ (document.createElement(tagString)); const childEl = /** @type {FormChild} */ (document.createElement(childTagString)); childEl.name = 'planet'; childEl.modelValue = 'earth'; expect(el.formElements.length).to.equal(0); const wrapper = await fixture('<div></div>'); el.appendChild(childEl); wrapper.appendChild(el); expect(el.formElements.length).to.equal(1); await el.registrationComplete; expect(el.modelValue).to.deep.equal({ planet: 'earth' }); }); it('does not list disabled values in this.modelValue', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="a" disabled .modelValue="${'x'}"></${childTag}> <${childTag} name="b" .modelValue="${'x'}"></${childTag}> <${tag} name="newFieldset"> <${childTag} name="c" .modelValue="${'x'}"></${childTag}> <${childTag} name="d" disabled .modelValue="${'x'}"></${childTag}> </${tag}> <${tag} name="disabledFieldset" disabled> <${childTag} name="e" .modelValue="${'x'}"></${childTag}> </${tag}> </${tag}> `) ); expect(el.modelValue).to.deep.equal({ b: 'x', newFieldset: { c: 'x', }, }); }); it('allows overriding whether fields are included in when fetching modelValue/serializedValue etc.', async () => { class FormGroupSubclass extends FormGroupMixin(LitElement) { constructor() { super(); /** @override from FormRegistrarMixin */ this._isFormOrFieldset = true; /** @type {'child'|'choice-group'|'fieldset'} */ this._repropagationRole = 'fieldset'; // configures FormControlMixin } /** * * @param {import('../../types/FormControlMixinTypes.js').FormControlHost & {disabled: boolean}} el * @param {string} type */ _getFromAllFormElementsFilter(el, type) { if (type === 'serializedValue') { return !el.disabled; } return true; } } const tagStringSubclass = defineCE(FormGroupSubclass); const tagSubclass = unsafeStatic(tagStringSubclass); const el = /** @type {FormGroup} */ ( await fixture(html` <${tagSubclass}> <${childTag} name="a" disabled .modelValue="${'x'}"></${childTag}> <${childTag} name="b" .modelValue="${'x'}"></${childTag}> <${tagSubclass} name="newFieldset"> <${childTag} name="c" .modelValue="${'x'}"></${childTag}> <${childTag} name="d" disabled .modelValue="${'x'}"></${childTag}> </${tagSubclass}> <${tagSubclass} name="disabledFieldset" disabled> <${childTag} name="e" .modelValue="${'x'}"></${childTag}> </${tagSubclass}> </${tagSubclass}> `) ); expect(el.modelValue).to.deep.equal({ a: 'x', b: 'x', newFieldset: { c: 'x', d: 'x', }, disabledFieldset: { e: 'x', }, }); expect(el.serializedValue).to.deep.equal({ b: 'x', newFieldset: { c: 'x', }, }); }); it('allows imperatively passing a filter function to _getFromAllFormElements', async () => { class FormGroupSubclass extends FormGroupMixin(LitElement) { constructor() { super(); /** @override from FormRegistrarMixin */ this._isFormOrFieldset = true; /** @type {'child'|'choice-group'|'fieldset'} */ this._repropagationRole = 'fieldset'; // configures FormControlMixin } /** * @param {import('../../types/FormControlMixinTypes.js').FormControlHost & {disabled: boolean}} el * @param {string} type */ _getFromAllFormElementsFilter(el, type) { if (type === 'serializedValue') { return !el.disabled; } return true; } } const tagStringSubclass = defineCE(FormGroupSubclass); const tagSubclass = unsafeStatic(tagStringSubclass); const el = /** @type {FormGroup} */ ( await fixture(html` <${tagSubclass}> <${childTag} name="a" disabled .modelValue="${'x'}"></${childTag}> <${childTag} name="b" .modelValue="${'x'}"></${childTag}> <${tagSubclass} name="newFieldset"> <${childTag} name="c" .modelValue="${'x'}"></${childTag}> <${childTag} name="d" disabled .modelValue="${'x'}"></${childTag}> </${tagSubclass}> <${tagSubclass} name="disabledFieldset" disabled> <${childTag} name="e" .modelValue="${'x'}"></${childTag}> </${tagSubclass}> </${tagSubclass}> `) ); // @ts-ignore -- we are testing a protected method here expect(el._getFromAllFormElements('serializedValue')).to.eql({ b: 'x', newFieldset: { c: 'x', }, }); // @ts-ignore -- we are testing a protected method here expect(el._getFromAllFormElements('serializedValue', () => true)).to.eql({ a: 'x', b: 'x', newFieldset: { c: 'x', }, disabledFieldset: {}, }); }); it('does not throw if setter data of this.modelValue can not be handled', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="firstName" .modelValue=${'foo'}></${childTag}> <${childTag} name="lastName" .modelValue=${'bar'}></${childTag}> </${tag}> `) ); const initState = { firstName: 'foo', lastName: 'bar', }; expect(el.modelValue).to.deep.equal(initState); el.modelValue = undefined; expect(el.modelValue).to.deep.equal(initState); el.modelValue = null; expect(el.modelValue).to.deep.equal(initState); }); it('disables/enables all its formElements if it becomes disabled/enabled', async () => { const el = /** @type {FormGroup} */ ( await fixture(html`<${tag} disabled>${inputSlots}</${tag}>`) ); expect(el.formElements.color.disabled).to.be.true; expect(el.formElements['hobbies[]'][0].disabled).to.be.true; expect(el.formElements['hobbies[]'][1].disabled).to.be.true; el.disabled = false; await el.updateComplete; expect(el.formElements.color.disabled).to.equal(false); expect(el.formElements['hobbies[]'][0].disabled).to.equal(false); expect(el.formElements['hobbies[]'][1].disabled).to.equal(false); }); it('does not propagate/override initial disabled value on nested form elements', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${tag} name="sub" disabled>${inputSlots}</${tag}> </${tag}> `) ); expect(el.disabled).to.equal(false); expect(el.formElements.sub.disabled).to.be.true; expect(el.formElements.sub.formElements.color.disabled).to.be.true; expect(el.formElements.sub.formElements['hobbies[]'][0].disabled).to.be.true; expect(el.formElements.sub.formElements['hobbies[]'][1].disabled).to.be.true; }); it('can set initial modelValue on creation', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .modelValue=${{ lastName: 'Bar' }}> <${childTag} name="lastName"></${childTag}> </${tag}> `) ); expect(el.modelValue).to.eql({ lastName: 'Bar', }); }); it('can set initial serializedValue on creation', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .modelValue=${{ lastName: 'Bar' }}> <${childTag} name="lastName"></${childTag}> </${tag}> `) ); expect(el.modelValue).to.eql({ lastName: 'Bar' }); }); describe('Validation', () => { it('validates on init', async () => { class IsCat extends Validator { static get validatorName() { return 'IsCat'; } /** * @param {string} value */ execute(value) { const hasError = value !== 'cat'; return hasError; } } const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="color" .validators=${[ new IsCat(), ]} .modelValue=${'blue'}></${childTag}> </${tag}> `) ); expect(el.formElements.color.validationStates.error.IsCat).to.be.true; }); it('validates when a value changes', async () => { const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`)); const spy = sinon.spy(el, 'validate'); el.formElements.color.modelValue = { checked: true, value: 'red' }; expect(spy.callCount).to.equal(1); }); it('has a special validator for all children - can be checked via this.error.FormElementsHaveNoError', async () => { class IsCat extends Validator { static get validatorName() { return 'IsCat'; } /** * @param {string} value */ execute(value) { const hasError = value !== 'cat'; return hasError; } } const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="color" .validators=${[ new IsCat(), ]} .modelValue=${'blue'}></${childTag}> </${tag}> `) ); expect(el.validationStates.error.FormElementsHaveNoError).to.be.true; expect(el.formElements.color.validationStates.error.IsCat).to.be.true; el.formElements.color.modelValue = 'cat'; expect(el.validationStates.error).to.deep.equal({}); }); it('works with Required', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="myGroup" .validators="${[new Required()]}"> <${childTag} name="fieldA"></${childTag}> <${childTag} name="fieldB"></${childTag}> </${tag}> `) ); // initially the group is invalid expect(el.validationStates.error.Required).to.be.true; el.formElements.fieldA.modelValue = 'foo'; // if at least one child is filled, the group is valid expect(el.validationStates.error.Required).to.be.undefined; // make Required trigger error state again el.formElements.fieldA.modelValue = ''; expect(el.validationStates.error.Required).to.be.true; }); it('validates on children (de)registration', async () => { class HasEvenNumberOfChildren extends Validator { static get validatorName() { return 'HasEvenNumberOfChildren'; } /** * @param {string} value */ execute(value) { const hasError = Object.keys(value).length % 2 !== 0; return hasError; } } const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .validators=${[new HasEvenNumberOfChildren()]}> <${childTag} id="c1" name="c1"></${childTag}> </${tag}> `) ); const child2 = /** @type {FormGroup} */ ( await fixture(html` <${childTag} name="c2"></${childTag}> `) ); expect(el.validationStates.error.HasEvenNumberOfChildren).to.be.true; el.appendChild(child2); expect(el.validationStates.error.HasEvenNumberOfChildren).to.equal(undefined); el.removeChild(child2); expect(el.validationStates.error.HasEvenNumberOfChildren).to.be.true; // Edge case: remove all children el.removeChild(/** @type {Node} */ (el.querySelector('[id=c1]'))); expect(el.validationStates.error.HasEvenNumberOfChildren).to.equal(undefined); }); }); describe('Interaction states', () => { it('has false states (dirty, touched, prefilled) on init', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html`<${tag}>${inputSlots}</${tag}>`) ); expect(fieldset.dirty).to.equal(false, 'dirty'); expect(fieldset.touched).to.equal(false, 'touched'); expect(fieldset.prefilled).to.equal(false, 'prefilled'); }); it('sets dirty when value changed', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html`<${tag}>${inputSlots}</${tag}>`) ); fieldset.formElements['hobbies[]'][0].modelValue = { checked: true, value: 'football' }; expect(fieldset.dirty).to.be.true; }); it('sets touched when last field in fieldset left after focus', async () => { const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`)); const { _inputNode: hobbyInputNode } = getFormControlMembers( el.formElements['hobbies[]'][0], ); const { _inputNode: genderInputNode } = getFormControlMembers( el.formElements['hobbies[]'][el.formElements['gender[]'].length - 1], ); await triggerFocusFor(hobbyInputNode); await triggerFocusFor(genderInputNode); const button = /** @type {FormGroup} */ (await fixture(html`<button>Button</button>`)); button.focus(); expect(el.touched).to.be.true; }); it('sets attributes [touched][dirty]', async () => { const el = /** @type {FormGroup} */ (await fixture(html`<${tag}></${tag}>`)); el.touched = true; await el.updateComplete; expect(el).to.have.attribute('touched'); el.dirty = true; await el.updateComplete; expect(el).to.have.attribute('dirty'); }); it('becomes prefilled if all form elements are prefilled', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="input1" .modelValue="${'prefilled'}"></${childTag}> <${childTag} name="input2"></${childTag}> </${tag}> `) ); expect(el.prefilled).to.be.false; const el2 = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="input1" .modelValue="${'prefilled'}"></${childTag}> <${childTag} name="input2" .modelValue="${'prefilled'}"></${childTag}> </${tag}> `) ); expect(el2.prefilled).to.be.true; }); it(`becomes "touched" once the last element of a group becomes blurred by keyboard interaction (e.g. tabbing through the checkbox-group)`, async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <label slot="label">My group</label> <${childTag} name="myGroup[]" label="Option 1" value="1"></${childTag}> <${childTag} name="myGroup[]" label="Option 2" value="2"></${childTag}> </${tag}> `) ); const button = /** @type {HTMLButtonElement} */ (await fixture(`<button>Blur</button>`)); expect(el.touched).to.equal(false, 'initially, touched state is false'); el.formElements[1].focus(); expect(el.touched).to.equal(false, 'focus is on second checkbox'); button.focus(); expect(el.touched).to.equal( true, `focus is on element behind second checkbox (group has blurred)`, ); }); it(`becomes "touched" once the group as a whole becomes blurred via mouse interaction after keyboard interaction (e.g. focus is moved inside the group and user clicks somewhere outside the group)`, async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="input1"></${childTag}> <${childTag} name="input2"></${childTag}> </${tag}> `) ); const el2 = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="input1"></${childTag}> <${childTag} name="input2"></${childTag}> </${tag}> `) ); const outside = /** @type {HTMLButtonElement} */ ( await fixture(html`<button>outside</button>`) ); outside.click(); expect(el.touched, 'unfocused fieldset should stay untouched').to.be.false; el.formElements[0].focus(); el.formElements[1].focus(); expect(el.touched).to.be.false; outside.click(); // blur the group via a click outside.focus(); // a real mouse click moves focus as well expect(el.touched).to.be.true; expect(el2.touched).to.be.false; }); it('potentially shows fieldset error message on interaction change', async () => { class Input1IsTen extends Validator { static get validatorName() { return 'Input1IsTen'; } /** * @param {{ input1:number }} value */ execute(value) { const hasError = value.input1 !== 10; return hasError; } } const outSideButton = /** @type {FormGroup} */ ( await fixture(html`<button>outside</button>`) ); const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .validators=${[new Input1IsTen()]}> <${childTag} name="input1" .validators=${[new IsNumber()]}></${childTag}> </${tag}> `) ); const input1 = /** @type {FormChild} */ (el.querySelector('[name=input1]')); input1.modelValue = 2; input1.focus(); outSideButton.focus(); await el.updateComplete; expect(el.validationStates.error.Input1IsTen).to.be.true; expect(el.showsFeedbackFor).to.deep.equal(['error']); }); it('show error if tabbing "out" of last ', async () => { class Input1IsTen extends Validator { static get validatorName() { return 'Input1IsTen'; } /** @param {?} value */ execute(value) { const hasError = value.input1 !== 10; return hasError; } } const outSideButton = /** @type {FormGroup} */ ( await fixture(html`<button>outside</button>`) ); const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .validators=${[new Input1IsTen()]}> <${childTag} name="input1" .validators=${[new IsNumber()]}></${childTag}> <${childTag} name="input2" .validators=${[new IsNumber()]}></${childTag}> </${tag}> `) ); const inputs = /** @type {FormChild[]} */ (Array.from(el.querySelectorAll(childTagString))); inputs[1].modelValue = 2; // make it dirty inputs[1].focus(); outSideButton.focus(); expect(el.validationStates.error.Input1IsTen).to.be.true; expect(el.hasFeedbackFor).to.deep.equal(['error']); }); it('does not become dirty when elements are prefilled', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .serializedValue="${{ input1: 'x', input2: 'y' }}"> <${childTag} name="input1" ></${childTag}> <${childTag} name="input2"></${childTag}> </${tag}> `) ); expect(el.dirty).to.be.false; const el2 = /** @type {FormGroup} */ ( await fixture(html` <${tag} .modelValue="${{ input1: 'x', input2: 'y' }}"> <${childTag} name="input1" ></${childTag}> <${childTag} name="input2"></${childTag}> </${tag}> `) ); expect(el2.dirty).to.be.false; }); }); // TODO: this should be tested in FormGroupMixin describe('serializedValue', () => { it('use form elements serializedValue', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html`<${tag}>${inputSlots}</${tag}>`) ); fieldset.formElements['hobbies[]'][0].serializer = /** @param {?} v */ v => `${v.value}-serialized`; fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'Bar' }; fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; fieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; expect(fieldset.formElements['hobbies[]'][0].serializedValue).to.equal('Bar-serialized'); expect(fieldset.serializedValue).to.deep.equal({ 'hobbies[]': ['Bar-serialized', { checked: false, value: 'rugby' }], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], color: { checked: false, value: 'blue' }, }); }); it('treats names with ending [] as arrays', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html`<${tag}>${inputSlots}</${tag}>`) ); fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; fieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; expect(fieldset.serializedValue).to.deep.equal({ 'hobbies[]': [ { checked: false, value: 'chess' }, { checked: false, value: 'rugby' }, ], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], color: { checked: false, value: 'blue' }, }); }); it('0 is a valid value to be serialized', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="price"></${childTag}> </${tag}>`) ); fieldset.formElements.price.modelValue = 0; expect(fieldset.serializedValue).to.deep.equal({ price: 0 }); }); it('allows for nested fieldsets', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="userData"> <${childTag} name="comment"></${childTag}> <${tag} name="newfieldset">${inputSlots}</${tag}> </${tag}> `) ); const newFieldset = /** @type {FormGroup} */ (fieldset.querySelector(tagString)); newFieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; newFieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; newFieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; newFieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; newFieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; fieldset.formElements.comment.modelValue = 'Foo'; // @ts-ignore expect(fieldset.formElements._keys().length).to.equal(2); // @ts-ignore expect(newFieldset.formElements._keys().length).to.equal(3); expect(fieldset.serializedValue).to.deep.equal({ comment: 'Foo', newfieldset: { 'hobbies[]': [ { checked: false, value: 'chess' }, { checked: false, value: 'rugby' }, ], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], color: { checked: false, value: 'blue' }, }, }); }); it('does not serialize disabled values', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="custom[]"></${childTag}> <${childTag} name="custom[]"></${childTag}> </${tag}> `) ); fieldset.formElements['custom[]'][0].modelValue = 'custom 1'; fieldset.formElements['custom[]'][1].disabled = true; expect(fieldset.serializedValue).to.deep.equal({ 'custom[]': ['custom 1'], }); }); it('will exclude form elements within a disabled fieldset', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="userData"> <${childTag} name="comment"></${childTag}> <${tag} name="newfieldset">${inputSlots}</${tag}> </${tag}> `) ); const newFieldset = /** @type {FormGroup} */ (fieldset.querySelector(tagString)); fieldset.formElements.comment.modelValue = 'Foo'; newFieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; newFieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; newFieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; newFieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; newFieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; newFieldset.formElements.color.disabled = true; expect(fieldset.serializedValue).to.deep.equal({ comment: 'Foo', newfieldset: { 'hobbies[]': [ { checked: false, value: 'chess' }, { checked: false, value: 'rugby' }, ], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], }, }); newFieldset.formElements.color.disabled = false; expect(fieldset.serializedValue).to.deep.equal({ comment: 'Foo', newfieldset: { 'hobbies[]': [ { checked: false, value: 'chess' }, { checked: false, value: 'rugby' }, ], 'gender[]': [ { checked: false, value: 'male' }, { checked: false, value: 'female' }, ], color: { checked: false, value: 'blue' }, }, }); }); it('updates the formElements keys when a name attribute changes', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="foo" .modelValue=${'qux'}></${childTag}> </${tag}> `) ); expect(fieldset.serializedValue.foo).to.equal('qux'); fieldset.formElements[0].name = 'bar'; await fieldset.updateComplete; await fieldset.formElements[0].updateComplete; expect(fieldset.serializedValue.bar).to.equal('qux'); }); }); describe('Reset', () => { it('restores default values if changes were made', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} id="firstName" name="firstName" .modelValue="${'Foo'}"></${childTag}> </${tag}> `) ); await /** @type {FormChild} */ (el.querySelector(childTagString)).updateComplete; const input = /** @type {FormChild} */ (el.querySelector('#firstName')); input.modelValue = 'Bar'; expect(el.modelValue).to.deep.equal({ firstName: 'Bar' }); expect(input.modelValue).to.equal('Bar'); el.resetGroup(); expect(el.modelValue).to.deep.equal({ firstName: 'Foo' }); expect(input.modelValue).to.equal('Foo'); }); it('restores default values of arrays if changes were made', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} id="firstName" name="firstName[]" .modelValue="${'Foo'}"></${childTag}> </${tag}> `) ); await /** @type {FormChild} */ (el.querySelector(childTagString)).updateComplete; const input = /** @type {FormChild} */ (el.querySelector('#firstName')); input.modelValue = 'Bar'; expect(el.modelValue).to.deep.equal({ 'firstName[]': ['Bar'] }); expect(input.modelValue).to.equal('Bar'); el.resetGroup(); expect(el.modelValue).to.deep.equal({ 'firstName[]': ['Foo'] }); expect(input.modelValue).to.equal('Foo'); }); it('restores default values of a nested fieldset if changes were made', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${tag} id="name" name="name[]"> <${childTag} id="firstName" name="firstName" .modelValue="${'Foo'}"></${childTag}> </${tag}> </${tag}> `) ); await Promise.all([ /** @type {FormChild} */ (el.querySelector(tagString)).updateComplete, /** @type {FormChild} */ (el.querySelector(childTagString)).updateComplete, ]); const input = /** @type {FormChild} */ (el.querySelector('#firstName')); const nestedFieldset = /** @type {FormGroup} */ (el.querySelector('#name')); input.modelValue = 'Bar'; expect(el.modelValue).to.deep.equal({ 'name[]': [{ firstName: 'Bar' }] }); expect(nestedFieldset.modelValue).to.deep.equal({ firstName: 'Bar' }); expect(input.modelValue).to.equal('Bar'); el.resetGroup(); expect(el.modelValue).to.deep.equal({ 'name[]': [{ firstName: 'Foo' }] }); expect(nestedFieldset.modelValue).to.deep.equal({ firstName: 'Foo' }); expect(input.modelValue).to.equal('Foo'); }); it('clears interaction state', async () => { const el = /** @type {FormGroup} */ ( await fixture(html`<${tag} touched dirty>${inputSlots}</${tag}>`) ); // Safety check initially // @ts-ignore [allow-protected] in test el._setValueForAllFormElements('prefilled', true); expect(el.dirty).to.equal(true, '"dirty" initially'); expect(el.touched).to.equal(true, '"touched" initially'); expect(el.prefilled).to.equal(true, '"prefilled" initially'); // Reset all children states, with prefilled false // @ts-ignore [allow-protected] in test el._setValueForAllFormElements('modelValue', {}); el.resetInteractionState(); expect(el.dirty).to.equal(false, 'not "dirty" after reset'); expect(el.touched).to.equal(false, 'not "touched" after reset'); expect(el.prefilled).to.equal(false, 'not "prefilled" after reset'); // Reset all children states with prefilled true // @ts-ignore [allow-protected] in test el._setValueForAllFormElements('modelValue', { checked: true }); // not prefilled el.resetInteractionState(); expect(el.dirty).to.equal(false, 'not "dirty" after 2nd reset'); expect(el.touched).to.equal(false, 'not "touched" after 2nd reset'); // prefilled state is dependant on value expect(el.prefilled).to.equal(true, '"prefilled" after 2nd reset'); }); it('clears submitted state', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html`<${tag}>${inputSlots}</${tag}>`) ); fieldset.submitted = true; fieldset.resetGroup(); expect(fieldset.submitted).to.equal(false); fieldset.formElements.forEach(el => { expect(el.submitted).to.equal(false); }); }); it('has correct validation afterwards', async () => { class IsCat extends Validator { static validatorName = 'IsCat'; /** * @param {string} value */ execute(value) { const hasError = value !== 'cat'; return hasError; } } class ColorContainsA extends Validator { static validatorName = 'ColorContainsA'; /** * @param {{ [x:string]:any }} value */ execute(value) { let hasError = true; if (value && value.color) { hasError = value.color.indexOf('a') === -1; } return hasError; } } const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} .validators=${[new ColorContainsA()]}> <${childTag} name="color" .validators=${[new IsCat()]}></${childTag}> <${childTag} name="color2"></${childTag}> </${tag}> `) ); expect(el.hasFeedbackFor).to.deep.equal(['error']); expect(el.validationStates.error.ColorContainsA).to.be.true; expect(el.formElements.color.hasFeedbackFor).to.deep.equal([]); el.formElements.color.modelValue = 'onlyb'; expect(el.hasFeedbackFor).to.deep.equal(['error']); expect(el.validationStates.error.ColorContainsA).to.be.true; expect(el.formElements.color.validationStates.error.IsCat).to.be.true; el.formElements.color.modelValue = 'cat'; expect(el.hasFeedbackFor).to.deep.equal([]); el.resetGroup(); expect(el.hasFeedbackFor).to.deep.equal(['error']); expect(el.validationStates.error.ColorContainsA).to.be.true; expect(el.formElements.color.hasFeedbackFor).to.deep.equal([]); }); it('has access to `_initialModelValue` based on initial children states', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="child[]" .modelValue="${'foo1'}"> </${childTag}> <${childTag} name="child[]" .modelValue="${'bar1'}"> </${childTag}> </${tag}> `) ); await el.updateComplete; el.modelValue['child[]'] = ['foo2', 'bar2']; // @ts-ignore [allow-protected] in test expect(el._initialModelValue['child[]']).to.eql(['foo1', 'bar1']); }); it('does not wrongly recompute `_initialModelValue` after dynamic changes of children', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <${childTag} name="child[]" .modelValue="${'foo1'}"> </${childTag}> </${tag}> `) ); el.modelValue['child[]'] = ['foo2']; const childEl = /** @type {FormGroup} */ ( await fixture(html` <${childTag} name="child[]" .modelValue="${'bar1'}"> </${childTag}> `) ); el.appendChild(childEl); // @ts-ignore [allow-protected] in test expect(el._initialModelValue['child[]']).to.eql(['foo1', 'bar1']); }); describe('resetGroup method', () => { it('calls resetGroup on children fieldsets', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="parentFieldset"> <${tag} name="childFieldset"> <${childTag} name="child[]" .modelValue="${'foo1'}"> </${childTag}> </${tag}> </${tag}> `) ); const childFieldsetEl = /** @type {FormGroup} */ (el.querySelector(tagString)); const resetGroupSpy = sinon.spy(childFieldsetEl, 'resetGroup'); el.resetGroup(); expect(resetGroupSpy.callCount).to.equal(1); }); it('calls reset on children fields', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="parentFieldset"> <${tag} name="childFieldset"> <${childTag} name="child[]" .modelValue="${'foo1'}"> </${childTag}> </${tag}> </${tag}> `) ); const childFieldsetEl = /** @type {FormChild} */ (el.querySelector(childTagString)); const resetSpy = sinon.spy(childFieldsetEl, 'reset'); el.resetGroup(); expect(resetSpy.callCount).to.equal(1); }); }); describe('clearGroup method', () => { it('calls clearGroup on children fieldset', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="parentFieldset"> <${tag} name="childFieldset"> <${childTag} name="child[]" .modelValue="${'foo1'}"> </${childTag}> </${tag}> </${tag}> `) ); const childFieldsetEl = /** @type {FormGroup} */ (el.querySelector(tagString)); const clearGroupSpy = sinon.spy(childFieldsetEl, 'clearGroup'); el.clearGroup(); expect(clearGroupSpy.callCount).to.equal(1); }); it('calls clear on children fields', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="parentFieldset"> <${tag} name="childFieldset"> <${childTag} name="child[]" .modelValue="${'foo1'}"> </${childTag}> </${tag}> </${tag}> `) ); const childFieldsetEl = /** @type {FormChild} */ (el.querySelector(childTagString)); const clearSpy = sinon.spy(childFieldsetEl, 'clear'); el.clearGroup(); expect(clearSpy.callCount).to.equal(1); }); it('should clear the value of fields', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag} name="parentFieldset"> <${tag} name="childFieldset"> <${childTag} name="child" .modelValue="${'foo1'}"> </${childTag}> </${tag}> </${tag}> `) ); el.clearGroup(); expect( /** @type {FormChild} */ (el.querySelector('[name="child"]')).modelValue, ).to.equal(''); }); }); }); describe('Accessibility', () => { it('has role="group" set', async () => { const fieldset = /** @type {FormGroup} */ ( await fixture(html`<${tag}>${inputSlots}</${tag}>`) ); fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; fieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; expect(fieldset.hasAttribute('role')).to.be.true; expect(fieldset.getAttribute('role')).to.contain('group'); }); it('has an aria-labelledby from element with slot="label"', async () => { const el = /** @type {FormGroup} */ ( await fixture(html` <${tag}> <label slot="label">My Label</label>