diff --git a/changelog/11963.txt b/changelog/11963.txt new file mode 100644 index 000000000..dd9c19650 --- /dev/null +++ b/changelog/11963.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Add validation support for open api form fields +``` \ No newline at end of file diff --git a/ui/app/components/generated-item.js b/ui/app/components/generated-item.js index c55dc7d7c..c237d0e18 100644 --- a/ui/app/components/generated-item.js +++ b/ui/app/components/generated-item.js @@ -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(); diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index a01e039eb..5280ec75d 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -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') { diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index ffe2dfdb4..dc1257610 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -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'), diff --git a/ui/app/services/path-help.js b/ui/app/services/path-help.js index 791b99870..422bb28b0 100644 --- a/ui/app/services/path-help.js +++ b/ui/app/services/path-help.js @@ -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 diff --git a/ui/app/styles/core/message.scss b/ui/app/styles/core/message.scss index dd5e3202d..197d7cdb5 100644 --- a/ui/app/styles/core/message.scss +++ b/ui/app/styles/core/message.scss @@ -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 { diff --git a/ui/app/templates/components/generated-item.hbs b/ui/app/templates/components/generated-item.hbs index 800d1192f..990fe4e3b 100644 --- a/ui/app/templates/components/generated-item.hbs +++ b/ui/app/templates/components/generated-item.hbs @@ -52,12 +52,12 @@
- +
{{#if (eq mode "create")}} diff --git a/ui/app/templates/components/mount-backend-form.hbs b/ui/app/templates/components/mount-backend-form.hbs index d19f49823..c1bf974f2 100644 --- a/ui/app/templates/components/mount-backend-form.hbs +++ b/ui/app/templates/components/mount-backend-form.hbs @@ -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 diff --git a/ui/app/utils/build-api-validators.js b/ui/app/utils/build-api-validators.js new file mode 100644 index 000000000..205d55831 --- /dev/null +++ b/ui/app/utils/build-api-validators.js @@ -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.`, + }); +}; diff --git a/ui/lib/core/addon/components/form-field-groups.js b/ui/lib/core/addon/components/form-field-groups.js index 30d8f4e0f..c37a094d4 100644 --- a/ui/lib/core/addon/components/form-field-groups.js +++ b/ui/lib/core/addon/components/form-field-groups.js @@ -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 * */ diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index d75130695..0d9049ddf 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -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, /* diff --git a/ui/lib/core/addon/templates/components/form-field-groups.hbs b/ui/lib/core/addon/templates/components/form-field-groups.hbs index c5265462c..7f3c2c5dd 100644 --- a/ui/lib/core/addon/templates/components/form-field-groups.hbs +++ b/ui/lib/core/addon/templates/components/form-field-groups.hbs @@ -7,6 +7,7 @@ {{/each}} diff --git a/ui/lib/core/addon/templates/components/form-field.hbs b/ui/lib/core/addon/templates/components/form-field.hbs index d7d5b27c9..e0eb20209 100644 --- a/ui/lib/core/addon/templates/components/form-field.hbs +++ b/ui/lib/core/addon/templates/components/form-field.hbs @@ -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)}} + + {{/if}} {{else if (or (eq attr.type "number") (eq attr.type "string"))}}
{{#if (eq attr.options.editType "textarea")}} @@ -251,6 +262,7 @@