Icu 11748 add free search for targets (#1995)

* refactor: 💡 fix how we access socket_address from status call

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* feat: 🎸 add free search capability to targets list

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* test: 💍 update test asserts to look for hds appl state title

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* refactor: 💡 remove filtering for yes, no, & narrow-results

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* refactor: 💡 add aria-label to dropdown search input

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* refactor: 💡 add to handleInput signature comment

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* refactor: 💡 move buildQuery to client daemon handler

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* refactor: 💡 Add new parser for MQL expressions

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* refactor: 💡 use queryParams for searching

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* fix: 🐛 Fix some MQL expressions not being properly filtered

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* style: 💄 update handleSearchInput comment signature

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748

* Update addons/api/addon/utils/mqlQuery.js

Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
This commit is contained in:
authored and Zhihe Li committed 2024-01-16 10:17:45 -05:00
1 parent 4bf1a88764
commit 221ab1f7bd
14 files changed
+419 -288

No files matched your search

@@ -1,11 +1,16 @@
import { pluralize } from 'ember-inflector';
import { generateMQLExpression } from '../utils/mqlQuery';
/**
* Not all types are yet supported by the client daemon so we'll
* just whitelist the ones we need to start with.
* @type {string[]}
* just whitelist the ones we need to start with. Keys are the resource
* types and the values are the fields that are searchable by the client.
* @type {{session: string[], target: string[]}}
*/
const supportedTypes = ['target', 'session'];
const supportedTypes = {
target: ['id', 'name', 'description', 'address', 'scope_id'],
session: ['id', 'type', 'status', 'endpoint', 'scope_id', 'target_id'],
};
/**
* Handler to sit in front of the API request layer
@@ -18,20 +23,36 @@ const ClientDaemonHandler = {
const { store, data } = context.request;
const { type, query } = data;
const isClientDaemonRunning = await this.ipc.invoke(
'isClientDaemonRunning'
'isClientDaemonRunning',
);
if (!supportedTypes.includes(type) || !isClientDaemonRunning) {
if (
!Object.keys(supportedTypes).includes(type) ||
!isClientDaemonRunning
) {
return next(context.request);
}
// TODO: Remove usages of recursive from callers and scope_id since
// all calls to daemon are recursive, scope_id can be part of search query instead
// eslint-disable-next-line no-unused-vars
let { recursive, scope_id, ...modifiedQuery } = query;
let { recursive, scope_id, ...remainingQuery } = query;
let searchQuery = '';
if (remainingQuery.query) {
const { search, filters } = remainingQuery.query;
searchQuery = generateMQLExpression({
search: {
text: search,
fields: supportedTypes[type],
},
filters,
});
}
const auth_token_id = this.session.data?.authenticated?.id;
modifiedQuery = {
...modifiedQuery,
remainingQuery = {
...remainingQuery,
query: searchQuery,
auth_token_id,
resource: pluralize(type),
};
@@ -40,7 +61,7 @@ const ClientDaemonHandler = {
// e.g. { targets: [...] } or { sessions: [..] }
// So this just unwraps to the array, or undefined
const [results] = Object.values(
await this.ipc.invoke('searchClientDaemon', modifiedQuery)
await this.ipc.invoke('searchClientDaemon', remainingQuery),
);
const payload = { items: results ?? [] };
@@ -51,7 +72,7 @@ const ClientDaemonHandler = {
schema,
payload,
null,
'query'
'query',
);
// TODO: Add some pagination
+128
View File
@@ -0,0 +1,128 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
/**
* Takes a POJO representing a filter query and converts it to an
* MQL expression string which is understood by the client daemon.
*
* @example
* const filter = generateMQLFilterExpression({
* name: { contains: 'search text' },
* scope: [{ equals: 'scope1'}, { equals: 'scope2' }],
* });
*
* // (name % "search text") and (scope = "scope1" or scope = "scope2")
*/
export function generateMQLFilterExpression(filterObj) {
if (!filterObj) {
return null;
}
const expressions = Object.entries(filterObj)
.map(([key, filterObjValue]) => {
const filterObjValueArray = Array.isArray(filterObjValue)
? filterObjValue
: [filterObjValue];
const orClauses = or(
filterObjValueArray.map((filterObjValue) => {
if (!filterObjValue) {
return null;
}
const [operation, value] = Object.entries(filterObjValue)[0];
switch (operation) {
case 'contains':
return contains(key, value);
case 'gt':
return gt(key, value);
case 'gte':
return gte(key, value);
case 'lt':
return lt(key, value);
case 'lte':
return lte(key, value);
default:
return equals(key, value);
}
}),
);
return parenthetical(orClauses);
})
.filter((item) => item);
return and(expressions);
}
/**
* Takes a POJO representing a search query and converts it to an
* MQL expression string which is understood by the client daemon.
*
* @example
* const search = generateMQLSearchExpression({
* text: 'search',
* fields: ['id', 'name', 'description']
* });
*
* // (id % 'search' or name % 'search' or description % 'search')
*/
export function generateMQLSearchExpression(searchObj) {
if (!searchObj) {
return null;
}
const { text, fields } = searchObj;
return parenthetical(
or(fields.map((field) => contains(field, text)).filter((item) => item)),
);
}
/**
* Generates the MQL expression when a user passes in both search and filters.
*/
export function generateMQLExpression(obj) {
const { search, filters } = obj;
const expressions = [
generateMQLSearchExpression(search),
generateMQLFilterExpression(filters),
].filter((item) => item);
return and(expressions);
}
const isEmpty = (input) =>
input === null || input === undefined || input === '';
// Escape any double quotes or backslashes
const sanitize = (input) => input?.replace(/(["\\])/g, '\\$1');
// Comparison Operators
const equals = (key, value) =>
!isEmpty(value) ? `${key} = "${sanitize(value)}"` : null;
const contains = (key, value) =>
!isEmpty(value) ? `${key} % "${sanitize(value)}"` : null;
const gt = (key, value) =>
!isEmpty(value) ? `${key} > "${sanitize(value)}"` : null;
const gte = (key, value) =>
!isEmpty(value) ? `${key} >= "${sanitize(value)}"` : null;
const lt = (key, value) =>
!isEmpty(value) ? `${key} < "${sanitize(value)}"` : null;
const lte = (key, value) =>
!isEmpty(value) ? `${key} <= "${sanitize(value)}"` : null;
// Logical Operators
const and = (clauses) => clauses.join(' and ');
const or = (clauses) => clauses.join(' or ');
const parenthetical = (expression) => (expression ? `(${expression})` : null);
@@ -52,3 +52,7 @@ toggle-color-theme: Toggle Color Theme
toggle-feature-edition: Toggle Feature Edition
toggle-licensed-features: Toggle Licensed Features
more: ', +{remainingItems} more'
search: Search
yes: Yes
no: No
narrow-results: Narrow results
@@ -564,6 +564,9 @@ target:
connect-more-info:
title: Connect for more info
description: Additional information for this target will be provided here after your session is established.
no-search-results:
title: No results found
description: Sorry, we weren't able to find any targets for "{query}".
titles:
new: New Target
actions:
@@ -7,6 +7,7 @@ import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { tracked } from '@glimmer/tracking';
export default class ScopesScopeProjectsTargetsIndexController extends Controller {
// =services
@@ -17,8 +18,31 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
@service session;
@service store;
// =attributes
queryParams = ['search'];
@tracked search;
@tracked searchItems = ['Project 1', 'Project 2', 'Project 3'];
// =methods
/**
* Returns true if model is empty but we have a search term
* @returns {boolean}
*/
get noResults() {
return this.model.length === 0 && this.search;
}
/**
* Returns true if model is empty and we have no search term
* @returns {boolean}
*/
get noTargets() {
return this.model.length === 0 && !this.search;
}
/**
* Quick connect method used to call main connect method and handle
* connection errors unique to this route
@@ -103,4 +127,17 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
session_id,
);
}
/**
* Handles input on each keystroke and the search queryParam
* @param {object} event
*/
@action
handleSearchInput(event) {
const { value } = event.target;
this.search = value;
this.router.replaceWith({
queryParams: { ...this.queryParams, search: this.search },
});
}
}
@@ -4,96 +4,5 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { resourceFilter } from 'core/decorators/resource-filter';
export default class ScopesScopeProjectsTargetsRoute extends Route {
// =services
@service can;
@service clusterUrl;
@service resourceFilterStore;
@service router;
@service session;
@service store;
// =attributes
@resourceFilter({
allowed: (route) => route.modelFor('scopes.scope.projects'),
serialize: ({ id }) => id,
findBySerialized: ({ id }, value) => id === value,
})
project;
// =methods
/**
* If arriving here unauthenticated, redirect to index for further processing.
*/
beforeModel() {
if (!this.session.isAuthenticated) this.router.transitionTo('index');
}
/**
* Loads all targets under current scope.
*
* NOTE: previously, targets were filtered with API filter queries.
* In an effort to offload processing from the controller, targets
* are now filtered on the client by projects and authorized_actions.
*
* @return {Promise{[TargetModel]}}
*/
async model() {
const { id: scope_id } = this.modelFor('scopes.scope');
const queryOptions = { scope_id, recursive: true };
// Recursively query all targets within the current scope
let targets = await this.store.query('target', queryOptions);
// Filter out targets to which users do not have the connect ability
targets = targets.filter((t) => this.can.can('connect target', t));
// If project filters are selected, filter targets by the selected projects
if (this.project?.length) {
const projectIDs = this.project.map((p) => p?.id);
targets = targets.filter((t) => projectIDs.includes(t?.scopeID));
}
return targets;
}
// =actions
@action
acknowledge(session) {
session.acknowledged = true;
}
/**
* Sets the specified resource filter field to the specified value.
* @param {string} field
* @param value
*/
@action
filterBy(field, value) {
this[field] = value;
}
/**
* Clears and filter selections.
*/
@action
clearAllFilters() {
this.project = [];
}
/**
* refreshes target data.
*/
@action
async refreshTargets() {
return this.refresh();
}
}
export default class ScopesScopeProjectsTargetsRoute extends Route {}
@@ -4,5 +4,52 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ScopesScopeProjectsTargetsIndexRoute extends Route {}
export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
// =services
@service can;
@service clusterUrl;
@service resourceFilterStore;
@service router;
@service session;
@service store;
// =attributes
queryParams = {
search: {
refreshModel: true,
},
};
// =methods
/**
* If arriving here unauthenticated, redirect to index for further processing.
*/
beforeModel() {
if (!this.session.isAuthenticated) this.router.transitionTo('index');
}
/**
* Loads all targets under current scope.
*
* @return {Promise{[TargetModel]}}
*/
async model({ search = '' }) {
// TODO: Filter targets by scope we're in manually
// const { id: scope_id } = this.modelFor('scopes.scope');
const queryOptions = { query: { search } };
let targets = await this.store.query('target', queryOptions);
// Filter out targets to which users do not have the connect ability
targets = targets.filter((target) =>
this.can.can('connect target', target),
);
return targets;
}
}
+15
View File
@@ -684,3 +684,18 @@ $chevron-down: url("data:image/svg+xml;utf-8,<svg viewBox='0 0 24 24' fill='%231
color: var(--token-color-foreground-faint);
}
}
// Search & Filtering
.targets {
.hds-application-state {
padding-top: 3rem;
}
.target-search-filtering {
padding-bottom: 1rem;
.hds-dropdown-toggle-button__text {
white-space: nowrap;
}
}
}
@@ -13,189 +13,152 @@
<p>{{t 'resources.target.description'}}</p>
</page.header>
<page.body>
{{#if
(or
@model
(has-resource-filter-selections
'scopes.scope.projects.targets' 'project'
)
)
}}
<Rose::Toolbar as |toolbar|>
<toolbar.primary>
<Rose::Form as |form|>
{{#let
(resource-filter 'scopes.scope.projects.targets' 'project')
as |filter|
}}
<Rose::Dropdown
@text={{t (concat 'resources.' filter.name '.title')}}
@count={{filter.selectedValue.length}}
@ignoreClickInside={{true}}
as |dropdown|
>
<ul>
{{#let
(group-by filter.allowedValues 'scopeModel')
as |groups|
}}
{{#each groups as |groupedItems|}}
<li>
<dropdown.item>
<Rose::Icon
@name='flight-icons/svg/org-16'
@size='medium'
/>
{{groupedItems.key.displayName}}
</dropdown.item>
<ul class='indent-label-2'>
<form.checkboxGroup
@name={{filter.name}}
@items={{groupedItems.items}}
@selectedItems={{filter.selectedValue}}
@onChange={{route-action 'filterBy' filter.name}}
as |group|
>
<li>
<dropdown.item>
<group.checkbox
@label={{group.item.displayName}}
value={{group.item}}
/>
</dropdown.item>
</li>
</form.checkboxGroup>
</ul>
</li>
{{/each}}
{{/let}}
</ul>
</Rose::Dropdown>
{{/let}}
{{#if
(has-resource-filter-selections
'scopes.scope.projects.targets' 'project'
)
}}
<Rose::Button
@style='inline-link-action'
@iconLeft='flight-icons/svg/x-16'
{{on 'click' (route-action 'clearAllFilters')}}
>{{t 'actions.clear-all-filters'}}
</Rose::Button>
{{/if}}
</Rose::Form>
</toolbar.primary>
<toolbar.secondary>
<ToolbarRefresher @onClick={{route-action 'refreshTargets'}} />
</toolbar.secondary>
</Rose::Toolbar>
{{/if}}
{{#if @model}}
<Hds::Table
@model={{@model}}
@columns={{array
(hash label=(t 'form.name.label'))
(hash label=(t 'form.id.label'))
(hash label=(t 'form.type.label'))
(hash label=(t 'resources.project.title'))
(hash label='')
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
<span class='hds-font-weight-semibold'>
{{#if (can 'read model' B.data)}}
<LinkTo
data-test-visit-target={{B.data.id}}
@route='scopes.scope.projects.targets.target'
@model={{B.data.id}}
@query={{hash isConnecting=false}}
>
<page.body class='targets'>
{{#if (or @model this.search)}}
<div class='target-search-filtering'>
<Hds::SegmentedGroup as |S|>
<S.TextInput
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
<S.Dropdown as |DD|>
<DD.ToggleButton
@color='secondary'
@text={{t 'resources.scope.title'}}
/>
<DD.Header @hasDivider={{true}}>
<Hds::Form::TextInput::Base
@type='search'
placeholder={{t 'actions.narrow-results'}}
aria-label={{t 'actions.narrow-results'}}
data-test-search
/>
</DD.Header>
{{#each this.searchItems as |item|}}
<DD.Checkbox @value={{item}} data-test-checkbox={{item}}>
{{item}}
</DD.Checkbox>
{{/each}}
</S.Dropdown>
<S.Dropdown as |DD|>
<DD.ToggleButton @color='secondary' @text='Active session' />
<DD.Checkbox>{{t 'actions.yes'}}</DD.Checkbox>
<DD.Checkbox>{{t 'actions.no'}}</DD.Checkbox>
</S.Dropdown>
<S.Dropdown as |DD|>
<DD.ToggleButton @color='secondary' @text='Type' @count='2' />
<DD.Checkbox>{{t 'resources.target.types.tcp'}}</DD.Checkbox>
<DD.Checkbox>{{t 'resources.target.types.ssh'}}</DD.Checkbox>
</S.Dropdown>
</Hds::SegmentedGroup>
</div>
{{#if @model}}
<Hds::Table
@model={{@model}}
@columns={{array
(hash label=(t 'form.name.label'))
(hash label=(t 'form.id.label'))
(hash label=(t 'form.type.label'))
(hash label=(t 'resources.project.title'))
(hash label='')
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
<span class='hds-font-weight-semibold'>
{{#if (can 'read model' B.data)}}
<LinkTo
data-test-visit-target={{B.data.id}}
@route='scopes.scope.projects.targets.target'
@model={{B.data.id}}
@query={{hash isConnecting=false}}
>
{{B.data.displayName}}
</LinkTo>
{{else}}
{{B.data.displayName}}
</LinkTo>
{{else}}
{{B.data.displayName}}
{{/if}}
{{#if B.data.description}}
<p>{{B.data.description}}</p>
{{/if}}
</span>
</B.Td>
<B.Td>
<Copyable
@text={{B.data.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<code>{{B.data.id}}</code>
</Copyable>
</B.Td>
<B.Td>
{{#if B.data.type}}
<Hds::Badge
@text={{t (concat 'resources.target.types.' B.data.type)}}
/>
{{/if}}
</B.Td>
<B.Td>
<div>
{{/if}}
{{#if B.data.description}}
<p>{{B.data.description}}</p>
{{/if}}
</span>
</B.Td>
<B.Td>
<Copyable
@text={{B.data.project.id}}
@text={{B.data.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<code>{{B.data.project.id}}</code>
<code>{{B.data.id}}</code>
</Copyable>
</div>
<Hds::Badge @color='neutral' @text={{B.data.project.name}} />
</B.Td>
<B.Td>
{{#if (can 'connect target' B.data)}}
{{#if (can 'read target' B.data)}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
@route='scopes.scope.projects.targets.target'
@model={{B.data.id}}
@query={{hash isConnecting=true}}
/>
{{else}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
{{on 'click' (fn this.quickConnect B.data)}}
</B.Td>
<B.Td>
{{#if B.data.type}}
<Hds::Badge
@text={{t (concat 'resources.target.types.' B.data.type)}}
/>
{{/if}}
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<Rose::Layout::Centered>
<Rose::Message
@title={{t 'resources.target.messages.none.title'}}
as |message|
>
<message.description>
{{t 'resources.target.messages.none.description'}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
</B.Td>
<B.Td>
<div>
<Copyable
@text={{B.data.project.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<code>{{B.data.project.id}}</code>
</Copyable>
</div>
<Hds::Badge @color='neutral' @text={{B.data.project.name}} />
</B.Td>
<B.Td>
{{#if (can 'connect target' B.data)}}
{{#if (can 'read target' B.data)}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
@route='scopes.scope.projects.targets.target'
@model={{B.data.id}}
@query={{hash isConnecting=true}}
/>
{{else}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
{{on 'click' (fn this.quickConnect B.data)}}
/>
{{/if}}
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{/if}}
{{#if this.noResults}}
<Hds::ApplicationState data-test-no-target-results as |A|>
<A.Header
@title={{t 'resources.target.messages.no-search-results.title'}}
/>
<A.Body
@text={{t
'resources.target.messages.no-search-results.description'
query=this.search
}}
/>
</Hds::ApplicationState>
{{/if}}
{{/if}}
{{#if this.noTargets}}
<Hds::ApplicationState data-test-no-targets as |A|>
<A.Header @title={{t 'resources.target.messages.none.title'}} />
<A.Body @text={{t 'resources.target.messages.none.description'}} />
</Hds::ApplicationState>
{{/if}}
</page.body>
+3 -3
View File
@@ -123,7 +123,7 @@ handle('checkCommand', async (command) => which(command, { nothrow: true }));
* Adds the user's token to the client daemon.
*/
handle('addTokenToClientDaemon', async (data) =>
clientDaemonManager.addToken(data)
clientDaemonManager.addToken(data),
);
/**
@@ -139,7 +139,7 @@ handle('checkOS', () => ({
* Call the client daemon's search endpoint to retrieve cached results.
*/
handle('searchClientDaemon', async (request) =>
clientDaemonManager.search(request)
clientDaemonManager.search(request),
);
/**
@@ -147,7 +147,7 @@ handle('searchClientDaemon', async (request) =>
* socket path as a proxy for whether the daemon is running.
*/
handle('isClientDaemonRunning', async (request) =>
Boolean(clientDaemonManager.socketPath)
Boolean(clientDaemonManager.socketPath),
);
/**
@@ -24,7 +24,7 @@ class ClientDaemonManager {
const data = spawnSync(daemonStatusCommand);
const status = jsonify(data);
this.#socketPath = status?.socket_address;
this.#socketPath = status?.item?.socket_address;
return status;
}
@@ -29,7 +29,6 @@ module('Acceptance | projects | targets', function (hooks) {
let getTargetCount;
const ROSE_APP_STATE_TITLE = '.rose-message-title';
const APP_STATE_TITLE = '.hds-application-state__title';
const TARGET_DETAILS_ROUTE_NAME =
'scopes.scope.projects.targets.target.index';
@@ -84,6 +83,8 @@ module('Acceptance | projects | targets', function (hooks) {
hooks.beforeEach(function () {
authenticateSession();
// bypass mirage config that expects recursive to be passed in as queryParam
this.server.get('/targets', ({ targets }) => targets.all());
// Generate scopes
instances.scopes.global = this.server.create('scope', { id: 'global' });
@@ -190,7 +191,7 @@ module('Acceptance | projects | targets', function (hooks) {
await click(`[href="${urls.targets}"]`);
assert.dom(ROSE_APP_STATE_TITLE).hasText('No Targets Available');
assert.dom(APP_STATE_TITLE).hasText('No Targets Available');
});
test('user cannot navigate to a target without proper authorization', async function (assert) {
@@ -68,6 +68,8 @@ module('Acceptance | projects | targets | target', function (hooks) {
hooks.beforeEach(function () {
authenticateSession();
// bypass mirage config that expects recursive to be passed in as queryParam
this.server.get('/targets', ({ targets }) => targets.all());
// Generate scopes
instances.scopes.global = this.server.create('scope', { id: 'global' });
+5 -4
View File
@@ -22,6 +22,8 @@ module('Acceptance | scopes', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
const APP_STATE_TITLE = '.hds-application-state__title';
const instances = {
scopes: {
global: null,
@@ -72,6 +74,8 @@ module('Acceptance | scopes', function (hooks) {
hooks.beforeEach(function () {
authenticateSession();
// bypass mirage config that expects recursive to be passed in as queryParam
this.server.get('/targets', ({ targets }) => targets.all());
// create scopes
instances.scopes.global = this.server.create('scope', { id: 'global' });
@@ -228,10 +232,7 @@ module('Acceptance | scopes', function (hooks) {
await visit(urls.targets);
assert.ok(
find('.rose-message-title').textContent.trim(),
'No Targets Available',
);
assert.ok(find(APP_STATE_TITLE).textContent.trim(), 'No Targets Available');
});
test.skip('connecting to a target', async function (assert) {