From c84c8e01b249b5a4511e1ff79f2b24427f3269d6 Mon Sep 17 00:00:00 2001 From: madalynrose Date: Mon, 10 Dec 2018 11:44:37 -0500 Subject: [PATCH] Search select (#5851) --- ui/app/adapters/policy.js | 4 +- ui/app/components/search-select.js | 95 ++++++++++ ui/app/components/string-list.js | 20 +- ui/app/models/identity/entity.js | 5 +- ui/app/models/identity/group.js | 17 +- ui/app/serializers/identity/_base.js | 19 +- ui/app/styles/app.scss | 5 +- ui/app/styles/components/popup-menu.scss | 100 +++++----- ui/app/styles/components/search-select.scss | 124 +++++++++++++ ui/app/styles/core.scss | 1 + ui/app/styles/core/forms.scss | 7 +- ui/app/styles/core/title.scss | 5 + ui/app/templates/components/form-field.hbs | 154 +++++++++------ .../templates/components/kv-object-editor.hbs | 2 +- .../components/search-select-placeholder.hbs | 6 + ui/app/templates/components/search-select.hbs | 58 ++++++ .../components/token-expire-warning.hbs | 11 +- .../vault/cluster/access/methods.hbs | 2 +- ui/ember-cli-build.js | 3 + ui/package.json | 1 + .../integration/components/form-field-test.js | 8 - .../components/search-select-test.js | 175 ++++++++++++++++++ ui/tests/pages/components/form-field.js | 1 + ui/tests/pages/components/search-select.js | 15 ++ ui/yarn.lock | 30 ++- 25 files changed, 722 insertions(+), 146 deletions(-) create mode 100644 ui/app/components/search-select.js create mode 100644 ui/app/styles/components/search-select.scss create mode 100644 ui/app/templates/components/search-select-placeholder.hbs create mode 100644 ui/app/templates/components/search-select.hbs create mode 100644 ui/tests/integration/components/search-select-test.js create mode 100644 ui/tests/pages/components/search-select.js diff --git a/ui/app/adapters/policy.js b/ui/app/adapters/policy.js index 7d86987b1..551c7f99b 100644 --- a/ui/app/adapters/policy.js +++ b/ui/app/adapters/policy.js @@ -30,6 +30,8 @@ export default ApplicationAdapter.extend({ }, query(store, type) { - return this.ajax(this.buildURL(type.modelName), 'GET', { data: { list: true } }); + return this.ajax(this.buildURL(type.modelName), 'GET', { + data: { list: true }, + }); }, }); diff --git a/ui/app/components/search-select.js b/ui/app/components/search-select.js new file mode 100644 index 000000000..e486113f6 --- /dev/null +++ b/ui/app/components/search-select.js @@ -0,0 +1,95 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { computed } from '@ember/object'; + +export default Component.extend({ + 'data-test-component': 'search-select', + classNames: ['field', 'search-select'], + store: service(), + + /* + * @public + * @param Function + * + * Function called when any of the inputs change + * accepts a single param `value` + * + */ + onChange: () => {}, + + /* + * @public + * @param String | Array + * A comma-separated string or an array of strings. + * Defaults to an empty array. + * + */ + inputValue: computed(function() { + return []; + }), + selectedOptions: null, //list of selected options + options: null, //all possible options + shouldUseFallback: false, + shouldRenderName: false, + init() { + this._super(...arguments); + this.set('selectedOptions', this.inputValue || []); + }, + fetchOptions: task(function*() { + for (let modelType of this.models) { + if (modelType.includes('identity')) { + this.set('shouldRenderName', true); + } + try { + let options = yield this.store.query(modelType, {}); + options = options.toArray().map(option => { + option.searchText = `${option.name} ${option.id}`; + return option; + }); + let formattedOptions = this.selectedOptions.map(option => { + let matchingOption = options.findBy('id', option); + options.removeObject(matchingOption); + return { id: option, name: matchingOption.name, searchText: matchingOption.searchText }; + }); + this.set('selectedOptions', formattedOptions); + if (this.options) { + options = this.options.concat(options); + } + this.set('options', options); + } catch (err) { + if (err.httpStatus === 404) { + //leave options alone, it's okay + return; + } + if (err.httpStatus === 403) { + this.set('shouldUseFallback', true); + return; + } + throw err; + } + } + }).on('didInsertElement'), + handleChange() { + if (this.selectedOptions.length && typeof this.selectedOptions.firstObject === 'object') { + this.onChange(Array.from(this.selectedOptions, option => option.id)); + } else { + this.onChange(this.selectedOptions); + } + }, + actions: { + onChange(val) { + this.onChange(val); + }, + selectOption(option) { + this.selectedOptions.pushObject(option); + this.options.removeObject(option); + this.handleChange(); + }, + discardSelection(selected) { + this.selectedOptions.removeObject(selected); + this.options.pushObject(selected); + this.handleChange(); + }, + }, +}); diff --git a/ui/app/components/string-list.js b/ui/app/components/string-list.js index 310e367d2..10cb41df8 100644 --- a/ui/app/components/string-list.js +++ b/ui/app/components/string-list.js @@ -83,7 +83,7 @@ export default Component.extend({ }, setType() { - const list = this.get('inputList'); + const list = this.inputList; if (!list) { return; } @@ -91,9 +91,7 @@ export default Component.extend({ }, toVal() { - const inputs = this.get('inputList') - .filter(x => x.value) - .mapBy('value'); + const inputs = this.inputList.filter(x => x.value).mapBy('value'); if (this.get('format') === 'string') { return inputs.join(','); } @@ -101,8 +99,8 @@ export default Component.extend({ }, toList() { - let input = this.get('inputValue') || []; - const inputList = this.get('inputList'); + let input = this.inputValue || []; + const inputList = this.inputList; if (typeof input === 'string') { input = input.split(','); } @@ -111,22 +109,22 @@ export default Component.extend({ actions: { inputChanged(idx, val) { - const inputObj = this.get('inputList').objectAt(idx); - const onChange = this.get('onChange'); + const inputObj = this.inputList.objectAt(idx); + const onChange = this.onChange; set(inputObj, 'value', val); onChange(this.toVal()); }, addInput() { - const inputList = this.get('inputList'); + const inputList = this.inputList; if (inputList.get('lastObject.value') !== '') { inputList.pushObject({ value: '' }); } }, removeInput(idx) { - const onChange = this.get('onChange'); - const inputs = this.get('inputList'); + const onChange = this.onChange; + const inputs = this.inputList; inputs.removeObject(inputs.objectAt(idx)); onChange(this.toVal()); }, diff --git a/ui/app/models/identity/entity.js b/ui/app/models/identity/entity.js index d6e3ec4cd..6911f179b 100644 --- a/ui/app/models/identity/entity.js +++ b/ui/app/models/identity/entity.js @@ -22,7 +22,10 @@ export default IdentityModel.extend({ editType: 'kv', }), policies: attr({ - editType: 'stringArray', + label: 'Policies', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['policy/acl', 'policy/rgp'], }), creationTime: attr('string', { readOnly: true, diff --git a/ui/app/models/identity/group.js b/ui/app/models/identity/group.js index 1e813032b..4d1aac831 100644 --- a/ui/app/models/identity/group.js +++ b/ui/app/models/identity/group.js @@ -36,19 +36,28 @@ export default IdentityModel.extend({ editType: 'kv', }), policies: attr({ - editType: 'stringArray', + label: 'Policies', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['policy/acl', 'policy/rgp'], }), memberGroupIds: attr({ label: 'Member Group IDs', - editType: 'stringArray', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['identity/group'], }), parentGroupIds: attr({ label: 'Parent Group IDs', - editType: 'stringArray', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['identity/group'], }), memberEntityIds: attr({ label: 'Member Entity IDs', - editType: 'stringArray', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['identity/entity'], }), hasMembers: computed( 'memberEntityIds', diff --git a/ui/app/serializers/identity/_base.js b/ui/app/serializers/identity/_base.js index 261acbd0b..2a6546c9c 100644 --- a/ui/app/serializers/identity/_base.js +++ b/ui/app/serializers/identity/_base.js @@ -4,23 +4,14 @@ import ApplicationSerializer from '../application'; export default ApplicationSerializer.extend({ normalizeItems(payload) { if (payload.data.keys && Array.isArray(payload.data.keys)) { - return payload.data.keys; + return payload.data.keys.map(key => { + let model = payload.data.key_info[key]; + model.id = key; + return model; + }); } assign(payload, payload.data); delete payload.data; return payload; }, - - extractLazyPaginatedData(payload) { - let list; - list = payload.data.keys.map(key => { - let model = payload.data.key_info[key]; - model.id = key; - return model; - }); - delete payload.data.key_info; - return list.sort((a, b) => { - return a.name.localeCompare(b.name); - }); - }, }); diff --git a/ui/app/styles/app.scss b/ui/app/styles/app.scss index fe76b668a..7f8fbdb71 100644 --- a/ui/app/styles/app.scss +++ b/ui/app/styles/app.scss @@ -1,2 +1,3 @@ -@import "ember-basic-dropdown"; -@import "./core"; +@import 'ember-basic-dropdown'; +@import 'ember-power-select'; +@import './core'; diff --git a/ui/app/styles/components/popup-menu.scss b/ui/app/styles/components/popup-menu.scss index e5530702a..0672f2f73 100644 --- a/ui/app/styles/components/popup-menu.scss +++ b/ui/app/styles/components/popup-menu.scss @@ -1,4 +1,5 @@ -.popup-menu-content { +.popup-menu-content, +.ember-power-select-options { border-radius: 2px; margin: -2px 0 0 0; @@ -23,54 +24,65 @@ .menu { padding: $size-11 0; - button.link, - a, - .menu-item { - background: transparent; - box-shadow: none; - border: none; - display: block; - height: auto; - font-size: $size-7; - font-weight: $font-weight-semibold; - padding: $size-9 $size-8; - text-align: left; - text-decoration: none; - width: 100%; - } - - button.link, - a { - color: $menu-item-color; - &:hover { - background-color: $menu-item-hover-background-color; - color: $menu-item-hover-color; - } - - &.is-destroy { - color: $red; - - &:hover { - background-color: $red; - color: $white; - } - } - - &.disabled { - opacity: 0.5; - - &:hover { - background: transparent; - cursor: default; - } - } - } - small code { margin-left: $spacing-xs; } } + button.link, + a, + .ember-power-select-option, + .ember-power-select-option[aria-current='true'], + .menu-item { + background: transparent; + box-shadow: none; + border: none; + display: block; + height: auto; + font-size: $size-7; + font-weight: $font-weight-semibold; + padding: $size-9 $size-8; + text-align: left; + text-decoration: none; + width: 100%; + } + + button.link, + .ember-power-select-option, + .ember-power-select-option[aria-current='true'], + a { + background-color: $white; + color: $menu-item-color; + + &:hover { + background-color: $menu-item-hover-background-color; + color: $menu-item-hover-color; + } + + &.is-active { + background-color: $menu-item-active-background-color; + color: $menu-item-active-color; + } + + &.is-destroy { + color: $red; + + &:hover { + background-color: $red; + color: $white; + } + } + + &.disabled { + opacity: 0.5; + + &:hover { + background: transparent; + cursor: default; + } + } + } + .menu-label { color: $grey-dark; font-size: $size-9; diff --git a/ui/app/styles/components/search-select.scss b/ui/app/styles/components/search-select.scss new file mode 100644 index 000000000..fe117126d --- /dev/null +++ b/ui/app/styles/components/search-select.scss @@ -0,0 +1,124 @@ +.ember-power-select-dropdown { + background: transparent; + box-shadow: none; + overflow: visible; + + &.ember-power-select-dropdown.ember-basic-dropdown-content--below { + border: 0; + } +} + +.ember-power-select-trigger { + border: 0; + border-radius: $radius; + padding-right: 0; + + &--active { + outline-width: 3px; + outline-offset: -2px; + } +} + +.ember-power-select-trigger:focus, +.ember-power-select-trigger--active { + border: 0; +} + +.ember-power-select-status-icon { + display: none; +} + +.ember-power-select-search { + left: 0; + padding: 0; + position: absolute; + top: 0; + right: 0; + transform: translateY(-100%); + z-index: -1; + + &::after { + background: $white; + bottom: $spacing-xxs; + content: ''; + left: $spacing-xxs + $spacing-l; + position: absolute; + right: $spacing-xxs; + top: $spacing-xxs; + z-index: -1; + } +} + +.ember-power-select-search-input { + background: transparent; + border: 0; + padding: $spacing-xxs $spacing-s; + padding-left: $spacing-xxs + $spacing-l; +} + +.ember-power-select-options { + background: $white; + border: $base-border; + box-shadow: $box-shadow-middle; + margin: -4px $spacing-xs 0; + padding: $spacing-xxs 0; + + .ember-power-select-option, + .ember-power-select-option[aria-current='true'] { + align-items: center; + display: flex; + justify-content: space-between; + } + + .ember-power-select-option[aria-current='true'] { + @extend .ember-power-select-option:hover; + } + + .ember-power-select-option--no-matches-message { + color: $grey; + font-size: $size-8; + font-weight: $font-weight-semibold; + text-transform: uppercase; + + &:hover, + &:focus { + background: transparent; + color: $grey; + } + } +} + +.search-select-list-item { + align-items: center; + display: flex; + margin: 0 $spacing-m; + padding: $spacing-xxs; + justify-content: space-between; + + &:not(:last-child) { + border-bottom: $light-border; + } + + .control .button { + color: $grey-light; + min-width: auto; + + &:hover, + &:focus { + color: $blue; + } + } +} + +.search-select-list-key { + color: $grey; + font-size: $size-8; +} + +.ember-power-select-dropdown.ember-basic-dropdown-content { + animation: none; + + .ember-power-select-options { + animation: drop-fade-above 0.15s; + } +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 0c5d9d8e0..de93f14ee 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -69,6 +69,7 @@ @import './components/popup-menu'; @import './components/radial-progress'; @import './components/role-item'; +@import './components/search-select'; @import './components/secret-control-bar'; @import './components/shamir-progress'; @import './components/sidebar'; diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 748791e97..ad484f197 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -184,8 +184,11 @@ label { } } -.select:not(.is-multiple)::after, -.select:not(.is-multiple)::before { +.select:not(.is-multiple) { + height: 2.5rem; +} + +.select:not(.is-multiple)::after { border-color: $black; border-width: 2px; margin-top: 0; diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index be110bb64..0df581575 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -1,5 +1,6 @@ .title:not(:last-child), .subtitle:not(:last-child) { + display: block; margin-bottom: 1rem; } @@ -11,3 +12,7 @@ text-decoration: none; } } + +.form-section .title { + margin-bottom: $spacing-s; +} diff --git a/ui/app/templates/components/form-field.hbs b/ui/app/templates/components/form-field.hbs index 4dbe7c003..c84e51cb7 100644 --- a/ui/app/templates/components/form-field.hbs +++ b/ui/app/templates/components/form-field.hbs @@ -1,52 +1,84 @@ -{{#unless (or (and attr.options.editType (not-eq attr.options.editType "textarea")) (eq attr.type "boolean"))}} +{{#unless + (or + (and attr.options.editType (not-eq attr.options.editType "textarea")) + (eq attr.type "boolean") + ) +}} {{/unless}} {{#if attr.options.possibleValues}} -
+
-{{else if (and (eq attr.type 'string') (eq attr.options.editType 'boolean'))}} +{{else if (and (eq attr.type "string") (eq attr.options.editType "boolean"))}}
- + +
+{{else if (eq attr.options.editType "searchSelect")}} +
+ -{{else if (eq attr.options.editType 'mountAccessor')}} +
+{{else if (eq attr.options.editType "mountAccessor")}} {{mount-accessor-select name=attr.name label=labelString @@ -54,8 +86,8 @@ helpText=attr.options.helpText value=(get model valuePath) onChange=(action "setAndBroadcast" valuePath) - }} -{{else if (eq attr.options.editType 'kv')}} + }} +{{else if (eq attr.options.editType "kv")}} {{kv-object-editor value=(get model valuePath) onChange=(action "setAndBroadcast" valuePath) @@ -63,15 +95,15 @@ warning=attr.options.warning helpText=attr.options.helpText }} -{{else if (eq attr.options.editType 'file')}} +{{else if (eq attr.options.editType "file")}} {{text-file - index='' + index="" file=file - onChange=(action 'setFile') + onChange=(action "setFile") warning=attr.options.warning label=labelString }} -{{else if (eq attr.options.editType 'ttl')}} +{{else if (eq attr.options.editType "ttl")}} {{ttl-picker data-test-input=attr.name initialValue=(or (get model valuePath) attr.options.defaultValue) @@ -80,7 +112,7 @@ setDefaultValue=(or attr.options.setDefault false) onChange=(action (action "setAndBroadcast" valuePath)) }} -{{else if (eq attr.options.editType 'stringArray')}} +{{else if (eq attr.options.editType "stringArray")}} {{string-list label=labelString warning=attr.options.warning @@ -96,29 +128,33 @@ /> {{else if (or (eq attr.type 'number') (eq attr.type 'string'))}}
- {{#if (eq attr.options.editType 'textarea')}} + {{#if (eq attr.options.editType "textarea")}} - {{else if (eq attr.options.editType 'json')}} - + > + {{else if (eq attr.options.editType "json")}} {{json-editor - value=(if (get model valuePath) (stringify (jsonify (get model valuePath)))) - valueUpdated=(action "codemirrorUpdated" attr.name 'string') + value=(if + (get model valuePath) (stringify (jsonify (get model valuePath))) + ) + valueUpdated=(action "codemirrorUpdated" attr.name "string") }} {{else}} + /> + {{#if attr.options.validationAttr}} - {{#if (and (get model valuePath) (not (get model attr.options.validationAttr)))}} + {{#if + (and + (get model valuePath) (not (get model attr.options.validationAttr)) + ) + }} + /> + {{/if}} {{/if}} {{/if}}
-{{else if (eq attr.type 'boolean')}} +{{else if (eq attr.type "boolean")}}
- + +
-{{else if (eq attr.type 'object')}} +{{else if (eq attr.type "object")}} {{json-editor value=(if (get model valuePath) (stringify (get model valuePath)) emptyData) valueUpdated=(action "codemirrorUpdated" attr.name false) }} -{{/if}} +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/kv-object-editor.hbs b/ui/app/templates/components/kv-object-editor.hbs index 9b216b829..c7a3f40b4 100644 --- a/ui/app/templates/components/kv-object-editor.hbs +++ b/ui/app/templates/components/kv-object-editor.hbs @@ -1,5 +1,5 @@ {{#if label}} -
- {{#popup-menu name="auth-backend-nav" contentClass="is-wide"}} + {{#popup-menu name="auth-backend-nav"}}