Handle form validation for open api form (#11963)

* Handle form validation for open api form

- Added required validator for all the default fields

* Fixed field group error and adedd comments

* Fixed acceptance tests

* Added changelog

* Fix validation in edit mode

- Handle read only inputs during edit mode

* Minor improvements

* Restrict validation only for userpass
This commit is contained in:
Arnav Palnitkar 2021-07-13 15:50:27 -07:00 committed by GitHub
parent e520e470a6
commit d1cc297cd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 133 additions and 6 deletions

3
changelog/11963.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Add validation support for open api form fields
```

View File

@ -1,7 +1,7 @@
import AdapterError from '@ember-data/adapter/error';
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { computed, set } from '@ember/object';
import { task } from 'ember-concurrency';
/**
@ -24,6 +24,8 @@ export default Component.extend({
itemType: null,
flashMessages: service(),
router: service(),
validationMessages: null,
isFormInvalid: true,
props: computed('model', function() {
return this.model.serialize();
}),
@ -41,7 +43,41 @@ export default Component.extend({
this.router.transitionTo('vault.cluster.access.method.item.list').followRedirects();
this.flashMessages.success(`Successfully saved ${this.itemType} ${this.model.id}.`);
}).withTestWaiter(),
init() {
this._super(...arguments);
this.set('validationMessages', {});
if (this.mode === 'edit') {
// For validation to work in edit mode,
// reconstruct the model values from field group
this.model.fieldGroups.forEach(element => {
if (element.default) {
element.default.forEach(attr => {
let fieldValue = attr.options && attr.options.fieldValue;
if (fieldValue) {
this.model[attr.name] = this.model[fieldValue];
}
});
}
});
}
},
actions: {
onKeyUp(name, value) {
this.model.set(name, value);
if (this.model.validations) {
// Set validation error message for updated attribute
this.model.validations.attrs[name] && this.model.validations.attrs[name].isValid
? set(this.validationMessages, name, '')
: set(this.validationMessages, name, this.model.validations.attrs[name].message);
// Set form button state
this.model.validate().then(({ validations }) => {
this.set('isFormInvalid', !validations.isValid);
});
} else {
this.set('isFormInvalid', false);
}
},
deleteItem() {
this.model.destroyRecord().then(() => {
this.router.transitionTo('vault.cluster.access.method.item.list').followRedirects();

View File

@ -43,6 +43,10 @@ export default Component.extend({
showEnable: false,
// cp-validation related properties
validationMessages: null,
isFormInvalid: false,
init() {
this._super(...arguments);
const type = this.mountType;
@ -108,6 +112,10 @@ export default Component.extend({
this.mountModel.validations.attrs.path.isValid
? set(this.validationMessages, 'path', '')
: set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message);
this.mountModel.validate().then(({ validations }) => {
this.set('isFormInvalid', !validations.isValid);
});
},
onTypeChange(path, value) {
if (path === 'type') {

View File

@ -4,11 +4,19 @@ import { computed } from '@ember/object';
import { fragment } from 'ember-data-model-fragments/attributes';
import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { memberAction } from 'ember-api-actions';
import { validator, buildValidations } from 'ember-cp-validations';
import apiPath from 'vault/utils/api-path';
import attachCapabilities from 'vault/lib/attach-capabilities';
let ModelExport = Model.extend({
const Validations = buildValidations({
path: validator('presence', {
presence: true,
message: "Path can't be blank.",
}),
});
let ModelExport = Model.extend(Validations, {
authConfigs: hasMany('auth-config', { polymorphic: true, inverse: 'backend', async: false }),
path: attr('string'),
accessor: attr('string'),

View File

@ -14,6 +14,7 @@ import { resolve, reject } from 'rsvp';
import { debug } from '@ember/debug';
import { dasherize, capitalize } from '@ember/string';
import { singularize } from 'ember-inflector';
import buildValidations from 'vault/utils/build-api-validators';
import generatedItemAdapter from 'vault/adapters/generated-item-list';
export function sanitizePath(path) {
@ -280,11 +281,18 @@ export default Service.extend({
// if our newModel doesn't have fieldGroups already
// we need to create them
try {
// Initialize prototype to access field groups
let fieldGroups = newModel.proto().fieldGroups;
if (!fieldGroups) {
debug(`Constructing fieldGroups for ${backend}`);
fieldGroups = this.getFieldGroups(newModel);
newModel = newModel.extend({ fieldGroups });
// Build and add validations on model
// NOTE: For initial phase, initialize validations only for user pass auth
if (backend === 'userpass') {
let validations = buildValidations(fieldGroups);
newModel = newModel.extend(validations);
}
}
} catch (err) {
// eat the error, fieldGroups is computed in the model definition

View File

@ -106,6 +106,7 @@
.message-inline {
display: flex;
align-items: center;
margin: 0 0 $spacing-l;
.hs-icon {
@ -131,6 +132,10 @@
&.is-marginless {
margin-bottom: 0;
}
> p::first-letter {
text-transform: capitalize;
}
}
.has-text-highlight {

View File

@ -52,12 +52,12 @@
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="save" @noun={{itemType}} />
<MessageError @model={{model}} />
<FormFieldGroups @model={{model}} @mode={{mode}} />
<FormFieldGroups @model={{model}} @mode={{mode}} @onKeyUp={{action "onKeyUp"}} @validationMessages={{validationMessages}}/>
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control">
<button type="submit" data-test-save-config="true"
class="button is-primary {{if saveModel.isRunning "loading"}}" disabled={{saveModel.isRunning}}>
class="button is-primary {{if saveModel.isRunning "loading"}}" disabled={{or saveModel.isRunning isFormInvalid}}>
Save
</button>
{{#if (eq mode "create")}}

View File

@ -68,7 +68,7 @@
type="submit"
data-test-mount-submit="true"
class="button is-primary {{if mountBackend.isRunning "loading"}}"
disabled={{or mountBackend.isRunning validationError}}
disabled={{or mountBackend.isRunning isFormInvalid}}
>
{{#if (eq mountType "auth")}}
Enable Method

View File

@ -0,0 +1,30 @@
import { validator, buildValidations } from 'ember-cp-validations';
/**
* Add validation on dynamic form fields generated via open api spec
* For fields grouped under default category, add the require/presence validator
* @param {Array} fieldGroups
* fieldGroups param example:
* [ { default: [{name: 'username'}, {name: 'password'}] },
* { Tokens: [{name: 'tokenBoundCidrs'}] }
* ]
* @returns ember cp validation class
*/
export default function initValidations(fieldGroups) {
let validators = {};
fieldGroups.forEach(element => {
if (element.default) {
element.default.forEach(v => {
validators[v.name] = createPresenceValidator(v.name);
});
}
});
return buildValidations(validators);
}
export const createPresenceValidator = function(label) {
return validator('presence', {
presence: true,
message: `${label} can't be blank.`,
});
};

View File

@ -18,12 +18,16 @@ import layout from '../templates/components/form-field-groups';
* @model={{mountModel}}
* @onChange={{action "onTypeChange"}}
* @renderGroup="Method Options"
* @onKeyUp={{action "onKeyUp"}}
* @validationMessages={{validationMessages}}
* />
* ```
*
* @param [renderGroup=null] {String} - An allow list of groups to include in the render.
* @param model=null {DS.Model} - Model to be passed down to form-field component. If `fieldGroups` is present on the model then it will be iterated over and groups of `FormField` components will be rendered.
* @param onChange=null {Func} - Handler that will get set on the `FormField` component.
* @param onKeyUp=null {Func} - Handler that will set the value and trigger validation on input changes
* @param validationMessages=null {Object} Object containing validation message for each property
*
*/

View File

@ -65,6 +65,8 @@ export default Component.extend({
*/
attr: null,
mode: null,
/*
* @private
* @param string
@ -93,6 +95,11 @@ export default Component.extend({
*/
valuePath: or('attr.options.fieldValue', 'attr.name'),
isReadOnly: computed('attr.options.readOnly', 'mode', function() {
let readonly = this.attr.options?.readOnly || false;
return readonly && this.mode === 'edit';
}),
model: null,
/*

View File

@ -7,6 +7,7 @@
<FormField
data-test-field
@attr={{attr}}
@mode={{mode}}
@model={{model}}
@onChange={{onChange}}
@onKeyUp={{onKeyUp}}
@ -29,6 +30,7 @@
<FormField
data-test-field
@attr={{attr}}
@mode={{mode}}
@model={{model}}
/>
{{/each}}

View File

@ -191,7 +191,18 @@
@value={{or (get model valuePath) attr.options.defaultValue}}
@allowCopy="true"
@onChange={{action (action "setAndBroadcast" valuePath)}}
onkeyup={{action
(action "handleKeyUp" attr.name)
value="target.value"
}}
/>
{{#if (get validationMessages attr.name)}}
<AlertInline
@type="danger"
@message={{get validationMessages attr.name}}
@paddingTop=true
/>
{{/if}}
{{else if (or (eq attr.type "number") (eq attr.type "string"))}}
<div class="control">
{{#if (eq attr.options.editType "textarea")}}
@ -251,6 +262,7 @@
<input
data-test-input={{attr.name}}
id={{attr.name}}
readonly={{isReadOnly}}
autocomplete="off"
spellcheck="false"
value={{or (get model valuePath) attr.options.defaultValue}}

View File

@ -1,4 +1,4 @@
import { click, fillIn, settled, visit } from '@ember/test-helpers';
import { click, fillIn, settled, visit, triggerKeyEvent } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import authPage from 'vault/tests/pages/auth';
@ -31,7 +31,9 @@ module('Acceptance | userpass secret backend', function(hooks) {
await visit(`/vault/access/${path1}/item/user/create`);
await settled();
await fillIn('[data-test-input="username"]', user1);
await triggerKeyEvent('[data-test-input="username"]', 'keyup', 65);
await fillIn('[data-test-textarea]', user1);
await triggerKeyEvent('[data-test-textarea]', 'keyup', 65);
await click('[data-test-save-config="true"]');
await settled();
@ -53,7 +55,9 @@ module('Acceptance | userpass secret backend', function(hooks) {
await click('[data-test-create="user"]');
await settled();
await fillIn('[data-test-input="username"]', user2);
await triggerKeyEvent('[data-test-input="username"]', 'keyup', 65);
await fillIn('[data-test-textarea]', user2);
await triggerKeyEvent('[data-test-textarea]', 'keyup', 65);
await click('[data-test-save-config="true"]');
await settled();