UI/add select dropdown (#7102)

* add SelectDropdown

* use SelectDropdown instead of HttpRequestsDropdown

* use html selector instead of class name

* ensure SelectDropdown still works when rendered inside a Toolbar

* add tests

* remove old HttpRequests component

* make SelectDropdown example easier to read in Storybook

* add isFullwidth prop

* add SelectDropbown inside a Toolbar story

* fix tests

* remove actions block and call this.onChange directly

* replace dropdownLabel with label

* rename SelectDropdown to SelecT

* add test for onChange

* remove selectedItem prop since we don't need it

* make Select accept options as an array of strings or objects

* Revert "remove selectedItem prop since we don't need it"

This reverts commit 7278516de87bb1df60482edb005137252819931e.

* use Select inside TtlPicker

* remove debugger

* use a test selector

* fix pki test selectors

* improve storybook docs

* fix selected value in ttl picker

* ensure httprequests dropdown updates the selected item

* ensure select dropdown correctly matches selectedItem

* rename selectedItem to selectedValue

* remove debugger lol

* update selectedItem test

* add valueAttribute and labelAttribute to Storybook knobs

* udpate jsdocs

* remove old httprequestsdropdown component

* add note that onChange will receive value of select

* use Select inside AuthForm

* use correct test selector
This commit is contained in:
Noelle Daley 2019-08-01 14:35:18 -07:00 committed by GitHub
parent 11508c78a4
commit 828185db49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 309 additions and 203 deletions

View File

@ -25,6 +25,21 @@ export default Component.extend({
classNames: ['http-requests-container'],
counters: null,
timeWindow: 'All',
dropdownOptions: computed('counters', function() {
let counters = this.counters || [];
let options = ['All', 'Last 12 Months'];
if (counters.length) {
const years = counters
.map(counter => {
const year = new Date(counter.start_time);
return year.getUTCFullYear().toString();
})
.uniq();
years.sort().reverse();
options = options.concat(years);
}
return options;
}),
filteredCounters: computed('counters', 'timeWindow', function() {
const { counters, timeWindow } = this;
if (timeWindow === 'All') {

View File

@ -1,51 +0,0 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
/**
* @module HttpRequestsDropdown
* HttpRequestsDropdown components are used to render a dropdown that filters the HttpRequestsBarChart.
*
* @example
* ```js
* <HttpRequestsDropdown @counters={counters} />
* ```
*
* @param counters=null {Array} - A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests` endpoint which looks like:
* COUNTERS = [
* {
* "start_time": "2019-05-01T00:00:00Z",
* "total": 50
* }
* ]
*/
export default Component.extend({
classNames: ['http-requests-dropdown'],
counters: null,
timeWindow: 'All',
options: computed('counters', function() {
let counters = this.counters || [];
let options = [];
if (counters.length) {
const years = counters
.map(counter => {
const year = new Date(counter.start_time);
return year.getUTCFullYear().toString();
})
.uniq();
years.sort().reverse();
options = options.concat(years);
}
return options;
}),
onChange() {},
actions: {
onSelectTimeWindow(e) {
const newValue = e.target.value;
const { timeWindow } = this;
if (newValue !== timeWindow) {
this.onChange(newValue);
}
},
},
});

View File

@ -0,0 +1,34 @@
import Component from '@ember/component';
/**
* @module Select
* Select components are used to render a dropdown.
*
* @example
* ```js
* <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
* ```
*
* @param label=null {String} - The label for the select element.
* @param options=null {Array} - A list of items that the user will select from. This can be an array of strings or objects.
* @param [selectedValue=null] {String} - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
* @param [name=null] {String} - The name of the select, used for the test selector.
* @param [valueAttribute=value] {String} - When `options` is an array objects, the key to check for when assigning the option elements value.
* @param [labelAttribute=label] {String} - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
* @param [isInline=false] {Bool} - Whether or not the select should be displayed as inline-block or block.
* @param [isFullwidth=false] {Bool} - Whether or not the select should take up the full width of the parent element.
* @param onChange=null {Func} - The action to take once the user has selected an item. This method will be passed the `value` of the select.
*/
export default Component.extend({
classNames: ['field'],
label: null,
selectedValue: null,
name: null,
options: null,
valueAttribute: 'value',
labelAttribute: 'label',
isInline: false,
isFullwidth: false,
onChange() {},
});

View File

@ -24,6 +24,11 @@
.select {
min-width: 190px;
}
label {
padding: $spacing-xs;
color: $grey;
}
}
.toolbar-scroller {
@ -95,11 +100,6 @@
}
}
.toolbar-label {
padding: $spacing-xs;
color: $grey;
}
.toolbar-separator {
border-right: $light-border;
height: 32px;

View File

@ -32,27 +32,15 @@
data-test-auth-error
/>
{{#if (or (not hasMethodsWithPath) (not selectedAuthIsPath))}}
<div class="field">
<label for="selectedMethod" class="is-label">
Method
</label>
<div class="control is-expanded" >
<div class="select is-fullwidth">
<select
name="selectedMethod"
id="selectedMethod"
onchange={{action (mut selectedAuth) value="target.value"}}
data-test-method-select
>
{{#each (supported-auth-backends) as |method|}}
<option selected={{eq selectedAuthBackend.type method.type}} value={{method.type}}>
{{method.typeDisplay}}
</option>
{{/each}}
</select>
</div>
</div>
</div>
<Select
@label='Method'
@name='auth-method'
@options={{supported-auth-backends}}
@valueAttribute={{'type'}}
@labelAttribute={{'typeDisplay'}}
@isFullwidth={{true}}
@onChange={{action (mut selectedAuth)}}
/>
{{/if}}
{{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}}
<AuthJwt

View File

@ -1,6 +1,13 @@
{{#if (gt counters.length 1) }}
<Toolbar>
<HttpRequestsDropdown @counters={{counters}} @onChange={{action "updateTimeWindow"}} @timeWindow={{timeWindow}}/>
<Select
@label='Date Range'
@name='requests-timewindow'
@options={{dropdownOptions}}
@onChange={{action "updateTimeWindow"}}
@selectedValue={{timeWindow}}
@isInline={{true}}
/>
</Toolbar>
<HttpRequestsBarChart @counters={{filteredCounters}} />

View File

@ -1,12 +0,0 @@
<label for="date-range" class="is-label toolbar-label">
Date Range
</label>
<div class="select">
<select class="select" id="date-range" data-test-date-range name="selectedTimeWindow" data-test-timewindow-select onchange={{action "onSelectTimeWindow"}}>
<option value="All" selected={{eq timeWindow "All"}}>All</option>/>
<option value="Last 12 Months" selected={{eq timeWindow "Last 12 Months"}}>Last 12 Months</option>
{{#each options as |op|}}
<option value={{op}} selected={{eq timeWindow op}}>{{op}}</option>
{{/each}}
</select>
</div>

View File

@ -0,0 +1,29 @@
{{#if label}}
<label
for="select-{{this.elementId}}"
class="is-label"
data-test-select-label
>
{{label}}
</label>
{{/if}}
<div class="control {{if isInline "select is-inline-block"}}">
<div class="select {{if isFullwidth "is-fullwidth"}}">
<select
class="select"
id="select-{{this.elementId}}"
onchange={{action this.onChange value="target.value"}}
data-test-select={{name}}
>
{{#each options as |op|}}
<option
value={{or (get op valueAttribute) op}}
selected={{eq selectedValue (or (get op valueAttribute) op)}}>
{{or (get op labelAttribute) op}}
</option>
{{/each}}
</select>
</div>
</div>

View File

@ -102,18 +102,17 @@ export default Component.extend({
},
actions: {
changedValue(key, event) {
let { type, value, checked } = event.target;
let val = type === 'checkbox' ? checked : value;
if (val && key === 'time') {
val = parseInt(val, 10);
if (Number.isNaN(val)) {
changedValue(key, value) {
if (value && key === 'time') {
value = parseInt(value, 10);
if (Number.isNaN(value)) {
this.set('errorMessage', ERROR_MESSAGE);
return;
}
}
this.set('errorMessage', null);
set(this, key, val);
set(this, key, value);
this.onChange(this.TTL);
},
},

View File

@ -2,18 +2,16 @@
<MessageError @errorMessage={{errorMessage}} data-test-ttl-error />
<div class="field is-grouped">
<div class="control is-expanded">
<input data-test-ttl-value value={{time}} id="time-{{elementId}}" type="text" name="time" class="input" oninput={{action 'changedValue' 'time'}}
<input data-test-ttl-value value={{time}} id="time-{{elementId}}" type="text" name="time" class="input" oninput={{action (action 'changedValue' 'time') value="target.value"}}
pattern="[0-9]*" />
</div>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select data-test-ttl-unit name="unit" id="unit" onchange={{action 'changedValue' 'unit'}}>
{{#each unitOptions as |unitOption|}}
<option selected={{eq unit unitOption.value}} value={{unitOption.value}}>
{{unitOption.label}}
</option>
{{/each}}
</select>
</div>
<Select
@name='ttl-unit'
@options={{unitOptions}}
@onChange={{action 'changedValue' 'unit'}}
@selectedValue={{unit.value}}
@isFullwidth={{true}}
/>
</div>
</div>
</div>

View File

@ -1,22 +0,0 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/http-requests-dropdown.js. To make changes, first edit that file and run "yarn gen-story-md http-requests-dropdown" to re-generate the content.-->
## HttpRequestsDropdown
HttpRequestsDropdown components are used to render a dropdown that filters the HttpRequestsBarChart.
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| counters | <code>Array</code> | <code></code> | A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests` endpoint. |
**Example**
```js
<HttpRequestsDropdown @counters={counters} />
```
**See**
- [Uses of HttpRequestsDropdown](https://github.com/hashicorp/vault/search?l=Handlebars&q=HttpRequestsDropdown+OR+http-requests-dropdown)
- [HttpRequestsDropdown Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/http-requests-dropdown.js)
---

View File

@ -1,28 +0,0 @@
/* eslint-disable import/extensions */
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import { withKnobs, object } from '@storybook/addon-knobs';
import notes from './http-requests-dropdown.md';
const COUNTERS = [
{ start_time: '2019-04-01T00:00:00Z', total: 5500 },
{ start_time: '2019-05-01T00:00:00Z', total: 4500 },
{ start_time: '2019-06-01T00:00:00Z', total: 5000 },
];
storiesOf('HttpRequests/Dropdown/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs())
.add(
`HttpRequestsDropdown`,
() => ({
template: hbs`
<h5 class="title is-5">Http Requests Dropdown</h5>
<HttpRequestsDropdown @counters={{counters}}/>
`,
context: {
counters: object('counters', COUNTERS),
},
}),
{ notes }
);

33
ui/stories/select.md Normal file
View File

@ -0,0 +1,33 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/select.js. To make changes, first edit that file and run "yarn gen-story-md select" to re-generate the content.-->
## Select
Select components are used to render a dropdown.
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| label | <code>String</code> | <code></code> | The label for the select element. |
| options | <code>Array</code> | <code></code> | A list of items that the user will select from. This can be an array of strings or objects. |
| [name] | <code>String</code> | <code></code> | The name of the select, used for the test selector. |
| [selectedValue] | <code>String</code> | <code></code> | The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s. |
| [valueAttribute] | <code>String</code> | <code>value</code> | When `options` is an array objects, the key to check for when assigning the option elements value. |
| [labelAttribute] | <code>String</code> | <code>label</code> | When `options` is an array objects, the key to check for when assigning the option elements' inner text. |
| [isInline] | <code>Bool</code> | <code>false</code> | Whether or not the select should be displayed as inline-block or block. |
| [isFullwidth] | <code>Bool</code> | <code>false</code> | Whether or not the select should take up the full width of the parent element. |
| onChange | <code>Func</code> | <code></code> | The action to take once the user has selected an item. This method will be passed the `value` of the select. |
**Example**
```js
<Select
@label='Date Range'
@options={{[{ value: 'berry', label: 'Berry' }]}}
@onChange={{onChange}}/>
```
**See**
- [Uses of Select](https://github.com/hashicorp/vault/search?l=Handlebars&q=Select+OR+select)
- [Select Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/select.js)
---

View File

@ -0,0 +1,65 @@
/* eslint-disable import/extensions */
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import { withKnobs, object, text, boolean, select } from '@storybook/addon-knobs';
import notes from './select.md';
const OPTIONS = [
{ value: 'mon', label: 'Monday', spanish: 'lunes' },
{ value: 'tues', label: 'Tuesday', spanish: 'martes' },
{ value: 'weds', label: 'Wednesday', spanish: 'miercoles' },
];
storiesOf('Select/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs())
.add(
`Select`,
() => ({
template: hbs`
<h5 class="title is-5">Select</h5>
<Select
@options={{options}}
@label={{label}}
@isInline={{isInline}}
@isFullwidth={{isFullwidth}}
@valueAttribute={{valueAttribute}}
@labelAttribute={{labelAttribute}}
@selectedValue={{selectedValue}}
/>
`,
context: {
options: object('options', OPTIONS),
label: text('label', 'Day of the week'),
isFullwidth: boolean('isFullwidth', false),
isInline: boolean('isInline', false),
valueAttribute: select('valueAttribute', Object.keys(OPTIONS[0]), 'value'),
labelAttribute: select('labelAttribute', Object.keys(OPTIONS[0]), 'label'),
selectedValue: select('selectedValue', OPTIONS.map(o => o.label), 'tues'),
},
}),
{ notes }
)
.add(
`Select in a Toolbar`,
() => ({
template: hbs`
<h5 class="title is-5">Select</h5>
<Toolbar>
<Select
@options={{options}}
@label={{label}}
@valueAttribute={{valueAttribute}}
@labelAttribute={{labelAttribute}}
@isInline={{true}}/>
</Toolbar>
`,
context: {
label: text('label', 'Day of the week'),
options: object('options', OPTIONS),
valueAttribute: select('valueAttribute', Object.keys(OPTIONS[0]), 'value'),
labelAttribute: select('labelAttribute', Object.keys(OPTIONS[0]), 'label'),
},
}),
{ notes }
);

View File

@ -18,8 +18,8 @@ module('Acceptance | settings/configure/secrets/pki/crl', function(hooks) {
await page.visit({ backend: path, section: 'crl' });
assert.equal(currentRouteName(), 'vault.cluster.settings.configure-secret-backend.section');
await page.form.fillInField('time', 3);
await page.form.fillInField('unit', 'h');
await page.form.fillInValue(3);
await page.form.fillInUnit('h');
await page.form.submit();
assert.equal(page.lastMessage, 'The crl config for this backend has been updated.');
});

View File

@ -21,8 +21,10 @@ module('Acceptance | settings/mount-secret-backend', function(hooks) {
const maxTTLSeconds = maxTTLHours * 60 * 60;
await page.visit();
assert.equal(currentRouteName(), 'vault.cluster.settings.mount-secret-backend');
await page.selectType('kv');
await page
.next()
.path(path)
@ -32,6 +34,7 @@ module('Acceptance | settings/mount-secret-backend', function(hooks) {
.maxTTLVal(maxTTLHours)
.maxTTLUnit('h')
.submit();
await configPage.visit({ backend: path });
assert.equal(configPage.defaultTTL, defaultTTLSeconds, 'shows the proper TTL');
assert.equal(configPage.maxTTL, maxTTLSeconds, 'shows the proper max TTL');

View File

@ -21,7 +21,7 @@ module('Integration | Component | http-requests-container', function(hooks) {
await render(hbs`<HttpRequestsContainer @counters={{counters}}/>`);
assert.dom('.http-requests-container').exists();
assert.dom('.http-requests-dropdown').exists();
assert.dom('.select').exists();
assert.dom('.http-requests-bar-chart-container').exists();
assert.dom('.http-requests-table').exists();
});
@ -43,7 +43,7 @@ module('Integration | Component | http-requests-container', function(hooks) {
test('it filters the data according to the dropdown', async function(assert) {
await render(hbs`<HttpRequestsContainer @counters={{counters}}/>`);
await fillIn('[data-test-timewindow-select]', '2018');
await fillIn('[data-test-select="requests-timewindow"]', '2018');
assert.dom('.shadow-bars> .bar').exists({ count: 1 }, 'filters the bar chart to the selected year');
assert.dom('.start-time').exists({ count: 1 }, 'filters the table to the selected year');

View File

@ -1,30 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
const COUNTERS = [
{ start_time: '2018-04-01T00:00:00Z', total: 5500 },
{ start_time: '2019-05-01T00:00:00Z', total: 4500 },
{ start_time: '2019-06-01T00:00:00Z', total: 5000 },
];
module('Integration | Component | http-requests-dropdown', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('counters', COUNTERS);
});
test('it renders with options', async function(assert) {
await render(hbs`<HttpRequestsDropdown @counters={{counters}} />`);
assert.dom('[data-test-date-range]').hasValue('All', 'shows all data by default');
assert.equal(
this.element.querySelector('[data-test-date-range]').options.length,
4,
'it adds an option for each year in the data set'
);
});
});

View File

@ -0,0 +1,77 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
const OPTIONS = ['foo', 'bar', 'baz'];
const LABEL = 'Boop';
module('Integration | Component | Select', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('options', OPTIONS);
this.set('label', LABEL);
this.set('name', 'foo');
});
test('it renders', async function(assert) {
await render(hbs`<Select @options={{options}} @label={{label}} @name={{name}}/>`);
assert.dom('[data-test-select-label]').hasText('Boop');
assert.dom('[data-test-select="foo"]').exists();
});
test('it renders when options is an array of strings', async function(assert) {
await render(hbs`<Select @options={{options}} @label={{label}} @name={{name}}/>`);
assert.dom('[data-test-select="foo"]').hasValue('foo');
assert.equal(this.element.querySelector('[data-test-select="foo"]').options.length, 3);
});
test('it renders when options is an array of objects', async function(assert) {
const objectOptions = [{ value: 'berry', label: 'Berry' }, { value: 'cherry', label: 'Cherry' }];
this.set('options', objectOptions);
await render(hbs`<Select @options={{options}} @label={{label}} @name={{name}}/>`);
assert.dom('[data-test-select="foo"]').hasValue('berry');
assert.equal(this.element.querySelector('[data-test-select="foo"]').options.length, 2);
});
test('it renders when options is an array of custom objects', async function(assert) {
const objectOptions = [{ day: 'mon', fullDay: 'Monday' }, { day: 'tues', fullDay: 'Tuesday' }];
const selectedValue = objectOptions[1].day;
this.setProperties({
options: objectOptions,
valueAttribute: 'day',
labelAttribute: 'fullDay',
selectedValue: selectedValue,
});
await render(
hbs`
<Select
@options={{options}}
@label={{label}}
@name={{name}}
@valueAttribute={{valueAttribute}}
@labelAttribute={{labelAttribute}}
@selectedValue={{selectedValue}}/>`
);
assert.dom('[data-test-select="foo"]').hasValue('tues', 'sets selectedValue by default');
assert.equal(
this.element.querySelector('[data-test-select="foo"]').options[1].textContent.trim(),
'Tuesday',
'uses the labelAttribute to determine the label'
);
});
test('it calls onChange when an item is selected', async function(assert) {
this.set('onChange', sinon.spy());
await render(hbs`<Select @options={{options}} @name={{name}} @onChange={{onChange}}/>`);
await fillIn('[data-test-select="foo"]', 'bar');
assert.ok(this.onChange.calledOnce);
});
});

View File

@ -40,12 +40,12 @@ module('Integration | Component | wrap ttl', function(hooks) {
await fillIn('[data-test-wrap-ttl-picker] input', '20');
assert.equal(this.lastOnChangeCall, '20m', 'calls onChange correctly on time input');
await fillIn('#unit', 'h');
await blur('#unit');
await fillIn('[data-test-select="ttl-unit"]', 'h');
await blur('[data-test-select="ttl-unit"]');
assert.equal(this.lastOnChangeCall, '20h', 'calls onChange correctly on unit change');
await fillIn('#unit', 'd');
await blur('#unit');
await fillIn('[data-test-select="ttl-unit"]', 'd');
await blur('[data-test-select="ttl-unit"]');
assert.equal(this.lastOnChangeCall, '480h', 'converts days to hours correctly');
});
});

View File

@ -5,7 +5,7 @@ export default {
name: text(),
link: clickable('[data-test-auth-method-link]'),
}),
selectMethod: fillable('[data-test-method-select]'),
selectMethod: fillable('[data-test-select=auth-method]'),
username: fillable('[data-test-username]'),
token: fillable('[data-test-token]'),
tokenValue: value('[data-test-token]'),

View File

@ -9,5 +9,6 @@ export default {
hasTitle: isPresent('[data-test-title]'),
hasError: isPresent('[data-test-error]'),
submit: clickable('[data-test-submit]'),
fillInField: fillable('[data-test-field]'),
fillInValue: fillable('[data-test-ttl-value]'),
fillInUnit: fillable('[data-test-select="ttl-unit"]'),
};

View File

@ -12,11 +12,11 @@ export default create({
certificate: text('[data-test-row-value="Certificate"]'),
toggleOptions: clickable('[data-test-toggle-group]'),
hasCert: isPresent('[data-test-row-value="Certificate"]'),
fillInField: fillable('[data-test-field]'),
fillInField: fillable('[data-test-select="ttl-unit"]'),
issueCert: async function(commonName) {
await this.commonName(commonName)
.toggleOptions()
.fillInField('unit', 'h')
.fillInField('h')
.submit();
},
@ -24,7 +24,7 @@ export default create({
return this.csr(csr)
.commonName(commonName)
.toggleOptions()
.fillInField('unit', 'h')
.fillInField('h')
.submit();
},
});

View File

@ -6,9 +6,9 @@ export default create({
...mountForm,
version: fillable('[data-test-input="options.version"]'),
maxTTLVal: fillable('[data-test-input="config.maxLeaseTtl"] [data-test-ttl-value]'),
maxTTLUnit: fillable('[data-test-input="config.maxLeaseTtl"] [data-test-ttl-unit]'),
maxTTLUnit: fillable('[data-test-input="config.maxLeaseTtl"] [data-test-select="ttl-unit"]'),
defaultTTLVal: fillable('[data-test-input="config.defaultLeaseTtl"] [data-test-ttl-value]'),
defaultTTLUnit: fillable('[data-test-input="config.defaultLeaseTtl"] [data-test-ttl-unit]'),
defaultTTLUnit: fillable('[data-test-input="config.defaultLeaseTtl"] [data-test-select="ttl-unit"]'),
enable: async function(type, path) {
await this.visit();
await this.mount(type, path);