use ember-power-select-with-create instead of ember-power-select (#6728)

* use ember-power-select-with-create instead of ember-power-select

* add custom Add message to clarify whether you need a name or ID

* add search-select to storybook

* add wormhole div for ember-basic-dropdown

* add search-select to storybook

* make sure knobs are working

* remove unused code
This commit is contained in:
Madalyn 2019-06-03 16:25:59 -04:00 committed by Matthew Irish
parent ff3e23e050
commit 43f4c5532d
9 changed files with 231 additions and 87 deletions

View File

@ -2,7 +2,23 @@ import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { computed } from '@ember/object';
import { singularize } from 'ember-inflector';
/**
* @module SearchSelect
* The `SearchSelect` is an implementation of the [ember-power-select-with-create](https://github.com/poteto/ember-cli-flash) used for form elements where options come dynamically from the API.
* @example
* <SearchSelect @id="group-policies" @models={{["policies/acl"]}} @onChange={{onChange}} @inputValue={{get model valuePath}} @helpText="Policies associated with this group" @label="Policies" @fallbackComponent="string-list" />
*
* @param id {String} - The name of the form field
* @param models {String} - An array of model types to fetch from the API.
* @param onChange {Func} - The onchange action for this form field.
* @param inputValue {String} - A comma-separated string or an array of strings.
* @param [helpText] {String} - Text to be displayed in the info tooltip for this form field
* @param label {String} - Label for this form field
* @param fallbackComponent {String} - name of component to be rendered if the API call 403s
*
*/
export default Component.extend({
'data-test-component': 'search-select',
classNames: ['field', 'search-select'],
@ -36,6 +52,22 @@ export default Component.extend({
this._super(...arguments);
this.set('selectedOptions', this.inputValue || []);
},
formatOptions: function(options) {
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);
},
fetchOptions: task(function*() {
for (let modelType of this.models) {
if (modelType.includes('identity')) {
@ -43,20 +75,7 @@ export default Component.extend({
}
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);
this.formatOptions(options);
} catch (err) {
if (err.httpStatus === 404) {
//leave options alone, it's okay
@ -66,6 +85,12 @@ export default Component.extend({
this.set('shouldUseFallback', true);
return;
}
//special case for storybook
if (this.staticOptions) {
let options = this.staticOptions;
this.formatOptions(options);
return;
}
throw err;
}
}
@ -81,6 +106,11 @@ export default Component.extend({
onChange(val) {
this.onChange(val);
},
createOption(optionId) {
let newOption = { name: optionId, id: optionId };
this.selectedOptions.pushObject(newOption);
this.handleChange();
},
selectOption(option) {
this.selectedOptions.pushObject(option);
this.options.removeObject(option);
@ -91,5 +121,12 @@ export default Component.extend({
this.options.pushObject(selected);
this.handleChange();
},
constructSuggestion(id) {
return `Add new ${singularize(this.label)}: ${id}`;
},
hideCreateOptionOnSameID(id) {
let existingOption = this.options.findBy('id', id) || this.options.findBy('name', id);
return !existingOption;
},
},
});

View File

@ -84,7 +84,6 @@
@onChange={{action (action "setAndBroadcast" valuePath)}}
@inputValue={{get model valuePath}}
@helpText={{attr.options.helpText}}
@warning={{attr.options.warning}}
@label={{labelString}}
@fallbackComponent={{attr.options.fallbackComponent}}
/>

View File

@ -4,23 +4,25 @@
label=label
onChange=(action "onChange")
inputValue=inputValue
warning=warning
helpText=helpText
}}
{{else}}
<label for="{{name}}" class="title is-4" data-test-field-label>
<label class="title is-4" data-test-field-label>
{{label}}
{{#if helpText}}
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}
{{/if}}
</label>
{{#power-select
{{#power-select-with-create
options=options
onchange=(action "selectOption")
placeholderComponent="search-select-placeholder"
oncreate=(action "createOption")
placeholderComponent=(component "search-select-placeholder")
renderInPlace=true
searchField="searchText"
verticalPosition="below" as |option|
verticalPosition="below"
showCreateWhen=(action "hideCreateOptionOnSameID")
buildSuggestion=(action "constructSuggestion") as |option|
}}
{{#if shouldRenderName}}
{{option.name}}
@ -30,7 +32,7 @@
{{else}}
{{option.id}}
{{/if}}
{{/power-select}}
{{/power-select-with-create}}
<ul class="search-select-list">
{{#each selectedOptions as |selected|}}
<li class="search-select-list-item" data-test-selected-option="true">
@ -43,12 +45,8 @@
{{selected.id}}
{{/if}}
<div class="control">
<button
type="button"
class="button is-ghost"
data-test-selected-list-button="delete"
{{action "discardSelection" selected}}
>
<button type="button" class="button is-ghost" data-test-selected-list-button="delete"
{{action "discardSelection" selected}}>
<Icon @glyph="trash" />
</button>
</div>

View File

@ -96,7 +96,7 @@
"ember-load-initializers": "^1.1.0",
"ember-maybe-import-regenerator": "^0.1.6",
"ember-maybe-in-element": "^0.1.3",
"ember-power-select": "^2.0.12",
"ember-power-select-with-create": "cibernox/ember-power-select-with-create#6203918f247c1c5d692db4f9185ab8321d2125e1",
"ember-qunit": "^4.4.1",
"ember-radio-button": "^1.1.1",
"ember-resolver": "^5.0.1",

View File

@ -16,6 +16,7 @@ const component = name
const options = {
files: inputFile,
template: fs.readFileSync('./lib/story-md.hbs', 'utf8'),
'example-lang': 'js',
};
let md = jsdoc2md.renderSync(options);

View File

@ -0,0 +1,28 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/search-select.js. To make changes, first edit that file and run "yarn gen-story-md search-select" to re-generate the content.-->
## SearchSelect
The `SearchSelect` is an implementation of the [ember-power-select-with-create](https://github.com/poteto/ember-cli-flash) used for form elements where options come dynamically from the API.
| Param | Type | Description |
| --- | --- | --- |
| id | <code>String</code> | The name of the form field |
| models | <code>String</code> | An array of model types to fetch from the API. |
| onChange | <code>Func</code> | The onchange action for this form field. |
| inputValue | <code>String</code> | A comma-separated string or an array of strings. |
| [helpText] | <code>String</code> | Text to be displayed in the info tooltip for this form field |
| label | <code>String</code> | Label for this form field |
| fallbackComponent | <code>String</code> | name of component to be rendered if the API call 403s |
**Example**
```js
<SearchSelect @id="group-policies" @models={{["policies/acl"]}} @onChange={{onChange}} @inputValue={{get model valuePath}} @helpText="Policies associated with this group" @label="Policies" @fallbackComponent="string-list" />
```
**See**
- [Uses of SearchSelect](https://github.com/hashicorp/vault/search?l=Handlebars&q=SearchSelect+OR+search-select)
- [SearchSelect Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/search-select.js)
---

View File

@ -0,0 +1,35 @@
/* eslint-disable import/extensions */
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import notes from './search-select.md';
import { withKnobs, text, select } from '@storybook/addon-knobs';
const onChange = (value) => alert(`New value is "${value}"`);
const models = ["identity/groups"];
storiesOf('SearchSelect/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs({ escapeHTML: false }))
.add(`SearchSelect`, () => ({
template: hbs`
<h5 class="title is-5">Search Select</h5>
<SearchSelect
@id="groups"
@models={{models}}
@onChange={{onChange}}
@inputValue={{inputValue}}
@label={{label}}
@fallbackComponent="string-list"
@staticOptions={{staticOptions}}/>
`,
context: {
label: text("Label", "Group IDs"),
helpText: text("Help Tooltip Text", "Group IDs to associate with this entity"),
inputValue: [],
models: models,
onChange: onChange,
staticOptions: [{ name: "my-group", id: "123dsafdsarf" }, { name: "my-other-group", id: "45ssadd435" }, { name: "example-1", id: "5678" }, { name: "group-2", id: "gro09283" }],
},
}),
{ notes }
);

View File

@ -74,7 +74,7 @@ module('Integration | Component | search select', function(hooks) {
);
});
test('it filters options when text is entered', async function(assert) {
test('it filters options and adds option to create new item when text is entered', async function(assert) {
const models = ['identity/entity'];
this.set('models', models);
this.set('onChange', sinon.spy());
@ -82,7 +82,11 @@ module('Integration | Component | search select', function(hooks) {
await clickTrigger();
assert.equal(component.options.length, 3, 'shows all options');
await typeInSearch('n');
assert.equal(component.options.length, 2, 'shows two options');
assert.equal(component.options.length, 3, 'list still shows three options, including the add option');
await typeInSearch('ni');
assert.equal(component.options.length, 2, 'list shows two options, including the add option');
await typeInSearch('nine');
assert.equal(component.options.length, 1, 'list shows one option');
});
test('it moves option from drop down to list when clicked', async function(assert) {
@ -126,6 +130,27 @@ module('Integration | Component | search select', function(hooks) {
assert.equal(component.options.length, 3, 'shows all options');
});
test('it adds created item to list items on create and reinserts into drop down on delete', async function(assert) {
const models = ['identity/entity'];
this.set('models', models);
this.set('onChange', sinon.spy());
await render(hbs`{{search-select label="foo" models=models onChange=onChange}}`);
await clickTrigger();
assert.equal(component.options.length, 3, 'shows all options');
await typeInSearch('n');
assert.equal(component.options.length, 3, 'list still shows three options, including the add option');
await typeInSearch('ni');
await component.selectOption();
assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
assert.ok(this.onChange.calledOnce);
assert.ok(this.onChange.calledWith(['ni']));
await component.deleteButtons.objectAt(0).click();
assert.equal(component.selectedOptions.length, 0, 'there are no selected options');
assert.ok(this.onChange.calledWith([]));
await clickTrigger();
assert.equal(component.options.length, 4, 'shows all options, including created option');
});
test('it uses fallback component if endpoint 403s', async function(assert) {
const models = ['policy/rgp'];
this.set('models', models);
@ -144,12 +169,8 @@ module('Integration | Component | search select', function(hooks) {
hbs`{{search-select label="foo" inputValue=inputValue models=models fallbackComponent="string-list" onChange=onChange}}`
);
await clickTrigger();
assert.equal(component.options.length, 1, 'has the disabled no results option');
assert.equal(
component.options.objectAt(0).text,
'No results found',
'text of option shows No results found'
);
assert.equal(component.options.length, 1, 'prompts for search to add new options');
assert.equal(component.options.objectAt(0).text, 'Type to search', 'text of option shows Type to search');
});
test('it shows both name and smaller id for identity endpoints', async function(assert) {

View File

@ -7061,6 +7061,15 @@ ember-basic-dropdown@^1.0.0:
ember-cli-htmlbars "^2.0.3"
ember-maybe-in-element "^0.1.3"
ember-basic-dropdown@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/ember-basic-dropdown/-/ember-basic-dropdown-1.1.2.tgz#6558eb2aa34d2feeb66e9de1feea560d46edc697"
integrity sha512-l38MNIUOI1nAKxSUlDI1wrP52a55HxN2dikDUwJOqx7NytK0/woPyy3uVUe7gfT2gJ4HCbRlL/7y0csvP0iMPg==
dependencies:
ember-cli-babel "^7.2.0"
ember-cli-htmlbars "^3.0.1"
ember-maybe-in-element "^0.2.0"
ember-cli-autoprefixer@^0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/ember-cli-autoprefixer/-/ember-cli-autoprefixer-0.8.1.tgz#071dd9574451057b03dcc03b71f5bd9cb07ef332"
@ -7158,31 +7167,7 @@ ember-cli-babel@^7.1.2:
ensure-posix-path "^1.0.2"
semver "^5.5.0"
ember-cli-babel@^7.4.3:
version "7.5.0"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.5.0.tgz#af654dcef23630391d2efe85aaa3bdf8b6ca17b7"
integrity sha512-wWXqPPQNRxCtEHvYaLBNiIVgCVCy8YqZ0tM8Dpql1D5nGnPDbaK073sS1vlOYBP7xe5Ab2nXhvQkFwUxFacJ2g==
dependencies:
"@babel/core" "^7.0.0"
"@babel/plugin-transform-modules-amd" "^7.0.0"
"@babel/plugin-transform-runtime" "^7.2.0"
"@babel/polyfill" "^7.0.0"
"@babel/preset-env" "^7.0.0"
"@babel/runtime" "^7.2.0"
amd-name-resolver "^1.2.1"
babel-plugin-debug-macros "^0.3.0"
babel-plugin-ember-modules-api-polyfill "^2.7.0"
babel-plugin-module-resolver "^3.1.1"
broccoli-babel-transpiler "^7.1.2"
broccoli-debug "^0.6.4"
broccoli-funnel "^2.0.1"
broccoli-source "^1.1.0"
clone "^2.1.2"
ember-cli-version-checker "^2.1.2"
ensure-posix-path "^1.0.2"
semver "^5.5.0"
ember-cli-babel@^7.5.0:
ember-cli-babel@^7.2.0, ember-cli-babel@^7.5.0:
version "7.7.3"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.7.3.tgz#f94709f6727583d18685ca6773a995877b87b8a0"
integrity sha512-/LWwyKIoSlZQ7k52P+6agC7AhcOBqPJ5C2u27qXHVVxKvCtg6ahNuRk/KmfZmV4zkuw4EjTZxfJE1PzpFyHkXg==
@ -7209,6 +7194,30 @@ ember-cli-babel@^7.5.0:
ensure-posix-path "^1.0.2"
semver "^5.5.0"
ember-cli-babel@^7.4.3:
version "7.5.0"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.5.0.tgz#af654dcef23630391d2efe85aaa3bdf8b6ca17b7"
integrity sha512-wWXqPPQNRxCtEHvYaLBNiIVgCVCy8YqZ0tM8Dpql1D5nGnPDbaK073sS1vlOYBP7xe5Ab2nXhvQkFwUxFacJ2g==
dependencies:
"@babel/core" "^7.0.0"
"@babel/plugin-transform-modules-amd" "^7.0.0"
"@babel/plugin-transform-runtime" "^7.2.0"
"@babel/polyfill" "^7.0.0"
"@babel/preset-env" "^7.0.0"
"@babel/runtime" "^7.2.0"
amd-name-resolver "^1.2.1"
babel-plugin-debug-macros "^0.3.0"
babel-plugin-ember-modules-api-polyfill "^2.7.0"
babel-plugin-module-resolver "^3.1.1"
broccoli-babel-transpiler "^7.1.2"
broccoli-debug "^0.6.4"
broccoli-funnel "^2.0.1"
broccoli-source "^1.1.0"
clone "^2.1.2"
ember-cli-version-checker "^2.1.2"
ensure-posix-path "^1.0.2"
semver "^5.5.0"
ember-cli-broccoli-sane-watcher@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ember-cli-broccoli-sane-watcher/-/ember-cli-broccoli-sane-watcher-2.1.1.tgz#1687adada9022de26053fba833dc7dd10f03dd08"
@ -7337,6 +7346,16 @@ ember-cli-htmlbars@^3.0.0:
json-stable-stringify "^1.0.0"
strip-bom "^3.0.0"
ember-cli-htmlbars@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-3.0.1.tgz#01e21f0fd05e0a6489154f26614b1041769e3e58"
integrity sha512-pyyB2s52vKTXDC5svU3IjU7GRLg2+5O81o9Ui0ZSiBS14US/bZl46H2dwcdSJAK+T+Za36ZkQM9eh1rNwOxfoA==
dependencies:
broccoli-persistent-filter "^1.4.3"
hash-for-dep "^1.2.3"
json-stable-stringify "^1.0.0"
strip-bom "^3.0.0"
ember-cli-inject-live-reload@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/ember-cli-inject-live-reload/-/ember-cli-inject-live-reload-1.8.2.tgz#29f875ad921e9a1dec65d2d75018891972d240bc"
@ -7653,7 +7672,7 @@ ember-concurrency-test-waiter@^0.3.1:
dependencies:
ember-cli-babel "^6.6.0"
ember-concurrency@^0.10.0:
ember-concurrency@^0.10.0, "ember-concurrency@^0.8.27 || ^0.9.0 || ^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/ember-concurrency/-/ember-concurrency-0.10.0.tgz#9c7c79dca411e01466119f02d7197c0a4ff0df08"
integrity sha512-KhNNkUqnAN0isuAwSHaTJtmY/MdHJogSSAj7lCigzfJn2+21yafQcwzoVqf5LdMOn2+owWYURr1YiwzuOvlGyQ==
@ -7663,15 +7682,6 @@ ember-concurrency@^0.10.0:
ember-compatibility-helpers "^1.2.0"
ember-maybe-import-regenerator "^0.1.5"
ember-concurrency@^0.8.19:
version "0.8.22"
resolved "https://registry.yarnpkg.com/ember-concurrency/-/ember-concurrency-0.8.22.tgz#900e870aae486e1f5fcb168bbb4efba02561a5ad"
integrity sha512-njLqyjMxBf8fapIV8WPyG2gFbSCIQgMpk33uSs5Ih7HsfFAz60KwVo9sMVPBYAJwQmF8jFPm7Ph+mjkiQXHmmA==
dependencies:
babel-core "^6.24.1"
ember-cli-babel "^6.8.2"
ember-maybe-import-regenerator "^0.1.5"
ember-copy@1.0.0, ember-copy@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ember-copy/-/ember-copy-1.0.0.tgz#426554ba6cf65920f31d24d0a3ca2cb1be16e4aa"
@ -7814,6 +7824,13 @@ ember-maybe-in-element@^0.1.3:
dependencies:
ember-cli-babel "^6.11.0"
ember-maybe-in-element@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ember-maybe-in-element/-/ember-maybe-in-element-0.2.0.tgz#9ac51cbbd9d83d6230ad996c11e33f0eca3032e0"
integrity sha512-R5e6N8yDbfNbA/3lMZsFs2KEzv/jt80TsATiKMCqdqKuSG82KrD25cRdU5VkaE8dTQbziyBeuJs90bBiqOnakQ==
dependencies:
ember-cli-babel "^7.1.0"
ember-native-dom-helpers@^0.5.3:
version "0.5.10"
resolved "https://registry.yarnpkg.com/ember-native-dom-helpers/-/ember-native-dom-helpers-0.5.10.tgz#9c7172e4ddfa5dd86830c46a936e2f8eca3e5896"
@ -7822,17 +7839,25 @@ ember-native-dom-helpers@^0.5.3:
broccoli-funnel "^1.1.0"
ember-cli-babel "^6.6.0"
ember-power-select@^2.0.12:
version "2.0.12"
resolved "https://registry.yarnpkg.com/ember-power-select/-/ember-power-select-2.0.12.tgz#24ee54333b9dab482e6da2e2e31966c40cdc445c"
integrity sha512-81I850ypsDqik2djv2hbDeV7yFjcxmjkkQk+TADoM+MVo4W5FzRsl87MiX1RK35ZADYxG1RTjVBcCrtKWuMEEg==
ember-power-select-with-create@cibernox/ember-power-select-with-create#6203918f247c1c5d692db4f9185ab8321d2125e1:
version "0.6.1"
resolved "https://codeload.github.com/cibernox/ember-power-select-with-create/tar.gz/6203918f247c1c5d692db4f9185ab8321d2125e1"
dependencies:
ember-basic-dropdown "^1.0.0"
ember-cli-babel "^6.16.0"
ember-cli-htmlbars "^3.0.0"
ember-concurrency "^0.8.19"
ember-text-measurer "^0.4.0"
ember-truth-helpers "^2.0.0"
ember-cli-babel "^6.6.0"
ember-cli-htmlbars "^2.0.1"
ember-power-select "^2.0.0"
ember-power-select@^2.0.0:
version "2.3.2"
resolved "https://registry.yarnpkg.com/ember-power-select/-/ember-power-select-2.3.2.tgz#8bd20989c52ec53e5f526bd5d3267aac6f0a76f4"
integrity sha512-8LyBKbAGW+ymVum7E7khrvm7ZAZt5MOQrFQrWEjeaeIwDIwjrFpT0ap1xSlbbk4zzOjUpq0P/4R2fAbFPpsv7w==
dependencies:
ember-basic-dropdown "^1.1.0"
ember-cli-babel "^7.2.0"
ember-cli-htmlbars "^3.0.1"
ember-concurrency "^0.8.27 || ^0.9.0 || ^0.10.0"
ember-text-measurer "^0.5.0"
ember-truth-helpers "^2.1.0"
ember-qunit@^4.4.1:
version "4.4.1"
@ -7983,14 +8008,14 @@ ember-test-selectors@^2.1.0:
ember-cli-babel "^6.8.2"
ember-cli-version-checker "^3.1.2"
ember-text-measurer@^0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/ember-text-measurer/-/ember-text-measurer-0.4.1.tgz#30ababa7100b2ffb86f8c37fe2b4b56d2592c626"
integrity sha512-fwRoaGF0pemMyJ5S/j5FYt7PZva8MyZA33DwkL1LLAR9T5hQyatSbZaLl4D1a5Kv8kDXX0tTFZT9xCPPKahYTg==
ember-text-measurer@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/ember-text-measurer/-/ember-text-measurer-0.5.0.tgz#b907aeb8cbc04560e5070dc0347cdd35d0040d0d"
integrity sha512-YhcOcce8kaHp4K0frKW7xlPJxz82RegGQCVNTcFftEL/jpEflZyFJx17FWVINfDFRL4K8wXtlzDXFgMOg8vmtQ==
dependencies:
ember-cli-babel "^6.8.2"
ember-cli-babel "^7.1.0"
ember-truth-helpers@^2.0.0, ember-truth-helpers@^2.1.0:
ember-truth-helpers@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/ember-truth-helpers/-/ember-truth-helpers-2.1.0.tgz#d4dab4eee7945aa2388126485977baeb33ca0798"
integrity sha512-BQlU8aTNl1XHKTYZ243r66yqtR9JU7XKWQcmMA+vkqfkE/c9WWQ9hQZM8YABihCmbyxzzZsngvldokmeX5GhAw==