diff --git a/e2e-tests/desktop/tests/pagination.spec.js b/e2e-tests/desktop/tests/pagination.spec.js
index b8cd1235b..3c82ba5ab 100644
--- a/e2e-tests/desktop/tests/pagination.spec.js
+++ b/e2e-tests/desktop/tests/pagination.spec.js
@@ -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();
});
});
diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js
index 046841384..d060a8ef9 100644
--- a/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js
+++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js
@@ -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;
- }
}
diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/target/index.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/target/index.js
new file mode 100644
index 000000000..c8d31c49f
--- /dev/null
+++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/target/index.js
@@ -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;
+ }
+}
diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js
index 6495c21d5..95577ac51 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js
@@ -12,6 +12,14 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
@service store;
+ // =attributes
+ queryParams = {
+ isConnecting: {
+ type: 'boolean',
+ refreshModel: true,
+ },
+ };
+
// =methods
/**
diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target/index.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target/index.js
index 991deae8d..190af08ed 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/targets/target/index.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target/index.js
@@ -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);
+ }
+ }
}
diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs
index 779821f8f..e151496f3 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs
@@ -32,143 +32,7 @@
-
-
- {{#if (gt @model.hosts.length 1)}}
-
- <:body as |B|>
-
-
-
- {{B.data.displayName}}
-
-
- {{B.data.description}}
-
-
-
-
- {{B.data.address}}
-
-
-
-
-
-
-
-
-
-
-
- {{else}}
-
-
-
-
- {{/if}}
-
-
- {{#if @model.target.aliases}}
-
- {{/if}}
-
-
-
+ {{outlet}}
\ No newline at end of file
diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs
index d14e21775..1d6394a00 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs
@@ -3,4 +3,157 @@
SPDX-License-Identifier: BUSL-1.1
}}
-{{outlet}}
\ No newline at end of file
+
+
+ {{#if (gt @model.hosts.length 1)}}
+
+
+
+ {{#if this.paginatedHosts.length}}
+
+ <:body as |B|>
+
+
+
+ {{B.data.displayName}}
+
+
+ {{B.data.description}}
+
+
+
+
+ {{B.data.address}}
+
+
+
+
+
+
+
+
+
+
+
+ {{/if}}
+ {{#if this.noResults}}
+
+
+
+
+ {{/if}}
+ {{else}}
+
+
+
+
+ {{/if}}
+
+
+ {{#if @model.target.aliases}}
+
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js
index dbda5d632..257d00014 100644
--- a/ui/desktop/tests/acceptance/projects/targets/target-test.js
+++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js
@@ -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 });
});
diff --git a/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/target/index-test.js b/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/target/index-test.js
new file mode 100644
index 000000000..9a6d292c6
--- /dev/null
+++ b/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/target/index-test.js
@@ -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);
+ });
+ },
+);