From 8be4e93a3c9a23067d937a651b059e5536df8ffa Mon Sep 17 00:00:00 2001 From: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com> Date: Thu, 28 Mar 2024 09:29:08 -0700 Subject: [PATCH] Icu 12590 add filtering pagination support for roles (#2204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add search & pagination for roles ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12590 --- addons/api/addon/services/indexed-db.js | 1 + addons/api/mirage/config.js | 4 +- .../controllers/scopes/scope/roles/index.js | 33 ++++ ui/admin/app/routes/scopes/scope/roles.js | 17 +- .../app/routes/scopes/scope/roles/index.js | 75 ++++++++ .../app/routes/scopes/scope/targets/index.js | 5 +- .../app/routes/scopes/scope/users/index.js | 22 ++- .../templates/scopes/scope/roles/index.hbs | 162 ++++++++++-------- .../templates/scopes/scope/targets/index.hbs | 1 + .../templates/scopes/scope/users/index.hbs | 2 + .../tests/acceptance/roles/create-test.js | 2 + ui/admin/tests/acceptance/roles/list-test.js | 89 ++++++++-- ui/admin/tests/acceptance/roles/read-test.js | 10 +- .../scopes/scope/roles/index-test.js | 22 +++ 14 files changed, 328 insertions(+), 117 deletions(-) create mode 100644 ui/admin/app/controllers/scopes/scope/roles/index.js create mode 100644 ui/admin/tests/unit/controllers/scopes/scope/roles/index-test.js diff --git a/addons/api/addon/services/indexed-db.js b/addons/api/addon/services/indexed-db.js index 2040fe7fc..b56c43ca0 100644 --- a/addons/api/addon/services/indexed-db.js +++ b/addons/api/addon/services/indexed-db.js @@ -13,6 +13,7 @@ export const modelIndexes = { session: '&id, attributes.created_time, attributes.type, attributes.status, attributes.endpoint, attributes.target_id, attributes.user_id, attributes.scope.scope_id', user: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id', + role: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id', }; export const formatDbName = (userId, clusterUrl) => diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 9121f0d0f..227948431 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -324,9 +324,7 @@ function routes() { ); // IAM: Roles - this.get('/roles', ({ roles }, { queryParams: { scope_id: scopeId } }) => { - return roles.where({ scopeId }); - }); + this.get('/roles'); this.post('/roles'); this.get('/roles/:id'); this.patch('/roles/:id'); diff --git a/ui/admin/app/controllers/scopes/scope/roles/index.js b/ui/admin/app/controllers/scopes/scope/roles/index.js new file mode 100644 index 000000000..4af9b3428 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/roles/index.js @@ -0,0 +1,33 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { debounce } from 'core/decorators/debounce'; + +export default class ScopesScopeRolesIndexController extends Controller { + // =services + + @service intl; + + // =attributes + + queryParams = ['search', 'page', 'pageSize']; + + @tracked search = ''; + @tracked page = 1; + @tracked pageSize = 10; + + // =methods + + /** + * 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/admin/app/routes/scopes/scope/roles.js b/ui/admin/app/routes/scopes/scope/roles.js index bb21ac163..4801d1410 100644 --- a/ui/admin/app/routes/scopes/scope/roles.js +++ b/ui/admin/app/routes/scopes/scope/roles.js @@ -13,8 +13,6 @@ import { notifySuccess, notifyError } from 'core/decorators/notify'; export default class ScopesScopeRolesRoute extends Route { // =services - @service store; - @service intl; @service session; @service can; @service router; @@ -28,19 +26,8 @@ export default class ScopesScopeRolesRoute extends Route { if (!this.session.isAuthenticated) this.router.transitionTo('index'); } - /** - * Load all roles under current scope. - * @return {Promise{[RoleModel]}} - */ - async model() { - const scope = this.modelFor('scopes.scope'); - const { id: scope_id } = scope; - if (this.can.can('list model', scope, { collection: 'roles' })) { - return this.store.query('role', { scope_id }); - } - } - // =actions + /** * Rollback changes on an role. * @param {RoleModel} role @@ -83,7 +70,7 @@ export default class ScopesScopeRolesRoute extends Route { @notifySuccess('notifications.delete-success') async delete(role) { await role.destroyRecord(); - await this.router.replaceWith('scopes.scope.roles'); + this.router.replaceWith('scopes.scope.roles'); this.refresh(); } } diff --git a/ui/admin/app/routes/scopes/scope/roles/index.js b/ui/admin/app/routes/scopes/scope/roles/index.js index 2992c0a6e..62bf916fe 100644 --- a/ui/admin/app/routes/scopes/scope/roles/index.js +++ b/ui/admin/app/routes/scopes/scope/roles/index.js @@ -4,9 +4,84 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + export default class ScopesScopeRolesIndexRoute extends Route { + // =attributes + + queryParams = { + search: { + refreshModel: true, + replace: true, + }, + page: { + refreshModel: true, + }, + pageSize: { + refreshModel: true, + }, + }; + + // =services + + @service can; + @service store; + // =methods + /** + * Loads queried roles and the number of roles under current scope. + * @returns {Promise<{totalItems: number, users: [RoleModel], rolesExist: boolean }> } + */ + async model({ search, page, pageSize }) { + const scope = this.modelFor('scopes.scope'); + const { id: scope_id } = scope; + const filters = { + scope_id: [{ equals: scope_id }], + }; + + let roles; + let totalItems = 0; + if (this.can.can('list model', scope, { collection: 'roles' })) { + roles = await this.store.query('role', { + query: { search, filters }, + page, + pageSize, + }); + totalItems = roles.meta?.totalItems; + } + const rolesExist = await this.getRolesExist(scope_id, totalItems); + + return { roles, rolesExist, totalItems }; + } + + /** + * Sets rolesExist to true if there exists any roles. + * @param {string} scope_id + * @param {number} totalItems + * @returns + */ + async getRolesExist(scope_id, totalItems) { + if (totalItems > 0) { + return true; + } + const options = { pushToStore: false }; + const role = await this.store.query( + 'role', + { + query: { + filters: { + scope_id: [{ equals: scope_id }], + }, + }, + page: 1, + pageSize: 1, + }, + options, + ); + return role.length > 0; + } + setupController(controller) { const scope = this.modelFor('scopes.scope'); super.setupController(...arguments); diff --git a/ui/admin/app/routes/scopes/scope/targets/index.js b/ui/admin/app/routes/scopes/scope/targets/index.js index 9a8006f40..40e729861 100644 --- a/ui/admin/app/routes/scopes/scope/targets/index.js +++ b/ui/admin/app/routes/scopes/scope/targets/index.js @@ -43,8 +43,9 @@ export default class ScopesScopeTargetsIndexRoute extends Route { // =methods /** - * Loads all targets under current scope. - * @return {Promise{[TargetModel]}} + * Loads queried targets, the number of targets under current scope, and + * active sessions filtering options. + * @returns {Promise<{totalItems: number, targets: [TargetModel], allTargets: [TargetModel] }> } */ async model({ search, availableSessions, types, page, pageSize }) { const scope = this.modelFor('scopes.scope'); diff --git a/ui/admin/app/routes/scopes/scope/users/index.js b/ui/admin/app/routes/scopes/scope/users/index.js index e03323622..f41ad10b8 100644 --- a/ui/admin/app/routes/scopes/scope/users/index.js +++ b/ui/admin/app/routes/scopes/scope/users/index.js @@ -30,8 +30,8 @@ export default class ScopesScopeUsersIndexRoute extends Route { // =methods /** - * Load all users under current scope. - * @return {Promise{[UserModel]}} + * Loads queried users and the number of users under current scope. + * @returns {Promise<{totalItems: number, users: [UserModel], usersExist: boolean }> } */ async model({ search, page, pageSize }) { const scope = this.modelFor('scopes.scope'); @@ -41,8 +41,6 @@ export default class ScopesScopeUsersIndexRoute extends Route { scope_id: [{ equals: scope_id }], }; - await this.getUsersExist(scope_id); - let users; let totalItems = 0; if (this.can.can('list model', scope, { collection: 'users' })) { @@ -53,11 +51,21 @@ export default class ScopesScopeUsersIndexRoute extends Route { }); totalItems = users.meta?.totalItems; } + const usersExist = await this.getUsersExist(scope_id, totalItems); - return { users, usersExist: this.usersExist, totalItems }; + return { users, usersExist, totalItems }; } - async getUsersExist(scopeId) { + /** + * Sets usersExist to true if there exists any users. + * @param {string} scopeId + * @param {number} totalItems + * @returns + */ + async getUsersExist(scopeId, totalItems) { + if (totalItems > 0) { + return true; + } const options = { pushToStore: false }; const user = await this.store.query( 'user', @@ -72,7 +80,7 @@ export default class ScopesScopeUsersIndexRoute extends Route { }, options, ); - this.usersExist = user.length > 0; + return user.length > 0; } setupController(controller) { diff --git a/ui/admin/app/templates/scopes/scope/roles/index.hbs b/ui/admin/app/templates/scopes/scope/roles/index.hbs index b2b85ec67..2940994a9 100644 --- a/ui/admin/app/templates/scopes/scope/roles/index.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/index.hbs @@ -14,89 +14,111 @@ {{t 'resources.role.title_plural'}} - {{#if @model}} + {{#if @model.rolesExist}}

{{t 'resources.role.description'}}

{{/if}} {{#if (can 'create model' this.scope collection='roles')}} - {{#if @model}} - {{t - 'resources.role.titles.new' - }} + {{#if @model.rolesExist}} + {{/if}} {{/if}} - - - {{#if @model}} - - - <:body as |B|> - - - {{#if (can 'read role' B.data)}} - - {{B.data.displayName}} - - {{else}} - {{B.data.displayName}} - {{/if}} -

{{B.data.description}}

-
- - - -
- -
- - {{else}} - - - + {{#if @model.rolesExist}} + + + + {{#if @model.roles}} + - - {{#if (can 'list model' this.scope collection='roles')}} - {{! can list (at least): show default welcome message}} - {{t 'resources.role.description'}} - {{else if (can 'create model' this.scope collection='roles')}} - {{! can create (only): show create-but-not-list welcome message}} - {{t + <:body as |B|> + + + {{#if (can 'read role' B.data)}} + + {{B.data.displayName}} + + {{else}} + {{B.data.displayName}} + {{/if}} +

{{B.data.description}}

+
+ + + +
+ +
+ + {{else}} + + + + + {{/if}} + {{else}} + + + - {{#if (can 'create model' this.scope collection='roles')}} - - - {{t 'titles.new'}} - - {{/if}} -
-
- + ) + ) + resource=(t 'resources.role.title_plural') + }} + /> + {{#if (can 'create model' this.scope collection='roles')}} + + + + {{/if}} + {{/if}}
diff --git a/ui/admin/app/templates/scopes/scope/targets/index.hbs b/ui/admin/app/templates/scopes/scope/targets/index.hbs index f7f223769..be2bc44e7 100644 --- a/ui/admin/app/templates/scopes/scope/targets/index.hbs +++ b/ui/admin/app/templates/scopes/scope/targets/index.hbs @@ -174,6 +174,7 @@ 'descriptions.neither-list-nor-create' ) ) + resource=(t 'resources.target.title_plural') }} /> {{#if (can 'create model' this.scope collection='targets')}} diff --git a/ui/admin/app/templates/scopes/scope/users/index.hbs b/ui/admin/app/templates/scopes/scope/users/index.hbs index 3f904cdac..c3d5ea57f 100644 --- a/ui/admin/app/templates/scopes/scope/users/index.hbs +++ b/ui/admin/app/templates/scopes/scope/users/index.hbs @@ -48,6 +48,7 @@ (hash label=(t 'form.name.label')) (hash label=(t 'form.id.label')) }} + @density='short' @valign='middle' > <:body as |B|> @@ -108,6 +109,7 @@ 'descriptions.neither-list-nor-create' ) ) + resource=(t 'resources.user.title_plural') }} /> {{#if (can 'create model' this.scope collection='users')}} diff --git a/ui/admin/tests/acceptance/roles/create-test.js b/ui/admin/tests/acceptance/roles/create-test.js index ab344d1fd..bfe5ed2b4 100644 --- a/ui/admin/tests/acceptance/roles/create-test.js +++ b/ui/admin/tests/acceptance/roles/create-test.js @@ -7,6 +7,7 @@ import { module, test } from 'qunit'; import { visit, currentURL, click, fillIn, find } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { Response } from 'miragejs'; import { @@ -19,6 +20,7 @@ import { module('Acceptance | roles | create', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); const instances = { scopes: { diff --git a/ui/admin/tests/acceptance/roles/list-test.js b/ui/admin/tests/acceptance/roles/list-test.js index 90a787315..00976612a 100644 --- a/ui/admin/tests/acceptance/roles/list-test.js +++ b/ui/admin/tests/acceptance/roles/list-test.js @@ -4,9 +4,10 @@ */ import { module, test } from 'qunit'; -import { visit, find } from '@ember/test-helpers'; +import { visit, findAll, click, fillIn, waitUntil } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import { authenticateSession, // These are left here intentionally for future reference. @@ -17,19 +18,26 @@ import { module('Acceptance | roles | list', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); + + const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]'; + const NO_RESULTS_MSG_SELECTOR = '[data-test-no-role-results]'; const instances = { scopes: { global: null, org: null, }, + role1: null, + role2: null, }; const urls = { - scopes: { - org: null, - }, + globalScope: null, + orgScope: null, roles: null, + role1: null, + role2: null, }; hooks.beforeEach(function () { @@ -38,35 +46,80 @@ module('Acceptance | roles | list', function (hooks) { type: 'org', scope: { id: 'global', type: 'global' }, }); - instances.role = this.server.create('role', { + instances.role1 = this.server.create('role', { scope: instances.scopes.org, }); - urls.scopes.org = `/scopes/${instances.scopes.org.id}`; - + instances.role2 = this.server.create('role', { + scope: instances.scopes.org, + }); + urls.globalScope = `/scopes/global/scopes`; + urls.orgScope = `/scopes/${instances.scopes.org.id}`; urls.roles = `/scopes/${instances.scopes.org.id}/roles`; + urls.role1 = `${urls.roles}/${instances.role1.id}`; + urls.role2 = `${urls.roles}/${instances.role2.id}`; authenticateSession({}); }); - test('Users can navigate to roles with proper authorization', async function (assert) { - await visit(urls.scopes.org); - assert.ok( + test('users can navigate to roles with proper authorization', async function (assert) { + await visit(urls.globalScope); + + await click(`[href="${urls.orgScope}"]`); + + assert.true( instances.scopes.org.authorized_collection_actions.roles.includes('list'), ); - assert.ok(find(`[href="${urls.roles}"]`)); + assert.dom(`[href="${urls.roles}"]`).exists(); }); - test('Users cannot navigate to index without either list or create actions', async function (assert) { + test('users cannot navigate to index without either list or create actions', async function (assert) { instances.scopes.org.authorized_collection_actions.roles = []; - await visit(urls.scopes.org); - assert.notOk( + await visit(urls.globalScope); + + await click(`[href="${urls.orgScope}"]`); + + assert.false( instances.scopes.org.authorized_collection_actions.roles.includes('list'), ); - assert.notOk(find(`[href="${urls.roles}"]`)); + assert.dom(`[href="${urls.roles}"]`).doesNotExist(); }); - test('Users can navigate to index with only create action', async function (assert) { + test('users can navigate to index with only create action', async function (assert) { instances.scopes.org.authorized_collection_actions.roles = ['create']; - await visit(urls.scopes.org); - assert.ok(find(`[href="${urls.roles}"]`)); + await visit(urls.globalScope); + + await click(`[href="${urls.orgScope}"]`); + + assert.dom(`[href="${urls.roles}"]`).exists(); + }); + + test('user can search for a specifc role by id', async function (assert) { + await visit(urls.orgScope); + + await click(`[href="${urls.roles}"]`); + + assert.dom(`[href="${urls.role1}"]`).exists(); + assert.dom(`[href="${urls.role2}"]`).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, instances.role1.id); + await waitUntil(() => findAll(`[href="${urls.role2}"]`).length === 0); + + assert.dom(`[href="${urls.role1}"]`).exists(); + assert.dom(`[href="${urls.role2}"]`).doesNotExist(); + }); + + test('user can search for roles and get no results', async function (assert) { + await visit(urls.orgScope); + + await click(`[href="${urls.roles}"]`); + + assert.dom(`[href="${urls.role1}"]`).exists(); + assert.dom(`[href="${urls.role2}"]`).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, 'fake role that does not exist'); + await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1); + + assert.dom(`[href="${urls.role1}"]`).doesNotExist(); + assert.dom(`[href="${urls.role2}"]`).doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); }); }); diff --git a/ui/admin/tests/acceptance/roles/read-test.js b/ui/admin/tests/acceptance/roles/read-test.js index 6f9151df6..8bc4844e0 100644 --- a/ui/admin/tests/acceptance/roles/read-test.js +++ b/ui/admin/tests/acceptance/roles/read-test.js @@ -7,6 +7,7 @@ import { module, test } from 'qunit'; import { visit, currentURL, click, find } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { authenticateSession, @@ -18,6 +19,7 @@ import { module('Acceptance | roles | read', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); const instances = { scopes: { @@ -55,7 +57,8 @@ module('Acceptance | roles | read', function (hooks) { }, 'withPrincipals', ); - urls.roles = `/scopes/${instances.scopes.org.id}/roles`; + urls.orgScope = `/scopes/${instances.scopes.org.id}`; + urls.roles = `${urls.orgScope}/roles`; urls.role = `${urls.roles}/${instances.role.id}`; urls.newRole = `${urls.roles}/new`; }); @@ -67,7 +70,10 @@ module('Acceptance | roles | read', function (hooks) { }); test('can navigate to a role form', async function (assert) { - await visit(urls.roles); + await visit(urls.orgScope); + + await click(`[href="${urls.roles}"]`); + await click('main tbody .hds-table__tr:nth-child(1) a'); await a11yAudit(); assert.strictEqual(currentURL(), urls.role); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/index-test.js new file mode 100644 index 000000000..baf825d06 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/index-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { waitUntil } from '@ember/test-helpers'; + +module('Unit | Controller | scopes/scope/roles/index', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/roles/index'); + assert.ok(controller); + }); + + test('handleSearchInput action sets expected values correctly', async function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/roles/index'); + const searchValue = 'test'; + controller.handleSearchInput({ target: { value: searchValue } }); + await waitUntil(() => controller.search === searchValue); + + assert.strictEqual(controller.page, 1); + assert.strictEqual(controller.search, searchValue); + }); +});