From eb365f0d3991cd24f8ff51f689b786eac569340c Mon Sep 17 00:00:00 2001 From: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:26:34 -0700 Subject: [PATCH] Icu 17197 update logic in global roles to only add project scopes when using children (#2874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 hide manage custom scopes button when using keyword * refactor: 💡 update to only allow projects for global roles * refactor: 💡 clean-up ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17197 --- addons/core/translations/resources/en-us.yaml | 5 +- .../form/role/manage-custom-scopes/index.hbs | 205 +++++++++++++----- .../form/role/manage-custom-scopes/index.js | 26 ++- .../manage-scopes/manage-custom-scopes.js | 45 +++- .../manage-scopes/manage-custom-scopes.js | 91 +++++--- ui/admin/app/styles/app.scss | 4 + .../manage-scopes/manage-custom-scopes.hbs | 14 +- .../scopes/scope/roles/role/scopes.hbs | 2 +- .../acceptance/roles/global-scope-test.js | 113 ++++++++++ 9 files changed, 405 insertions(+), 100 deletions(-) diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index e524bfdab..db10a8a63 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -631,8 +631,11 @@ role: link: You can read more about it here manage-custom-scopes: title: Manage Custom Scopes - description: Select which specific orgs you want to apply to this role's grant sets. + description: + 0: Select which specific orgs you want to apply to this role's grant sets. + 1: Select specific projects from each org to apply to this role's grant sets. You cannot add specific orgs because applying "children" has already added them to this role. success: Custom scope selection saved + tooltip: The org this project is under. manage-org-projects: title: Manage Projects in {orgDisplayName} description: Select which projects in this org you want to apply to this role's grant sets. diff --git a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs index a23439d9c..b213f15ad 100644 --- a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs +++ b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs @@ -19,68 +19,153 @@ aria-label={{t 'actions.search'}} {{on 'input' @handleSearchInput}} /> + {{#unless @model.canSelectOrgs}} + + + + {{/unless}} - {{#if @model.orgScopes}} -
- {{#if @model.role.grantScopeOrgIDs.length}} - - {{t - 'resources.role.scope.titles.selected' - items=@model.role.grantScopeOrgIDs.length - total=@model.totalItemsCount - }} - - {{/if}} -
- - <:body as |B|> - - - - {{B.data.displayName}} - - - - - - - {{#let (get @model.projectTotals B.data.id) as |projectTotals|}} - {{#if projectTotals}} - + + {{#if @model.scopes}} + {{#if @model.canSelectOrgs}} +
+ {{#if @model.role.grantScopeOrgIDs.length}} + + {{t + 'resources.role.scope.titles.selected' + items=@model.role.grantScopeOrgIDs.length + total=@model.totalItemsCount + }} + + {{/if}} +
+ + + <:body as |B|> + + + + {{B.data.displayName}} + + + + + + + {{#let (get @model.projectTotals B.data.id) as |projectTotals|}} + {{#if projectTotals}} + + {{/if}} + {{/let}} + + + + + {{else}} + + +
+ {{#if @model.role.grantScopeProjectIDs.length}} + + {{t + 'resources.role.scope.titles.selected' + items=@model.role.grantScopeProjectIDs.length + total=@model.totalItemsCount + }} + + {{/if}} +
+ + + <:body as |B|> + + + + {{B.data.displayName}} + + + + + + + {{#if (can 'read model' B.data)}} + + {{~or B.data.scope.name B.data.scope.id}} + + {{else}} + {{or B.data.scope.name B.data.scope.id}} {{/if}} - {{/let}} - - - - +
+
+ +
+ {{/if}} id === this.selectedOrg, ); return org.displayName; @@ -27,12 +27,12 @@ export default class FormRoleManageCustomScopesIndexComponent extends Component // =actions /** - * Handles the selection changes for the paginated table. + * Handles the org selection changes for the paginated table. * @param {object} selectableRowsStates * @param {object} selectionKey */ @action - selectionChange({ selectableRowsStates, selectionKey }) { + orgSelectionChange({ selectableRowsStates, selectionKey }) { const { role, projectTotals } = this.args.model; const addOrRemoveValues = (add, remove, orgId) => { @@ -109,4 +109,24 @@ export default class FormRoleManageCustomScopesIndexComponent extends Component toggleRemoveAllModal() { this.selectedOrgs = []; } + + /** + * Handles the project selection changes for the paginated table. + * @param {object} selectableRowsStates + */ + @action + projectSelectionChange({ selectableRowsStates }) { + const { role } = this.args.model; + selectableRowsStates.forEach((row) => { + const { isSelected, selectionKey: key } = row; + const includesId = role.grant_scope_ids.includes(key); + if (isSelected && !includesId) { + role.grant_scope_ids = [...role.grant_scope_ids, key]; + } else if (!isSelected && includesId) { + role.grant_scope_ids = role.grant_scope_ids.filter( + (item) => item !== key, + ); + } + }); + } } diff --git a/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js b/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js index 1d8802549..99620c11e 100644 --- a/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js +++ b/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js @@ -19,12 +19,43 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesControlle // =attributes - queryParams = ['search', 'page', 'pageSize']; + queryParams = ['search', { orgs: { type: 'array' } }, 'page', 'pageSize']; @tracked search; + @tracked orgs = []; @tracked page = 1; @tracked pageSize = 10; + /** + * Returns unique org scopes from projects. + * @returns {[object]} + */ + get orgScopes() { + const uniqueMap = new Map(); + this.model.allProjects.forEach(({ scope: { id, name } }) => { + if (!uniqueMap.has(id)) { + const orgName = name || id; + uniqueMap.set(id, { id, name: orgName }); + } + }); + return Array.from(uniqueMap.values()); + } + + /** + * Returns the filters object used for displaying filter tags. + * @type {object} + */ + get filters() { + return { + allFilters: { + orgs: this.orgScopes, + }, + selectedFilters: { + orgs: this.orgs, + }, + }; + } + // =actions /** @@ -38,6 +69,18 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesControlle this.page = 1; } + /** + * Sets a query param to the value of selectedItems + * and resets the page to 1. + * @param {string} paramKey + * @param {[string]} selectedItems + */ + @action + applyFilter(paramKey, selectedItems) { + this[paramKey] = [...selectedItems]; + this.page = 1; + } + /** * Save grant scope IDs to current role via the API. * @param {RoleModel} role diff --git a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js index 462381b71..479991258 100644 --- a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js +++ b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js @@ -9,6 +9,7 @@ import { service } from '@ember/service'; import { TrackedObject } from 'tracked-built-ins'; import { TYPE_SCOPE_PROJECT } from 'api/models/scope'; import { restartableTask, timeout } from 'ember-concurrency'; +import { GRANT_SCOPE_CHILDREN } from 'api/models/role'; export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute extends Route { // =attributes @@ -18,6 +19,10 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext refreshModel: true, replace: true, }, + orgs: { + refreshModel: true, + replace: true, + }, page: { refreshModel: true, }, @@ -36,7 +41,7 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext /** * Loads sub scopes for the current scope. - * @returns {Promise<{role: RoleModel, orgScopes: [ScopeModel], projectTotals: object, totalItems: number, totalItemsCount: number}> } + * @returns {Promise<{role: RoleModel, orgScopes: [ScopeModel], projectTotals: object, totalItems: number, totalItemsCount: number, canSelectOrgs: boolean}> } */ async model(params) { const useDebounce = @@ -45,46 +50,75 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext } retrieveData = restartableTask( - async ({ search, page, pageSize, useDebounce }) => { + async ({ search, orgs, page, pageSize, useDebounce }) => { if (useDebounce) { await timeout(250); } const role = this.modelFor('scopes.scope.roles.role'); - const currentScope = this.modelFor('scopes.scope'); - const { id: scope_id } = currentScope; - const filters = { - scope_id: [{ equals: scope_id }], - }; + const canSelectOrgs = + !role.grant_scope_ids.includes(GRANT_SCOPE_CHILDREN); - const orgScopes = await this.store.query('scope', { - scope_id, + let filters; + if (canSelectOrgs) { + filters = { scope_id: [{ equals: 'global' }] }; + } else { + filters = { type: [{ equals: TYPE_SCOPE_PROJECT }], scope_id: [] }; + orgs.forEach((org) => { + filters.scope_id.push({ equals: org }); + }); + } + const scopes = await this.store.query('scope', { + scope_id: 'global', query: { search, filters }, page, pageSize, recursive: true, }); - const totalItems = orgScopes.meta?.totalItems; + + let projectTotals; + let allProjects; + if (canSelectOrgs) { + projectTotals = await this.getProjectTotals(role.grantScopeProjectIDs); + } else { + allProjects = await this.getAllProjects(); + } + + const totalItems = scopes.meta?.totalItems; const totalItemsCount = await this.getTotalItemsCount( - scope_id, search, totalItems, - ); - - const projectTotals = await this.getProjectTotals( - role.grantScopeProjectIDs, + canSelectOrgs, ); return { role, - orgScopes, + scopes, projectTotals, + allProjects, totalItems, totalItemsCount, + canSelectOrgs, }; }, ); + /** + * Retrieves + * @returns {Promise<[ScopeModel]>} + */ + async getAllProjects() { + const options = { pushToStore: false, peekIndexedDB: true }; + return this.store.query( + 'scope', + { + scope_id: 'global', + query: { filters: { type: [{ equals: TYPE_SCOPE_PROJECT }] } }, + }, + options, + ); + } + /** * Creates an object that contains the number of selected projects * and the total number of projects for each org scope. @@ -92,15 +126,7 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext * @returns {object} */ async getProjectTotals(projectIDs) { - const options = { pushToStore: false, peekIndexedDB: true }; - const projects = await this.store.query( - 'scope', - { - scope_id: 'global', - query: { filters: { type: [{ equals: TYPE_SCOPE_PROJECT }] } }, - }, - options, - ); + const projects = await this.getAllProjects(); // We want this object to be tracked so that changes to this object // cause the "Projects selected" column to re-render with updates. @@ -121,25 +147,24 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext /** * Sets scopesExist to true if there exists any scopes and * sets totalItemsCount to the number of scopes that exist. - * @param {string} scope_id * @param {string} search * @param {number} totalItems + * @param {boolean} canSelectOrgs * @returns {Promise} */ - async getTotalItemsCount(scope_id, search, totalItems) { + async getTotalItemsCount(search, totalItems, canSelectOrgs) { if (!search) { return totalItems; } const options = { pushToStore: false, peekIndexedDB: true }; + const filters = canSelectOrgs + ? { scope_id: [{ equals: 'global' }] } + : { type: [{ equals: TYPE_SCOPE_PROJECT }] }; const scopes = await this.store.query( 'scope', { - scope_id, - query: { - filters: { - scope_id: [{ equals: scope_id }], - }, - }, + scope_id: 'global', + query: { filters }, page: 1, pageSize: 1, }, diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index ef2b365a3..a627600ab 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -904,6 +904,10 @@ } .role-manage-custom-scopes-form { + .filters-applied { + margin-top: 1rem; + } + .table-selected-items-text { display: flex; justify-content: end; diff --git a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs index 91880b639..092274500 100644 --- a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs @@ -21,7 +21,15 @@ - {{t 'resources.role.scope.messages.manage-custom-scopes.description'}} + {{#if @model.canSelectOrgs}} + {{t + 'resources.role.scope.messages.manage-custom-scopes.description.0' + }} + {{else}} + {{t + 'resources.role.scope.messages.manage-custom-scopes.description.1' + }} + {{/if}} @@ -30,9 +38,13 @@ diff --git a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs index 48a05249c..793b64f81 100644 --- a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs @@ -168,7 +168,7 @@ @icon='org' @iconPosition='leading' > - {{~if B.data.scope.name B.data.scope.name B.data.scope.id}} + {{~or B.data.scope.name B.data.scope.id}} {{/if}} diff --git a/ui/admin/tests/acceptance/roles/global-scope-test.js b/ui/admin/tests/acceptance/roles/global-scope-test.js index 0eb4b2dfb..9bda46181 100644 --- a/ui/admin/tests/acceptance/roles/global-scope-test.js +++ b/ui/admin/tests/acceptance/roles/global-scope-test.js @@ -11,6 +11,7 @@ import { findAll, fillIn, waitUntil, + waitFor, } from '@ember/test-helpers'; import { setupApplicationTest } from 'admin/tests/helpers'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; @@ -714,6 +715,118 @@ module('Acceptance | roles | global-scope', function (hooks) { .includesText('No results found'); }); + test('user can select projects when `children` is applied on manage custom scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] }); + await visit(urls.role); + + await click(commonSelectors.HREF(urls.roleScopes)); + + assert.dom(selectors.GRANT_SCOPE_ROW(GRANT_SCOPE_CHILDREN)).isVisible(); + + await click(selectors.MANAGE_DROPDOWN_ROLES); + await click(selectors.MANAGE_DROPDOWN_ROLES_SCOPES); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(commonSelectors.HREF(urls.manageCustomScopes)); + + assert.strictEqual(currentURL(), urls.manageCustomScopes); + + // Click three times to select, unselect, then reselect (for coverage) + const projectID = instances.scopes.project.id; + await click(selectors.SCOPE_CHECKBOX('project', projectID)); + await click(selectors.SCOPE_CHECKBOX('project', projectID)); + await click(selectors.SCOPE_CHECKBOX('project', projectID)); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(currentURL(), urls.manageScopes); + assert.dom(selectors.MANAGE_CUSTOM_SCOPES_BUTTON_ICON).isVisible(); + + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 2 }); + assert.dom(selectors.GRANT_SCOPE_ROW(GRANT_SCOPE_CHILDREN)).isVisible(); + assert.dom(selectors.GRANT_SCOPE_ROW(projectID)).isVisible(); + }); + + test('user can search for a specific project scope by id on manage custom scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] }); + const anotherProject = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + await visit(urls.manageScopes); + + await click(commonSelectors.HREF(urls.manageCustomScopes)); + + assert + .dom(selectors.SCOPE_CHECKBOX('project', instances.scopes.project.id)) + .isVisible(); + assert + .dom(selectors.SCOPE_CHECKBOX('project', anotherProject.id)) + .isVisible(); + + await fillIn(commonSelectors.SEARCH_INPUT, instances.scopes.project.id); + await waitFor(selectors.SCOPE_CHECKBOX('project', anotherProject.id), { + count: 0, + }); + + assert + .dom(selectors.SCOPE_CHECKBOX('project', instances.scopes.project.id)) + .isVisible(); + assert + .dom(selectors.SCOPE_CHECKBOX('project', anotherProject.id)) + .doesNotExist(); + }); + + test('user can search for a specific project scope by id and get no results on manage custom scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] }); + await visit(urls.manageScopes); + + await click(commonSelectors.HREF(urls.manageCustomScopes)); + + assert + .dom(selectors.SCOPE_CHECKBOX('project', instances.scopes.project.id)) + .isVisible(); + + await fillIn( + commonSelectors.SEARCH_INPUT, + 'fake project scope that does not exist', + ); + await waitFor(selectors.NO_RESULTS_GRANT_SCOPE_MSG, { count: 1 }); + + assert + .dom(selectors.NO_RESULTS_GRANT_SCOPE_MSG) + .includesText('No results found'); + }); + + test('user can filter a project scope by parent scope on manage custom scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] }); + const anotherOrg = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + const anotherProject = this.server.create('scope', { + type: 'project', + scope: { id: anotherOrg.id, type: 'org' }, + }); + await visit(urls.manageScopes); + + await click(commonSelectors.HREF(urls.manageCustomScopes)); + + assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 2 }); + + await click(commonSelectors.FILTER_DROPDOWN('org')); + await click(commonSelectors.FILTER_DROPDOWN_ITEM(anotherOrg.id)); + await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('org')); + + assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 1 }); + assert + .dom(selectors.SCOPE_CHECKBOX('project', anotherProject.id)) + .isVisible(); + }); + test('user can save custom scopes to add on manage org projects page', async function (assert) { instances.role.update({ grant_scope_ids: [] }); await visit(urls.role);