From 40dec54689147e774d0bc783d5e19148712d47c4 Mon Sep 17 00:00:00 2001
From: Bartheleway
Date: Tue, 3 Nov 2020 01:49:53 +0100
Subject: [PATCH 1/9] Fix backtracking on form
---
.../paper-autocomplete/component.js | 95 +++++-
addon/components/paper-form.js | 181 +++++++++---
addon/components/paper-input.js | 271 ++++++++++++------
addon/components/paper-select/component.js | 92 +++++-
addon/mixins/child-mixin.js | 46 ---
addon/mixins/parent-mixin.js | 25 --
addon/mixins/validation-mixin.js | 141 ---------
addon/templates/components/paper-form.hbs | 24 +-
addon/templates/components/paper-input.hbs | 4 -
addon/utils/validation.js | 91 ++++++
package.json | 2 +-
tests/dummy/app/templates/forms.hbs | 19 +-
.../integration/components/paper-form-test.js | 101 ++++++-
.../components/paper-input-test.js | 35 ++-
14 files changed, 726 insertions(+), 401 deletions(-)
delete mode 100644 addon/mixins/child-mixin.js
delete mode 100644 addon/mixins/parent-mixin.js
delete mode 100644 addon/mixins/validation-mixin.js
create mode 100644 addon/utils/validation.js
diff --git a/addon/components/paper-autocomplete/component.js b/addon/components/paper-autocomplete/component.js
index 9c8fe0c5d..dd756e7be 100644
--- a/addon/components/paper-autocomplete/component.js
+++ b/addon/components/paper-autocomplete/component.js
@@ -3,22 +3,90 @@ import template from './template';
import { tagName, layout } from '@ember-decorators/component';
import { action, computed } from '@ember/object';
+import { tracked } from '@glimmer/tracking'
import calculatePosition from 'ember-paper/utils/calculate-ac-position';
-import ValidationMixin from 'ember-paper/mixins/validation-mixin';
-
+import { invokeAction } from 'ember-invoke-action';
import { assert } from '@ember/debug';
import { indexOfOption } from 'ember-power-select/utils/group-utils';
+
+import { buildComputedValidationMessages, notifyValidityChange } from 'ember-paper/utils/validation';
+import requiredValidator from 'ember-paper/validators/required';
+import minValidator from 'ember-paper/validators/min';
+import maxValidator from 'ember-paper/validators/max';
+import minlengthValidator from 'ember-paper/validators/minlength';
+import maxlengthValidator from 'ember-paper/validators/maxlength';
+
+const validations = [
+ requiredValidator,
+ minValidator,
+ maxValidator,
+ minlengthValidator,
+ maxlengthValidator
+];
+
@tagName('')
@layout(template)
-class PaperAutocomplete extends Component.extend(ValidationMixin) {
+class PaperAutocomplete extends Component {
+ @tracked
isTouched = false;
+ @computed('isTouched')
+ get formHasBeenValidated () {
+ return this.isTouched
+ }
+
+ set formHasBeenValidated (value) {
+ this.isTouched = value
+ }
+
+ validations = validations;
+
+ @tracked
+ errorMessages
+
+ @tracked
+ customValidations = []
+
+ @tracked
+ errors = []
+
+ @computed(
+ 'onSearchTextChange',
+ 'onSelectionChange',
+ 'searchText',
+ 'selected',
+ 'errors.[]',
+ 'customValidations.[]',
+ 'errorMessages',
+ requiredValidator.param,
+ minValidator.param,
+ maxValidator.param,
+ minlengthValidator.param,
+ maxlengthValidator.param
+ )
+ get validationErrorMessages () {
+ const validationProperty = this.onSearchTextChange ? 'searchText' : 'selected';
+
+ return buildComputedValidationMessages.call(this, validationProperty)
+ }
+
+ @computed.bool('validationErrorMessages.length')
+ hasErrorMessages
+
+ @computed.reads('hasErrorMessages')
+ isInvalid
+
+ @computed.not('isInvalid')
+ isValid
+
init() {
this._initComponent();
super.init(...arguments);
+
+ invokeAction(this, 'onRegister', this.get('elementId'), this.get('isValid'), this.get('isTouched'), this.get('isInvalidAndTouched'));
}
// Init autocomplete component
@@ -31,6 +99,18 @@ class PaperAutocomplete extends Component.extend(ValidationMixin) {
assert(' requires at least one of the `@onSelectionChange` or `@onSearchTextChange` functions to be provided.', hasTextChange || hasSelectionChange);
}
+ destroy () {
+ const eltId = this.get('elementId')
+
+ super.destroy(...arguments);
+
+ invokeAction(this, 'onUnregister', eltId);
+ }
+
+ notifyValidityChange() {
+ notifyValidityChange.call(this);
+ }
+
@action
_onChange() {
if (this.onSelectionChange) {
@@ -40,15 +120,6 @@ class PaperAutocomplete extends Component.extend(ValidationMixin) {
calculatePosition = calculatePosition;
- @computed('onSearchTextChange', 'onSelectionChange')
- get validationProperty() {
- if (this.onSearchTextChange) {
- return 'searchText';
- } else {
- return 'selected';
- }
- }
-
didReceiveAttrs() {
super.didReceiveAttrs(...arguments);
this.notifyValidityChange();
diff --git a/addon/components/paper-form.js b/addon/components/paper-form.js
index 17f3b05fd..f22ec7775 100644
--- a/addon/components/paper-form.js
+++ b/addon/components/paper-form.js
@@ -4,57 +4,152 @@
import { not, and } from '@ember/object/computed';
import Component from '@ember/component';
-import { computed } from '@ember/object';
-import layout from '../templates/components/paper-form';
-import ParentMixin from 'ember-paper/mixins/parent-mixin';
+import { tagName, layout } from '@ember-decorators/component';
+import { set, action } from '@ember/object';
+import template from '../templates/components/paper-form';
+import { A } from '@ember/array';
import { invokeAction } from 'ember-invoke-action';
+import { tracked } from '@glimmer/tracking';
/**
* @class PaperForm
* @extends Ember.Component
* @uses ParentMixin
*/
-export default Component.extend(ParentMixin, {
- layout,
- tagName: 'form',
-
- inputComponent: 'paper-input',
- submitButtonComponent: 'paper-button',
- selectComponent: 'paper-select',
- autocompleteComponent: 'paper-autocomplete',
-
- isValid: not('isInvalid'),
- isInvalid: computed('childComponents.@each.isInvalid', function() {
- return this.get('childComponents').isAny('isInvalid');
- }),
-
- isTouched: computed('childComponents.@each.isTouched', function() {
- return this.get('childComponents').isAny('isTouched');
- }),
-
- isInvalidAndTouched: and('isInvalid', 'isTouched'),
-
- submit() {
- this.send('onSubmit');
- return false;
- },
-
- actions: {
- onValidityChange() {
- if (this.get('lastIsValid') !== this.get('isValid') || this.get('lastIsTouched') !== this.get('isTouched')) {
- invokeAction(this, 'onValidityChange', this.get('isValid'), this.get('isTouched'), this.get('isInvalidAndTouched'));
- this.set('lastIsValid', this.get('isValid'));
- this.set('lastIsTouched', this.get('isTouched'));
+@tagName('form')
+@layout(template)
+export default class PaperForm extends Component {
+ inputComponent = 'paper-input';
+ submitButtonComponent = 'paper-button';
+ selectComponent = 'paper-select';
+ autocompleteComponent = 'paper-autocomplete';
+
+ finishFirstRender = false
+
+ @tracked
+ formHasBeenValidated = false
+
+ isValid = true
+ isTouched = false
+
+ @not('isValid')
+ isInvalid
+
+ @and('isInvalid', 'isTouched')
+ isInvalidAndTouched
+
+ childComponents = A()
+
+ updateValidity ({ childId, isValid, isTouched, isInvalidAndTouched }) {
+ const child = this.get('childComponents').findBy('childId', childId);
+
+ if (child) {
+ const lastIsValid = child.isValid;
+ const lastIsTouched = child.isTouched;
+
+ if (
+ lastIsValid !== isValid
+ || lastIsTouched !== isTouched
+ ) {
+ set(child, 'isValid', isValid);
+ set(child, 'isTouched', isTouched);
+ set(child, 'isInvalidAndTouched', isInvalidAndTouched);
+
+ this.notifyPropertyChange('childComponents')
}
- },
- onSubmit() {
- if (this.get('isInvalid')) {
- this.get('childComponents').setEach('isTouched', true);
- invokeAction(this, 'onInvalid');
- } else {
- invokeAction(this, 'onSubmit');
- this.get('childComponents').setEach('isTouched', false);
+ }
+
+ this.triggerValidityChange()
+ }
+
+ triggerValidityChange () {
+ const lastIsValid = this.get('isValid');
+ const lastIsTouched = this.get('isTouched');
+
+ if (
+ this.get('finishFirstRender')
+ && (
+ lastIsValid !== this.getIsValid()
+ || lastIsTouched !== this.getIsTouched()
+ )
+ ) {
+ this.setNewValidity()
+ }
+ }
+
+ getIsValid () {
+ return this.get('childComponents').isEvery('isValid')
+ }
+
+ getIsTouched () {
+ return this.get('childComponents').isAny('isTouched')
+ }
+
+ setNewValidity () {
+ this.set('isValid', this.getIsValid());
+ this.set('isTouched', this.getIsTouched());
+
+ invokeAction(this, 'onValidityChange', this.get('isValid'), this.get('isTouched'), this.get('isInvalidAndTouched'));
+ }
+
+ @action
+ onChildValidityChange ({ elementId: childId, isValid, isTouched, isInvalidAndTouched }) {
+ if (!this.isDestroying) {
+ this.updateValidity({
+ childId,
+ isValid,
+ isTouched,
+ isInvalidAndTouched
+ })
+ }
+ }
+
+ didRender () {
+ if (!this.get('finishFirstRender')) {
+ this.set('finishFirstRender', true)
+
+ this.setNewValidity()
+ }
+ }
+
+ submit (event) {
+ event.preventDefault()
+
+ this.onInternalSubmit(...arguments)
+ }
+
+ reset () {
+ this.set('formHasBeenValidated', false);
+ }
+
+ @action
+ onInternalSubmit () {
+ if (this.get('isInvalid')) {
+ this.set('formHasBeenValidated', true);
+
+ invokeAction(this, 'onInvalid');
+ } else {
+ invokeAction(this, 'onSubmit');
+
+ this.set('formHasBeenValidated', false);
+ }
+ }
+
+ @action
+ onRegister (childId) {
+ this.get('childComponents').pushObject({ childId });
+ }
+
+ @action
+ onUnregister (childId) {
+ if (!this.isDestroying) {
+ const child = this.get('childComponents').findBy('childId', childId)
+
+ if (child) {
+ this.get('childComponents').removeObject(child);
+
+ this.triggerValidityChange()
}
}
}
-});
+}
diff --git a/addon/components/paper-input.js b/addon/components/paper-input.js
index 59ce8925b..253eae482 100644
--- a/addon/components/paper-input.js
+++ b/addon/components/paper-input.js
@@ -1,33 +1,46 @@
/**
* @module ember-paper
*/
-import { or, bool, and } from '@ember/object/computed';
+import { or, bool, and, not } from '@ember/object/computed';
import Component from '@ember/component';
-import { computed, set } from '@ember/object';
+import { tagName, layout } from '@ember-decorators/component';
+import { computed, set, action } from '@ember/object';
+import { tracked } from '@glimmer/tracking'
import { isEmpty } from '@ember/utils';
import { run } from '@ember/runloop';
import { assert } from '@ember/debug';
-import layout from '../templates/components/paper-input';
+import template from '../templates/components/paper-input';
import FocusableMixin from 'ember-paper/mixins/focusable-mixin';
import ColorMixin from 'ember-paper/mixins/color-mixin';
-import ChildMixin from 'ember-paper/mixins/child-mixin';
-import ValidationMixin from 'ember-paper/mixins/validation-mixin';
+import { buildComputedValidationMessages, notifyValidityChange } from 'ember-paper/utils/validation';
+import requiredValidator from 'ember-paper/validators/required';
+import minValidator from 'ember-paper/validators/min';
+import maxValidator from 'ember-paper/validators/max';
+import minlengthValidator from 'ember-paper/validators/minlength';
+import maxlengthValidator from 'ember-paper/validators/maxlength';
import { invokeAction } from 'ember-invoke-action';
+const validations = [
+ requiredValidator,
+ minValidator,
+ maxValidator,
+ minlengthValidator,
+ maxlengthValidator
+];
+
/**
* @class PaperInput
* @extends Ember.Component
* @uses FocusableMixin
- * @uses ChildMixin
* @uses ColorMixin
- * @uses ValidationMixin
*/
-export default Component.extend(FocusableMixin, ColorMixin, ChildMixin, ValidationMixin, {
- layout,
- tagName: 'md-input-container',
- classNames: ['md-default-theme'],
- classNameBindings: [
+@tagName('md-input-container')
+@layout(template)
+export default class PaperInput extends Component.extend(FocusableMixin, ColorMixin) {
+ classNames = ['md-default-theme'];
+
+ classNameBindings = [
'hasValue:md-input-has-value',
'isInvalidAndTouched:md-input-invalid',
'hasLeftIcon:md-icon-left',
@@ -35,99 +48,163 @@ export default Component.extend(FocusableMixin, ColorMixin, ChildMixin, Validati
'focused:md-input-focused',
'block:md-block',
'placeholder:md-input-has-placeholder'
- ],
- type: 'text',
- autofocus: false,
- tabindex: null,
- hideAllMessages: false,
- isTouched: false,
+ ];
+
+ type = 'text';
+ autofocus = false;
+ tabindex = null;
+ hideAllMessages = false;
+
+ @tracked
+ isTouched = false;
+
+ set formHasBeenValidated (value) {
+ this.set('isTouched', value)
+ }
+
+ iconComponent = 'paper-icon';
+
+ validations = validations;
- iconComponent: 'paper-icon',
+ @tracked
+ errorMessages
+
+ @tracked
+ customValidations = []
+
+ @tracked
+ errors = []
+
+ @computed(
+ 'value',
+ 'errors.[]',
+ 'customValidations.[]',
+ 'errorMessages',
+ requiredValidator.param,
+ minValidator.param,
+ maxValidator.param,
+ minlengthValidator.param,
+ maxlengthValidator.param,
+ )
+ get validationErrorMessages () {
+ return buildComputedValidationMessages.call(this, 'value')
+ }
+
+ @bool('validationErrorMessages.length')
+ hasErrorMessages
+
+ @not('isInvalid')
+ isValid
// override validation mixin `isInvalid` to account for the native input validity
- isInvalid: or('hasErrorMessages', 'isNativeInvalid'),
+ @or('hasErrorMessages', 'isNativeInvalid')
+ isInvalid
- hasValue: computed('value', 'isNativeInvalid', function() {
+ @computed('value', 'isNativeInvalid')
+ get hasValue () {
let value = this.get('value');
let isNativeInvalid = this.get('isNativeInvalid');
+
return !isEmpty(value) || isNativeInvalid;
- }),
+ }
- shouldAddPlaceholder: computed('label', 'focused', function() {
+ @computed('label', 'focused')
+ get shouldAddPlaceholder () {
// if has label, only add placeholder when focused
return isEmpty(this.get('label')) || this.get('focused');
- }),
+ }
- inputElementId: computed('elementId', {
- get() {
- return `input-${this.get('elementId')}`;
- },
+ @computed('elementId')
+ get inputElementId () {
// elementId can be set from outside and it will override the computed value.
// Please check the deprecations for further details
// https://deprecations.emberjs.com/v3.x/#toc_computed-property-override
- set(key, value) {
- // To make sure the context updates properly, We are manually set value using @ember/object#set as recommended.
- return set(this, "elementId", value);
- }
- }),
-
+ return `input-${this.get('elementId')}`;
+ }
+ set inputElementId (value) {
+ // To make sure the context updates properly, We are manually set value using @ember/object#set as recommended.
+ return set(this, "elementId", value);
+ }
- renderCharCount: computed('value', function() {
+ @computed('value')
+ get renderCharCount () {
let currentLength = this.get('value') ? this.get('value').length : 0;
return `${currentLength}/${this.get('maxlength')}`;
- }),
+ }
- hasLeftIcon: bool('icon'),
- hasRightIcon: bool('iconRight'),
- isInvalidAndTouched: and('isInvalid', 'isTouched'),
+ @bool('icon')
+ hasLeftIcon
- validationProperty: 'value', // property that validations should be run on
+ @bool('iconRight')
+ hasRightIcon
+
+ @and('isInvalid', 'isTouched')
+ isInvalidAndTouched
// Lifecycle hooks
- didReceiveAttrs() {
- this._super(...arguments);
+ init () {
+ super.init(...arguments);
+
+ invokeAction(this, 'onRegister', this.get('elementId'));
+ }
+
+ didReceiveAttrs () {
+ super.didReceiveAttrs(...arguments);
+
assert('{{paper-input}} requires an `onChange` action or null for no action.', this.get('onChange') !== undefined);
let { value, errors } = this.getProperties('value', 'errors');
let { _prevValue, _prevErrors } = this.getProperties('_prevValue', '_prevErrors');
+
if (value !== _prevValue || errors !== _prevErrors) {
this.notifyValidityChange();
}
+
this._prevValue = value;
this._prevErrors = errors;
- },
+ }
+
+ didInsertElement () {
+ super.didInsertElement(...arguments);
- didInsertElement() {
- this._super(...arguments);
if (this.get('textarea')) {
this._growTextareaOnResize = run.bind(this, this.growTextarea);
window.addEventListener('resize', this._growTextareaOnResize);
}
- },
+ }
- didRender() {
- this._super(...arguments);
+ didRender () {
+ super.didRender(...arguments);
// setValue below ensures that the input value is the same as this.value
this.setValue(this.get('value'));
this.growTextarea();
- },
+ }
+
+ willDestroyElement () {
+ super.willDestroyElement(...arguments);
- willDestroyElement() {
- this._super(...arguments);
if (this.get('textarea')) {
window.removeEventListener('resize', this._growTextareaOnResize);
this._growTextareaOnResize = null;
}
- },
+ }
+
+ destroy () {
+ const eltId = this.get('elementId')
+
+ super.destroy(...arguments);
+
+ invokeAction(this, 'onUnregister', eltId);
+ }
- growTextarea() {
+ growTextarea () {
if (this.get('textarea')) {
- let inputElement = this.element.querySelector('input, textarea');
+ const inputElement = this.element.querySelector('input, textarea');
inputElement.classList.add('md-no-flex');
inputElement.setAttribute('rows', 1);
- let minRows = this.get('passThru.rows');
+ const minRows = this.get('passThru.rows');
let height = this.getHeight(inputElement);
if (minRows) {
if (!this.lineHeight) {
@@ -135,9 +212,11 @@ export default Component.extend(FocusableMixin, ColorMixin, ChildMixin, Validati
this.lineHeight = inputElement.clientHeight;
inputElement.style.minHeight = null;
}
+
if (this.lineHeight) {
height = Math.max(height, this.lineHeight * minRows);
}
+
let proposedHeight = Math.round(height / this.lineHeight);
let maxRows = this.get('passThru.maxRows') || Number.MAX_VALUE;
let rowsToSet = Math.min(proposedHeight, maxRows);
@@ -150,7 +229,6 @@ export default Component.extend(FocusableMixin, ColorMixin, ChildMixin, Validati
} else {
inputElement.classList.remove('md-textarea-scrollable');
}
-
} else {
inputElement.style.height = 'auto';
inputElement.scrollTop = 0;
@@ -162,50 +240,63 @@ export default Component.extend(FocusableMixin, ColorMixin, ChildMixin, Validati
inputElement.classList.remove('md-no-flex');
}
- },
+ }
+
+ getHeight (inputElement) {
+ const { offsetHeight } = inputElement;
+ const line = inputElement.scrollHeight - offsetHeight;
- getHeight(inputElement) {
- let { offsetHeight } = inputElement;
- let line = inputElement.scrollHeight - offsetHeight;
return offsetHeight + (line > 0 ? line : 0);
- },
+ }
- setValue(value) {
+ setValue (value) {
// normalize falsy values to empty string
value = isEmpty(value) ? '' : value;
if (this.element.querySelector('input, textarea').value !== value) {
this.element.querySelector('input, textarea').value = value;
}
- },
-
- actions: {
- handleInput(e) {
- invokeAction(this, 'onChange', e.target.value);
- // setValue below ensures that the input value is the same as this.value
- run.next(() => {
- if (this.isDestroyed) {
- return;
- }
- this.setValue(this.get('value'));
- });
- this.growTextarea();
- let inputElement = this.element.querySelector('input');
- let isNativeInvalid = inputElement && inputElement.validity && inputElement.validity.badInput;
- if (this.type === 'date' && e.target.value === '') {
- // Chrome doesn't fire the onInput event when clearing the second and third date components.
- // This means that we won't see another event when badInput becomes false if the user is clearing
- // the date field. The reported value is empty, though, so we can already mark it as valid.
- isNativeInvalid = false;
+ }
+
+ notifyValidityChange () {
+ notifyValidityChange.call(this);
+ }
+
+ @action
+ handleInput (e) {
+ invokeAction(this, 'onChange', e.target.value);
+ // setValue below ensures that the input value is the same as this.value
+ run.next(() => {
+ if (this.isDestroyed) {
+ return;
}
- this.set('isNativeInvalid', isNativeInvalid);
- this.notifyValidityChange();
- },
+ this.setValue(this.get('value'));
+ });
- handleBlur(e) {
- invokeAction(this, 'onBlur', e);
- this.set('isTouched', true);
- this.notifyValidityChange();
+ this.growTextarea();
+
+ let inputElement = this.element.querySelector('input');
+ let isNativeInvalid = inputElement && inputElement.validity && inputElement.validity.badInput;
+
+ if (this.type === 'date' && e.target.value === '') {
+ // Chrome doesn't fire the onInput event when clearing the second and third date components.
+ // This means that we won't see another event when badInput becomes false if the user is clearing
+ // the date field. The reported value is empty, though, so we can already mark it as valid.
+ isNativeInvalid = false;
}
+
+ this.set('isNativeInvalid', isNativeInvalid);
+ this.notifyValidityChange();
+ }
+
+ focusOut (e) {
+ invokeAction(this, 'onBlur', e);
+
+ this.set('isTouched', true);
+ this.notifyValidityChange();
+ }
+
+ focusIn (e) {
+ invokeAction(this, 'onFocus', e);
}
-});
+}
diff --git a/addon/components/paper-select/component.js b/addon/components/paper-select/component.js
index d72528e46..68210c50b 100644
--- a/addon/components/paper-select/component.js
+++ b/addon/components/paper-select/component.js
@@ -3,10 +3,9 @@ import template from './template';
import { tagName, layout } from '@ember-decorators/component';
import { action } from '@ember/object';
-import { and } from '@ember/object/computed';
-import ChildMixin from 'ember-paper/mixins/child-mixin';
-
-import ValidationMixin from 'ember-paper/mixins/validation-mixin';
+import { tracked } from '@glimmer/tracking'
+import { computed } from '@ember/object';
+import { invokeAction } from 'ember-invoke-action';
import clamp from 'ember-paper/utils/clamp';
@@ -21,20 +20,97 @@ function getOffsetRect(node) {
} : { left: 0, top: 0, width: 0, height: 0 };
}
+import { buildComputedValidationMessages, notifyValidityChange } from 'ember-paper/utils/validation';
+import requiredValidator from 'ember-paper/validators/required';
+import minValidator from 'ember-paper/validators/min';
+import maxValidator from 'ember-paper/validators/max';
+import minlengthValidator from 'ember-paper/validators/minlength';
+import maxlengthValidator from 'ember-paper/validators/maxlength';
+
+const validations = [
+ requiredValidator,
+ minValidator,
+ maxValidator,
+ minlengthValidator,
+ maxlengthValidator
+];
+
@tagName('')
@layout(template)
-class PaperSelect extends Component.extend(ValidationMixin, ChildMixin) {
+class PaperSelect extends Component {
- validationProperty = 'selected';
+ @tracked
isTouched = false;
isFocused = false;
- @and('isInvalid', 'isTouched')
+ @computed('isTouched')
+ get formHasBeenValidated () {
+ return this.isTouched
+ }
+
+ set formHasBeenValidated (value) {
+ this.isTouched = value
+ }
+
+ @tracked
+ errorMessages
+
+ @tracked
+ customValidations = []
+
+ @tracked
+ errors = []
+
+ @computed.and('isInvalid', 'isTouched')
isInvalidAndTouched;
- @and('isFocused', 'selected')
+ @computed.and('isFocused', 'selected')
isFocusedAndSelected;
+ validations = validations;
+
+ @computed(
+ 'selected',
+ 'errors.[]',
+ 'customValidations.[]',
+ 'errorMessages',
+ requiredValidator.param,
+ minValidator.param,
+ maxValidator.param,
+ minlengthValidator.param,
+ maxlengthValidator.param
+ )
+ get validationErrorMessages () {
+ return buildComputedValidationMessages.call(this, 'selected')
+ }
+
+ @computed.bool('validationErrorMessages.length')
+ hasErrorMessages
+
+ @computed.reads('hasErrorMessages')
+ isInvalid
+
+ @computed.not('isInvalid')
+ isValid
+
+ init() {
+ super.init(...arguments);
+
+ invokeAction(this, 'onRegister', this.get('elementId'), this.get('isValid'), this.get('isTouched'), this.get('isInvalidAndTouched'));
+ }
+
+ destroy () {
+ const eltId = this.get('elementId')
+
+ super.destroy(...arguments);
+
+ invokeAction(this, 'onUnregister', eltId);
+ }
+
+ notifyValidityChange() {
+ notifyValidityChange.call(this);
+ }
+
didReceiveAttrs() {
super.didReceiveAttrs(...arguments);
this.notifyValidityChange();
diff --git a/addon/mixins/child-mixin.js b/addon/mixins/child-mixin.js
deleted file mode 100644
index b7ee8806d..000000000
--- a/addon/mixins/child-mixin.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @module ember-paper
- */
-import Mixin from '@ember/object/mixin';
-
-import { computed } from '@ember/object';
-import ParentMixin from 'ember-paper/mixins/parent-mixin';
-
-/**
- * @class ChildMixin
- * @extends Ember.Mixin
- */
-export default Mixin.create({
-
- // override to look for a specific parent class
- parentClass: ParentMixin,
-
- // this will typically be overriden when yielding a contextual component
- parentComponent: computed({
- get() {
- if (this._parentComponent !== undefined) {
- return this._parentComponent;
- }
-
- return this.nearestOfType(this.get('parentClass'));
- },
-
- set(key, value) {
- return this._parentComponent = value;
- }
- }),
-
- init() {
- this._super(...arguments);
- if (this.get('parentComponent')) {
- this.get('parentComponent').register(this);
- }
- },
-
- willDestroyElement() {
- this._super(...arguments);
- if (this.get('parentComponent')) {
- this.get('parentComponent').unregister(this);
- }
- }
-});
diff --git a/addon/mixins/parent-mixin.js b/addon/mixins/parent-mixin.js
deleted file mode 100644
index bf3df43ca..000000000
--- a/addon/mixins/parent-mixin.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @module ember-paper
- */
-import Mixin from '@ember/object/mixin';
-
-import { computed } from '@ember/object';
-import { A } from '@ember/array';
-
-/**
- * @class ParentMixin
- * @extends Ember.Mixin
- */
-export default Mixin.create({
- childComponents: computed(function() {
- return A();
- }),
-
- register(child) {
- this.get('childComponents').pushObject(child);
- },
-
- unregister(child) {
- this.get('childComponents').removeObject(child);
- }
-});
diff --git a/addon/mixins/validation-mixin.js b/addon/mixins/validation-mixin.js
deleted file mode 100644
index f2a43015f..000000000
--- a/addon/mixins/validation-mixin.js
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * @module ember-paper
- */
-
-import Mixin from '@ember/object/mixin';
-import { assert, warn } from '@ember/debug';
-import { isArray, A } from '@ember/array';
-import { get, computed, defineProperty } from '@ember/object';
-import { bool, reads, not } from '@ember/object/computed';
-import { loc } from '@ember/string';
-import { isBlank } from '@ember/utils';
-import requiredValidator from 'ember-paper/validators/required';
-import minValidator from 'ember-paper/validators/min';
-import maxValidator from 'ember-paper/validators/max';
-import minlengthValidator from 'ember-paper/validators/minlength';
-import maxlengthValidator from 'ember-paper/validators/maxlength';
-import { invokeAction } from 'ember-invoke-action';
-
-/**
- * In order to make validation generic it is required that components using the validation mixin
- * specify what property the validation logic should be based on.
- *
- * @public
- *
- * @return computed property that depends on the supplied property name
- */
-function buildComputedValidationMessages(property, validations = [], customValidations = []) {
- let validationParams = validations.map((v) => get(v, 'param')).filter((v) => !isBlank(v));
- let customValidationParams = customValidations.map((v) => get(v, 'param')).filter((v) => !isBlank(v));
-
- return computed(property, 'errors.[]', 'customValidations.[]', ...validationParams, ...customValidationParams, function() {
- let validations = A();
- let messages = A();
-
- // built-in validations
- validations.pushObjects(this.validations());
-
- // custom validations
- let customValidations = this.get('customValidations');
- assert('`customValidations` must be an array', isArray(customValidations));
- validations.pushObjects(customValidations);
-
- // execute validations
- let currentValue = this.get(property);
- validations.forEach((validation) => {
- assert('validation must include a `validate(value)` function', validation && validation.validate && typeof validation.validate === 'function');
- try {
- let valParam = get(validation, 'param');
- let paramValue = valParam ? this.get(valParam) : undefined;
- if (!validation.validate(currentValue, paramValue)) {
- let message = this.get(`errorMessages.${valParam}`) || get(validation, 'message');
- messages.pushObject({
- message: loc(message.string || message, paramValue, currentValue)
- });
- }
- } catch(error) {
- warn(`Exception with validation: ${validation} ${error}`, false);
- }
- });
-
- // error messages array
- let errors = this.get('errors') || [];
- assert('`errors` must be an array', isArray(errors));
- messages.pushObjects(errors.map((e) => {
- return get(e, 'message') ? e : { message: e };
- }));
-
- return messages;
- });
-}
-
-/**
- * @class ValidationMixin
- * @extends Ember.Mixin
- */
-export default Mixin.create({
- validationErrorMessages: null,
- lastIsInvalid: undefined,
- validationProperty: null, // property that validation should be based on
-
- init() {
- this._super(...arguments);
- assert('validationProperty must be set', this.get('validationProperty'));
- if (!this.get('validationErrorMessages')) {
- let computedValidationMessages = buildComputedValidationMessages(
- this.get('validationProperty'),
- this.validations(),
- this.get('customValidations')
- );
- defineProperty(this, 'validationErrorMessages', computedValidationMessages);
- }
- },
-
- hasErrorMessages: bool('validationErrorMessages.length'),
-
- /**
- * The result of isInvalid is appropriate for controlling the display of
- * validation error messages. It also may be used to distinguish whether
- * the input would be considered valid after it is touched.
- *
- * @public
- *
- * @return {boolean} Whether the input is or would be invalid.
- * false: input is valid (touched or not), or is no longer rendered
- * true: input has been touched and is invalid.
- */
- isInvalid: reads('hasErrorMessages'),
- isValid: not('isInvalid'),
-
- /**
- * Return the built-in validations.
- *
- * May be overridden to provide additional built-in validations. Be sure to
- * call this._super() to retrieve the standard validations.
- *
- * @public
- */
- validations() {
- return [
- requiredValidator,
- minValidator,
- maxValidator,
- minlengthValidator,
- maxlengthValidator
- ];
- },
-
- notifyValidityChange() {
- let isValid = this.get('isValid');
- let lastIsValid = this.get('lastIsValid');
- let isTouched = this.get('isTouched');
- let lastIsTouched = this.get('lastIsTouched');
- if (lastIsValid !== isValid || lastIsTouched !== isTouched) {
- invokeAction(this, 'onValidityChange', isValid);
- this.set('lastIsValid', isValid);
- this.set('lastIsTouched', isTouched);
- }
- },
- customValidations: [],
- errors: []
-});
diff --git a/addon/templates/components/paper-form.hbs b/addon/templates/components/paper-form.hbs
index 726b0c954..a792fc301 100644
--- a/addon/templates/components/paper-form.hbs
+++ b/addon/templates/components/paper-form.hbs
@@ -3,20 +3,30 @@
isInvalid=isInvalid
isTouched=isTouched
isInvalidAndTouched=isInvalidAndTouched
+ formHasBeenValidated=formHasBeenValidated
input=(component inputComponent
- parentComponent=this
- onValidityChange=(action "onValidityChange")
+ onValidityChange=(action "onChildValidityChange")
+ onRegister=(action "onRegister")
+ onUnregister=(action "onUnregister")
+ formHasBeenValidated=formHasBeenValidated
)
submit-button=(component submitButtonComponent
type="submit"
)
select=(component selectComponent
- parentComponent=this
- onValidityChange=(action "onValidityChange")
+ onValidityChange=(action "onChildValidityChange")
+ onRegister=(action "onRegister")
+ onUnregister=(action "onUnregister")
+ formHasBeenValidated=formHasBeenValidated
)
autocomplete=(component autocompleteComponent
- parentComponent=this
- onValidityChange=(action "onValidityChange")
+ onValidityChange=(action "onChildValidityChange")
+ onRegister=(action "onRegister")
+ onUnregister=(action "onUnregister")
+ formHasBeenValidated=formHasBeenValidated
)
- onSubmit=(action "onSubmit")
+ onSubmit=(action "onInternalSubmit")
+ register=(action "onRegister")
+ unregister=(action "onUnregister")
+ validityChange=(action "onChildValidityChange")
)}}
diff --git a/addon/templates/components/paper-input.hbs b/addon/templates/components/paper-input.hbs
index 09f0d93cf..f80ce9291 100644
--- a/addon/templates/components/paper-input.hbs
+++ b/addon/templates/components/paper-input.hbs
@@ -14,8 +14,6 @@
disabled={{disabled}}
autofocus={{autofocus}}
aria-describedby={{concat elementId "-char-count " elementId "-error-messages"}}
- onfocus={{onFocus}}
- onblur={{action "handleBlur"}}
onkeydown={{onKeyDown}}
onkeyup={{onKeyUp}}
onclick={{onClick}}
@@ -44,8 +42,6 @@
disabled={{disabled}}
autofocus={{autofocus}}
aria-describedby={{concat elementId "-char-count " elementId "-error-messages"}}
- onfocus={{onFocus}}
- onblur={{action "handleBlur"}}
onkeydown={{onKeyDown}}
onkeyup={{onKeyUp}}
onclick={{onClick}}
diff --git a/addon/utils/validation.js b/addon/utils/validation.js
new file mode 100644
index 000000000..51fa5cfe3
--- /dev/null
+++ b/addon/utils/validation.js
@@ -0,0 +1,91 @@
+import { assert, warn } from '@ember/debug';
+import { isArray, A } from '@ember/array';
+import { get } from '@ember/object';
+import { loc } from '@ember/string';
+import { invokeAction } from 'ember-invoke-action';
+
+/**
+ * In order to make validation generic it is required that components using the validation mixin
+ * specify what property the validation logic should be based on.
+ *
+ * @public
+ *
+ * @return computed property that depends on the supplied property name
+ */
+export function buildComputedValidationMessages(property) {
+ const validators = A();
+ const messages = A();
+
+ // built-in validations
+ validators.pushObjects(this.get('validations'));
+
+ // custom validations
+ let customValidators = this.get('customValidations');
+
+ assert('`customValidations` must be an array', isArray(customValidators));
+
+ validators.pushObjects(customValidators);
+
+ // execute validations
+ let currentValue = this.get(property);
+
+ validators.forEach((validation) => {
+ assert('validation must include a `validate(value)` function', validation && validation.validate && typeof validation.validate === 'function');
+
+ try {
+ let valParam = get(validation, 'param');
+ let paramValue = valParam ? this.get(valParam) : undefined;
+
+ if (!validation.validate(currentValue, paramValue)) {
+ const message = this.get(`errorMessages.${valParam}`) || get(validation, 'message');
+
+ messages.pushObject({
+ message: loc(message.string || message, paramValue, currentValue)
+ });
+ }
+ } catch(error) {
+ warn(`Exception with validation: ${validation} ${error}`, false, {
+ id: 'ember-paper-compute-validation-message'
+ });
+ }
+ });
+
+ // error messages array
+ let errors = this.get('errors') || [];
+
+ assert('`errors` must be an array', isArray(errors));
+
+ messages.pushObjects(errors.map((e) => {
+ return get(e, 'message') ? e : { message: e };
+ }));
+
+ return messages;
+}
+
+export function notifyValidityChange() {
+ const isValid = this.get('isValid');
+ const lastIsValid = this.get('lastIsValid');
+ const isTouched = this.get('isTouched');
+ const lastIsTouched = this.get('lastIsTouched');
+ const isInvalidAndTouched = this.get('isInvalidAndTouched');
+
+ if (
+ lastIsValid !== isValid
+ || lastIsTouched !== isTouched
+ ) {
+ invokeAction(this, 'onValidityChange', {
+ elementId: this.get('elementId'),
+ isValid,
+ isTouched,
+ isInvalidAndTouched
+ });
+
+ this.set('lastIsValid', isValid);
+ this.set('lastIsTouched', isTouched);
+ }
+}
+
+export default {
+ buildComputedValidationMessages,
+ notifyValidityChange
+};
diff --git a/package.json b/package.json
index 00d1ace04..4f3fb0346 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,6 @@
"@ember/jquery": "^1.1.0",
"@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0",
- "@glimmer/tracking": "^1.0.0",
"babel-eslint": "^8.2.6",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~3.16.0",
@@ -56,6 +55,7 @@
"qunit-dom": "^1.0.0"
},
"dependencies": {
+ "@glimmer/tracking": "^1.0.0",
"@html-next/vertical-collection": "^1.0.0",
"angular-material-styles": "1.1.21",
"broccoli-file-creator": "^2.1.1",
diff --git a/tests/dummy/app/templates/forms.hbs b/tests/dummy/app/templates/forms.hbs
index 75e98cbe8..e33491f32 100644
--- a/tests/dummy/app/templates/forms.hbs
+++ b/tests/dummy/app/templates/forms.hbs
@@ -91,6 +91,10 @@
+
+ You can pass a customValidations array property to each input component (see paper-input for more details).
+
+
If you prefer, you can trigger the submit action without using a submit button.
paper-form also yields an onSubmit action you
@@ -103,16 +107,21 @@
- You can also extend paper-input to make your own custom components
- (money inputs, phone inputs, etc.) and the form validation will still
- work out of the box!
- Just replace paper-input with your component's name.
+ Extending paper-input component will give you the opportunity to override some default behavior.
+ You can also work with any components you'd like.
+ For this, paper-form yields three actions and one property :
+
+
register: to inform the form your component is influencing the global valid state
+
unregister: to detach your component when it destroys (usually called in willDestroyElement hook)
+
validityChange: to inform the form that the validity of your component changed
+
formHasBeenValidated: changes when form is submitted / resetted