diff --git a/addons/api/mirage/factories/target.js b/addons/api/mirage/factories/target.js index fb61cda74..99ffb2bfa 100644 --- a/addons/api/mirage/factories/target.js +++ b/addons/api/mirage/factories/target.js @@ -103,6 +103,27 @@ export default factory.extend({ }, }), + withManyHosts: trait({ + afterCreate(target, server) { + const { scope } = target; + const hostCatalog = server.create('host-catalog', { scope }); + const hosts = server.createList('host', 15, { + scope, + hostCatalog, + type: hostCatalog.type, + }); + const hostSets = [ + server.create('host-set', { + scope, + hostCatalog, + hosts, + type: hostCatalog.type, + }), + ]; + target.update({ hostSets }); + }, + }), + withTwoHosts: trait({ afterCreate(target, server) { const { scope } = target; diff --git a/e2e-tests/desktop/tests/pagination.spec.js b/e2e-tests/desktop/tests/pagination.spec.js new file mode 100644 index 000000000..b8cd1235b --- /dev/null +++ b/e2e-tests/desktop/tests/pagination.spec.js @@ -0,0 +1,143 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { expect, test } from '../fixtures/baseTest.js'; +import * as boundaryHttp from '../../helpers/boundary-http.js'; + +let hosts = []; +let org; +let targetWithManyHosts; + +test.beforeEach(async ({ request, targetAddress, targetPort }) => { + org = await boundaryHttp.createOrg(request); + const project = await boundaryHttp.createProject(request, org.id); + + // Create host set + const hostCatalog = await boundaryHttp.createStaticHostCatalog( + request, + project.id, + ); + + // Create hosts + const hostCount = 15; + for (let i = 0; i < hostCount; i++) { + const host = await boundaryHttp.createHost(request, { + hostCatalogId: hostCatalog.id, + address: targetAddress, + name: `Host ${i + 1}`, + }); + hosts.push(host); + } + + const hostSet = await boundaryHttp.createHostSet(request, hostCatalog.id); + await boundaryHttp.addHostToHostSet(request, { + hostSet, + hostIds: hosts.map((host) => host.id), + }); + + // Create target + targetWithManyHosts = await boundaryHttp.createTarget(request, { + scopeId: project.id, + type: 'tcp', + port: targetPort, + }); + targetWithManyHosts = await boundaryHttp.addHostSource(request, { + target: targetWithManyHosts, + hostSourceIds: [hostSet.id], + }); +}); + +test.afterEach(async ({ request }) => { + if (org) { + await boundaryHttp.deleteOrg(request, org.id); + } +}); + +test.describe('Target with many hosts', () => { + // TODO: Add tests for searching host sources + test('should display all hosts in the target', async ({ authedPage }) => { + await authedPage + .getByRole('link', { name: targetWithManyHosts.name }) + .click(); + + // Check pagination + await expect( + authedPage.getByRole('cell', { name: hosts[0].name, exact: true }), + ).toBeVisible(); + await expect( + authedPage.getByRole('cell', { + name: hosts[hosts.length - 1].name, + exact: true, + }), + ).toBeHidden(); + + // Navigate to the second page. The last host should now be visible. + await authedPage + .getByRole('navigation', { name: 'pagination' }) + .getByRole('button', { name: 'page 2' }) + .click(); + await expect( + authedPage.getByRole('cell', { name: hosts[0].name, exact: true }), + ).toBeHidden(); + await expect( + authedPage.getByRole('cell', { + name: hosts[hosts.length - 1].name, + exact: true, + }), + ).toBeVisible(); + + // Use the "previous page" button to navigate back to the first page. + await authedPage + .getByRole('navigation', { name: 'pagination' }) + .getByRole('button', { name: 'Previous page' }) + .click(); + await expect( + authedPage.getByRole('cell', { name: hosts[0].name, exact: true }), + ).toBeVisible(); + await expect( + authedPage.getByRole('cell', { + name: hosts[hosts.length - 1].name, + exact: true, + }), + ).toBeHidden(); + + // Use the "next page" button to navigate to the second page again. + await authedPage + .getByRole('navigation', { name: 'pagination' }) + .getByRole('button', { name: 'Next page' }) + .click(); + await expect( + authedPage.getByRole('cell', { name: hosts[0].name, exact: true }), + ).toBeHidden(); + await expect( + authedPage.getByRole('cell', { + name: hosts[hosts.length - 1].name, + exact: true, + }), + ).toBeVisible(); + + // Use the "Items per page" options to show 30 items per page. + await authedPage + .getByRole('combobox', { name: 'Items per page' }) + .selectOption('30'); + await expect( + authedPage + .getByRole('row') + .filter({ hasNot: authedPage.getByRole('columnheader') }), + ).toHaveCount(15); + + // Use the "Items per page" options to show 10 items per page again. + await authedPage + .getByRole('combobox', { name: 'Items per page' }) + .selectOption('10'); + await expect( + authedPage + .getByRole('row') + .filter({ hasNot: authedPage.getByRole('columnheader') }), + ).toHaveCount(10); + }); +}); + +// TODO: Add tests for pagination of targets and sessions 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 aeaf6728c..046841384 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js @@ -20,11 +20,22 @@ 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, + ); + } + /** * Connect method that calls parent connect method and handles * connection errors unique to this route @@ -48,4 +59,15 @@ 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/templates/scopes/scope/projects/targets/target.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs index 72f73c2e6..8afa998c4 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs @@ -39,7 +39,7 @@ {{#if (gt @model.hosts.length 1)}} + {{else}} { @@ -107,6 +110,11 @@ module('Acceptance | projects | targets | target', function (hooks) { { scope: instances.scopes.project }, 'withTwoHosts', ); + instances.targetWithManyHosts = this.server.create( + 'target', + { scope: instances.scopes.project }, + 'withManyHosts', + ); instances.alias = this.server.create('alias', { scope: instances.scopes.global, destination_id: instances.targetWithOneHost.id, @@ -128,6 +136,7 @@ module('Acceptance | projects | targets | target', function (hooks) { urls.target = `${urls.targets}/${instances.target.id}`; urls.targetWithOneHost = `${urls.targets}/${instances.targetWithOneHost.id}`; urls.targetWithTwoHosts = `${urls.targets}/${instances.targetWithTwoHosts.id}`; + urls.targetWithManyHosts = `${urls.targets}/${instances.targetWithManyHosts.id}`; // Mock the postMessage interface used by IPC. this.owner.register('service:browser/window', WindowMockIPC); @@ -237,7 +246,7 @@ module('Acceptance | projects | targets | target', function (hooks) { await visit(urls.targets); await click(TARGET_RESOURCE_LINK(targetId)); - assert.dom('tbody tr').exists({ count: 2 }); + assert.dom(TABLE_ROWS).exists({ count: 2 }); }); test('user can see host source table when visiting a target via connect button', async function (assert) { @@ -246,7 +255,7 @@ module('Acceptance | projects | targets | target', function (hooks) { await visit(urls.targets); await click(TARGET_TABLE_CONNECT_BUTTON(targetId)); - assert.dom('tbody tr').exists({ count: 2 }); + assert.dom(TABLE_ROWS).exists({ count: 2 }); }); test('user can connect to a target with two hosts using host source table', async function (assert) { @@ -265,7 +274,7 @@ module('Acceptance | projects | targets | target', function (hooks) { await click(`[href="${urls.targetWithTwoHosts}"]`); - assert.dom('tbody tr').exists({ count: 2 }); + assert.dom(TABLE_ROWS).exists({ count: 2 }); await click(TARGET_HOST_SOURCE_CONNECT_BUTTON(hostId)); @@ -294,6 +303,29 @@ module('Acceptance | projects | targets | target', function (hooks) { }, ); + test('user can use table pagination to see more hosts', async function (assert) { + await visit(urls.targets); + await click(`[href="${urls.targetWithManyHosts}"]`); + + assert.dom(TABLE_ROWS).exists({ count: 10 }); + assert.dom('[data-test-pagination]').isVisible(); + + await click('button[aria-label="Next page"]'); + + assert.dom(TABLE_ROWS).exists({ count: 5 }); + }); + + test('user can change page size', async function (assert) { + await visit(urls.targets); + await click(`[href="${urls.targetWithManyHosts}"]`); + + assert.dom(TABLE_ROWS).exists({ count: 10 }); + + await select('[data-test-pagination] select', '30'); + + assert.dom(TABLE_ROWS).exists({ count: 15 }); + }); + test('user can visit target details screen without read permissions for host-set', async function (assert) { assert.expect(1); this.server.get('/host-sets/:id', () => new Response(403));