ui: Create PopoverSelect, CatalogToolbar, and update tests (#7489)

* Create PopoverSelect component and styling

* Create CatalogToolbar component and Styling

* ui: Adds `selectable-key-values` helper (#7472)

Preferably we want all copy/text to live in the template. Whilst you can
achieve what we've done here with a combination of different helpers, as
we will be using this approach in various places it's probably best to
make a helper.

We also hit an ember bug related to using the `let` helper and trying to
access `thingThatWasLet.firstObject` (which can also be worked around
using `object-at`).

Moving everything to a helper 'sorted' everything.

Probably worthwhile noting that if the sort option themselves become
dynamic, I'm not sure if the helper here would actually react as you
would expect (I'm aware that ember helpers on react on the root
arguments, not necesarily sub properties of those arguments). If we get
to that point this helper could take the same approach as what I believe
ember-composable-helpers does to get around this, or move them to the
view controller. If we do ever moved this to the view controller, we
can still use the exported function from the new helper here to keep
using the same functionality and tests we have here.

* Create tests for sorting services with CatalogToolbar

* Add rule to print 'ember/no-global-jquery' as a warning

Co-authored-by: John Cowen <johncowen@users.noreply.github.com>
This commit is contained in:
Kenia 2020-05-11 10:04:27 -04:00 committed by John Cowen
parent 2eb34e07ea
commit 7c203a9220
31 changed files with 434 additions and 31 deletions

View File

@ -16,7 +16,8 @@ module.exports = {
rules: {
'no-unused-vars': ['error', { args: 'none' }],
'ember/no-new-mixins': ['warn'],
'ember/no-jquery': 'warn'
'ember/no-jquery': 'warn',
'ember/no-global-jquery': 'warn'
},
overrides: [
// node files

View File

@ -0,0 +1,10 @@
<form class="catalog-toolbar" data-test-catalog-toolbar>
<FreetextFilter @searchable={{searchable}} @value={{value}} @placeholder="Search" />
<PopoverSelect
data-popover-select
@selected={{selected}}
@options={{options}}
@onchange={{onchange}}
@title='Sort By'
/>
</form>

View File

@ -0,0 +1,5 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
});

View File

@ -1,7 +1,7 @@
{{yield}}
{{#if (gt items.length 0)}}
<ListCollection @cellHeight={{73}} @items={{items}} class="consul-service-list" as |item index|>
<a href={{href-to routeName item.Name}} class={{service/health-checks item}}>
<a data-test-service-name href={{href-to routeName item.Name}} class={{service/health-checks item}}>
{{item.Name}}
</a>
<ul>

View File

@ -0,0 +1,19 @@
<div class="popover-select" ...attributes>
<PopoverMenu @keyboardAccess={{false}}>
<BlockSlot @name="trigger">
<span>
{{selected.value}}
</span>
</BlockSlot>
<BlockSlot @name="menu" as |id send keypressClick change|>
<li role="separator">
{{title}}
</li>
{{#each options as |option|}}
<li role="none" class={{if (eq selected.key option.key) 'is-active'}}>
<button tabindex="-1" role="menuitem" type="button" value={{option.key}} onclick={{action (queue (action 'change' option) change )}}>{{option.value}}</button>
</li>
{{/each}}
</BlockSlot>
</PopoverMenu>
</div>

View File

@ -0,0 +1,19 @@
import Component from '@ember/component';
export default Component.extend({
actions: {
change: function(option, e) {
// We fake an event here, which could be a bit of a footbun if we treat
// it completely like an event, we should be abe to avoid doing this
// when we move to glimmer components (this.args.selected vs this.selected)
this.onchange({
target: {
selected: option,
},
// make this vaguely event like to avoid
// having a separate property
preventDefault: function(e) {},
});
},
},
});

View File

@ -4,6 +4,7 @@ import WithEventSource from 'consul-ui/mixins/with-event-source';
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithEventSource, WithSearching, {
queryParams: {
sortBy: 'sort',
s: {
as: 'filter',
},

View File

@ -0,0 +1,46 @@
import { helper } from '@ember/component/helper';
import { slugify } from 'consul-ui/helpers/slugify';
export const selectableKeyValues = function(params = [], hash = {}) {
let selected;
const items = params.map(function(item, i) {
let key, value;
switch (typeof item) {
case 'string':
key = slugify([item]);
value = item;
break;
default:
if (item.length > 1) {
key = item[0];
value = item[1];
} else {
key = slugify([item[0]]);
value = item[0];
}
break;
}
const kv = {
key: key,
value: value,
};
switch (typeof hash.selected) {
case 'string':
if (hash.selected === item[0]) {
selected = kv;
}
break;
case 'number':
if (hash.selected === i) {
selected = kv;
}
break;
}
return kv;
});
return {
items: items,
selected: typeof selected === 'undefined' ? items[0] : selected,
};
};
export default helper(selectableKeyValues);

View File

@ -48,3 +48,9 @@
display: inline-block;
box-sizing: border-box;
}
%split-button {
@extend %secondary-button;
padding: 0 8px !important;
position: relative;
height: 100%;
}

View File

@ -112,3 +112,23 @@
%internal-button-dangerous:hover {
@extend %internal-button-dangerous-intent;
}
%split-button span::after {
@extend %as-pseudo;
position: absolute;
background-color: $gray-300;
width: 1px;
top: 0;
height: 100%;
margin-left: 8px;
}
%split-button::before {
@extend %as-pseudo;
height: 16px;
}
%sort-button::before {
@extend %with-sort-icon;
margin-top: 8px;
width: 16px;
height: 16px;
}

View File

@ -27,3 +27,28 @@
%more-popover-menu-panel [id$='-']:first-child:checked ~ ul label[for$='-'] + [role='menu'] {
display: block;
}
%popover-menu {
@extend %display-toggle-siblings;
}
%popover-menu + label > * {
@extend %toggle-button;
}
%popover-menu-panel {
@extend %menu-panel;
width: 192px;
}
%popover-menu + label + div {
@extend %popover-menu-panel;
}
%popover-menu-panel:not(.above) {
top: 38px;
}
%popover-menu-panel:not(.left) {
right: 10px;
}
%popover-menu-panel li [role='menu'] {
display: none;
}
%popover-menu-panel [id$='-']:first-child:checked ~ ul label[for$='-'] + [role='menu'] {
display: block;
}

View File

@ -7,3 +7,12 @@
%more-popover-menu + label > * {
font-size: 0;
}
%popover-menu + label > *::after {
@extend %with-chevron-down-icon, %as-pseudo;
width: 16px;
height: 16px;
margin-left: 16px;
}
%popover-menu + label > * {
@extend %split-button, %sort-button;
}

View File

@ -146,6 +146,7 @@ $search-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill
$service-identity-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path d="M6.5 13a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm11-3a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm-4 11a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z" id="a"/></defs><use fill="%239E2159" xlink:href="%23a" fill-rule="evenodd"/></svg>');
$settings-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.488.488 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 16c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4z" fill="%23000"/></svg>');
$source-file-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.714 9.143l3.572 3.571-3.572 3.572-.714-1.4 2.143-2.172L13 10.571l.714-1.428zm-3.571 1.4L8 12.714l2.143 2.143-.714 1.429-3.572-3.572L9.43 9.143l.714 1.4zm8.571 10.028H4.43V3.43h10l4.285 4.285v12.857zM15.143 2H4.429C3.643 2 3 2.643 3 3.429V20.57C3 21.357 3.643 22 4.429 22h14.285c.786 0 1.429-.643 1.429-1.429V7l-5-5z" fill="%23000"/></svg>');
$sort-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M0,10.0867585 L6,10.0867585 L6,8.40563206 L0,8.40563206 L0,10.0867585 L0,10.0867585 Z M3,12.4056321 L3,14.0867585 L0,14.0867585 L0,12.4056321 L3,12.4056321 Z M15.1301377,0 L15.1301377,1.68112641 L0,1.68112641 L0,0 L15.1301377,0 Z M13.8692929,4.62309763 L13.8692929,11.8384922 L16.8112641,8.89802258 L18,10.0867585 L13.0287297,15.0580288 L8.05745938,10.0867585 L9.24619526,8.89802258 L12.1881665,11.8393328 L12.1881665,4.62309763 L13.8692929,4.62309763 Z M10.0867585,4.20281603 L10.0867585,5.88394244 L0,5.88394244 L0,4.20281603 L10.0867585,4.20281603 Z" fill="%23000"/></svg>');
$star-fill-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27z" fill="%23000"/></svg>');
$star-outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z" fill="%23000"/></svg>');
$star-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="10" height="9" viewBox="0 0 10 9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M5 7.196L7.575 8.75l-.683-2.93 2.275-1.97-2.996-.254L5 .833 3.83 3.596.832 3.85l2.275 1.97-.683 2.93z"/></defs><use fill="%239E2159" xlink:href="%23a" fill-rule="evenodd"/></svg>');

View File

@ -1468,6 +1468,16 @@
mask-image: $source-file-svg;
}
%with-sort-icon {
@extend %with-icon;
background-image: $sort-svg;
}
%with-sort-mask {
@extend %with-mask;
-webkit-mask-image: $sort-svg;
mask-image: $sort-svg;
}
%with-star-fill-icon {
@extend %with-icon;
background-image: $star-fill-svg;

View File

@ -3,6 +3,12 @@
.filter-bar {
@extend %filter-bar;
}
.catalog-toolbar {
@extend %catalog-toolbar;
}
%catalog-toolbar {
@extend %filter-bar;
}
%filter-bar [role='radiogroup'] {
@extend %expanded-single-select;
}

View File

@ -7,19 +7,36 @@
%filter-bar + :not(.notice) {
margin-top: 1.8em;
}
%catalog-toolbar {
padding: 4px 8px;
display: flex;
margin-top: 0 !important;
margin-bottom: -12px !important;
border-bottom: 1px solid $gray-200;
}
@media #{$--horizontal-filters} {
%filter-bar {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
}
%catalog-toolbar {
flex-direction: row;
}
%filter-bar > *:first-child {
margin-left: 12px;
}
%catalog-toolbar > *:first-child {
margin-left: 0px;
}
%filter-bar fieldset {
min-width: 210px;
width: auto;
}
%catalog-toolbar fieldset {
min-width: none;
width: 100%;
}
}
@media #{$--lt-horizontal-filters} {
%filter-bar > *:first-child {

View File

@ -3,6 +3,9 @@
border: $decor-border-100;
border-radius: $decor-radius-100;
}
%catalog-toolbar > div {
border: none;
}
// TODO: Move this elsewhere
@media #{$--horizontal-selects} {
%filter-bar label:not(:last-child) {

View File

@ -34,6 +34,7 @@
@import './grid-collection';
@import './consul-service-list';
@import './consul-service-instance-list';
@import './popover-select';
/**/

View File

@ -0,0 +1,15 @@
.popover-select {
@extend %popover-select;
}
%popover-select > [type='checkbox'] {
@extend %popover-menu;
}
%popover-select {
position: relative;
z-index: 3;
padding-left: 12px;
height: 100%;
}
%popover-select label {
border-right: none !important;
}

View File

@ -1,26 +1,43 @@
{{title 'Services'}}
<AppView @class="service list">
<BlockSlot @name="header">
<h1>
Services <em>{{format-number services.length}} total</em>
</h1>
<label for="toolbar-toggle"></label>
</BlockSlot>
<BlockSlot @name="toolbar">
{{#if (gt items.length 0) }}
<PhraseEditor @placeholder="service:name tag:name status:critical search-term" @value={{slice 0 terms.length terms}} @onchange={{action (mut terms) value='target.value'}} @searchable={{searchable}} />
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
{{#let (selectable-key-values
(array "Name:asc" "A to Z")
(array "Name:desc" "Z to A")
selected=sortBy
)
as |sort|
}}
<AppView @class="service list">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}}
</BlockSlot>
<BlockSlot @name="header">
<h1>
Services <em>{{format-number items.length}} total</em>
</h1>
<label for="toolbar-toggle"></label>
</BlockSlot>
<BlockSlot @name="toolbar">
{{#if (gt items.length 0) }}
<CatalogToolbar
@searchable={{searchable}}
@value={{search}}
@selected={{sort.selected}}
@options={{sort.items}}
@onchange={{action (mut sortBy) value='target.selected.key'}}
/>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
<ChangeableSet @dispatcher={{searchable}}>
<BlockSlot @name="set" as |filtered|>
<ConsulServiceList @routeName="dc.services.show" @items={{filtered}} @proxies={{proxies}}/> </BlockSlot>
<ConsulServiceList @routeName="dc.services.show" @items={{sort-by sort.selected.key filtered}} @proxies={{proxies}}/>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no services.
</p>
</BlockSlot>
</ChangeableSet>
</BlockSlot>
</AppView>
</BlockSlot>
</AppView>
{{/let}}

View File

@ -2,17 +2,14 @@
Feature: dc / services / list blocking
Scenario: Viewing the listing pages for service
Given 1 datacenter model with the value "dc-1"
And 6 service models from yaml
And 3 service models from yaml
---
- Name: Service-0
- Name: Service-0-proxy
Kind: 'connect-proxy'
Kind: ~
- Name: Service-1
- Name: Service-1-proxy
Kind: 'connect-proxy'
Kind: ~
- Name: Service-2
- Name: Service-2-proxy
Kind: 'connect-proxy'
Kind: ~
---
And a network latency of 100
When I visit the services page for yaml

View File

@ -0,0 +1,45 @@
@setupApplicationTest
Feature: dc / services / sorting
Scenario:
Given 1 datacenter model with the value "dc-1"
And 6 service models from yaml
---
- Name: Service-A
Kind: ~
- Name: Service-B
Kind: ~
- Name: Service-C
Kind: ~
- Name: Service-D
Kind: ~
- Name: Service-E
Kind: ~
- Name: Service-F
Kind: ~
---
When I visit the services page for yaml
---
dc: dc-1
---
When I click selected on the sort
When I click options.1.button on the sort
Then I see name on the services vertically like yaml
---
- Service-F
- Service-E
- Service-D
- Service-C
- Service-B
- Service-A
---
When I click selected on the sort
When I click options.0.button on the sort
Then I see name on the services vertically like yaml
---
- Service-A
- Service-B
- Service-C
- Service-D
- Service-E
- Service-F
---

View File

@ -0,0 +1,10 @@
import steps from '../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from '../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | catalog-toolbar', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<CatalogToolbar />`);
assert.equal(this.element.querySelector('form').length, 1);
// Template block usage:
await render(hbs`
<CatalogToolbar>
template block text
</CatalogToolbar>
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@ -21,6 +21,8 @@ import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup';
import tabgroup from 'consul-ui/tests/lib/page-object/tabgroup';
import freetextFilter from 'consul-ui/tests/pages/components/freetext-filter';
import catalogFilter from 'consul-ui/tests/pages/components/catalog-filter';
import catalogToolbar from 'consul-ui/tests/pages/components/catalog-toolbar';
import popoverSort from 'consul-ui/tests/pages/components/popover-sort';
import aclFilter from 'consul-ui/tests/pages/components/acl-filter';
import intentionFilter from 'consul-ui/tests/pages/components/intention-filter';
import tokenListFactory from 'consul-ui/tests/pages/components/token-list';
@ -75,10 +77,10 @@ export default {
index: create(index(visitable, collection)),
dcs: create(dcs(visitable, clickable, attribute, collection)),
services: create(
services(visitable, clickable, text, attribute, collection, page, catalogFilter, radiogroup)
services(visitable, clickable, text, attribute, collection, page, popoverSort, radiogroup)
),
service: create(
service(visitable, attribute, collection, text, consulIntentionList, catalogFilter, tabgroup)
service(visitable, attribute, collection, text, consulIntentionList, catalogToolbar, tabgroup)
),
instance: create(instance(visitable, attribute, collection, text, tabgroup)),
nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)),

View File

@ -0,0 +1,4 @@
import { triggerable } from 'ember-cli-page-object';
export default {
search: triggerable('keypress', '[name="s"]'),
};

View File

@ -0,0 +1,8 @@
import { clickable, collection } from 'ember-cli-page-object';
export default {
scope: '[data-popover-select]',
selected: clickable('button'),
options: collection('li[role="none"]', {
button: clickable('button'),
}),
};

View File

@ -1,6 +1,6 @@
export default function(visitable, clickable, text, attribute, collection, page, filter) {
export default function(visitable, clickable, text, attribute, collection, page, popoverSort) {
const service = {
name: text('a span:nth-child(2)'),
name: text('[data-test-service-name]'),
service: clickable('a'),
externalSource: attribute('data-test-external-source', '[data-test-external-source]'),
kind: attribute('data-test-kind', '[data-test-kind]'),
@ -12,7 +12,7 @@ export default function(visitable, clickable, text, attribute, collection, page,
name: clickable('a'),
}),
navigation: page.navigation,
filter: filter,
home: clickable('[data-test-home]'),
sort: popoverSort,
};
}

View File

@ -1,4 +1,6 @@
/* eslint no-console: "off" */
import $ from '-jquery';
const notFound = 'Element not found';
const cannotDestructure = "Cannot destructure property 'context'";
const cannotReadContext = "Cannot read property 'context' of undefined";
@ -57,6 +59,40 @@ export default function(scenario, assert, find, currentPage) {
);
});
})
.then('I see $property on the $component vertically like yaml\n$yaml', function(
property,
component,
yaml
) {
const _component = currentPage()[component];
const iterator = new Array(_component.length).fill(true);
assert.ok(iterator.length > 0);
const items = _component.toArray().sort((a, b) => {
return (
$(a.scope)
.get(0)
.getBoundingClientRect().top -
$(b.scope)
.get(0)
.getBoundingClientRect().top
);
});
iterator.forEach(function(item, i, arr) {
const actual = typeof items[i][property] === 'undefined' ? null : items[i][property];
const expected = typeof yaml[i] === 'number' ? yaml[i].toString() : yaml[i];
assert.deepEqual(
actual,
expected,
`Expected to see ${property} on ${component}[${i}] as ${JSON.stringify(
expected
)}, was ${JSON.stringify(actual)}`
);
});
})
.then(['I see $property on the $component'], function(property, component) {
// TODO: Time to work on repetition
// Collection

View File

@ -0,0 +1,34 @@
import { selectableKeyValues } from 'consul-ui/helpers/selectable-key-values';
import { module, test } from 'qunit';
module('Unit | Helper | selectable-key-values', function() {
test('it turns arrays into key values and selects the first item by default', function(assert) {
const actual = selectableKeyValues([['key-1', 'value-1'], ['key-2', 'value-2']]);
assert.equal(actual.items.length, 2);
assert.deepEqual(actual.selected, { key: 'key-1', value: 'value-1' });
});
test('it turns arrays into key values and selects the defined key', function(assert) {
const actual = selectableKeyValues([['key-1', 'value-1'], ['key-2', 'value-2']], {
selected: 'key-2',
});
assert.equal(actual.items.length, 2);
assert.deepEqual(actual.selected, { key: 'key-2', value: 'value-2' });
});
test('it turns arrays into key values and selects the defined index', function(assert) {
const actual = selectableKeyValues([['key-1', 'value-1'], ['key-2', 'value-2']], {
selected: 1,
});
assert.equal(actual.items.length, 2);
assert.deepEqual(actual.selected, { key: 'key-2', value: 'value-2' });
});
test('it turns arrays with only one element into key values and selects the defined index', function(assert) {
const actual = selectableKeyValues([['Value 1'], ['Value 2']], { selected: 1 });
assert.equal(actual.items.length, 2);
assert.deepEqual(actual.selected, { key: 'value-2', value: 'Value 2' });
});
test('it turns strings into key values and selects the defined index', function(assert) {
const actual = selectableKeyValues(['Value 1', 'Value 2'], { selected: 1 });
assert.equal(actual.items.length, 2);
assert.deepEqual(actual.selected, { key: 'value-2', value: 'Value 2' });
});
});