diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml
index ff85c94f4..c6b9cc28e 100644
--- a/addons/core/translations/resources/en-us.yaml
+++ b/addons/core/translations/resources/en-us.yaml
@@ -615,6 +615,10 @@ role:
help: Customize which scopes are associated with this role.
view-projects: '{selected}/{total} total projects'
apply: Apply changes to org
+ remove-org-and-projects: Remove org and projects
+ remove-org-only: Remove just the org
+ remove-orgs-and-projects: Remove orgs and projects
+ remove-orgs-only: Remove just the orgs
form:
this:
label: Add this scope
@@ -627,6 +631,12 @@ role:
descendants:
label: Add all descendants
help: Add all of the orgs and the projects in the orgs that are underneath this scope.
+ remove-org:
+ title: Remove this org?
+ description: You're about to remove {orgDisplayName}. Do you want to remove the projects under {orgDisplayName} as well? Or do you just want to remove {orgDisplayName} from this project?
+ remove-all-orgs:
+ title: Remove all selected orgs?
+ description: You're about to remove all the selected orgs. Do you want to remove the projects under these orgs as well? Or do you just want to remove just the orgs?
target:
title: Target
title_plural: Targets
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 841c3a3d6..67c4d53cb 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
@@ -69,7 +69,7 @@
@iconPosition='trailing'
@text={{t
'resources.role.scope.actions.view-projects'
- selected=projectTotals.selected
+ selected=projectTotals.selected.length
total=projectTotals.total
}}
@route='scopes.scope.roles.role.manage-scopes.manage-org-projects'
@@ -127,4 +127,63 @@
/>
+{{/if}}
+
+{{#if this.selectedOrg}}
+
+ {{t 'resources.role.scope.remove-org.title'}}
+
+
+ {{t
+ 'resources.role.scope.remove-org.description'
+ orgDisplayName=this.orgDisplayName
+ }}
+
+
+
+
+
+
+
+
+
+{{/if}}
+
+{{#if this.selectedOrgs}}
+
+ {{t 'resources.role.scope.remove-all-orgs.title'}}
+
+
+ {{t 'resources.role.scope.remove-all-orgs.description'}}
+
+
+
+
+
+
+
+
+
{{/if}}
\ No newline at end of file
diff --git a/ui/admin/app/components/form/role/manage-custom-scopes/index.js b/ui/admin/app/components/form/role/manage-custom-scopes/index.js
index dc67d0b91..cce8f3aae 100644
--- a/ui/admin/app/components/form/role/manage-custom-scopes/index.js
+++ b/ui/admin/app/components/form/role/manage-custom-scopes/index.js
@@ -5,27 +5,108 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
export default class FormRoleManageCustomScopesIndexComponent extends Component {
+ // =attributes
+
+ @tracked selectedOrgs = [];
+ @tracked selectedOrg = '';
+
+ /**
+ * Returns the display name of the selectedOrg.
+ * @type {string}
+ */
+ get orgDisplayName() {
+ const org = this.args.model.orgScopes.find(
+ ({ id }) => id === this.selectedOrg,
+ );
+ return org.displayName;
+ }
+
// =actions
/**
* Handles the selection changes for the paginated table.
* @param {object} selectableRowsStates
+ * @param {object} selectionKey
*/
@action
- selectionChange({ 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) {
+ selectionChange({ selectableRowsStates, selectionKey }) {
+ const { role, projectTotals } = this.args.model;
+
+ const addOrRemoveValues = (add, remove, orgId) => {
+ let selectedOrg;
+ const includesId = role.grant_scope_ids.includes(orgId);
+ const selected = projectTotals[orgId]?.selected;
+ if (add && !includesId) {
+ role.grant_scope_ids = [...role.grant_scope_ids, orgId];
+ } else if (remove && includesId) {
+ if (selected?.length) {
+ selectedOrg = orgId;
+ }
role.grant_scope_ids = role.grant_scope_ids.filter(
- (item) => item !== key,
+ (item) => item !== orgId,
);
}
+ return selectedOrg;
+ };
+
+ if (selectionKey === 'all') {
+ const selectedOrgs = [];
+ selectableRowsStates.forEach(({ selectionKey, isSelected }) => {
+ const selectedOrg = addOrRemoveValues(
+ isSelected,
+ !isSelected,
+ selectionKey,
+ );
+ if (selectedOrg) {
+ selectedOrgs.push(selectedOrg);
+ }
+ });
+ this.selectedOrgs = selectedOrgs;
+ } else {
+ this.selectedOrg = addOrRemoveValues(true, true, selectionKey);
+ }
+ }
+
+ /**
+ * Removes projects from an org that was deselected and toggles the correct modal off.
+ * @param {boolean} toggleRemoveAllModal
+ */
+ @action
+ removeProjects(toggleRemoveAllModal) {
+ const selectedOrgs = toggleRemoveAllModal
+ ? this.selectedOrgs
+ : [this.selectedOrg];
+ const { role, projectTotals } = this.args.model;
+ selectedOrgs.forEach((orgId) => {
+ const { selected, total } = projectTotals[orgId];
+ role.grant_scope_ids = role.grant_scope_ids.filter(
+ (item) => !selected.includes(item),
+ );
+ projectTotals[orgId] = { selected: [], total };
});
+ if (toggleRemoveAllModal) {
+ this.toggleRemoveAllModal();
+ } else {
+ this.toggleRemoveOrgModal();
+ }
+ }
+
+ /**
+ * Toggles the modal to remove an org and projects off.
+ */
+ @action
+ toggleRemoveOrgModal() {
+ this.selectedOrg = '';
+ }
+
+ /**
+ * Toggles the modal to remove orgs and projects off.
+ */
+ @action
+ toggleRemoveAllModal() {
+ this.selectedOrgs = [];
}
}
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 075c785d5..e5ed284d9 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
@@ -6,6 +6,7 @@
import Route from '@ember/routing/route';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+import { TrackedObject } from 'tracked-built-ins';
import { TYPE_SCOPE_PROJECT } from 'api/models/scope';
export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute extends Route {
@@ -88,13 +89,15 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext
options,
);
- const projectTotals = {};
+ // We want this object to be tracked so that changes to this object
+ // cause the "Projects selected" column to re-render with updates.
+ const projectTotals = new TrackedObject({});
projects.forEach(({ id, scope }) => {
if (!projectTotals[scope.id]) {
- projectTotals[scope.id] = { selected: 0, total: 0 };
+ projectTotals[scope.id] = { selected: [], total: 0 };
}
if (projectIDs.includes(id)) {
- projectTotals[scope.id].selected++;
+ projectTotals[scope.id].selected.push(id);
}
projectTotals[scope.id].total++;
});
diff --git a/ui/admin/tests/acceptance/roles/global-scope-test.js b/ui/admin/tests/acceptance/roles/global-scope-test.js
index 47ea17e27..660286fcb 100644
--- a/ui/admin/tests/acceptance/roles/global-scope-test.js
+++ b/ui/admin/tests/acceptance/roles/global-scope-test.js
@@ -62,6 +62,11 @@ module('Acceptance | roles | global-scope', function (hooks) {
'.rose-dialog-footer .rose-button-primary';
const DISCARD_CHANGES_CANCEL_BUTTON =
'.rose-dialog-footer .rose-button-secondary';
+ const REMOVE_ORG_MODAL = (name) =>
+ `[data-test-manage-scopes-remove-${name}-modal]`;
+ const REMOVE_ORG_PROJECTS_BUTTON = '.hds-modal .hds-button--color-primary';
+ const REMOVE_ORG_ONLY_BUTTON = '.hds-modal .hds-button--color-secondary';
+ const TABLE_ALL_CHECKBOX = 'thead tr [type="checkbox"]';
const instances = {
scopes: {
@@ -394,7 +399,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(`[href="${urls.roleScopes}"]`);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
await click(MANAGE_DROPDOWN_SELECTOR);
await click(MANAGE_SCOPES_SELECTOR);
@@ -418,7 +423,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(SAVE_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.roleScopes);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 1 });
});
test('user can cancel custom scopes to add on manage custom scopes page', async function (assert) {
@@ -427,7 +432,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(`[href="${urls.roleScopes}"]`);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
await click(MANAGE_DROPDOWN_SELECTOR);
await click(MANAGE_SCOPES_SELECTOR);
@@ -447,7 +452,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(CANCEL_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.roleScopes);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
});
test('shows error message on custom scope save on manage custom scopes page', async function (assert) {
@@ -509,6 +514,147 @@ module('Acceptance | roles | global-scope', function (hooks) {
assert.strictEqual(currentURL(), urls.manageCustomScopes);
});
+ test('user can choose to only deselect an org using modal on manage custom scopes page', async function (assert) {
+ instances.role.update({
+ grant_scope_ids: [instances.scopes.org.id, instances.scopes.project.id],
+ });
+ await visit(urls.manageScopes);
+
+ await click(`[href="${urls.manageCustomScopes}"]`);
+ await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id));
+
+ assert.dom(REMOVE_ORG_MODAL('org')).isVisible();
+
+ await click(REMOVE_ORG_ONLY_BUTTON);
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(BUTTON_ICON_SELECTOR).isVisible();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 1 });
+ });
+
+ test('user can choose to deselect an org and projects using modal on manage custom scopes page', async function (assert) {
+ instances.role.update({
+ grant_scope_ids: [instances.scopes.org.id, instances.scopes.project.id],
+ });
+ await visit(urls.manageScopes);
+
+ await click(`[href="${urls.manageCustomScopes}"]`);
+ await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id));
+
+ assert.dom(REMOVE_ORG_MODAL('org')).isVisible();
+
+ await click(REMOVE_ORG_PROJECTS_BUTTON);
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(BUTTON_ICON_SELECTOR).isVisible();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
+ });
+
+ test('user cannot trigger modal when deselecting an org with no projects selected on manage custom scopes page', async function (assert) {
+ instances.role.update({ grant_scope_ids: [instances.scopes.org.id] });
+ await visit(urls.manageScopes);
+
+ await click(`[href="${urls.manageCustomScopes}"]`);
+ await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id));
+
+ assert.dom(REMOVE_ORG_MODAL('org')).doesNotExist();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(BUTTON_ICON_SELECTOR).isVisible();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
+ });
+
+ test('user can choose to only deselect all orgs using modal on manage custom scopes page', async function (assert) {
+ instances.role.update({
+ grant_scope_ids: [instances.scopes.org.id, instances.scopes.project.id],
+ });
+ await visit(urls.manageScopes);
+
+ await click(`[href="${urls.manageCustomScopes}"]`);
+ // Click once to select all
+ await click(TABLE_ALL_CHECKBOX);
+ // Click once more to deselect all
+ await click(TABLE_ALL_CHECKBOX);
+
+ assert.dom(REMOVE_ORG_MODAL('all-orgs')).isVisible();
+
+ await click(REMOVE_ORG_ONLY_BUTTON);
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(BUTTON_ICON_SELECTOR).isVisible();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 1 });
+ });
+
+ test('user can choose to deselect all orgs and projects using modal on manage custom scopes page', async function (assert) {
+ instances.role.update({
+ grant_scope_ids: [instances.scopes.org.id, instances.scopes.project.id],
+ });
+ await visit(urls.manageScopes);
+
+ await click(`[href="${urls.manageCustomScopes}"]`);
+ // Click once to select all
+ await click(TABLE_ALL_CHECKBOX);
+ // Click once more to deselect all
+ await click(TABLE_ALL_CHECKBOX);
+
+ assert.dom(REMOVE_ORG_MODAL('all-orgs')).isVisible();
+
+ await click(REMOVE_ORG_PROJECTS_BUTTON);
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(BUTTON_ICON_SELECTOR).isVisible();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
+ });
+
+ test('user cannot trigger modal when deselecting all orgs with no projects selected on manage custom scopes page', async function (assert) {
+ instances.role.update({ grant_scope_ids: [instances.scopes.org.id] });
+ await visit(urls.manageScopes);
+
+ await click(`[href="${urls.manageCustomScopes}"]`);
+ // Click once to select all
+ await click(TABLE_ALL_CHECKBOX);
+ // Click once more to deselect all
+ await click(TABLE_ALL_CHECKBOX);
+
+ assert.dom(REMOVE_ORG_MODAL('all-orgs')).doesNotExist();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(BUTTON_ICON_SELECTOR).isVisible();
+
+ await click(SAVE_BTN_SELECTOR);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
+ });
+
test('user can search for a specific org scope by id on manage custom scopes page', async function (assert) {
const anotherOrg = this.server.create('scope', {
type: 'org',
@@ -564,7 +710,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(`[href="${urls.roleScopes}"]`);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
await click(MANAGE_DROPDOWN_SELECTOR);
await click(MANAGE_SCOPES_SELECTOR);
@@ -596,7 +742,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(SAVE_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.roleScopes);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 1 });
});
test('user can cancel custom scopes to add on manage org projects page', async function (assert) {
@@ -605,7 +751,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(`[href="${urls.roleScopes}"]`);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
await click(MANAGE_DROPDOWN_SELECTOR);
await click(MANAGE_SCOPES_SELECTOR);
@@ -635,7 +781,7 @@ module('Acceptance | roles | global-scope', function (hooks) {
await click(CANCEL_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.roleScopes);
- assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
+ assert.dom(TABLE_ROW_SELECTOR).exists({ count: 0 });
});
test('shows error message on custom scope save on manage org projects page', async function (assert) {