feat: 🎸 add search capability for host sources (#2861)

* feat: 🎸 add search capability for host sources

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

* refactor: 💡 use Rose::Pagination component

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

* refactor: 💡 move target detail body to index route

to fix whole page reload issue

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

* Add missing copyright headers

* refactor: 💡 PR feedback

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

---------

Co-authored-by: cameronperera <29386339+cameronperera@users.noreply.github.com>
This commit is contained in:
Cameron Pereraandcameronperera authored and GitHub committed 2025-06-06 16:07:48 -05:00
1 parent 738fa1f15f
commit 57e614e8db
9 files changed
+394 -173

No files matched your search

+51 -6
View File
@@ -55,9 +55,8 @@ test.afterEach(async ({ request }) => {
}
});
test.describe('Target with many hosts', () => {
// TODO: Add tests for searching host sources
test('should display all hosts in the target', async ({ authedPage }) => {
test.describe('Search and Pagination', () => {
test('target details', async ({ authedPage }) => {
await authedPage
.getByRole('link', { name: targetWithManyHosts.name })
.click();
@@ -76,7 +75,7 @@ test.describe('Target with many hosts', () => {
// Navigate to the second page. The last host should now be visible.
await authedPage
.getByRole('navigation', { name: 'pagination' })
.getByRole('button', { name: 'page 2' })
.getByRole('link', { name: 'page 2' })
.click();
await expect(
authedPage.getByRole('cell', { name: hosts[0].name, exact: true }),
@@ -91,7 +90,7 @@ test.describe('Target with many hosts', () => {
// Use the "previous page" button to navigate back to the first page.
await authedPage
.getByRole('navigation', { name: 'pagination' })
.getByRole('button', { name: 'Previous page' })
.getByRole('link', { name: 'Previous page' })
.click();
await expect(
authedPage.getByRole('cell', { name: hosts[0].name, exact: true }),
@@ -106,7 +105,7 @@ test.describe('Target with many hosts', () => {
// Use the "next page" button to navigate to the second page again.
await authedPage
.getByRole('navigation', { name: 'pagination' })
.getByRole('button', { name: 'Next page' })
.getByRole('link', { name: 'Next page' })
.click();
await expect(
authedPage.getByRole('cell', { name: hosts[0].name, exact: true }),
@@ -137,6 +136,52 @@ test.describe('Target with many hosts', () => {
.getByRole('row')
.filter({ hasNot: authedPage.getByRole('columnheader') }),
).toHaveCount(10);
// Search for a specific host
const host = hosts[12];
await authedPage.getByRole('searchbox', { name: 'Search' }).fill(host.name);
await expect(
authedPage
.getByRole('row')
.filter({ hasNot: authedPage.getByRole('columnheader') }),
).toHaveCount(1);
// Clear the search box
await authedPage.getByRole('searchbox', { name: 'Search' }).fill('');
await expect(
authedPage
.getByRole('row')
.filter({ hasNot: authedPage.getByRole('columnheader') }),
).toHaveCount(10);
await expect(authedPage.getByText('1–10 of 15')).toBeVisible();
// Search with query that matches multiple hosts
await authedPage.getByRole('searchbox', { name: 'Search' }).fill('Host 1');
await expect(
authedPage.getByRole('cell', {
name: hosts[0].name,
exact: true,
}),
).toBeVisible();
await expect(
authedPage.getByRole('cell', {
name: hosts[10].name,
exact: true,
}),
).toBeVisible();
await expect(
authedPage
.getByRole('row')
.filter({ hasNot: authedPage.getByRole('columnheader') }),
).toHaveCount(7);
// Search for a host that does not exist
await authedPage
.getByRole('searchbox', { name: 'Search' })
.fill('Nonexistent Host');
await expect(authedPage.getByRole('table')).toBeHidden();
});
});
@@ -20,21 +20,10 @@ export default class ScopesScopeProjectsTargetsTargetController extends Controll
queryParams = [{ isConnecting: { type: 'boolean' } }];
@tracked page = 1;
@tracked pageSize = 10;
@tracked totalItems = this.model.hosts.length;
@tracked isConnecting = false;
isConnectionError = false;
// =methods
get paginatedHosts() {
return this.model.hosts.slice(
(this.page - 1) * this.pageSize,
this.page * this.pageSize,
);
}
// =actions
/**
* Connect method that calls parent connect method and handles
@@ -59,15 +48,4 @@ export default class ScopesScopeProjectsTargetsTargetController extends Controll
});
}
}
@action
async handlePageChange(page) {
this.page = page;
}
@action
async handlePageSizeChange(pageSize) {
this.page = 1;
this.pageSize = pageSize;
}
}
@@ -0,0 +1,71 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller, { inject as controller } from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { debounce } from 'core/decorators/debounce';
export default class ScopesScopeProjectsTargetsTargetIndexController extends Controller {
// standard naming for importing a controller would be 'target' but
// this is a special case where using 'target' conflicts with the
// target route, so we use 'targetController' instead
@controller('scopes/scope/projects/targets/target') targetController;
// =attributes
queryParams = ['page', 'pageSize'];
@tracked search;
@tracked page = 1;
@tracked pageSize = 10;
// =methods
get paginatedHosts() {
return this.hosts.slice(
(this.page - 1) * this.pageSize,
this.page * this.pageSize,
);
}
get hosts() {
let filteredHosts = this.model.hosts;
if (this.search) {
filteredHosts = this.model.hosts.filter((host) => {
const searchTerm = this.search?.toLowerCase() ?? '';
return (
host.displayName.toLowerCase().includes(searchTerm) ||
host.description?.toLowerCase().includes(searchTerm) ||
host.address.toLowerCase().includes(searchTerm)
);
});
}
return filteredHosts;
}
/**
* Returns true if the search query is empty
* @returns {boolean}
*/
get noResults() {
return this.model.hosts.length > 0 && this.hosts.length === 0;
}
// =actions
/**
* Handles input on each keystroke and the search queryParam
* @param {object} event
*/
@action
@debounce(250)
handleSearchInput(event) {
const { value } = event.target;
this.search = value;
this.page = 1;
}
}
@@ -12,6 +12,14 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
@service store;
// =attributes
queryParams = {
isConnecting: {
type: 'boolean',
refreshModel: true,
},
};
// =methods
/**
@@ -4,12 +4,28 @@
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ScopesScopeProjectsTargetsTargetIndexRoute extends Route {
// =services
// =attributes
@service router;
queryParams = {
page: {
refreshModel: true,
},
pageSize: {
refreshModel: true,
},
};
// =methods
model() {
return this.modelFor('scopes.scope.projects.targets.target');
}
resetController(controller, isExiting) {
if (isExiting) {
controller.set('search', '');
controller.set('page', 1);
controller.set('pageSize', 10);
}
}
}
@@ -32,143 +32,7 @@
</page.header>
<page.body class='details-screen-body'>
<Rose::Layout::BodyContent as |bc|>
<bc.Body class='target-details-body'>
{{#if (gt @model.hosts.length 1)}}
<Hds::Table
@model={{this.paginatedHosts}}
@columns={{array
(hash label=(t 'resources.target.host-source.title_plural'))
(hash label=(t 'form.address.label'))
(hash
label=(t 'titles.actions') isVisuallyHidden=true align='right'
)
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
<Hds::Text::Body @weight='semibold' @tag='p'>
{{B.data.displayName}}
</Hds::Text::Body>
<Hds::Text::Body @tag='p'>
{{B.data.description}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Text::Body @tag='p'>
{{B.data.address}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Button
data-test-target-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
{{on 'click' (fn this.connect @model.target B.data)}}
/>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
<Hds::Pagination::Numbered
data-test-pagination
@totalItems={{this.totalItems}}
@currentPage={{this.page}}
@currentPageSize={{this.pageSize}}
@onPageChange={{this.handlePageChange}}
@onPageSizeChange={{this.handlePageSizeChange}}
/>
{{else}}
<Hds::ApplicationState as |A|>
<A.Header
@title={{t 'resources.target.messages.connect-more-info.title'}}
/>
<A.Body
@text={{t
'resources.target.messages.connect-more-info.description'
}}
/>
</Hds::ApplicationState>
{{/if}}
</bc.Body>
<bc.Sidebar>
{{#if @model.target.aliases}}
<div class='details-sidebar aliases'>
<div
class='title hds-typography-display-200 hds-font-weight-semibold'
>
{{t 'resources.alias.details'}}
</div>
<Rose::MetadataList @orientation='vertical' as |list|>
{{#each @model.target.aliases as |alias|}}
<list.Item @color='faint'>
<Hds::Copy::Snippet
@textToCopy={{alias.value}}
@color='secondary'
/>
</list.Item>
{{/each}}
</Rose::MetadataList>
</div>
{{/if}}
<div class='details-sidebar'>
<div>
<div
class='title hds-typography-display-200 hds-font-weight-semibold'
>
{{t 'resources.target.details'}}
</div>
<Rose::MetadataList @orientation='vertical' as |list|>
{{! Target name }}
<list.Item @icon='target' @color='faint'>
{{@model.target.name}}
</list.Item>
{{! Project name }}
<list.Item @icon='grid' @color='faint'>
{{@model.target.project.displayName}}
</list.Item>
{{! Target description }}
{{#if @model.target.description}}
<list.Item @icon='message-circle-fill' @color='faint'>
{{@model.target.description}}
</list.Item>
{{/if}}
{{! Target type }}
<list.Item class='resource-type' @icon='terminal' @color='faint'>
{{#if @model.target.isSSH}}
{{t 'resources.target.types.ssh'}}
{{else}}
{{t 'resources.target.types.tcp'}}
{{/if}}
</list.Item>
{{! Target address }}
{{#if @model.target.address}}
<list.Item
class='resource-type'
@icon='network-alt'
@color='faint'
>
{{@model.target.address}}
</list.Item>
{{/if}}
{{! Target ID }}
<list.Item @icon='hash' @color='faint'>
<Hds::Copy::Snippet
@textToCopy={{@model.target.id}}
@color='secondary'
/>
</list.Item>
</Rose::MetadataList>
</div>
</div>
</bc.Sidebar>
</Rose::Layout::BodyContent>
{{outlet}}
</page.body>
</Rose::Layout::Page>
@@ -3,4 +3,157 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{outlet}}
<Rose::Layout::BodyContent as |bc|>
<bc.Body class='search-filtering'>
{{#if (gt @model.hosts.length 1)}}
<Hds::SegmentedGroup as |S|>
<S.TextInput
@value={{this.search}}
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
</Hds::SegmentedGroup>
{{#if this.paginatedHosts.length}}
<Hds::Table
@model={{this.paginatedHosts}}
@columns={{array
(hash label=(t 'resources.target.host-source.title_plural'))
(hash label=(t 'form.address.label'))
(hash
label=(t 'titles.actions') isVisuallyHidden=true align='right'
)
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
<Hds::Text::Body @weight='semibold' @tag='p'>
{{B.data.displayName}}
</Hds::Text::Body>
<Hds::Text::Body @tag='p'>
{{B.data.description}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Text::Body @tag='p'>
{{B.data.address}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Button
data-test-target-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
{{on
'click'
(fn this.targetController.connect @model.target B.data)
}}
/>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
<Rose::Pagination
@totalItems={{this.hosts.length}}
@currentPage={{this.page}}
@currentPageSize={{this.pageSize}}
/>
{{/if}}
{{#if this.noResults}}
<Hds::ApplicationState data-test-no-host-source-results as |A|>
<A.Header @title={{t 'titles.no-results-found'}} />
<A.Body
@text={{t
(if
this.search
'descriptions.no-search-results'
'descriptions.no-filter-results'
)
query=this.search
resource=(t 'resources.target.host-source.title_plural')
}}
/>
</Hds::ApplicationState>
{{/if}}
{{else}}
<Hds::ApplicationState as |A|>
<A.Header
@title={{t 'resources.target.messages.connect-more-info.title'}}
/>
<A.Body
@text={{t 'resources.target.messages.connect-more-info.description'}}
/>
</Hds::ApplicationState>
{{/if}}
</bc.Body>
<bc.Sidebar>
{{#if @model.target.aliases}}
<div class='details-sidebar aliases'>
<div class='title hds-typography-display-200 hds-font-weight-semibold'>
{{t 'resources.alias.details'}}
</div>
<Rose::MetadataList @orientation='vertical' as |list|>
{{#each @model.target.aliases as |alias|}}
<list.Item @color='faint'>
<Hds::Copy::Snippet
@textToCopy={{alias.value}}
@color='secondary'
/>
</list.Item>
{{/each}}
</Rose::MetadataList>
</div>
{{/if}}
<div class='details-sidebar'>
<div>
<div class='title hds-typography-display-200 hds-font-weight-semibold'>
{{t 'resources.target.details'}}
</div>
<Rose::MetadataList @orientation='vertical' as |list|>
{{! Target name }}
<list.Item @icon='target' @color='faint'>
{{@model.target.name}}
</list.Item>
{{! Project name }}
<list.Item @icon='grid' @color='faint'>
{{@model.target.project.displayName}}
</list.Item>
{{! Target description }}
{{#if @model.target.description}}
<list.Item @icon='message-circle-fill' @color='faint'>
{{@model.target.description}}
</list.Item>
{{/if}}
{{! Target type }}
<list.Item class='resource-type' @icon='terminal' @color='faint'>
{{#if @model.target.isSSH}}
{{t 'resources.target.types.ssh'}}
{{else}}
{{t 'resources.target.types.tcp'}}
{{/if}}
</list.Item>
{{! Target address }}
{{#if @model.target.address}}
<list.Item class='resource-type' @icon='network-alt' @color='faint'>
{{@model.target.address}}
</list.Item>
{{/if}}
{{! Target ID }}
<list.Item @icon='hash' @color='faint'>
<Hds::Copy::Snippet
@textToCopy={{@model.target.id}}
@color='secondary'
/>
</list.Item>
</Rose::MetadataList>
</div>
</div>
</bc.Sidebar>
</Rose::Layout::BodyContent>
@@ -4,7 +4,15 @@
*/
import { module, test } from 'qunit';
import { visit, currentURL, click, find, select } from '@ember/test-helpers';
import {
visit,
currentURL,
click,
find,
select,
fillIn,
waitFor,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { authenticateSession } from 'ember-simple-auth/test-support';
@@ -30,6 +38,10 @@ module('Acceptance | projects | targets | target', function (hooks) {
const HDS_DIALOG_CANCEL_BUTTON =
'.hds-modal__footer .hds-button--color-secondary';
const TABLE_ROWS = 'tbody tr';
const FIRST_ROW = 'tbody tr:first-child';
const SECOND_ROW = 'tbody tr:nth-child(2)';
const SEARCH_INPUT = 'input[type="search"]';
const NO_RESULTS_MSG = '[data-test-no-host-source-results]';
const instances = {
scopes: {
@@ -240,6 +252,58 @@ module('Acceptance | projects | targets | target', function (hooks) {
assert.dom(APP_STATE_TITLE).hasText('Connected');
});
test('user can search for a host source by name, description, and address', async function (assert) {
const host1 =
instances.targetWithTwoHosts.hostSets.models[0].hosts.models[0];
const host2 =
instances.targetWithTwoHosts.hostSets.models[0].hosts.models[1];
await visit(urls.targets);
await click(`[href="${urls.targetWithTwoHosts}"]`);
assert.dom(TABLE_ROWS).exists({ count: 2 });
assert.dom(FIRST_ROW).includesText(host1.name);
assert.dom(SECOND_ROW).includesText(host2.name);
await fillIn(SEARCH_INPUT, host2.name);
await waitFor(SECOND_ROW, { count: 0 });
assert.dom(TABLE_ROWS).exists({ count: 1 });
assert.dom(FIRST_ROW).includesText(host2.name);
assert.dom(SECOND_ROW).doesNotExist();
await fillIn(SEARCH_INPUT, '');
await waitFor(TABLE_ROWS, { count: 2 });
await fillIn(SEARCH_INPUT, host1.description);
await waitFor(SECOND_ROW, { count: 0 });
assert.dom(TABLE_ROWS).exists({ count: 1 });
assert.dom(FIRST_ROW).includesText(host1.description);
assert.dom(SECOND_ROW).doesNotExist();
await fillIn(SEARCH_INPUT, '');
await waitFor(TABLE_ROWS, { count: 2 });
await fillIn(SEARCH_INPUT, host2.attributes.address);
await waitFor(SECOND_ROW, { count: 0 });
assert.dom(TABLE_ROWS).exists({ count: 1 });
assert.dom(FIRST_ROW).includesText(host2.attributes.address);
assert.dom(SECOND_ROW).doesNotExist();
});
test('user can search for host sources and get no results', async function (assert) {
await visit(urls.targets);
await click(`[href="${urls.targetWithTwoHosts}"]`);
assert.dom(TABLE_ROWS).exists({ count: 2 });
await fillIn(SEARCH_INPUT, 'non-existing-host');
await waitFor(TABLE_ROWS, { count: 0 });
assert.dom(NO_RESULTS_MSG).includesText('No results found');
});
test('user can see host source table when visiting a target via resource link', async function (assert) {
const targetId = instances.targetWithTwoHosts.id;
@@ -310,7 +374,7 @@ module('Acceptance | projects | targets | target', function (hooks) {
assert.dom(TABLE_ROWS).exists({ count: 10 });
assert.dom('[data-test-pagination]').isVisible();
await click('button[aria-label="Next page"]');
await click('a[aria-label="Next page"]');
assert.dom(TABLE_ROWS).exists({ count: 5 });
});
@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'desktop/tests/helpers';
module(
'Unit | Controller | scopes/scope/projects/targets/target/index',
function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/projects/targets/target/index',
);
assert.ok(controller);
});
},
);