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}} -
-
- {{t 'resources.alias.details'}} -
- - {{#each @model.target.aliases as |alias|}} - - - - - {{/each}} - - -
- {{/if}} -
-
-
- {{t 'resources.target.details'}} -
- - {{! Target name }} - - {{@model.target.name}} - - {{! Project name }} - - {{@model.target.project.displayName}} - - {{! Target description }} - {{#if @model.target.description}} - - {{@model.target.description}} - - {{/if}} - {{! Target type }} - - {{#if @model.target.isSSH}} - {{t 'resources.target.types.ssh'}} - {{else}} - {{t 'resources.target.types.tcp'}} - {{/if}} - - {{! Target address }} - {{#if @model.target.address}} - - {{@model.target.address}} - - {{/if}} - {{! Target ID }} - - - - -
-
-
-
+ {{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}} +
+
+ {{t 'resources.alias.details'}} +
+ + {{#each @model.target.aliases as |alias|}} + + + + + {{/each}} + + +
+ {{/if}} +
+
+
+ {{t 'resources.target.details'}} +
+ + {{! Target name }} + + {{@model.target.name}} + + {{! Project name }} + + {{@model.target.project.displayName}} + + {{! Target description }} + {{#if @model.target.description}} + + {{@model.target.description}} + + {{/if}} + {{! Target type }} + + {{#if @model.target.isSSH}} + {{t 'resources.target.types.ssh'}} + {{else}} + {{t 'resources.target.types.tcp'}} + {{/if}} + + {{! Target address }} + {{#if @model.target.address}} + + {{@model.target.address}} + + {{/if}} + {{! Target ID }} + + + + +
+
+
+
\ 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); + }); + }, +);