Icu 13897 create remove tag modal (#2377)

* feat: 🎸 add action dropdown in tags table

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* feat: 🎸 add removeApiTags method to worker

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* feat: 🎸 add tag removal workflow

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* test: 💍 add tests for removing a worker tag

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* test: 💍 add tests for removing a tag

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 fix spelling issue

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 use mirage worker serializer to remove empty tag

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 update modal id to class

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 add autofocus to remove tag modal

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* feat: 🎸 update tags controller attributes

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 create normalizer to add empty object for api_tags

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 update getter signature

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 remove unneeded parens

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 remove type from Hds::Button

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 update confirmation modal class name

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* feat: 🎸 add emptyObjectIfMissing model attribute

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 PR feedback

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897

* refactor: 💡 remove default to null in toggleModal

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13897
This commit is contained in:
Cameron Perera authored and cameronperera committed 2024-07-16 15:49:30 -05:00
1 parent 723101b779
commit f9af4f0092
16 files changed
+443 -12

No files matched your search

@@ -4,10 +4,70 @@
*/
import Controller, { inject as controller } from '@ember/controller';
import { TAG_TYPE_CONFIG } from 'api/models/worker';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifySuccess, notifyError } from 'core/decorators/notify';
import { TAG_TYPE_CONFIG, TAG_TYPE_API } from 'api/models/worker';
export default class ScopesScopeWorkersWorkerTagsController extends Controller {
@controller('scopes/scope/workers/index') workers;
// =services
@service intl;
// =attributes
tagTypeConfig = TAG_TYPE_CONFIG;
tagTypeApi = TAG_TYPE_API;
@tracked removeModal = false;
@tracked removalConfirmation = null;
@tracked modalTag = null;
/**
* Build the display name for a worker tag
* @type {string}
*/
get tagDisplayName() {
return `"${this.modalTag?.key} = ${this.modalTag?.value}"`;
}
/**
* Checks if user entered REMOVE to enable the remove button.
* @type {boolean}
*/
get isRemovalConfirmed() {
return (
this.removalConfirmation === this.intl.t('actions.remove').toUpperCase()
);
}
/**
* Removes selected tag from the worker.
*/
@action
@loading
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('resources.worker.tags.remove.success')
async removeApiTag() {
await this.model.removeApiTags({
[this.modalTag.key]: [this.modalTag.value],
});
this.removeModal = false;
this.modalTag = null;
this.removalConfirmation = null;
}
/**
* Opens the remove tag modal.
* @param {object} apiTag
*/
@action
toggleRemoveModal(apiTag) {
this.removeModal = !this.removeModal;
this.modalTag = apiTag;
this.removalConfirmation = null;
}
}
+11
View File
@@ -1057,3 +1057,14 @@
overflow: hidden;
text-overflow: ellipsis;
}
.confirmation-modal {
.hds-button-set {
margin-bottom: 0;
}
.hds-form-field--layout-vertical {
margin-top: 1.5rem;
margin-bottom: 0;
}
}
@@ -101,7 +101,7 @@
<Hds::Dropdown as |dd|>
<dd.ToggleIcon
@icon='more-horizontal'
@text='Overflow Options'
@text={{t 'actions.overflow-options'}}
@hasChevron={{false}}
@size='small'
/>
@@ -38,6 +38,7 @@
(hash label=(t 'resources.worker.table.key'))
(hash label=(t 'resources.worker.table.value'))
(hash label=(t 'resources.worker.table.type'))
(hash label=(t 'titles.actions') isVisuallyHidden=true width='60px')
}}
@valign='middle'
>
@@ -61,11 +62,78 @@
<Hds::Badge @text={{B.data.type}} />
{{/if}}
</B.Td>
<B.Td>
{{#if (eq B.data.type this.tagTypeApi)}}
<Hds::Dropdown as |dd|>
<dd.ToggleIcon
@icon='more-horizontal'
@text={{t 'actions.overflow-options'}}
@hasChevron={{false}}
@size='small'
/>
{{#if (can 'removeTags worker' @model)}}
<dd.Interactive
@text={{t 'resources.worker.tags.actions.remove'}}
{{on 'click' (fn this.toggleRemoveModal B.data)}}
/>
{{/if}}
</Hds::Dropdown>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{/if}}
{{#if this.removeModal}}
<Hds::Modal
class='confirmation-modal'
@color='critical'
@onClose={{this.toggleRemoveModal}}
as |M|
>
<M.Header @icon='alert-diamond'>
{{t 'resources.worker.tags.remove.title'}}
</M.Header>
<M.Body>
<Hds::Text::Body @color='primary'>
{{this.tagDisplayName}}
{{t 'resources.worker.tags.remove.warning'}}
</Hds::Text::Body>
{{! template-lint-disable no-autofocus-attribute }}
<Hds::Form::TextInput::Field
name='removal-confirmation'
autofocus={{true}}
@value={{this.removalConfirmation}}
{{on 'input' (set-from-event this 'removalConfirmation')}}
as |F|
>
<F.Label>{{t
'resources.worker.tags.remove.confirm.label'
}}</F.Label>
<F.HelperText>{{t
'resources.worker.tags.remove.confirm.help'
}}</F.HelperText>
</Hds::Form::TextInput::Field>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
disabled={{not this.isRemovalConfirmed}}
@color='critical'
@text={{t 'actions.remove'}}
{{on 'click' this.removeApiTag}}
/>
<Hds::Button
@color='secondary'
@text={{t 'actions.cancel'}}
{{on 'click' F.close}}
/>
</Hds::ButtonSet>
</M.Footer>
</Hds::Modal>
{{/if}}
</page.body>
</Rose::Layout::Page>
+56 -1
View File
@@ -4,7 +4,7 @@
*/
import { module, test } from 'qunit';
import { visit, currentURL, click, focus } from '@ember/test-helpers';
import { visit, currentURL, click, focus, fillIn } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
@@ -18,6 +18,15 @@ module('Acceptance | workers | worker | tags', function (hooks) {
'tbody tr:first-child td:nth-child(3) button';
const CONFIG_TAG_TOOLTIP_TEXT_SELECTOR =
'tbody tr:first-child td:nth-child(3) > div';
const API_TAG_ACTION_SELECTOR =
'tbody tr:nth-child(4) td:nth-child(4) button';
const API_TAG_REMOVE_ACTION_SELECTOR =
'tbody tr:nth-child(4) td:nth-child(4) ul li button';
const CONFIRMATION_MODAL_INPUT_SELECTOR = '.confirmation-modal input';
const CONFIRMATION_MODAL_REMOVE_BUTTON_SELECTOR =
'.confirmation-modal button:first-child';
const CONFIRMATION_MODAL_CANCEL_BUTTON_SELECTOR =
'.confirmation-modal button:last-child';
const instances = {
scopes: {
@@ -61,4 +70,50 @@ module('Acceptance | workers | worker | tags', function (hooks) {
.dom(CONFIG_TAG_TOOLTIP_TEXT_SELECTOR)
.hasText('To edit config tags, edit them in your worker config file.');
});
test('users can remove a specific tag', async function (assert) {
await visit(urls.tags);
assert.dom('tbody tr').exists({ count: 11 });
await click(API_TAG_ACTION_SELECTOR);
await click(API_TAG_REMOVE_ACTION_SELECTOR);
assert.dom(CONFIRMATION_MODAL_REMOVE_BUTTON_SELECTOR).isDisabled();
await fillIn(CONFIRMATION_MODAL_INPUT_SELECTOR, 'REMOVE');
assert.dom(CONFIRMATION_MODAL_REMOVE_BUTTON_SELECTOR).isEnabled();
await click(CONFIRMATION_MODAL_REMOVE_BUTTON_SELECTOR);
assert.dom('tbody tr').exists({ count: 10 });
});
test('user can cancel tag removal', async function (assert) {
await visit(urls.tags);
assert.dom('tbody tr').exists({ count: 11 });
await click(API_TAG_ACTION_SELECTOR);
await click(API_TAG_REMOVE_ACTION_SELECTOR);
await click(CONFIRMATION_MODAL_CANCEL_BUTTON_SELECTOR);
assert.dom('tbody tr').exists({ count: 11 });
});
test('user cannot remove a tag without proper permission', async function (assert) {
instances.worker.authorized_actions =
instances.worker.authorized_actions.filter(
(item) => item !== 'remove-worker-tags',
);
await visit(urls.tags);
assert.dom('tbody tr').exists({ count: 11 });
await click(API_TAG_ACTION_SELECTOR);
assert.dom(API_TAG_REMOVE_ACTION_SELECTOR).doesNotExist();
});
});
@@ -4,18 +4,94 @@
*/
import { module, test } from 'qunit';
import { setupTest } from 'admin/tests/helpers';
import { visit } from '@ember/test-helpers';
import { setupTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { authenticateSession } from 'ember-simple-auth/test-support';
module(
'Unit | Controller | scopes/scope/workers/worker/tags',
function (hooks) {
setupTest(hooks);
setupMirage(hooks);
test('it exists', function (assert) {
let controller = this.owner.lookup(
let store;
let controller;
const instances = {
scopes: {
global: null,
},
worker: null,
};
const urls = {
workers: null,
tags: null,
};
hooks.beforeEach(function () {
authenticateSession({});
store = this.owner.lookup('service:store');
controller = this.owner.lookup(
'controller:scopes/scope/workers/worker/tags',
);
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.worker = this.server.create('worker', {
scope: instances.scopes.global,
api_tags: { test: ['test'], key: ['value'] },
});
urls.workers = `/scopes/global/workers`;
urls.tags = `${urls.workers}/${instances.worker.id}/tags`;
});
test('it exists', function (assert) {
assert.ok(controller);
});
test('tagDisplayName returns the display name of the tag', function (assert) {
controller.modalTag = { key: 'test', value: 'test' };
assert.strictEqual(controller.tagDisplayName, '"test = test"');
});
test('isRemovalConfirmed returns true if the user has entered REMOVE', function (assert) {
controller.removalConfirmation = 'REMOVE';
assert.true(controller.isRemovalConfirmed);
});
test('isRemovalConfirmed returns false if the user has entered anything but REMOVE', function (assert) {
controller.removalConfirmation = 'remove';
assert.false(controller.isRemovalConfirmed);
});
test('removeApiTag calls removeApiTags on the model and closes modal', async function (assert) {
await visit(urls.tags);
const worker = await store.findRecord('worker', instances.worker.id);
const apiTagCount = worker.apiTagList.length;
controller.modalTag = { key: 'test', value: 'test' };
await controller.removeApiTag();
assert.strictEqual(worker.apiTagList.length, apiTagCount - 1);
assert.false(controller.removeModal);
assert.strictEqual(controller.modalTag, null);
});
test('toggleRemoveModal toggles the modal and sets the modalTag', function (assert) {
assert.false(controller.removeModal);
assert.strictEqual(controller.modalTag, null);
controller.toggleRemoveModal({ key: 'test', value: 'test' });
assert.true(controller.removeModal);
assert.deepEqual(controller.modalTag, {
key: 'test',
value: 'test',
});
});
},
);