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

+10
View File
@@ -18,7 +18,17 @@ export default class WorkerAbility extends ModelAbility {
return this.hasAuthorizedCollectionAction('create:worker-led');
}
/**
* @type {boolean}
*/
get canSetWorkerTags() {
return this.hasAuthorizedAction('set-worker-tags');
}
/**
* @type {boolean}
*/
get canRemoveTags() {
return this.hasAuthorizedAction('remove-worker-tags');
}
}
+14
View File
@@ -86,6 +86,7 @@ export default class WorkerModel extends GeneratedWorkerModel {
'The deduplicated union of the tags reported by the worker ' +
'from its configuration and any tags added through other means.\nOutput only.',
readOnly: true,
emptyObjectIfMissing: true,
})
canonical_tags;
@@ -93,11 +94,13 @@ export default class WorkerModel extends GeneratedWorkerModel {
description:
"The tags set in the worker's configuration file.\nOutput only.",
readOnly: true,
emptyObjectIfMissing: true,
})
config_tags;
@attr({
description: 'The api tags set for the worker.\nOutput only.',
emptyObjectIfMissing: true,
})
api_tags;
@@ -132,4 +135,15 @@ export default class WorkerModel extends GeneratedWorkerModel {
adapterOptions: { method: 'set-worker-tags', apiTags },
});
}
/**
* Method to remove api tags on the worker.
* @param {object} apiTags
* @return {Promise}
*/
removeApiTags(apiTags) {
return this.save({
adapterOptions: { method: 'remove-worker-tags', apiTags },
});
}
}
+27 -1
View File
@@ -179,6 +179,8 @@ export default class ApplicationSerializer extends RESTSerializer {
payload = structuredClone(payload);
// Check for and normalize missing arrays
payload = this.normalizeMissingArrays(store, primaryModelClass, payload);
// Check for and normalize missing objects
payload = this.normalizeMissingObjects(store, primaryModelClass, payload);
// Setup a new payload data structure.
const transformedPayload = {};
// Find the Ember-data-expected root key name.
@@ -199,7 +201,7 @@ export default class ApplicationSerializer extends RESTSerializer {
/**
* Resets missing array fields to an empty array if they are annotated by
* `normalizeToEmptyArray: true` in the associated model attribute
* `emptyArrayIfMissing: true` in the associated model attribute
* declaration. Our API excludes arrays when they are empty from
* singular responses.
*
@@ -222,6 +224,30 @@ export default class ApplicationSerializer extends RESTSerializer {
return payload;
}
/**
* Resets missing object fields to an empty object if they are annotated by
* `emptyObjectIfMissing: true` in the associated model attribute
* declaration.
*
* @param {Store} store
* @param {Model} primaryModelClass
* @param {object} payload
* @return {object}
*/
normalizeMissingObjects(store, primaryModelClass, payload) {
const attrDefs = store
.getSchemaDefinitionService()
.attributesDefinitionFor({ type: primaryModelClass.modelName });
if (attrDefs) {
Object.keys(attrDefs).forEach((key) => {
if (!payload[key] && attrDefs[key]?.options?.emptyObjectIfMissing) {
payload[key] = {};
}
});
}
return payload;
}
/**
* Hydrates the incoming scope JSON with additional boolean fields and
* delegates nested attributes and secrets normalization.
+15 -5
View File
@@ -689,13 +689,23 @@ function routes() {
const id = idMethod.split(':')[0];
const method = idMethod.split(':')[1];
const worker = workers.find(id);
let updatedAttrs = {};
let updatedAttrs = {
version: attrs.version,
api_tags: worker.api_tags,
};
if (method === 'remove-worker-tags') {
const key = Object.keys(attrs.apiTags)[0];
const value = attrs.apiTags[key][0];
updatedAttrs.api_tags[key] = updatedAttrs.api_tags[key].filter(
(tag) => {
return tag !== value;
},
);
}
if (method === 'set-worker-tags') {
updatedAttrs = {
version: attrs.version,
apiTags: attrs.apiTags,
};
updatedAttrs.api_tags = attrs.apiTags;
}
return worker.update(updatedAttrs);
+1
View File
@@ -17,6 +17,7 @@ export default factory.extend({
'update',
'delete',
'set-worker-tags',
'remove-worker-tags',
],
config_tags: (i) => {
if (i % 3 === 0) {
+17
View File
@@ -7,4 +7,21 @@ import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
modelName: 'worker',
_hashForModel(model) {
const json = ApplicationSerializer.prototype._hashForModel.apply(
this,
arguments,
);
if (json.api_tags) {
const keys = Object.keys(model.api_tags);
keys.forEach((key) => {
if (json.api_tags[key]?.length === 0) {
delete json.api_tags[key];
}
});
}
return json;
},
});
@@ -71,6 +71,38 @@ module('Unit | Model | worker', function (hooks) {
const model = store.peekRecord('worker', workerId);
await model.setApiTags(tags);
});
test('it has a `removeApiTags` method that targets a specific POST API', async function (assert) {
assert.expect(1);
const workerId = 'w_123';
const tags = {
tag1: ['value1'],
};
this.server.post(
`/workers/${workerId}:remove-worker-tags`,
(schema, request) => {
const body = JSON.parse(request.requestBody);
assert.deepEqual(body, { api_tags: tags, version: 1 });
return { id: workerId };
},
);
const store = this.owner.lookup('service:store');
store.push({
data: {
id: workerId,
type: 'worker',
attributes: {
name: 'fake worker',
version: 1,
api_tags: tags,
},
},
});
const model = store.peekRecord('worker', workerId);
await model.removeApiTags(tags);
});
test('it has a `configTagList` method that returns an array of key/value pair objects', function (assert) {
const store = this.owner.lookup('service:store');
@@ -71,4 +71,45 @@ module('Unit | Serializer | worker', function (hooks) {
api_tags: { key: ['value'] },
});
});
test('it normalizes missing `api_tags`, `config_tags`, and `canonical_tags`', function (assert) {
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('worker');
const worker = store.createRecord('worker').constructor;
const payload = {
id: 'w_123',
name: 'hardest worker',
scope: { id: 'global' },
version: 1,
address: 'localhost',
};
const normalized = serializer.normalizeSingleResponse(
store,
worker,
payload,
);
assert.deepEqual(normalized, {
data: {
attributes: {
address: 'localhost',
api_tags: {},
authorized_actions: [],
canonical_tags: {},
config_tags: {},
name: 'hardest worker',
scope: {
id: 'global',
scope_id: 'global',
},
version: 1,
},
id: 'w_123',
relationships: {},
type: 'worker',
},
included: [],
});
});
});