diff --git a/addons/api/addon/models/group.js b/addons/api/addon/models/group.js
index 899c71d0c..0a06fd404 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
+ });
+ });
+
+});
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/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 6df274629..c55b8124b 100644
--- a/ui/core/app/router.js
+++ b/ui/core/app/router.js
@@ -54,7 +54,10 @@ 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('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..f3b8717a9
--- /dev/null
+++ b/ui/core/app/routes/scopes/scope/groups/group/add-members.js
@@ -0,0 +1,85 @@
+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 {
+
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Emtpy out any previously loaded users.
+ */
+ beforeModel() {
+ 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
+ */
+ 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/routes/scopes/scope/groups/group/members.js b/ui/core/app/routes/scopes/scope/groups/group/members.js
new file mode 100644
index 000000000..eb3fe8eb2
--- /dev/null
+++ b/ui/core/app/routes/scopes/scope/groups/group/members.js
@@ -0,0 +1,49 @@
+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 {
+
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Returns users associated with this group.
+ * @return {Promise{group: GroupModel, members: Promise{[UserModel]}}}
+ */
+ model() {
+ const { id: scopeID } = this.modelFor('scopes.scope');
+ const group = this.modelFor('scopes.scope.groups.group');
+ return hash({
+ group,
+ members: all(group.member_ids.map(id =>
+ this.store.findRecord('user', id, { adapterOptions: { scopeID } })
+ ))
+ });
+ }
+
+ // =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);
+ await this.refresh();
+ 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..2a878ff51 100644
--- a/ui/core/app/templates/scopes/scope/groups/group.hbs
+++ b/ui/core/app/templates/scopes/scope/groups/group.hbs
@@ -1,3 +1,25 @@
{{title @model.displayName}}
-{{outlet}}
+
+
+
+
+
+
+
+ {{outlet "header"}}
+
+
+
+ {{outlet "navigation"}}
+
+
+
+ {{outlet "actions"}}
+
+
+
+ {{outlet}}
+
+
+
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..6f674b02d
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/groups/group/add-members.hbs
@@ -0,0 +1,8 @@
+{{title (t "actions.add-members")}}
+
+
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/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..fb562164b
--- /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/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..9531de4f8 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', 2, { 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;
+ }
+
});
diff --git a/ui/core/tests/acceptance/groups-test.js b/ui/core/tests/acceptance/groups-test.js
index 0d622f1f0..a6ad6ccd9 100644
--- a/ui/core/tests/acceptance/groups-test.js
+++ b/ui/core/tests/acceptance/groups-test.js
@@ -217,4 +217,21 @@ module('Acceptance | groups', function (hooks) {
'Displays primary error message.'
);
});
+
+ 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/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/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 427c1b956..7c354af99 100644
--- a/ui/core/translations/en-us.yaml
+++ b/ui/core/translations/en-us.yaml
@@ -24,6 +24,7 @@ actions:
add-host: Create Host
add-host-sets: Add Host Sets
add-principals: Add Principals
+ add-members: Add Members
auth:
titles:
no-scopes: No Scopes Available
@@ -94,6 +95,9 @@ 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
+ 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.
@@ -110,6 +114,8 @@ 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.
+ no-available-users: No users are available to add to this group.
states:
info: Info
success: Success