diff --git a/.eslintrc.js b/.eslintrc.js index d75337651..b5540c30f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,26 @@ module.exports = { extends: ['@open-wc/eslint-config', 'eslint-config-prettier'].map(require.resolve), + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: [ + '**/test-suites/**/*.js', + '**/test/**/*.js', + '**/stories/**/*.js', + '**/*.config.js', + ], + }, + ], + }, + overrides: [ + { + files: ['**/test-suites/**/*.js', '**/test/**/*.js', '**/stories/**/*.js', '**/*.config.js'], + rules: { + 'no-console': 'off', + 'no-unused-expressions': 'off', + 'class-methods-use-this': 'off', + }, + }, + ], }; diff --git a/packages/field/package.json b/packages/field/package.json index 5b4e98b75..8d8629cd3 100644 --- a/packages/field/package.json +++ b/packages/field/package.json @@ -28,6 +28,7 @@ "src", "stories", "test", + "test-suites", "translations", "*.js" ], diff --git a/packages/field/src/InteractionStateMixin.js b/packages/field/src/InteractionStateMixin.js index 5d9968a6b..91a2e02a1 100644 --- a/packages/field/src/InteractionStateMixin.js +++ b/packages/field/src/InteractionStateMixin.js @@ -3,13 +3,14 @@ import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { Unparseable } from '@lion/validate'; /** - * `InteractionStateMixin` adds meta information about touched and dirty states, that can + * @desc `InteractionStateMixin` adds meta information about touched and dirty states, that can * be read by other form components (ing-uic-input-error for instance, uses the touched state * to determine whether an error message needs to be shown). * Interaction states will be set when a user: * - leaves a form field(blur) -> 'touched' will be set to true. 'prefilled' when a * field is left non-empty * - on keyup (actually, on the model-value-changed event) -> 'dirty' will be set to true + * @param {HTMLElement} superclass */ export const InteractionStateMixin = dedupeMixin( superclass => @@ -58,13 +59,15 @@ export const InteractionStateMixin = dedupeMixin( if (modelValue instanceof Unparseable) { value = modelValue.viewValue; } - // Checks for empty objects and arrays + // Checks for empty platform types: Objects, Arrays, Dates if (typeof value === 'object' && value !== null) { return !!Object.keys(value).length; } // eslint-disable-next-line no-mixed-operators + // Checks for empty platform types: Numbers, Booleans const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value)); const isBooleanValue = typeof value === 'boolean' && value === false; + return !!value || isNumberValue || isBooleanValue; } diff --git a/packages/field/test-suites/InteractionStateMixin.suite.js b/packages/field/test-suites/InteractionStateMixin.suite.js new file mode 100644 index 000000000..8d6cff351 --- /dev/null +++ b/packages/field/test-suites/InteractionStateMixin.suite.js @@ -0,0 +1,207 @@ +import { + expect, + fixture, + unsafeStatic, + html, + defineCE, + triggerFocusFor, + triggerBlurFor, +} from '@open-wc/testing'; +import sinon from 'sinon'; +import { LitElement } from '@lion/core'; +import { InteractionStateMixin } from '../src/InteractionStateMixin.js'; + +export function runInteractionStateMixinSuite(customConfig) { + const cfg = { + tagString: null, + allowedModelValueTypes: [Array, Object, Number, Boolean, String], + suffix: '', + ...customConfig, + }; + + describe(`InteractionStateMixin ${cfg.suffix ? `(${cfg.suffix})` : ''}`, async () => { + let tag; + before(() => { + if (!cfg.tagString) { + cfg.tagString = defineCE( + class IState extends InteractionStateMixin(LitElement) { + connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; + } + + set modelValue(v) { + this._modelValue = v; + this.dispatchEvent( + new CustomEvent('model-value-changed', { bubbles: true, composed: true }), + ); + } + + get modelValue() { + return this._modelValue; + } + }, + ); + } + tag = unsafeStatic(cfg.tagString); + }); + + it('sets states to false on init', async () => { + const el = await fixture(html`<${tag}>`); + expect(el.dirty).to.be.false; + expect(el.touched).to.be.false; + expect(el.prefilled).to.be.false; + }); + + it('sets dirty when value changed', async () => { + const el = await fixture(html`<${tag}>`); + expect(el.dirty).to.be.false; + el.modelValue = 'foobar'; + expect(el.dirty).to.be.true; + }); + + it('sets touched to true when field left after focus', async () => { + const el = await fixture(html`<${tag}>`); + await triggerFocusFor(el); + await triggerBlurFor(el); + expect(el.touched).to.be.true; + }); + + // classes are added only for backward compatibility - they are deprecated + it('sets a class "state-(touched|dirty)"', async () => { + const el = await fixture(html`<${tag}>`); + el.touched = true; + await el.updateComplete; + expect(el.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"'); + + el.dirty = true; + await el.updateComplete; + expect(el.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"'); + }); + + it('sets an attribute "touched', async () => { + const el = await fixture(html`<${tag}>`); + el.touched = true; + await el.updateComplete; + expect(el.hasAttribute('touched')).to.be.true; + }); + + it('sets an attribute "dirty', async () => { + const el = await fixture(html`<${tag}>`); + el.dirty = true; + await el.updateComplete; + expect(el.hasAttribute('dirty')).to.be.true; + }); + + it('fires "(touched|dirty)-state-changed" event when state changes', async () => { + const touchedSpy = sinon.spy(); + const dirtySpy = sinon.spy(); + const el = await fixture( + html`<${tag} @touched-changed=${touchedSpy} @dirty-changed=${dirtySpy}>`, + ); + + el.touched = true; + expect(touchedSpy.callCount).to.equal(1); + + el.dirty = true; + expect(dirtySpy.callCount).to.equal(1); + }); + + it('sets prefilled once instantiated', async () => { + const el = await fixture(html` + <${tag} .modelValue=${'prefilled'}> + `); + expect(el.prefilled).to.be.true; + + const nonPrefilled = await fixture(html` + <${tag} .modelValue=${''}> + `); + expect(nonPrefilled.prefilled).to.be.false; + }); + + // This method actually tests the implementation of the _isPrefilled method. + it(`can determine "prefilled" based on different modelValue types + (${cfg.allowedModelValueTypes.map(t => t.name).join(', ')})`, async () => { + const el = await fixture(html`<${tag}>`); + + const changeModelValueAndLeave = modelValue => { + const targetEl = el.inputElement || el; + targetEl.dispatchEvent(new Event('focus', { bubbles: true })); + el.modelValue = modelValue; + targetEl.dispatchEvent(new Event(el._leaveEvent, { bubbles: true })); + }; + + // Prefilled + if (cfg.allowedModelValueTypes.includes(Array)) { + changeModelValueAndLeave(['not-empty']); + expect(el.prefilled, 'not empty array should be "prefilled"').to.be.true; + changeModelValueAndLeave([]); + expect(el.prefilled, 'empty array should not be "prefilled"').to.be.false; + } + if (cfg.allowedModelValueTypes.includes(Object)) { + changeModelValueAndLeave({ not: 'empty' }); + expect(el.prefilled, 'not empty object should be "prefilled"').to.be.true; + changeModelValueAndLeave({}); + expect(el.prefilled, 'empty object should not be "prefilled"').to.be.false; + } + if (cfg.allowedModelValueTypes.includes(Number)) { + changeModelValueAndLeave(0); + expect(el.prefilled, 'numbers should be "prefilled"').to.be.true; + } + if (cfg.allowedModelValueTypes.includes(String)) { + changeModelValueAndLeave(false); + expect(el.prefilled, 'booleans should be "prefilled"').to.be.true; + changeModelValueAndLeave(''); + expect(el.prefilled, 'empty string should not be "prefilled"').to.be.false; + } + + // Not prefilled + changeModelValueAndLeave(null); + expect(el.prefilled, 'null should not be "prefilled"').to.be.false; + changeModelValueAndLeave(undefined); + expect(el.prefilled, 'undefined should not be "prefilled"').to.be.false; + }); + + it('has a method resetInteractionState()', async () => { + const el = await fixture(html`<${tag}>`); + el.dirty = true; + el.touched = true; + el.prefilled = true; + el.resetInteractionState(); + expect(el.dirty).to.be.false; + expect(el.touched).to.be.false; + expect(el.prefilled).to.be.false; + + el.dirty = true; + el.touched = true; + el.prefilled = false; + el.modelValue = 'Some value'; + el.resetInteractionState(); + expect(el.dirty).to.be.false; + expect(el.touched).to.be.false; + expect(el.prefilled).to.be.true; + }); + + describe('SubClassers', () => { + it('can override the `_leaveEvent`', async () => { + const tagLeaveString = defineCE( + class IState extends InteractionStateMixin(LitElement) { + constructor() { + super(); + this._leaveEvent = 'custom-blur'; + } + }, + ); + const tagLeave = unsafeStatic(tagLeaveString); + const el = await fixture(html`<${tagLeave}>`); + el.dispatchEvent(new Event('custom-blur')); + expect(el.touched).to.be.true; + }); + + it('can override the deprecated `leaveEvent`', async () => { + const el = await fixture(html`<${tag} .leaveEvent=${'custom-blur'}>`); + expect(el._leaveEvent).to.equal('custom-blur'); + }); + }); + }); +} diff --git a/packages/field/test/InteractionStateMixin.test.js b/packages/field/test/InteractionStateMixin.test.js index c95555b66..709ba7e4c 100644 --- a/packages/field/test/InteractionStateMixin.test.js +++ b/packages/field/test/InteractionStateMixin.test.js @@ -1,189 +1,3 @@ -import { - expect, - fixture, - unsafeStatic, - html, - defineCE, - triggerFocusFor, - triggerBlurFor, -} from '@open-wc/testing'; -import sinon from 'sinon'; -import { LitElement } from '@lion/core'; +import { runInteractionStateMixinSuite } from '../test-suites/InteractionStateMixin.suite.js'; -import { InteractionStateMixin } from '../src/InteractionStateMixin.js'; - -describe('InteractionStateMixin', async () => { - let tagString; - let tag; - before(() => { - tagString = defineCE( - class IState extends InteractionStateMixin(LitElement) { - connectedCallback() { - super.connectedCallback(); - this.tabIndex = 0; - } - - set modelValue(v) { - this._modelValue = v; - this.dispatchEvent( - new CustomEvent('model-value-changed', { bubbles: true, composed: true }), - ); - } - - get modelValue() { - return this._modelValue; - } - }, - ); - tag = unsafeStatic(tagString); - }); - - it('sets states to false on init', async () => { - const el = await fixture(html`<${tag}>`); - expect(el.dirty).to.be.false; - expect(el.touched).to.be.false; - expect(el.prefilled).to.be.false; - }); - - it('sets dirty when value changed', async () => { - const el = await fixture(html`<${tag}>`); - expect(el.dirty).to.be.false; - el.modelValue = 'foobar'; - expect(el.dirty).to.be.true; - }); - - it('sets touched to true when field left after focus', async () => { - const el = await fixture(html`<${tag}>`); - await triggerFocusFor(el); - await triggerBlurFor(el); - expect(el.touched).to.be.true; - }); - - // classes are added only for backward compatibility - they are deprecated - it('sets a class "state-(touched|dirty)"', async () => { - const el = await fixture(html`<${tag}>`); - el.touched = true; - await el.updateComplete; - expect(el.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"'); - - el.dirty = true; - await el.updateComplete; - expect(el.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"'); - }); - - it('sets an attribute "touched', async () => { - const el = await fixture(html`<${tag}>`); - el.touched = true; - await el.updateComplete; - expect(el.hasAttribute('touched')).to.be.true; - }); - - it('sets an attribute "dirty', async () => { - const el = await fixture(html`<${tag}>`); - el.dirty = true; - await el.updateComplete; - expect(el.hasAttribute('dirty')).to.be.true; - }); - - it('fires "(touched|dirty)-state-changed" event when state changes', async () => { - const touchedSpy = sinon.spy(); - const dirtySpy = sinon.spy(); - const el = await fixture( - html`<${tag} @touched-changed=${touchedSpy} @dirty-changed=${dirtySpy}>`, - ); - - el.touched = true; - expect(touchedSpy.callCount).to.equal(1); - - el.dirty = true; - expect(dirtySpy.callCount).to.equal(1); - }); - - it('sets prefilled once instantiated', async () => { - const el = await fixture(html` - <${tag} .modelValue=${'prefilled'}> - `); - expect(el.prefilled).to.be.true; - - const nonPrefilled = await fixture(html` - <${tag} .modelValue=${''}> - `); - expect(nonPrefilled.prefilled).to.be.false; - }); - - // This method actually tests the implementation of the _isPrefilled method. - it(`can determine "prefilled" based on different modelValue types (Arrays, Objects, Numbers, - Booleans, Strings)`, async () => { - const el = await fixture(html`<${tag}>`); - - const changeModelValueAndLeave = modelValue => { - el.dispatchEvent(new Event('focus', { bubbles: true })); - el.modelValue = modelValue; - el.dispatchEvent(new Event('blur', { bubbles: true })); - }; - - // Prefilled - changeModelValueAndLeave(['not-empty']); - expect(el.prefilled, 'not empty array should be "prefilled"').to.be.true; - changeModelValueAndLeave({ not: 'empty' }); - expect(el.prefilled, 'not empty object should be "prefilled"').to.be.true; - changeModelValueAndLeave(0); - expect(el.prefilled, 'numbers should be "prefilled"').to.be.true; - changeModelValueAndLeave(false); - expect(el.prefilled, 'booleans should be "prefilled"').to.be.true; - - // Not prefilled - changeModelValueAndLeave([]); - expect(el.prefilled, 'empty array should not be "prefilled"').to.be.false; - changeModelValueAndLeave({}); - expect(el.prefilled, 'empty object should not be "prefilled"').to.be.false; - changeModelValueAndLeave(''); - expect(el.prefilled, 'empty string should not be "prefilled"').to.be.false; - changeModelValueAndLeave(null); - expect(el.prefilled, 'null should not be "prefilled"').to.be.false; - changeModelValueAndLeave(undefined); - expect(el.prefilled, 'undefined should not be "prefilled"').to.be.false; - }); - - it('has a method resetInteractionState()', async () => { - const el = await fixture(html`<${tag}>`); - el.dirty = true; - el.touched = true; - el.prefilled = true; - el.resetInteractionState(); - expect(el.dirty).to.be.false; - expect(el.touched).to.be.false; - expect(el.prefilled).to.be.false; - - el.dirty = true; - el.touched = true; - el.prefilled = false; - el.modelValue = 'Some value'; - el.resetInteractionState(); - expect(el.dirty).to.be.false; - expect(el.touched).to.be.false; - expect(el.prefilled).to.be.true; - }); - - describe('SubClassers', () => { - it('can override the `_leaveEvent`', async () => { - const tagLeaveString = defineCE( - class IState extends InteractionStateMixin(LitElement) { - constructor() { - super(); - this._leaveEvent = 'custom-blur'; - } - }, - ); - const tagLeave = unsafeStatic(tagLeaveString); - const el = await fixture(html`<${tagLeave}>`); - el.dispatchEvent(new Event('custom-blur')); - expect(el.touched).to.be.true; - }); - - it('can override the deprecated `leaveEvent`', async () => { - const el = await fixture(html`<${tag} .leaveEvent=${'custom-blur'}>`); - expect(el._leaveEvent).to.equal('custom-blur'); - }); - }); -}); +runInteractionStateMixinSuite(); diff --git a/packages/field/test/field-integrations.test.js b/packages/field/test/field-integrations.test.js new file mode 100644 index 000000000..ada7fdece --- /dev/null +++ b/packages/field/test/field-integrations.test.js @@ -0,0 +1,22 @@ +import { defineCE } from '@open-wc/testing'; +import { runInteractionStateMixinSuite } from '../test-suites/InteractionStateMixin.suite.js'; +import '../lion-field.js'; + +const fieldTagString = defineCE( + class extends customElements.get('lion-field') { + get slots() { + return { + ...super.slots, + // LionField needs to have an inputElement defined in order to work... + input: () => document.createElement('input'), + }; + } + }, +); + +describe(' integrations', () => { + runInteractionStateMixinSuite({ + tagString: fieldTagString, + suffix: 'lion-field', + }); +});