From 40a43fd9bee4963fcd05cf3f8f0d9ae50d90202a Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Tue, 1 Sep 2020 10:42:16 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20group=20custom?= =?UTF-8?q?=20methods=20for=20add/remove=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/api/addon/models/group.js | 88 +++++++++++- addons/api/addon/serializers/group.js | 34 +++++ addons/api/app/serializers/group.js | 1 + addons/api/tests/unit/models/group-test.js | 129 +++++++++++++++++- .../api/tests/unit/serializers/group-test.js | 48 +++++++ 5 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 addons/api/addon/serializers/group.js create mode 100644 addons/api/app/serializers/group.js create mode 100644 addons/api/tests/unit/serializers/group-test.js diff --git a/addons/api/addon/models/group.js b/addons/api/addon/models/group.js index 899c71d0c..64e9b2c9d 100644 --- a/addons/api/addon/models/group.js +++ b/addons/api/addon/models/group.js @@ -1,3 +1,89 @@ import GeneratedGroupModel from '../generated/models/group'; +import { attr } from '@ember-data/model'; +import { computed } from '@ember/object'; -export default class GroupModel extends GeneratedGroupModel {} +export default class GroupModel extends GeneratedGroupModel { + + // =attributes + + /** + * Members is read-only under normal circumstances. But members can + * be persisted via calls to `addMembers()` or `removeMembers()`. + */ + @attr( {readOnly: true}) member_ids; + + /** + * An array of resolved user instances. + * Unresolvable instances are excluded from the array. + * @type {[UserModel]} + */ + @computed('member_ids.[]') + get members() { + return this.member_ids + .map(id => this.store.peekRecord('user', id)) + .filter(Boolean); + } + + // =methods + + /** + * Adds members via the `add-members` method. + * See serializer and adapter for more information. + * @param {[string]} memberIDs + * @param {object} options + * @param {object} options.adapterOptions + * @return {Promise} + */ + addMembers(memberIDs, options={ adapterOptions: {} }) { + const defaultAdapterOptions = { + method: 'add-members', + memberIDs + }; + // There is no "deep merge" in ES. + // All of this nonsense is here to ensure we get + // a decent merge of `adapterOptions`. + return this.save({ + ...options, + adapterOptions: { + ...defaultAdapterOptions, + ...options.adapterOptions + } + }); + } + + /** + * Delete members via the `remove-members` method. + * See serializer and adapter for more information. + * @param {[string]} memberIDs + * @param {object} options + * @param {object} options.adapterOptions + * @return {Promise} + */ + removeMembers(memberIDs, options={ adapterOptions: {} }) { + const defaultAdapterOptions = { + method: 'remove-members', + memberIDs + }; + // There is no "deep merge" in ES. + // All of this nonsense is here to ensure we get + // a decent merge of `adapterOptions`. + return this.save({ + ...options, + adapterOptions: { + ...defaultAdapterOptions, + ...options.adapterOptions + } + }); + } + + /** + * Delete a single member via the `remove-members` method. + * @param {number} memberID + * @param {object} options + * @return {Promise} + */ + removeMember(memberID, options) { + return this.removeMembers([memberID], options); + } + +} diff --git a/addons/api/addon/serializers/group.js b/addons/api/addon/serializers/group.js new file mode 100644 index 000000000..bbccc068f --- /dev/null +++ b/addons/api/addon/serializers/group.js @@ -0,0 +1,34 @@ +import ApplicationSerializer from './application'; + +export default class GroupSerializer extends ApplicationSerializer { + + // =methods + + /** + * If `adapterOptions.memberIDs` is set (to an array of user IDs), + * then the payload is serialized via `serializewithMembers`. + * @override + * @param {Snapshot} snapshot + * @return {object} + */ + serialize(snapshot) { + let serialized = super.serialize(...arguments); + const memberIDs = snapshot?.adapterOptions?.memberIDs; + if (memberIDs) serialized = this.serializewithMembers(snapshot, memberIDs); + return serialized; + } + + /** + * Returns a payload containing only the `member_ids` array (and version). + * @param {Snapshot} snapshot + * @param {[string]} memberIDs + * @return {object} + */ + serializewithMembers(snapshot, memberIDs) { + return { + version: snapshot.attr('version'), + member_ids: memberIDs + }; + } + +} diff --git a/addons/api/app/serializers/group.js b/addons/api/app/serializers/group.js new file mode 100644 index 000000000..d469741cc --- /dev/null +++ b/addons/api/app/serializers/group.js @@ -0,0 +1 @@ +export { default } from 'api/serializers/group'; diff --git a/addons/api/tests/unit/models/group-test.js b/addons/api/tests/unit/models/group-test.js index 4ab06e07a..75216bf6f 100644 --- a/addons/api/tests/unit/models/group-test.js +++ b/addons/api/tests/unit/models/group-test.js @@ -1,12 +1,133 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; module('Unit | Model | group', function(hooks) { setupTest(hooks); + setupMirage(hooks); - test('it exists', function(assert) { - let store = this.owner.lookup('service:store'); - let model = store.createRecord('group', {}); - assert.ok(model); + test('it has a `members` array of resolved model instances (if those instances are already in the store)', function(assert) { + assert.expect(4); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: 'group_123', + type: 'group', + attributes: { + member_ids: [ '1', '2', ] + } + } + }); + const group = store.peekRecord('group', 'group_123'); + assert.equal(group.member_ids.length, 2, 'Group has two entires in member_ids'); + assert.equal(group.members.length, 0, 'Group has no resolved members because they are not loaded yet'); + store.push({ + data: { + id: '1', + type: 'user', + attributes: {} + } + }); + store.push({ + data: { + id: '2', + type: 'user', + attributes: {} + } + }); + // eslint-disable-next-line no-self-assign + group.member_ids = [...group.member_ids]; + assert.equal(group.member_ids.length, 2, 'Group has two entires in member_ids'); + assert.equal(group.members.length, 2, 'Group has two resolved members'); + }); + + test('it has an `addMembers` method that groups a specific POST API endpoint and serialization', async function (assert) { + assert.expect(1); + this.server.post('/v1/scopes/o_1/groups/123abc:add-members', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + member_ids: ['123_abc', 'foobar'], + version: 1 + }); + }); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '123abc', + type: 'group', + attributes: { + name: 'Group', + description: 'Description', + member_ids: [ '1', '2' ], + version: 1, + scope: { + scope_id: 'o_1', + type: 'scope' + } + } + } + }); + const model = store.peekRecord('group', '123abc'); + await model.addMembers(['123_abc', 'foobar']); + }); + + test('it has a `removeMembers` method that groups a specific POST API endpoint and serialization', async function (assert) { + assert.expect(1); + this.server.post('/v1/scopes/o_1/groups/123abc:remove-members', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + member_ids: ['1', '3'], + version: 1 + }); + }); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '123abc', + type: 'group', + attributes: { + name: 'group', + description: 'Description', + member_ids: [ '4', '5' ], + version: 1, + scope: { + scope_id: 'o_1', + type: 'scope' + } + } + } + }); + const model = store.peekRecord('group', '123abc'); + await model.removeMembers(['1', '3']); + }); + + test('it has a `removeMember` method that deletes a single member set using `removeMembers` method', async function (assert) { + assert.expect(1); + this.server.post('/v1/scopes/o_1/groups/123abc:remove-members', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + member_ids: ['3'], + version: 1 + }); + }); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '123abc', + type: 'group', + attributes: { + name: 'group', + description: 'Description', + member_ids: [ '1', '3' ], + version: 1, + scope: { + scope_id: 'o_1', + type: 'scope' + } + } + } + }); + const model = store.peekRecord('group', '123abc'); + await model.removeMember('3'); }); }); diff --git a/addons/api/tests/unit/serializers/group-test.js b/addons/api/tests/unit/serializers/group-test.js new file mode 100644 index 000000000..2a7da2b06 --- /dev/null +++ b/addons/api/tests/unit/serializers/group-test.js @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | group', function(hooks) { + setupTest(hooks); + + test('it serializes groups normally, without members', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('group'); + const record = store.createRecord('group', { + name: 'Group', + description: 'Description', + member_ids: [ '1', '2', ], + version: 1 + }); + const snapshot = record._createSnapshot(); + snapshot.adapterOptions = {}; + const serializedRecord = serializer.serialize(snapshot); + assert.deepEqual(serializedRecord, { + name: 'Group', + description: 'Description', + version: 1 + }); + }); + + test('it serializes only host sets and version when an `adapterOptions.memberIDs` array is passed', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('group'); + const record = store.createRecord('group', { + name: 'Group', + description: 'Description', + member_ids: [ '1', '2', ], + version: 1 + }); + const snapshot = record._createSnapshot(); + snapshot.adapterOptions = { + memberIDs: ['4', '5'] + }; + const serializedRecord = serializer.serialize(snapshot); + assert.deepEqual(serializedRecord, { + member_ids: ['4', '5'], + version: 1 + }); + }); + +}); From 4ea8007c207dd5a4c3e4f43a7ea9832afa3bc303 Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Tue, 1 Sep 2020 12:03:36 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20mocks=20for=20?= =?UTF-8?q?group=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/core/mirage/config.js | 25 +++++++++++++++++++++++++ ui/core/mirage/factories/group.js | 15 ++++++++++++++- ui/core/mirage/models/group.js | 7 +++++++ ui/core/mirage/scenarios/default.js | 6 ++---- ui/core/mirage/serializers/group.js | 16 +++++++++++++++- 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 ui/core/mirage/models/group.js diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js index 2142ae804..8bc387276 100644 --- a/ui/core/mirage/config.js +++ b/ui/core/mirage/config.js @@ -110,6 +110,31 @@ export default function() { this.get('/scopes/:scope_id/groups/:id'); this.patch('/scopes/:scope_id/groups/:id'); this.del('/scopes/:scope_id/groups/:id'); + this.post('/scopes/:scope_id/groups/:idMethod', function ({ groups }, { params: { idMethod } }) { + const attrs = this.normalizedRequestAttrs(); + const id = idMethod.split(':')[0]; + const method = idMethod.split(':')[1]; + const group = groups.find(id); + const updatedAttrs = { + version: attrs.version, + memberIds: group.memberIds + }; + // If adding members, push them into the array + if (method === 'add-members') { + attrs.memberIds.forEach(id => { + if (!updatedAttrs.memberIds.includes(id)) { + updatedAttrs.memberIds.push(id); + } + }); + } + // If deleting members, filter them out of the array + if (method === 'remove-members') { + updatedAttrs.memberIds = updatedAttrs.memberIds.filter(id => { + return !attrs.memberIds.includes(id); + }); + } + return group.update(updatedAttrs); + }); // Other resources // host-catalog diff --git a/ui/core/mirage/factories/group.js b/ui/core/mirage/factories/group.js index 41bd91a03..80ea3e5d6 100644 --- a/ui/core/mirage/factories/group.js +++ b/ui/core/mirage/factories/group.js @@ -1,5 +1,18 @@ import factory from '../generated/factories/group'; +import { trait } from 'ember-cli-mirage'; export default factory.extend({ - id: (i) => `group-${i}` + id: (i) => `group-${i}`, + + /** + * Generates some users for the same scope and assigns them to the group + * as members. + */ + withMembers: trait({ + afterCreate(group, server) { + const scope = group.scope; + const members = server.createList('user', 5, { scope }); + group.update({ members }); + } + }) }); diff --git a/ui/core/mirage/models/group.js b/ui/core/mirage/models/group.js new file mode 100644 index 000000000..7f556c505 --- /dev/null +++ b/ui/core/mirage/models/group.js @@ -0,0 +1,7 @@ +import { Model, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + + members: hasMany('user') + +}); diff --git a/ui/core/mirage/scenarios/default.js b/ui/core/mirage/scenarios/default.js index 2b212f999..7d4c343f8 100644 --- a/ui/core/mirage/scenarios/default.js +++ b/ui/core/mirage/scenarios/default.js @@ -21,10 +21,8 @@ export default function(server) { // same items for request at all scopes. server.createList('auth-method', 3, { scope }); - // User - server.createList('user', 5, { scope }); - // Group - server.createList('group', 5, { scope }); + // Groups and Users + server.createList('group', 5, { scope }, 'withMembers'); // Role server.createList('role', 5, { scope }, 'withPrincipals'); diff --git a/ui/core/mirage/serializers/group.js b/ui/core/mirage/serializers/group.js index 13ec42167..2854224f8 100644 --- a/ui/core/mirage/serializers/group.js +++ b/ui/core/mirage/serializers/group.js @@ -1,5 +1,19 @@ import ApplicationSerializer from './application'; +import { underscore } from '@ember/string'; export default ApplicationSerializer.extend({ - modelName: 'group' + modelName: 'group', + + keyForRelationshipIds(relationshipName) { + return `${this._container.inflector.singularize( + underscore(relationshipName) + )}_ids`; + }, + + _hashForModel(model) { + const json = ApplicationSerializer.prototype._hashForModel.apply(this, arguments); + json.member_ids = model.memberIds; + return json; + } + }); From 559fbb36eb9c037888d30ffbb62f3671b0e70eab Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Tue, 1 Sep 2020 12:03:53 -0400 Subject: [PATCH 3/9] chore: reduce number of users --- ui/core/mirage/factories/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/core/mirage/factories/group.js b/ui/core/mirage/factories/group.js index 80ea3e5d6..9531de4f8 100644 --- a/ui/core/mirage/factories/group.js +++ b/ui/core/mirage/factories/group.js @@ -11,7 +11,7 @@ export default factory.extend({ withMembers: trait({ afterCreate(group, server) { const scope = group.scope; - const members = server.createList('user', 5, { scope }); + const members = server.createList('user', 2, { scope }); group.update({ members }); } }) From 61008ee24bc87e8028affaf5e181a34c805709ba Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Tue, 1 Sep 2020 16:44:57 -0400 Subject: [PATCH 4/9] Update addons/api/addon/models/group.js Co-authored-by: Susmitha Girumala --- addons/api/addon/models/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/api/addon/models/group.js b/addons/api/addon/models/group.js index 64e9b2c9d..0a06fd404 100644 --- a/addons/api/addon/models/group.js +++ b/addons/api/addon/models/group.js @@ -10,7 +10,7 @@ export default class GroupModel extends GeneratedGroupModel { * Members is read-only under normal circumstances. But members can * be persisted via calls to `addMembers()` or `removeMembers()`. */ - @attr( {readOnly: true}) member_ids; + @attr({readOnly: true}) member_ids; /** * An array of resolved user instances. From bbf9038b9ff071c57f99dfceee5cdca74a19e99d Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Tue, 1 Sep 2020 17:31:22 -0400 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20view=20members?= =?UTF-8?q?=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/core/app/router.js | 4 +- .../scopes/scope/groups/group/members.js | 50 +++++++++++++++++ .../templates/scopes/scope/groups/group.hbs | 39 +++++++++++++- .../scopes/scope/groups/group/index.hbs | 44 ++------------- .../scopes/scope/groups/group/members.hbs | 53 +++++++++++++++++++ ui/core/tests/acceptance/groups-test.js | 5 ++ .../scopes/scope/groups/group/members-test.js | 11 ++++ ui/core/translations/en-us.yaml | 3 ++ 8 files changed, 168 insertions(+), 41 deletions(-) create mode 100644 ui/core/app/routes/scopes/scope/groups/group/members.js create mode 100644 ui/core/app/templates/scopes/scope/groups/group/members.hbs create mode 100644 ui/core/tests/unit/routes/scopes/scope/groups/group/members-test.js diff --git a/ui/core/app/router.js b/ui/core/app/router.js index effcb4523..6800b5103 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -54,7 +54,9 @@ Router.map(function () { }); this.route('groups', function () { - this.route('group', { path: ':group_id' }, function () {}); + this.route('group', { path: ':group_id' }, function () { + this.route('members'); + }); this.route('new'); }); diff --git a/ui/core/app/routes/scopes/scope/groups/group/members.js b/ui/core/app/routes/scopes/scope/groups/group/members.js new file mode 100644 index 000000000..7e2dc6658 --- /dev/null +++ b/ui/core/app/routes/scopes/scope/groups/group/members.js @@ -0,0 +1,50 @@ +import Route from '@ember/routing/route'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeGroupsGroupMembersRoute extends Route { + + // =services + + @service intl; + @service notify; + + // =methods + + /** + * Empty out all users before loading members. + */ + beforeModel() { + this.store.unloadAll('user'); + } + + /** + * Returns users associated with this group. + * @return {Promise{[UserModel]}} + */ + async model() { + const { id: scopeID } = this.modelFor('scopes.scope'); + const group = this.modelFor('scopes.scope.groups.group'); + await this.store.findAll('user', { adapterOptions: { scopeID } }); + return group; + } + + // =actions + + /** + * Remove a member from the current role and redirect to members index. + * @param {GroupModel} group + * @param {UserModel} member + */ + @action + async removeMember(group, member) { + try { + await group.removeMember(member.id); + this.notify.success(this.intl.t('notify.delete-success')); + } catch (error) { + // TODO: replace with translated strings + this.notify.error(error.message, { closeAfter: null }); + } + } + +} diff --git a/ui/core/app/templates/scopes/scope/groups/group.hbs b/ui/core/app/templates/scopes/scope/groups/group.hbs index d66e83390..bb0ba8b8a 100644 --- a/ui/core/app/templates/scopes/scope/groups/group.hbs +++ b/ui/core/app/templates/scopes/scope/groups/group.hbs @@ -1,3 +1,40 @@ {{title @model.displayName}} -{{outlet}} + + + + + + + +

{{t "resources.group"}}

+
+ + + + + {{t 'titles.details'}} + + + {{t 'titles.members'}} + + + + + + + + {{t 'actions.delete-group'}} + + + + + + {{outlet}} + + +
diff --git a/ui/core/app/templates/scopes/scope/groups/group/index.hbs b/ui/core/app/templates/scopes/scope/groups/group/index.hbs index ffc59c7cd..1fb27689d 100644 --- a/ui/core/app/templates/scopes/scope/groups/group/index.hbs +++ b/ui/core/app/templates/scopes/scope/groups/group/index.hbs @@ -1,39 +1,5 @@ - - - - - - - -

{{t "resources.group"}}

-
- - - - - {{t 'titles.details'}} - - - - - - - - {{t 'actions.delete-group'}} - - - - - - - - -
+ diff --git a/ui/core/app/templates/scopes/scope/groups/group/members.hbs b/ui/core/app/templates/scopes/scope/groups/group/members.hbs new file mode 100644 index 000000000..a2445b90d --- /dev/null +++ b/ui/core/app/templates/scopes/scope/groups/group/members.hbs @@ -0,0 +1,53 @@ +{{title (t 'titles.members')}} + +{{#if @model.members}} + + + + + {{t 'form.label.name'}} + {{t 'form.label.id'}} + {{t "form.label.actions"}} + + + + {{#each @model.members as |member|}} + + + {{member.displayName}} + + + {{member.id}} + + + + + {{t "actions.remove"}} + + + + + {{/each}} + + + +{{else}} + + + + + {{t "descriptions.group-members-welcome"}} + + + + +{{/if}} diff --git a/ui/core/tests/acceptance/groups-test.js b/ui/core/tests/acceptance/groups-test.js index 0d622f1f0..c33014378 100644 --- a/ui/core/tests/acceptance/groups-test.js +++ b/ui/core/tests/acceptance/groups-test.js @@ -217,4 +217,9 @@ module('Acceptance | groups', function (hooks) { 'Displays primary error message.' ); }); + + test('can view group members', async function (assert) { + assert.expect(0); + }); + }); diff --git a/ui/core/tests/unit/routes/scopes/scope/groups/group/members-test.js b/ui/core/tests/unit/routes/scopes/scope/groups/group/members-test.js new file mode 100644 index 000000000..5872d940c --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/groups/group/members-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/groups/group/members', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/groups/group/members'); + assert.ok(route); + }); +}); diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index 655821ec8..d33ec0514 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -93,6 +93,8 @@ titles: no-host-set-hosts: No Hosts Available no-host-sets-for-target: No Host Sets Available role-principals-welcome: No Principals Associated + members: Members + group-members-welcome: Welcome to Members descriptions: host-catalogs: Overview of collections of hosts and host sets. empty-set: There are no items to display yet. You may be able to add items or try back later. @@ -109,6 +111,7 @@ descriptions: no-host-set-hosts: Before associating hosts with this host set, create hosts within the host catalog. no-host-sets-for-target: No host sets are available to add to this target. role-principals-welcome: No principals are available in this role. + group-members-welcome: Users belonging to this group appear here. states: info: Info success: Success From 16031c151f95e3f4b70cf98274e8ac9d419d7787 Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Wed, 2 Sep 2020 13:29:20 -0400 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20setup=20initial=20ad?= =?UTF-8?q?d=20members=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scopes/scope/groups/group/add-members.js | 20 ++++++ ui/core/app/router.js | 1 + .../app/routes/scopes/scope/groups/group.js | 26 ++++++- .../scopes/scope/groups/group/add-members.js | 72 +++++++++++++++++++ .../templates/scopes/scope/groups/group.hbs | 21 +----- .../scopes/scope/groups/group/-actions.hbs | 12 ++++ .../scopes/scope/groups/group/-header.hbs | 1 + .../scopes/scope/groups/group/-navigation.hbs | 8 +++ .../scopes/scope/groups/group/add-members.hbs | 3 + .../groups/group/add-members/-header.hbs | 1 + .../scope/groups/group/add-members-test.js | 12 ++++ .../scope/groups/group/add-members-test.js | 11 +++ ui/core/translations/en-us.yaml | 1 + 13 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 ui/core/app/controllers/scopes/scope/groups/group/add-members.js create mode 100644 ui/core/app/routes/scopes/scope/groups/group/add-members.js create mode 100644 ui/core/app/templates/scopes/scope/groups/group/-actions.hbs create mode 100644 ui/core/app/templates/scopes/scope/groups/group/-header.hbs create mode 100644 ui/core/app/templates/scopes/scope/groups/group/-navigation.hbs create mode 100644 ui/core/app/templates/scopes/scope/groups/group/add-members.hbs create mode 100644 ui/core/app/templates/scopes/scope/groups/group/add-members/-header.hbs create mode 100644 ui/core/tests/unit/controllers/scopes/scope/groups/group/add-members-test.js create mode 100644 ui/core/tests/unit/routes/scopes/scope/groups/group/add-members-test.js diff --git a/ui/core/app/controllers/scopes/scope/groups/group/add-members.js b/ui/core/app/controllers/scopes/scope/groups/group/add-members.js new file mode 100644 index 000000000..7a94c1034 --- /dev/null +++ b/ui/core/app/controllers/scopes/scope/groups/group/add-members.js @@ -0,0 +1,20 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeGroupsGroupAddMembersController extends Controller { + + // =services + + @service intl; + + // =attributes + + /** + * Translated new group breadcrumb + * @type {string} + */ + get breadCrumb() { + return this.intl.t('actions.add-members'); + } + +} diff --git a/ui/core/app/router.js b/ui/core/app/router.js index 6800b5103..a8699d517 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -56,6 +56,7 @@ Router.map(function () { this.route('groups', function () { this.route('group', { path: ':group_id' }, function () { this.route('members'); + this.route('add-members'); }); this.route('new'); }); diff --git a/ui/core/app/routes/scopes/scope/groups/group.js b/ui/core/app/routes/scopes/scope/groups/group.js index 6231ab13f..bd638b343 100644 --- a/ui/core/app/routes/scopes/scope/groups/group.js +++ b/ui/core/app/routes/scopes/scope/groups/group.js @@ -1,6 +1,7 @@ import Route from '@ember/routing/route'; export default class ScopesScopeGroupsGroupRoute extends Route { + // =methods /** @@ -9,10 +10,33 @@ export default class ScopesScopeGroupsGroupRoute extends Route { * @param {string} params.group_id * @return {groupModel} */ - async model({ group_id }) { + model({ group_id }) { const { id: scopeID } = this.modelFor('scopes.scope'); return this.store.findRecord('group', group_id, { adapterOptions: { scopeID }, }); } + + /** + * Renders the route-set specific page sections + * @override + */ + renderTemplate() { + super.renderTemplate(...arguments); + + this.render('scopes/scope/groups/group/-header', { + into: 'scopes/scope/groups/group', + outlet: 'header' + }); + + this.render('scopes/scope/groups/group/-actions', { + into: 'scopes/scope/groups/group', + outlet: 'actions' + }); + + this.render('scopes/scope/groups/group/-navigation', { + into: 'scopes/scope/groups/group', + outlet: 'navigation' + }); + } } diff --git a/ui/core/app/routes/scopes/scope/groups/group/add-members.js b/ui/core/app/routes/scopes/scope/groups/group/add-members.js new file mode 100644 index 000000000..7ad1a8280 --- /dev/null +++ b/ui/core/app/routes/scopes/scope/groups/group/add-members.js @@ -0,0 +1,72 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ScopesScopeGroupsGroupAddMembersRoute extends Route { + + // =services + + @service intl; + @service notify; + + // =methods + + /** + * Emtpy out any previously loaded users. + */ + beforeModel() { + this.store.unloadAll('user'); + } + + /** + * Renders the route-set specific page sections + * @override + */ + renderTemplate() { + super.renderTemplate(...arguments); + + this.render('scopes/scope/groups/group/add-members/-header', { + into: 'scopes/scope/groups/group', + outlet: 'header' + }); + + this.render('-empty', { + into: 'scopes/scope/groups/group', + outlet: 'actions' + }); + + this.render('-empty', { + into: 'scopes/scope/groups/group', + outlet: 'navigation' + }); + } + + // =actions + + /** + * Adds members to the group and saves, replaces with the members index + * route, and notifies the user of success or error. + * @param {GroupModel} group + * @param {[string]} userIDs + */ + @action + async addMembers(group, userIDs) { + try { + await group.addMembers(userIDs); + await this.replaceWith('scopes.scope.groups.group.members'); + this.notify.success(this.intl.t('notify.save-success')); + } catch (error) { + // TODO: replace with translated strings + this.notify.error(error.message, { closeAfter: null }); + } + } + + /** + * Redirect to group members as if nothing ever happened. + */ + @action + cancel() { + this.replaceWith('scopes.scope.groups.group.members'); + } + +} diff --git a/ui/core/app/templates/scopes/scope/groups/group.hbs b/ui/core/app/templates/scopes/scope/groups/group.hbs index bb0ba8b8a..2a878ff51 100644 --- a/ui/core/app/templates/scopes/scope/groups/group.hbs +++ b/ui/core/app/templates/scopes/scope/groups/group.hbs @@ -7,30 +7,15 @@ -

{{t "resources.group"}}

+ {{outlet "header"}}
- - - {{t 'titles.details'}} - - - {{t 'titles.members'}} - - + {{outlet "navigation"}} - - - {{t 'actions.delete-group'}} - - + {{outlet "actions"}} diff --git a/ui/core/app/templates/scopes/scope/groups/group/-actions.hbs b/ui/core/app/templates/scopes/scope/groups/group/-actions.hbs new file mode 100644 index 000000000..1ebd9d2c6 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/groups/group/-actions.hbs @@ -0,0 +1,12 @@ + + + {{t "actions.add-members"}} + + + {{t 'actions.delete-group'}} + + diff --git a/ui/core/app/templates/scopes/scope/groups/group/-header.hbs b/ui/core/app/templates/scopes/scope/groups/group/-header.hbs new file mode 100644 index 000000000..6631940a3 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/groups/group/-header.hbs @@ -0,0 +1 @@ +

{{t "resources.group"}}

diff --git a/ui/core/app/templates/scopes/scope/groups/group/-navigation.hbs b/ui/core/app/templates/scopes/scope/groups/group/-navigation.hbs new file mode 100644 index 000000000..6dc45b637 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/groups/group/-navigation.hbs @@ -0,0 +1,8 @@ + + + {{t 'titles.details'}} + + + {{t 'titles.members'}} + + diff --git a/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs b/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs new file mode 100644 index 000000000..d8ca3dcef --- /dev/null +++ b/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs @@ -0,0 +1,3 @@ +{{title (t "actions.add-members")}} + +{{outlet}} diff --git a/ui/core/app/templates/scopes/scope/groups/group/add-members/-header.hbs b/ui/core/app/templates/scopes/scope/groups/group/add-members/-header.hbs new file mode 100644 index 000000000..48dca079b --- /dev/null +++ b/ui/core/app/templates/scopes/scope/groups/group/add-members/-header.hbs @@ -0,0 +1 @@ +

{{t "actions.add-members"}}

diff --git a/ui/core/tests/unit/controllers/scopes/scope/groups/group/add-members-test.js b/ui/core/tests/unit/controllers/scopes/scope/groups/group/add-members-test.js new file mode 100644 index 000000000..17daef1db --- /dev/null +++ b/ui/core/tests/unit/controllers/scopes/scope/groups/group/add-members-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | scopes/scope/groups/group/add-members', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let controller = this.owner.lookup('controller:scopes/scope/groups/group/add-members'); + assert.ok(controller); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/groups/group/add-members-test.js b/ui/core/tests/unit/routes/scopes/scope/groups/group/add-members-test.js new file mode 100644 index 000000000..8500b2957 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/groups/group/add-members-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/groups/group/add-members', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/groups/group/add-members'); + assert.ok(route); + }); +}); diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index d33ec0514..c5b005d53 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -23,6 +23,7 @@ actions: add-host-set: Create Host Set add-host: Create Host add-host-sets: Add Host Sets + add-members: Add Members auth: titles: no-scopes: No Scopes Available From 1f202ac6dd4bec1dfd574fb3859f532e17b7693e Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Wed, 2 Sep 2020 13:55:27 -0400 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20complete=20add?= =?UTF-8?q?=20members=20form=20component=20and=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../form/group/add-members/index.hbs | 48 +++++++++++++++++++ .../form/group/add-members/index.js | 42 ++++++++++++++++ .../scopes/scope/groups/group/add-members.js | 13 +++++ .../scopes/scope/groups/group/add-members.hbs | 7 ++- ui/core/tests/acceptance/groups-test.js | 14 +++++- ui/core/translations/en-us.yaml | 2 + 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 ui/core/app/components/form/group/add-members/index.hbs create mode 100644 ui/core/app/components/form/group/add-members/index.js diff --git a/ui/core/app/components/form/group/add-members/index.hbs b/ui/core/app/components/form/group/add-members/index.hbs new file mode 100644 index 000000000..fef2f383c --- /dev/null +++ b/ui/core/app/components/form/group/add-members/index.hbs @@ -0,0 +1,48 @@ +{{#if this.availableUsers}} + + + + + + + + {{t 'form.label.id'}} + {{t 'form.label.name'}} + + + + {{#each this.availableUsers as |user|}} + + + + + {{user.name}} + + {{/each}} + + + + +{{/if}} + +{{#unless this.availableUsers}} + + + + {{t "descriptions.no-available-users"}} + + + +{{/unless}} diff --git a/ui/core/app/components/form/group/add-members/index.js b/ui/core/app/components/form/group/add-members/index.js new file mode 100644 index 000000000..dbf5256bb --- /dev/null +++ b/ui/core/app/components/form/group/add-members/index.js @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import { computed, action } from '@ember/object'; +import { A } from '@ember/array'; + +export default class FormGroupAddMembersComponent extends Component { + + // =properties + + /** + * Array of selected user IDs. + * @type {EmberArray} + */ + selectedMemberIDs = A(); + + /** + * Host sets not already added to the target. + * @type {[UserModel]} + */ + @computed('args.{model.members_ids.[],users.[]}') + get availableUsers() { + const memberIDs = this.args.model.member_ids; + const users = this.args.users; + return users.filter(({ id }) => !memberIDs.includes(id)); + } + + // =actions + + @action + toggleMember(user) { + if (!this.selectedMemberIDs.includes(user.id)) { + this.selectedMemberIDs.addObject(user.id); + } else { + this.selectedMemberIDs.removeObject(user.id); + } + } + + @action + submit(fn) { + fn(this.selectedMemberIDs); + } + +} diff --git a/ui/core/app/routes/scopes/scope/groups/group/add-members.js b/ui/core/app/routes/scopes/scope/groups/group/add-members.js index 7ad1a8280..f3b8717a9 100644 --- a/ui/core/app/routes/scopes/scope/groups/group/add-members.js +++ b/ui/core/app/routes/scopes/scope/groups/group/add-members.js @@ -1,6 +1,7 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { hash } from 'rsvp'; export default class ScopesScopeGroupsGroupAddMembersRoute extends Route { @@ -18,6 +19,18 @@ export default class ScopesScopeGroupsGroupAddMembersRoute extends Route { this.store.unloadAll('user'); } + /** + * Loads all users and returns them with the group. + * @return {Promise{GroupModel, [UserModel]}} + */ + model() { + const { id: scopeID } = this.modelFor('scopes.scope'); + return hash({ + group: this.modelFor('scopes.scope.groups.group'), + users: this.store.findAll('user', { adapterOptions: { scopeID } }) + }); + } + /** * Renders the route-set specific page sections * @override diff --git a/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs b/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs index d8ca3dcef..6f674b02d 100644 --- a/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs +++ b/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs @@ -1,3 +1,8 @@ {{title (t "actions.add-members")}} -{{outlet}} + diff --git a/ui/core/tests/acceptance/groups-test.js b/ui/core/tests/acceptance/groups-test.js index c33014378..a6ad6ccd9 100644 --- a/ui/core/tests/acceptance/groups-test.js +++ b/ui/core/tests/acceptance/groups-test.js @@ -221,5 +221,17 @@ module('Acceptance | groups', function (hooks) { test('can view group members', async function (assert) { assert.expect(0); }); - + + test('can delete members', async function(assert) { + assert.expect(0); + }); + + test('select and save members to add', async function (assert) { + assert.expect(0); + }); + + test('select and cancel members to add', async function (assert) { + assert.expect(0); + }); + }); diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index c5b005d53..581610766 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -96,6 +96,7 @@ titles: role-principals-welcome: No Principals Associated members: Members group-members-welcome: Welcome to Members + no-available-users: No Users Available descriptions: host-catalogs: Overview of collections of hosts and host sets. empty-set: There are no items to display yet. You may be able to add items or try back later. @@ -113,6 +114,7 @@ descriptions: no-host-sets-for-target: No host sets are available to add to this target. role-principals-welcome: No principals are available in this role. group-members-welcome: Users belonging to this group appear here. + no-available-users: No users are available to add to this group. states: info: Info success: Success From 2217b14d03e5b2e6140fa929976d92af60063d2a Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Wed, 2 Sep 2020 16:20:12 -0400 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20=F0=9F=A4=96=20fixes=20a=20brief?= =?UTF-8?q?=20flash=20of=20empty=20list=20after=20adding=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scopes/scope/groups/group/members.js | 19 +++++++++---------- .../scopes/scope/groups/group/members.hbs | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/ui/core/app/routes/scopes/scope/groups/group/members.js b/ui/core/app/routes/scopes/scope/groups/group/members.js index 7e2dc6658..47854dae1 100644 --- a/ui/core/app/routes/scopes/scope/groups/group/members.js +++ b/ui/core/app/routes/scopes/scope/groups/group/members.js @@ -1,6 +1,7 @@ import Route from '@ember/routing/route'; import { action } from '@ember/object'; import { inject as service } from '@ember/service'; +import { hash, all } from 'rsvp'; export default class ScopesScopeGroupsGroupMembersRoute extends Route { @@ -11,22 +12,19 @@ export default class ScopesScopeGroupsGroupMembersRoute extends Route { // =methods - /** - * Empty out all users before loading members. - */ - beforeModel() { - this.store.unloadAll('user'); - } - /** * Returns users associated with this group. - * @return {Promise{[UserModel]}} + * @return {Promise{group: GroupModel, members: Promise{[UserModel]}}} */ async model() { const { id: scopeID } = this.modelFor('scopes.scope'); const group = this.modelFor('scopes.scope.groups.group'); - await this.store.findAll('user', { adapterOptions: { scopeID } }); - return group; + return hash({ + group, + members: all(group.member_ids.map(id => + this.store.findRecord('user', id, { adapterOptions: { scopeID } }) + )) + }); } // =actions @@ -40,6 +38,7 @@ export default class ScopesScopeGroupsGroupMembersRoute extends Route { async removeMember(group, member) { try { await group.removeMember(member.id); + await this.refresh(); this.notify.success(this.intl.t('notify.delete-success')); } catch (error) { // TODO: replace with translated strings diff --git a/ui/core/app/templates/scopes/scope/groups/group/members.hbs b/ui/core/app/templates/scopes/scope/groups/group/members.hbs index a2445b90d..fb562164b 100644 --- a/ui/core/app/templates/scopes/scope/groups/group/members.hbs +++ b/ui/core/app/templates/scopes/scope/groups/group/members.hbs @@ -29,7 +29,7 @@ > {{t "actions.remove"}} From 198ce0838873a278041b285e0f244ec4f5e563c9 Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Wed, 2 Sep 2020 16:45:43 -0400 Subject: [PATCH 9/9] chore: remove unnecessary async --- ui/core/app/routes/scopes/scope/groups/group/members.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/core/app/routes/scopes/scope/groups/group/members.js b/ui/core/app/routes/scopes/scope/groups/group/members.js index 47854dae1..eb3fe8eb2 100644 --- a/ui/core/app/routes/scopes/scope/groups/group/members.js +++ b/ui/core/app/routes/scopes/scope/groups/group/members.js @@ -16,7 +16,7 @@ export default class ScopesScopeGroupsGroupMembersRoute extends Route { * Returns users associated with this group. * @return {Promise{group: GroupModel, members: Promise{[UserModel]}}} */ - async model() { + model() { const { id: scopeID } = this.modelFor('scopes.scope'); const group = this.modelFor('scopes.scope.groups.group'); return hash({