UI/Fix form validation issues (#15560)

* clean up validators

* fix getter overriding user input

* add changelog

* remove asString option

* move invalid check up

* remove asString everywhere

* revert input value defaults

* undo form disabling if validation errors

* address comments

* remove or

* add validation message to form, create pseudo loading icon

* whole alert disappears with refresh

* glimmerize alert-inline

* add tests

* rename variables for consistency

* spread attributes to glimmerized component

* address comments

* add validation test
This commit is contained in:
claire bontempo
2022-05-25 11:22:36 -07:00
committed by GitHub
parent 242a6f984b
commit 9fd8a97a88
14 changed files with 245 additions and 70 deletions

3
changelog/15560.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
ui: fix form validations ignoring default values and disabling submit button
```

View File

@@ -47,7 +47,7 @@ export default Component.extend({
// validation related properties // validation related properties
modelValidations: null, modelValidations: null,
isFormInvalid: false, invalidFormAlert: null,
mountIssue: false, mountIssue: false,
@@ -88,10 +88,24 @@ export default Component.extend({
} }
}, },
checkModelValidity(model) {
const { isValid, state, invalidFormMessage } = model.validate();
this.setProperties({
modelValidations: state,
invalidFormAlert: invalidFormMessage,
});
return isValid;
},
mountBackend: task( mountBackend: task(
waitFor(function* () { waitFor(function* () {
const mountModel = this.mountModel; const mountModel = this.mountModel;
const { type, path } = mountModel; const { type, path } = mountModel;
// only submit form if validations pass
if (!this.checkModelValidity(mountModel)) {
return;
}
let capabilities = null; let capabilities = null;
try { try {
capabilities = yield this.store.findRecord('capabilities', `${path}/config`); capabilities = yield this.store.findRecord('capabilities', `${path}/config`);
@@ -120,7 +134,6 @@ export default Component.extend({
} catch (err) { } catch (err) {
if (err.httpStatus === 403) { if (err.httpStatus === 403) {
this.mountIssue = true; this.mountIssue = true;
this.set('isFormInvalid', this.mountIssue);
this.flashMessages.danger( this.flashMessages.danger(
'You do not have access to the sys/mounts endpoint. The secret engine was not mounted.' 'You do not have access to the sys/mounts endpoint. The secret engine was not mounted.'
); );
@@ -163,12 +176,8 @@ export default Component.extend({
actions: { actions: {
onKeyUp(name, value) { onKeyUp(name, value) {
this.mountModel.set(name, value); this.mountModel.set(name, value);
const { isValid, state } = this.mountModel.validate();
this.setProperties({
modelValidations: state,
isFormInvalid: !isValid,
});
}, },
onTypeChange(path, value) { onTypeChange(path, value) {
if (path === 'type') { if (path === 'type') {
this.wizard.set('componentState', value); this.wizard.set('componentState', value);

View File

@@ -69,6 +69,7 @@ export function withModelValidations(validations) {
validate() { validate() {
let isValid = true; let isValid = true;
const state = {}; const state = {};
let errorCount = 0;
for (const key in this._validations) { for (const key in this._validations) {
const rules = this._validations[key]; const rules = this._validations[key];
@@ -110,9 +111,18 @@ export function withModelValidations(validations) {
} }
} }
} }
errorCount += state[key].errors.length;
state[key].isValid = !state[key].errors.length; state[key].isValid = !state[key].errors.length;
} }
return { isValid, state };
return { isValid, state, invalidFormMessage: this.generateErrorCountMessage(errorCount) };
}
generateErrorCountMessage(errorCount) {
if (errorCount < 1) return null;
// returns count specific message: 'There is an error/are N errors with this form.'
let isPlural = errorCount > 1 ? `are ${errorCount} errors` : false;
return `There ${isPlural ? isPlural : 'is an error'} with this form.`;
} }
}; };
}; };

View File

@@ -12,7 +12,7 @@ const LIST_EXCLUDED_BACKENDS = ['system', 'identity'];
const validations = { const validations = {
path: [{ type: 'presence', message: "Path can't be blank." }], path: [{ type: 'presence', message: "Path can't be blank." }],
maxVersions: [ maxVersions: [
{ type: 'number', options: { asString: true }, message: 'Maximum versions must be a number.' }, { type: 'number', message: 'Maximum versions must be a number.' },
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
], ],
}; };

View File

@@ -8,7 +8,7 @@ import { withModelValidations } from 'vault/decorators/model-validations';
const validations = { const validations = {
maxVersions: [ maxVersions: [
{ type: 'number', options: { asString: true }, message: 'Maximum versions must be a number.' }, { type: 'number', message: 'Maximum versions must be a number.' },
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
], ],
}; };

View File

@@ -67,7 +67,7 @@
type="submit" type="submit"
data-test-mount-submit="true" data-test-mount-submit="true"
class="button is-primary {{if this.mountBackend.isRunning 'loading'}}" class="button is-primary {{if this.mountBackend.isRunning 'loading'}}"
disabled={{or this.mountBackend.isRunning this.isFormInvalid}} disabled={{this.mountBackend.isRunning}}
> >
{{#if (eq this.mountType "auth")}} {{#if (eq this.mountType "auth")}}
Enable Method Enable Method
@@ -81,6 +81,11 @@
Back Back
</button> </button>
</div> </div>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
{{else}} {{else}}
<button <button
data-test-mount-next data-test-mount-next

View File

@@ -3,20 +3,20 @@ import { isPresent } from '@ember/utils';
export const presence = (value) => isPresent(value); export const presence = (value) => isPresent(value);
export const length = (value, { nullable = false, min, max } = {}) => { export const length = (value, { nullable = false, min, max } = {}) => {
let isValid = nullable; if (!min && !max) return;
if (typeof value === 'string') { // value could be an integer if the attr has a default value of some number
const underMin = min && value.length < min; const valueLength = value?.toString().length;
const overMax = max && value.length > max; if (valueLength) {
isValid = underMin || overMax ? false : true; const underMin = min && valueLength < min;
const overMax = max && valueLength > max;
return underMin || overMax ? false : true;
} }
return isValid; return nullable;
}; };
export const number = (value, { nullable = false, asString } = {}) => { export const number = (value, { nullable = false } = {}) => {
if (!value) return nullable; // since 0 is falsy, !value returns true even though 0 is a valid number
if (typeof value === 'string' && !asString) { if (!value && value !== 0) return nullable;
return false;
}
return !isNaN(value); return !isNaN(value);
}; };

View File

@@ -0,0 +1,19 @@
<div
{{did-update this.refresh @message}}
class={{concat "message-inline" this.paddingTop this.isMarginless this.sizeSmall}}
data-test-inline-alert
...attributes
>
{{#if this.isRefreshing}}
<Icon @name="loading" class="loading" />
{{else}}
<Icon @name={{this.alertType.glyph}} class={{this.alertType.glyphClass}} />
<p class={{this.textClass}} data-test-inline-error-message>
{{#if (has-block)}}
{{yield}}
{{else}}
{{@message}}
{{/if}}
</p>
{{/if}}
</div>

View File

@@ -1,7 +1,8 @@
import Component from '@ember/component'; import Component from '@glimmer/component';
import { computed } from '@ember/object'; import { action } from '@ember/object';
import { later } from '@ember/runloop';
import { tracked } from '@glimmer/tracking';
import { messageTypes } from 'core/helpers/message-types'; import { messageTypes } from 'core/helpers/message-types';
import layout from '../templates/components/alert-inline';
/** /**
* @module AlertInline * @module AlertInline
@@ -12,31 +13,51 @@ import layout from '../templates/components/alert-inline';
* <AlertInline @type="danger" @message="{{model.keyId}} is not a valid lease ID"/> * <AlertInline @type="danger" @message="{{model.keyId}} is not a valid lease ID"/>
* ``` * ```
* *
* @param type=null{String} - The alert type. This comes from the message-types helper. * @param type=null{String} - The alert type passed to the message-types helper.
* @param [message=null]{String} - The message to display within the alert. * @param [message=null]{String} - The message to display within the alert.
* @param [sizeSmall=false]{Boolean} - Whether or not to display a small font with padding below of alert message.
* @param [paddingTop=false]{Boolean} - Whether or not to add padding above component. * @param [paddingTop=false]{Boolean} - Whether or not to add padding above component.
* @param [isMarginless=false]{Boolean} - Whether or not to remove margin bottom below component. * @param [isMarginless=false]{Boolean} - Whether or not to remove margin bottom below component.
* @param [sizeSmall=false]{Boolean} - Whether or not to display a small font with padding below of alert message.
* @param [mimicRefresh=false]{Boolean} - If true will display a loading icon when attributes change (e.g. when a form submits and the alert message changes).
*/ */
export default Component.extend({ export default class AlertInlineComponent extends Component {
layout, @tracked isRefreshing = false;
type: null,
message: null,
sizeSmall: false,
paddingTop: false,
classNames: ['message-inline'],
classNameBindings: ['sizeSmall:size-small', 'paddingTop:padding-top', 'isMarginless:is-marginless'],
textClass: computed('type', function () { get mimicRefresh() {
if (this.type == 'danger') { return this.args.mimicRefresh || false;
return messageTypes([this.type]).glyphClass;
} }
return; get paddingTop() {
}), return this.args.paddingTop ? ' padding-top' : '';
}
alertType: computed('type', function () { get isMarginless() {
return messageTypes([this.type]); return this.args.isMarginless ? ' is-marginless' : '';
}), }
});
get sizeSmall() {
return this.args.sizeSmall ? ' size-small' : '';
}
get textClass() {
if (this.args.type === 'danger') {
return this.alertType.glyphClass;
}
return null;
}
get alertType() {
return messageTypes([this.args.type]);
}
@action
refresh() {
if (this.mimicRefresh) {
this.isRefreshing = true;
later(() => {
this.isRefreshing = false;
}, 200);
}
}
}

View File

@@ -85,7 +85,7 @@ export default class FormFieldComponent extends Component {
get validationError() { get validationError() {
const validations = this.args.modelValidations || {}; const validations = this.args.modelValidations || {};
const state = validations[this.valuePath]; const state = validations[this.valuePath];
return state && !state.isValid ? state.errors.join('. ') : null; return state && !state.isValid ? state.errors.join(' ') : null;
} }
onChange() { onChange() {

View File

@@ -1,8 +0,0 @@
<Icon @name={{this.alertType.glyph}} class={{this.alertType.glyphClass}} />
<p class={{this.textClass}} data-test-inline-error-message>
{{#if (has-block)}}
{{yield}}
{{else}}
{{@message}}
{{/if}}
</p>

View File

@@ -1,17 +1,82 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit'; import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers'; import { render, settled, find, waitUntil } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | alert-inline', function (hooks) { module('Integration | Component | alert-inline', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
test('it renders', async function (assert) { hooks.beforeEach(function () {
// Set any properties with this.set('myProperty', 'value'); this.set('message', 'some very important alert');
// Handle any actions with this.set('myAction', function(val) { ... }); });
await render(hbs`{{alert-inline type="danger" message="test message"}}`); test('it renders alert message with correct class args', async function (assert) {
await render(hbs`
<AlertInline
@paddingTop={{true}}
@isMarginless={{true}}
@sizeSmall={{true}}
@message={{this.message}}
/>
`);
assert.dom('[data-test-inline-error-message]').hasText('some very important alert');
assert
.dom('[data-test-inline-alert]')
.hasAttribute('class', 'message-inline padding-top is-marginless size-small');
});
assert.dom(this.element).hasText('test message'); test('it yields to block text', async function (assert) {
await render(hbs`
<AlertInline @message={{this.message}}>
A much more important alert
</AlertInline>
`);
assert.dom('[data-test-inline-error-message]').hasText('A much more important alert');
});
test('it renders correctly for type=danger', async function (assert) {
this.set('type', 'danger');
await render(hbs`
<AlertInline
@type={{this.type}}
@message={{this.message}}
/>
`);
assert
.dom('[data-test-inline-error-message]')
.hasAttribute('class', 'has-text-danger', 'has danger text');
assert.dom('[data-test-icon="x-square-fill"]').exists('danger icon exists');
});
test('it renders correctly for type=warning', async function (assert) {
this.set('type', 'warning');
await render(hbs`
<AlertInline
@type={{this.type}}
@message={{this.message}}
/>
`);
assert.dom('[data-test-inline-error-message]').doesNotHaveAttribute('class', 'does not have styled text');
assert.dom('[data-test-icon="alert-triangle-fill"]').exists('warning icon exists');
});
test('it mimics loading when message changes', async function (assert) {
await render(hbs`
<AlertInline
@message={{this.message}}
@mimicRefresh={{true}}
/>
`);
assert
.dom('[data-test-inline-error-message]')
.hasText('some very important alert', 'it renders original message');
this.set('message', 'some changed alert!!!');
await waitUntil(() => find('[data-test-icon="loading"]'));
assert.ok(find('[data-test-icon="loading"]'), 'it shows loading icon when message changes');
await settled();
assert
.dom('[data-test-inline-error-message]')
.hasText('some changed alert!!!', 'it shows updated message');
}); });
}); });

View File

@@ -12,6 +12,7 @@ const createClass = (validations) => {
const foo = Foo.extend({ const foo = Foo.extend({
modelName: 'bar', modelName: 'bar',
foo: null, foo: null,
integer: null,
}); });
return new foo(); return new foo();
}; };
@@ -81,4 +82,32 @@ module('Unit | Decorators | ModelValidations', function (hooks) {
'Correct state returned when property is valid' 'Correct state returned when property is valid'
); );
}); });
test('invalid form message has correct error count', function (assert) {
const message = 'This field is required';
const messageII = 'This field must be a number';
const validations = {
foo: [{ type: 'presence', message }],
integer: [{ type: 'number', messageII }],
};
const fooClass = createClass(validations);
const v1 = fooClass.validate();
assert.equal(
v1.invalidFormMessage,
'There are 2 errors with this form.',
'error message says form as 2 errors'
);
fooClass.integer = 9;
const v2 = fooClass.validate();
assert.equal(
v2.invalidFormMessage,
'There is an error with this form.',
'error message says form has an error'
);
fooClass.foo = true;
const v3 = fooClass.validate();
assert.equal(v3.invalidFormMessage, null, 'invalidFormMessage is null when form is valid');
});
}); });

View File

@@ -6,10 +6,18 @@ module('Unit | Util | validators', function (hooks) {
setupTest(hooks); setupTest(hooks);
test('it should validate presence', function (assert) { test('it should validate presence', function (assert) {
let isValid = validators.presence(null); let isValid;
assert.false(isValid); const check = (value) => (isValid = validators.presence(value));
isValid = validators.presence(true); check(null);
assert.true(isValid); assert.false(isValid, 'Invalid when value is null');
check('');
assert.false(isValid, 'Invalid when value is empty string');
check(true);
assert.true(isValid, 'Valid when value is true');
check(0);
assert.true(isValid, 'Valid when value is 0 as integer');
check('0');
assert.true(isValid, 'Valid when value is 0 as string');
}); });
test('it should validate length', function (assert) { test('it should validate length', function (assert) {
@@ -22,30 +30,44 @@ module('Unit | Util | validators', function (hooks) {
check(null); check(null);
assert.false(isValid, 'Invalid when nullable is false'); assert.false(isValid, 'Invalid when nullable is false');
check('12'); check('12');
assert.false(isValid, 'Invalid when not min length'); assert.false(isValid, 'Invalid when string not min length');
check('123456'); check('123456');
assert.false(isValid, 'Invalid when over max length'); assert.false(isValid, 'Invalid when string over max length');
check('1234'); check('1234');
assert.true(isValid, 'Valid when in between min and max length'); assert.true(isValid, 'Valid when string between min and max length');
check(12);
assert.false(isValid, 'Invalid when integer not min length');
check(123456);
assert.false(isValid, 'Invalid when integer over max length');
check(1234);
assert.true(isValid, 'Valid when integer between min and max length');
options.min = 1;
check(0);
assert.true(isValid, 'Valid when integer is 0 and min is 1');
check('0');
assert.true(isValid, 'Valid when string is 0 and min is 1');
}); });
test('it should validate number', function (assert) { test('it should validate number', function (assert) {
let isValid; let isValid;
const options = { nullable: true, asString: false }; const options = { nullable: true };
const check = (prop) => (isValid = validators.number(prop, options)); const check = (prop) => (isValid = validators.number(prop, options));
check(null); check(null);
assert.true(isValid, 'Valid when nullable is true'); assert.true(isValid, 'Valid when nullable is true');
options.nullable = false; options.nullable = false;
check(null); check(null);
assert.false(isValid, 'Invalid when nullable is false'); assert.false(isValid, 'Invalid when nullable is false');
check('9');
assert.false(isValid, 'Invalid for string when asString is false');
check(9); check(9);
assert.true(isValid, 'Valid for number'); assert.true(isValid, 'Valid for number');
options.asString = true;
check('9'); check('9');
assert.true(isValid, 'Valid for number as string'); assert.true(isValid, 'Valid for number as string');
check('foo'); check('foo');
assert.false(isValid, 'Invalid for string that is not a number'); assert.false(isValid, 'Invalid for string that is not a number');
check('12foo');
assert.false(isValid, 'Invalid for string that contains a number');
check(0);
assert.true(isValid, 'Valid for 0 as an integer');
check('0');
assert.true(isValid, 'Valid for 0 as a string');
}); });
}); });