UI: OIDC config cleanup (#17105)

* cleanup infotableitemarray, add render name option to component

* wait until items fetched before rendering child component

* update test

* finish tests for info table item array

* remove unused capability checks

* remove unnecessary path alias

* fix info table row arg

* fix wildcards getting info tooltip
This commit is contained in:
claire bontempo 2022-09-13 09:06:19 -07:00 committed by GitHub
parent ed0a9feb7f
commit fcf6467cbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 262 additions and 197 deletions

View File

@ -29,11 +29,6 @@ export default class OidcAssignmentModel extends Model {
// CAPABILITIES
@lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath;
@lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath;
get canCreate() {
return this.assignmentPath.get('canCreate');
}
get canRead() {
return this.assignmentPath.get('canRead');
}
@ -43,17 +38,4 @@ export default class OidcAssignmentModel extends Model {
get canDelete() {
return this.assignmentPath.get('canDelete');
}
get canList() {
return this.assignmentsPath.get('canList');
}
@lazyCapabilities(apiPath`identity/entity`) entitiesPath;
get canListEntities() {
return this.entitiesPath.get('canList');
}
@lazyCapabilities(apiPath`identity/group`) groupsPath;
get canListGroups() {
return this.groupsPath.get('canList');
}
}

View File

@ -73,45 +73,6 @@ export default class OidcClientModel extends Model {
@attr('string', { label: 'Client ID' }) clientId;
@attr('string') clientSecret;
// CAPABILITIES //
@lazyCapabilities(apiPath`identity/oidc/client/${'name'}`, 'name') clientPath;
@lazyCapabilities(apiPath`identity/oidc/client`) clientsPath;
get canCreate() {
return this.clientPath.get('canCreate');
}
get canRead() {
return this.clientPath.get('canRead');
}
get canEdit() {
return this.clientPath.get('canUpdate');
}
get canDelete() {
return this.clientPath.get('canDelete');
}
get canList() {
return this.clientsPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/key`) keysPath;
get canListKeys() {
return this.keysPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath;
@lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath;
get canCreateAssignments() {
return this.assignmentPath.get('canCreate');
}
get canListAssignments() {
return this.assignmentsPath.get('canList');
}
// API WIP
@lazyCapabilities(apiPath`identity/oidc/${'name'}/provider`, 'backend', 'name') clientProvidersPath;
get canListProviders() {
return this.clientProvidersPath.get('canList');
}
// TODO refactor when field-to-attrs util is refactored as decorator
_attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
get formFields() {
@ -131,4 +92,16 @@ export default class OidcClientModel extends Model {
}
return this._fieldToAttrsGroups;
}
// CAPABILITIES //
@lazyCapabilities(apiPath`identity/oidc/client/${'name'}`, 'name') clientPath;
get canRead() {
return this.clientPath.get('canRead');
}
get canEdit() {
return this.clientPath.get('canUpdate');
}
get canDelete() {
return this.clientPath.get('canDelete');
}
}

View File

@ -42,10 +42,6 @@ export default class OidcKeyModel extends Model {
@lazyCapabilities(apiPath`identity/oidc/key/${'name'}`, 'name') keyPath;
@lazyCapabilities(apiPath`identity/oidc/key/${'name'}/rotate`, 'name') rotatePath;
@lazyCapabilities(apiPath`identity/oidc/key`) keysPath;
get canCreate() {
return this.keyPath.get('canCreate');
}
get canRead() {
return this.keyPath.get('canRead');
}
@ -58,7 +54,4 @@ export default class OidcKeyModel extends Model {
get canDelete() {
return this.keyPath.get('canDelete');
}
get canList() {
return this.keysPath.get('canList');
}
}

View File

@ -45,11 +45,8 @@ export default class OidcProviderModel extends Model {
}
return this._attributeMeta;
}
@lazyCapabilities(apiPath`identity/oidc/provider/${'name'}`, 'name') providerPath;
@lazyCapabilities(apiPath`identity/oidc/provider`) providersPath;
get canCreate() {
return this.providerPath.get('canCreate');
}
get canRead() {
return this.providerPath.get('canRead');
}
@ -59,16 +56,4 @@ export default class OidcProviderModel extends Model {
get canDelete() {
return this.providerPath.get('canDelete');
}
get canList() {
return this.providersPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/client`) clientsPath;
get canListClients() {
return this.clientsPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath;
get canListScopes() {
return this.scopesPath.get('canList');
}
}

View File

@ -23,10 +23,6 @@ export default class OidcScopeModel extends Model {
}
@lazyCapabilities(apiPath`identity/oidc/scope/${'name'}`, 'name') scopePath;
@lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath;
get canCreate() {
return this.scopePath.get('canCreate');
}
get canRead() {
return this.scopePath.get('canRead');
}
@ -36,7 +32,4 @@ export default class OidcScopeModel extends Model {
get canDelete() {
return this.scopePath.get('canDelete');
}
get canList() {
return this.scopesPath.get('canList');
}
}

View File

@ -26,7 +26,7 @@
<LinkTo
@route="vault.cluster.access.oidc.providers.provider.details"
@model={{provider.name}}
@disabled={{not provider.canRead}}
@disabled={{eq provider.canRead false}}
data-test-oidc-provider-menu-link="details"
>
Details

View File

@ -66,7 +66,8 @@
@value={{@model.entityIds}}
@model={{@model}}
@isLink={{true}}
@modelType="oidc/assignment"
@renderItemName={{true}}
@modelType="identity/entity"
@itemRoute={{(array "vault.cluster.access.identity.show" "entities" "details")}}
@alwaysRender={{true}}
@toggleViewAll={{true}}
@ -77,7 +78,8 @@
@value={{@model.groupIds}}
@model={{@model}}
@isLink={{true}}
@modelType="oidc/assignment"
@renderItemName={{true}}
@modelType="identity/group"
@itemRoute={{(array "vault.cluster.access.identity.show" "groups" "details")}}
@alwaysRender={{true}}
@doNotTruncate={{true}}

View File

@ -1,72 +1,68 @@
{{! the class linkable-item is needed for the read-more component }}
<div data-test-info-table-item-array {{did-insert (perform this.fetchOptions)}} class="linkable-item">
<div data-test-info-table-item-array {{did-insert this.fetchOptions}} class="linkable-item">
{{#if @isLink}}
<div data-test-row-value={{@label}}>
<ReadMore>
{{#each this.displayArrayTruncated as |name|}}
{{#if (is-wildcard-string name)}}
{{#let (filter-wildcard name this.allOptions) as |wildcardCount|}}
<span>{{name}}</span>
<span class="tag is-light has-text-grey-dark" data-test-count="{{if wildcardCount wildcardCount 0}}">
includes
{{if wildcardCount wildcardCount 0}}
{{if (eq wildcardCount 1) @wildcardLabel (pluralize @wildcardLabel)}}
</span>
{{#if (eq this.displayArrayTruncated.lastObject name)}}
<LinkTo @route={{this.rootRoute}} @query={{hash tab=@queryParam}}>
<span data-test-view-all={{lowercase @label}}>View all {{lowercase @label}}.</span>
</LinkTo>
{{/if}}
{{/let}}
{{else}}
{{#if (is-array this.itemRoute)}}
<LinkTo
@route={{(get this.itemRoute "0")}}
@models={{array (get this.itemRoute "1") name (get this.itemRoute "2")}}
>
{{name}}
</LinkTo>
{{#if this.fetchComplete}}
<ReadMore>
{{#each this.displayArrayTruncated as |item|}}
{{#if (is-wildcard-string item)}}
{{#let (filter-wildcard item this.allOptions) as |wildcardCount|}}
<span>{{item}}</span>
<span class="tag is-light has-text-grey-dark" data-test-count={{wildcardCount}}>
{{if (not-eq wildcardCount undefined) (concat "includes " wildcardCount)}}
{{if (eq wildcardCount 1) @wildcardLabel (pluralize @wildcardLabel)}}
</span>
{{#if (eq this.displayArrayTruncated.lastObject item)}}
<LinkTo @route={{this.rootRoute}} @query={{hash tab=@queryParam}}>
<span data-test-view-all={{lowercase @label}}>View all {{lowercase @label}}.</span>
</LinkTo>
{{/if}}
{{/let}}
{{else}}
<LinkTo
@route={{this.itemRoute}}
@model={{if @queryParam (concat @queryParam "/" name) name}}
data-test-item={{name}}
>
<span>{{name}}</span>
</LinkTo>
{{/if}}
{{/if}}
{{#if
(or
(and (not-eq name this.displayArrayTruncated.lastObject) this.wildcardInDisplayArray)
(not-eq name this.displayArrayTruncated.lastObject)
)
}}
,&nbsp;
{{/if}}
{{#unless this.doNotTruncate}}
{{#if (and (eq name this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}}
{{! dec is a math helper that decrements by 5 the length of the array ex: 11-5 = "and 6 others."}}
<span data-test-and={{dec 5 @displayArray.length}}>
&nbsp;and
{{dec 5 @displayArray.length}}
others.&nbsp;
</span>
{{/if}}
{{#if (and (eq name this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}}
{{#if (is-array @rootRoute)}}
<LinkTo @route={{(get @rootRoute "0")}} @model={{(get @rootRoute "1")}}>
<span data-test-view-all={{lowercase @label}}>View all {{lowercase @label}}.</span>
{{#if (is-array this.itemRoute)}}
<LinkTo
@route={{(get this.itemRoute "0")}}
@models={{array (get this.itemRoute "1") item (get this.itemRoute "2")}}
>
{{or (get this.itemNameById item) item}}
</LinkTo>
{{else}}
<LinkTo @route={{this.rootRoute}} @query={{hash tab=@queryParam}}>
<span data-test-view-all={{lowercase @label}}>View all {{lowercase @label}}.</span>
<LinkTo
@route={{this.itemRoute}}
@model={{if @queryParam (concat @queryParam "/" item) item}}
data-test-item={{item}}
>
<span>{{or (get this.itemNameById item) item}}</span>
</LinkTo>
{{/if}}
{{/if}}
{{/unless}}
{{/each}}
</ReadMore>
{{#if (not-eq item this.displayArrayTruncated.lastObject)}}
,&nbsp;
{{/if}}
{{#unless this.doNotTruncate}}
{{#if (and (eq item this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}}
{{! dec is a math helper that decrements by 5 the length of the array ex: 11-5 = "and 6 others."}}
<span data-test-and={{dec 5 @displayArray.length}}>
&nbsp;and
{{dec 5 @displayArray.length}}
others.&nbsp;
</span>
{{/if}}
{{#if (and (eq item this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}}
{{#if (is-array @rootRoute)}}
<LinkTo @route={{(get @rootRoute "0")}} @model={{(get @rootRoute "1")}}>
<span data-test-view-all={{lowercase @label}}>View all {{lowercase @label}}.</span>
</LinkTo>
{{else}}
<LinkTo @route={{this.rootRoute}} @query={{hash tab=@queryParam}}>
<span data-test-view-all={{lowercase @label}}>View all {{lowercase @label}}.</span>
</LinkTo>
{{/if}}
{{/if}}
{{/unless}}
{{/each}}
</ReadMore>
{{/if}}
</div>
{{else}}
<code class="is-word-break has-text-black" data-test-row-value={{@label}}>

View File

@ -1,8 +1,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { isWildcardString } from 'vault/helpers/is-wildcard-string';
import { action } from '@ember/object';
/**
* @module InfoTableItemArray
@ -30,15 +29,17 @@ import { isWildcardString } from 'vault/helpers/is-wildcard-string';
* @param {boolean} [isLink] - Indicates if the item should contain a link-to component. Only setup for arrays, but this could be changed if needed.
* @param {string || array} [rootRoute="vault.cluster.secrets.backend.list-root"] - Tells what route the link should go to when selecting "view all". If the route requires more than one dynamic param, insert an array.
* @param {string || array} [itemRoute=vault.cluster.secrets.backend.show] - Tells what route the link should go to when selecting the individual item. If the route requires more than one dynamic param, insert an array.
* @param {string} [modelType] - Tells which model you want data for the allOptions to be returned from. Used in conjunction with the the isLink.
* @param {string} [modelType] - Tells which model you want to query and set allOptions. Used in conjunction with the the isLink.
* @param {string} [wildcardLabel] - when you want the component to return a count on the model for options returned when using a wildcard you must provide a label of the count e.g. role. Should be singular.
* @param {string} [backend] - To specify which backend to point the link to.
* @param {boolean} [doNotTruncate=false] - Determines whether to show the View all "roles" link.
* @param {boolean} [doNotTruncate=false] - Determines whether to show the View all "roles" link. Otherwise uses the ReadMore component's "See More" toggle
* @param {boolean} [renderItemName=false] - If true renders the item name instead of its id
*/
export default class InfoTableItemArray extends Component {
@tracked allOptions = null;
@tracked wildcardInDisplayArray = false;
@service store;
@tracked allOptions = null;
@tracked itemNameById; // object is only created if renderItemName=true
@tracked fetchComplete = false;
get rootRoute() {
return this.args.rootRoute || 'vault.cluster.secrets.backend.list-root';
@ -62,29 +63,26 @@ export default class InfoTableItemArray extends Component {
return displayArray;
}
async checkWildcardInArray() {
if (!this.args.displayArray) {
return;
}
let filteredArray = await this.args.displayArray.filter((item) => isWildcardString([item]));
this.wildcardInDisplayArray = filteredArray.length > 0 ? true : false;
}
@task *fetchOptions() {
@action async fetchOptions() {
if (this.args.isLink && this.args.modelType) {
let queryOptions = {};
let queryOptions = this.args.backend ? { backend: this.args.backend } : {};
if (this.args.backend) {
queryOptions = { backend: this.args.backend };
let modelRecords = await this.store.query(this.args.modelType, queryOptions).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
return null;
}
});
this.allOptions = modelRecords ? modelRecords.mapBy('id') : null;
if (this.args.renderItemName && modelRecords) {
modelRecords.forEach(({ id, name }) => {
// create key/value pair { item-id: item-name } for each record
this.itemNameById = { ...this.itemNameById, [id]: name };
});
}
let options = yield this.store.query(this.args.modelType, queryOptions);
this.formatOptions(options);
}
this.checkWildcardInArray();
}
formatOptions(options) {
this.allOptions = options.mapBy('id');
this.fetchComplete = true;
}
}

View File

@ -74,6 +74,7 @@
@rootRoute={{@rootRoute}}
@itemRoute={{@itemRoute}}
@doNotTruncate={{@doNotTruncate}}
@renderItemName={{@renderItemName}}
/>
{{else}}
{{#if @tooltipText}}

View File

@ -6,6 +6,7 @@ import { singularize } from 'ember-inflector';
import { resolve } from 'rsvp';
import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-utils';
import layout from '../templates/components/search-select';
import { isWildcardString } from 'vault/helpers/is-wildcard-string';
/**
* @module SearchSelect
@ -87,8 +88,9 @@ export default Component.extend({
this.set('allOptions', allOptions); // used by filter-wildcard helper
let formattedOptions = this.selectedOptions.map((option) => {
let matchingOption = options.findBy(this.idKey, option);
// an undefined matchingOption means a selectedOption, on edit, didn't match a model returned from the query and therefore doesn't exist
let addTooltip = matchingOption ? false : true; // add tooltip to let user know the selection can be discarded
// an undefined matchingOption means a selectedOption, on edit, didn't match a model returned from the query
// this means it is a wildcard string or no longer exists
let addTooltip = matchingOption || isWildcardString([option]) ? false : true; // add tooltip to let user know the selection can be discarded
options.removeObject(matchingOption);
return {
id: option,

View File

@ -76,7 +76,7 @@
<InfoTooltip>
The item with this
{{to-label this.idKey}}
no longer exists and can safely be removed.
no longer exists.
</InfoTooltip>
{{/if}}
<button

View File

@ -233,7 +233,7 @@ module('Acceptance | oidc-config clients and assignments', function (hooks) {
// assert default values in assignment details view are correct
assert.dom('[data-test-value-div="Name"]').hasText('test-assignment');
assert.dom('[data-test-value-div="Entities"]').hasText('1234-12345', 'shows the entity id.');
assert.dom('[data-test-value-div="Entities"]').hasText('test-entity', 'shows the entity name.');
// edit assignment
await click(SELECTORS.assignmentEditButton);
@ -252,8 +252,8 @@ module('Acceptance | oidc-config clients and assignments', function (hooks) {
'renders success flash upon updating the assignment'
);
assert.dom('[data-test-value-div="Entities"]').hasText('1234-12345', 'it still shows the entity id.');
assert.dom('[data-test-value-div="Groups"]').hasText('abcdef-123', 'shows updated group name id.');
assert.dom('[data-test-value-div="Entities"]').hasText('test-entity', 'it still shows the entity name.');
assert.dom('[data-test-value-div="Groups"]').hasText('test-group', 'shows updated group name id.');
// delete the assignment
await click(SELECTORS.assignmentDeleteButton);

View File

@ -1,23 +1,42 @@
import { module, test } from 'qunit';
import Service from '@ember/service';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { findAll, render } from '@ember/test-helpers';
import { run } from '@ember/runloop';
import hbs from 'htmlbars-inline-precompile';
const DISPLAY_ARRAY = ['role-1', 'role-2', 'role-3', 'role-4', 'role-5'];
const storeService = Service.extend({
query() {
return new Promise((resolve) => {
resolve([
{ id: 'role-1' },
{ id: 'role-2' },
{ id: 'role-3' },
{ id: 'role-4' },
{ id: 'role-5' },
{ id: 'role-6' },
]);
query(modelType) {
return new Promise((resolve, reject) => {
switch (modelType) {
case 'transform/role':
resolve([
{ id: 'role-1' },
{ id: 'role-2' },
{ id: 'role-3' },
{ id: 'role-4' },
{ id: 'role-5' },
{ id: 'role-6' },
]);
break;
case 'model/no-permission':
reject({ httpStatus: 403, message: 'permission denied' });
break;
case 'identity/entity':
resolve([
{ id: '1', name: 'one' },
{ id: '6', name: 'six' },
{ id: '7', name: 'seven' },
{ id: '8', name: 'eight' },
{ id: '9', name: 'nine' },
]);
break;
default:
reject({ httpStatus: 404, message: 'not found' });
break;
}
});
},
});
@ -128,4 +147,123 @@ module('Integration | Component | InfoTableItemArray', function (hooks) {
.exists('correctly counts with wildcard filter and shows the count');
assert.dom('[data-test-view-all="roles"]').hasText('View all roles.', 'renders correct view all text');
});
test('it fails gracefully if query returns 403 and display array contains wildcard', async function (assert) {
const displayArrayWithWildcard = [
'role-1',
'role-2',
'role-3',
'r*',
'role-4',
'role-5',
'role-6',
'role-7',
'role-8',
'role-9',
'role-10',
];
this.set('displayArrayWithWildcard', displayArrayWithWildcard);
this.set('modelType', 'model/no-permission');
await render(hbs`
<InfoTableItemArray
@label={{this.label}}
@displayArray={{this.displayArrayWithWildcard}}
@isLink={{this.isLink}}
@modelType={{this.modelType}}
@queryParam={{this.queryParam}}
@backend={{this.backend}}
/>`);
assert.equal(findAll('[data-test-item]').length, 4, 'lists 4 roles');
assert.dom('[data-test-readmore-content]').hasTextContaining('r*', 'renders wildcard');
assert.dom('[data-test-count="0"]').doesNotExist('does not render badge');
assert.dom('[data-test-view-all="roles"]').hasText('View all roles.', 'renders correct view all text');
assert.dom('[data-test-and="6"]').exists(`renders correct 'and 6 others' text`);
});
test('it fails gracefully if query returns 404 and display array contains wildcard', async function (assert) {
const displayArrayWithWildcard = [
'role-1',
'role-2',
'role-3',
'r*',
'role-4',
'role-5',
'role-6',
'role-7',
'role-8',
'role-9',
'role-10',
];
this.set('displayArrayWithWildcard', displayArrayWithWildcard);
this.set('modelType', 'model-not-found');
await render(hbs`
<InfoTableItemArray
@label={{this.label}}
@displayArray={{this.displayArrayWithWildcard}}
@isLink={{this.isLink}}
@modelType={{this.modelType}}
@queryParam={{this.queryParam}}
@backend={{this.backend}}
/>`);
assert.dom('[data-test-count="0"]').hasText('includes 0', 'renders badge');
assert.equal(findAll('[data-test-item]').length, 4, 'renders list of 4 roles');
assert.dom('[data-test-view-all="roles"]').hasText('View all roles.', 'renders view all text');
});
test('it renders name if renderItemName=true or id if name not found', async function (assert) {
const value = ['6', '8', '123-id'];
this.set('value', value);
this.set('modelType', 'identity/entity');
await render(hbs`
<InfoTableItemArray
@label={{this.label}}
@displayArray={{this.value}}
@isLink={{this.isLink}}
@modelType={{this.modelType}}
@renderItemName={{true}}
/>`);
assert.dom('[data-test-item="6"]').hasText('six', `renders name of 'six' instead of id`);
assert.dom('[data-test-item="8"]').hasText('eight', `renders 'eight' instead of id`);
assert.equal(findAll('[data-test-item]').length, 3, 'renders all entities');
assert
.dom('[data-test-item="123-id"]')
.hasText('123-id', 'renders id instead of name if no record for name');
});
test('it truncates and renders name when renderItemName=true', async function (assert) {
const value = ['1', '2', '3-id', '4', '5', '6', '7', '8', '9', '10'];
this.set('value', value);
this.set('modelType', 'identity/entity');
await render(hbs`
<InfoTableItemArray
@label="Entities"
@displayArray={{this.value}}
@isLink={{this.isLink}}
@modelType={{this.modelType}}
@renderItemName={{true}}
/>`);
assert.dom('[data-test-item="1"]').hasText('one', `renders name of 'one' instead of id`);
assert.dom('[data-test-item="3-id"]').hasText('3-id', 'renders id instead of name if no record for name');
assert.equal(findAll('[data-test-item]').length, 5, 'only lists 5 entities');
});
test('it truncates using read more component when overflows div', async function (assert) {
const value = ['1', '2', '3-id', '4', '5', '6', '7', '8', '9', '10'];
this.set('value', value);
this.set('modelType', 'identity/entity');
await render(hbs`
<div style="width: 200px">
<InfoTableItemArray
@label="Entities"
@displayArray={{this.value}}
@isLink={{this.isLink}}
@modelType={{this.modelType}}
@renderItemName={{true}}
@doNotTruncate={{true}}
/>
</div>
`);
assert.dom('[data-test-readmore-toggle]').exists('renders see more toggle');
assert.dom('[data-test-view-all]').doesNotExist('Does not render view all text');
});
});

View File

@ -687,7 +687,7 @@ module('Integration | Component | search select', function (hooks) {
test('it renders an info tooltip beside selection if does not match a record returned from query when passObject=false and idKey=id', async function (assert) {
const models = ['some/model'];
const spy = sinon.spy();
const inputValue = ['model-a-id', 'non-existent-model'];
const inputValue = ['model-a-id', 'non-existent-model', 'wildcard*'];
this.set('models', models);
this.set('onChange', spy);
this.set('inputValue', inputValue);
@ -700,16 +700,18 @@ module('Integration | Component | search select', function (hooks) {
@passObject={{false}}
/>
`);
assert.equal(component.selectedOptions.length, 2, 'there are two selected options');
assert.equal(component.selectedOptions.length, 3, 'there are three selected options');
assert.dom('[data-test-selected-option="0"]').hasText('model-a-id');
assert.dom('[data-test-selected-option="1"]').hasText('non-existent-model');
assert.dom('[data-test-selected-option="2"]').hasText('wildcard*');
assert
.dom('[data-test-selected-option="0"] [data-test-component="info-tooltip"]')
.doesNotExist('does not render info tooltip for model that exists');
assert
.dom('[data-test-selected-option="1"] [data-test-component="info-tooltip"]')
.exists('renders info tooltip for model not returned from query');
assert
.dom('[data-test-selected-option="2"] [data-test-component="info-tooltip"]')
.doesNotExist('does not render info tooltip for wildcard option');
});
});