diff --git a/ui/core/app/components/form/target/add-host-sets/index.hbs b/ui/core/app/components/form/target/add-host-sets/index.hbs new file mode 100644 index 000000000..372c52f69 --- /dev/null +++ b/ui/core/app/components/form/target/add-host-sets/index.hbs @@ -0,0 +1,60 @@ +{{#if this.hasAvailableHostSets}} + + + + + + + + {{t 'form.id.label'}} + {{t 'form.name.label'}} + {{t 'form.type.label'}} + {{t 'resources.host-catalog.title'}} + + + + {{#each this.filteredHostSets as |hostSet|}} + + + + + {{hostSet.name}} + + {{t (concat 'resources.host-set.types.' hostSet.type)}} + + + {{hostSet.host_catalog_id}} + + + {{/each}} + + + + +{{/if}} + +{{#unless this.hasAvailableHostSets}} + + + + {{t "resources.target.host-set.messages.none.description"}} + + + + {{t "actions.back"}} + + + +{{/unless}} diff --git a/ui/core/app/components/form/target/add-host-sets/index.js b/ui/core/app/components/form/target/add-host-sets/index.js new file mode 100644 index 000000000..f49dda570 --- /dev/null +++ b/ui/core/app/components/form/target/add-host-sets/index.js @@ -0,0 +1,55 @@ +import Component from '@glimmer/component'; +import { computed, action } from '@ember/object'; +import { A } from '@ember/array'; + +export default class FormTargetAddHostSetsComponent extends Component { + // =properties + + /** + * Array of selected host set IDs. + * @type {EmberArray} + */ + selectedHostSetIDs = A(); + + /** + * Checks for unassigned hostsets. + * @param {[HostSetModel]} filteredHostSets + * @type {boolean} + */ + @computed('filteredHostSets.length') + get hasAvailableHostSets() { + return this.filteredHostSets.length > 0; + } + + /** + * Host sets not already added to the target. + * @type {[HostSetModel]} + */ + @computed('args.{hostSets.[],model.host_sets.[]}') + get filteredHostSets() { + // Get IDs for host sets already added to the current target + const alreadyAddedHostSetIDs = this.args.model.host_sets.map( + ({ host_set_id }) => host_set_id + ); + const notAddedHostSets = this.args.hostSets.filter( + ({ id }) => !alreadyAddedHostSetIDs.includes(id) + ); + return notAddedHostSets; + } + + // =actions + + @action + toggleHostSet(hostSet) { + if (!this.selectedHostSetIDs.includes(hostSet.id)) { + this.selectedHostSetIDs.addObject(hostSet.id); + } else { + this.selectedHostSetIDs.removeObject(hostSet.id); + } + } + + @action + submit(fn) { + fn(this.selectedHostSetIDs); + } +} diff --git a/ui/core/app/router.js b/ui/core/app/router.js index a88062809..41725c425 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -58,7 +58,10 @@ Router.map(function () { this.route('sessions', function() {}); this.route('targets', function() { - this.route('target', { path: ':target_id' }, function() {}); + this.route('target', { path: ':target_id' }, function() { + this.route('host-sets', function() {}); + this.route('add-host-sets'); + }); this.route('new'); }); diff --git a/ui/core/app/routes/scopes/scope/targets/target/add-host-sets.js b/ui/core/app/routes/scopes/scope/targets/target/add-host-sets.js new file mode 100644 index 000000000..1a211d30c --- /dev/null +++ b/ui/core/app/routes/scopes/scope/targets/target/add-host-sets.js @@ -0,0 +1,92 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { all } from 'rsvp'; +import loading from 'ember-loading/decorator'; +import { notifySuccess, notifyError } from '../../../../../decorators/notify'; + +export default class ScopesScopeTargetsTargetAddHostSetsRoute extends Route { + // =services + + @service intl; + @service notify; + + // =methods + + /** + * Empty out any previously loaded host sets. + */ + beforeModel() { + this.store.unloadAll('host-set'); + } + + /** + * Returns the current target, all host catalogs, and all host sets. + * @return {{target: TargetModel, hostCatalogs: [HostCatalogModel], hostSets: [HostSetModel]}} + */ + async model() { + const target = this.modelFor( + 'scopes.scope.targets.target' + ); + const { id: scope_id } = this.modelFor('scopes.scope'); + const hostCatalogs = await this.store.query('host-catalog', { scope_id }); + await all( + hostCatalogs.map(({ id: host_catalog_id }) => + this.store.query('host-set', { host_catalog_id }) + ) + ); + const hostSets = this.store.peekAll('host-set'); + return { + target, + hostCatalogs, + hostSets, + }; + } + + /** + * Renders the add-host-sets-specific header template. + * @override + */ + renderTemplate() { + super.renderTemplate(...arguments); + + this.render( + 'scopes/scope/targets/target/add-host-sets/-header', + { + into: 'scopes/scope/targets/target', + outlet: 'header', + } + ); + + this.render('-empty', { + into: 'scopes/scope/targets/target', + outlet: 'navigation', + }); + + this.render('-empty', { + into: 'scopes/scope/targets/target', + outlet: 'actions', + }); + } + + // =actions + + @action + @loading + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.add-success') + async save(target, hostSetIDs) { + await target.addHostSets(hostSetIDs); + this.replaceWith( + 'scopes.scope.targets.target.host-sets' + ); + } + + /** + * Redirect to target host sets as if nothing ever happened. + */ + @action + cancel() { + this.replaceWith('scopes.scope.targets.target.host-sets'); + } +} diff --git a/ui/core/app/routes/scopes/scope/targets/target/host-sets.js b/ui/core/app/routes/scopes/scope/targets/target/host-sets.js new file mode 100644 index 000000000..e181d4cdd --- /dev/null +++ b/ui/core/app/routes/scopes/scope/targets/target/host-sets.js @@ -0,0 +1,67 @@ +import Route from '@ember/routing/route'; +import { all, hash } from 'rsvp'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import loading from 'ember-loading/decorator'; +import { confirm } from '../../../../../decorators/confirm'; +import { notifySuccess, notifyError } from '../../../../../decorators/notify'; + +export default class ScopesScopeTargetsTargetHostSetsRoute extends Route { + // =services + + @service intl; + @service notify; + + // =methods + + /** + * Loads all host-sets and associated host-catalogs under the current target + * and it's parent scope. + * @return {Promise{[HostSetModel, HostCatalogModel]}} + */ + beforeModel() { + const { scopeID, host_sets } = this.modelFor( + 'scopes.scope.targets.target' + ); + const promises = host_sets.map( + ({ host_set_id, host_catalog_id: hostCatalogID }) => + hash({ + // TODO: multiple host sets may belong to the same catalog, + // resulting in the catalog being loaded multiple times. + // An improvement would be to find the unique set of catalogs first. + hostCatalog: this.store.findRecord('host-catalog', hostCatalogID, { + adapterOptions: { scopeID }, + }), + hostSet: this.store.findRecord('host-set', host_set_id, { + adapterOptions: { scopeID, hostCatalogID }, + }), + }) + ); + return all(promises); + } + + /** + * Returns the previously loaded target instance. + * @return {TargetModel} + */ + model() { + return this.modelFor('scopes.scope.targets.target'); + } + + // =actions + + /** + * Removes a host set from the current target and redirects to index. + * @param {TargetModel} target + * @param {HostSetModel} hostSet + */ + @action + @loading + @confirm('questions.remove-confirm') + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.remove-success') + async removeHostSet(target, hostSet) { + await target.removeHostSet(hostSet.id); + this.refresh(); + } +} diff --git a/ui/core/app/templates/scopes/scope/targets/target/-actions.hbs b/ui/core/app/templates/scopes/scope/targets/target/-actions.hbs index 0b07c9565..1d87ec790 100644 --- a/ui/core/app/templates/scopes/scope/targets/target/-actions.hbs +++ b/ui/core/app/templates/scopes/scope/targets/target/-actions.hbs @@ -1,9 +1,7 @@ - {{!-- {{t 'resources.target.actions.add-host-sets'}} - --}} {{t 'titles.details'}} - {{!-- {{t 'resources.host-set.title_plural'}} - --}} diff --git a/ui/core/app/templates/scopes/scope/targets/target/add-host-sets.hbs b/ui/core/app/templates/scopes/scope/targets/target/add-host-sets.hbs new file mode 100644 index 000000000..e214aa895 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/targets/target/add-host-sets.hbs @@ -0,0 +1,25 @@ +{{title (t 'resources.target.actions.add-host-sets')}} + +{{#if @model.hostSets}} + +{{/if}} + +{{#unless @model.hostSets}} + + + + {{t "resources.target.host-set.messages.none.description"}} + + + + {{t "actions.back"}} + + + +{{/unless}} diff --git a/ui/core/app/templates/scopes/scope/targets/target/add-host-sets/-header.hbs b/ui/core/app/templates/scopes/scope/targets/target/add-host-sets/-header.hbs new file mode 100644 index 000000000..64fc3df9a --- /dev/null +++ b/ui/core/app/templates/scopes/scope/targets/target/add-host-sets/-header.hbs @@ -0,0 +1,5 @@ +

+ {{t "resources.target.host-set.messages.add.title"}} + +

+

{{t "resources.target.host-set.messages.add.description"}}

diff --git a/ui/core/app/templates/scopes/scope/targets/target/host-sets.hbs b/ui/core/app/templates/scopes/scope/targets/target/host-sets.hbs new file mode 100644 index 000000000..464dc32e1 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/targets/target/host-sets.hbs @@ -0,0 +1,68 @@ +{{title (t "resources.host-set.title_plural")}} + +{{#if @model.hostSets}} + + + + + {{t "form.name.label"}} + {{t "form.type.label"}} + {{t "form.id.label"}} + {{t "titles.actions"}} + + + + {{#each @model.hostSets as |hostSet|}} + + + {{hostSet.model.displayName}} +

{{hostSet.model.description}}

+
+ + {{t (concat 'resources.host-set.types.' hostSet.model.type)}} + + + + {{hostSet.model.id}} + + + + + + {{t "actions.remove"}} + + + +
+ {{/each}} +
+
+ +{{else}} + + + + + {{t "resources.target.host-set.messages.welcome.description"}} + + + + {{t 'resources.target.actions.add-host-sets'}} + + + + +{{/if}} diff --git a/ui/core/tests/acceptance/targets-test.js b/ui/core/tests/acceptance/targets-test.js index d8e9c811b..594756b52 100644 --- a/ui/core/tests/acceptance/targets-test.js +++ b/ui/core/tests/acceptance/targets-test.js @@ -23,9 +23,7 @@ module('Acceptance | targets', function (hooks) { scopes: { global: null, org: null, - org2: null, project: null, - project2: null, } }; const urls = { @@ -55,10 +53,10 @@ module('Acceptance | targets', function (hooks) { urls.globalScope = `/scopes/global/scopes`; urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; urls.projectScope = `/scopes/${instances.scopes.project.id}`; - urls.targets = `/scopes/${instances.scopes.project.id}/targets`; - urls.target = `/scopes/${instances.scopes.project.id}/targets/${instances.target.id}`; - urls.unknownTarget = `/scopes/${instances.scopes.project.id}/targets/foo`; - urls.newTarget = `/scopes/${instances.scopes.project.id}/targets/new`; + urls.targets = `${urls.projectScope}/targets`; + urls.target = `${urls.targets}/${instances.target.id}`; + urls.unknownTarget = `${urls.targets}/foo`; + urls.newTarget = `${urls.targets}/new`; // Generate resource couner getTargetCount = () => this.server.schema.targets.all().models.length; authenticateSession({}); diff --git a/ui/core/tests/acceptance/targets/host-sets-test.js b/ui/core/tests/acceptance/targets/host-sets-test.js new file mode 100644 index 000000000..21655f223 --- /dev/null +++ b/ui/core/tests/acceptance/targets/host-sets-test.js @@ -0,0 +1,160 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, find, findAll, click } 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'; +import { Response } from 'miragejs'; +import { + authenticateSession, + // These are left here intentionally for future reference. + //currentSession, + //invalidateSession, +} from 'ember-simple-auth/test-support'; + +module('Acceptance | targets | host-sets', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + const instances = { + scopes: { + global: null, + org: null, + project: null, + } + }; + const urls = { + globalScope: null, + orgScope: null, + projectScope: null, + targets: null, + target: null, + targetHostSets: null, + targetAddHostSets: null, + }; + + hooks.beforeEach(function () { + // Generate resources + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.project = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + instances.hostCatalog = this.server.create('host-catalog', { + scope: instances.scopes.project + }, 'withChildren'); + instances.target = this.server.create('target', { + scope: instances.scopes.project, + hostSets: instances.hostCatalog.hostSets + }); + // Generate route URLs for resources + urls.globalScope = `/scopes/global/scopes`; + urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; + urls.projectScope = `/scopes/${instances.scopes.project.id}`; + urls.targets = `/scopes/${instances.scopes.project.id}/targets`; + urls.target = `${urls.targets}/${instances.target.id}`; + urls.targetHostSets = `${urls.target}/host-sets`; + urls.targetAddHostSets = `${urls.target}/add-host-sets`; + authenticateSession({}); + }); + + test('visiting target host sets', async function (assert) { + assert.expect(3); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + await a11yAudit(); + assert.equal(currentURL(), urls.targetHostSets); + assert.ok(targetHostSetCount); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + }); + + test('can remove a host sets', async function (assert) { + assert.expect(2); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + await click('tbody tr .rose-dropdown-button-danger'); + assert.equal(findAll('tbody tr').length, targetHostSetCount - 1); + }); + + test('removing a target host set which errors displays error messages', async function (assert) { + assert.expect(2); + this.server.post('/targets/:idMethod', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: {}, + } + ); + }); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + await click('tbody tr .rose-dropdown-button-danger'); + assert.ok(find('[role="alert"]')); + }); + + test('select and save host sets to add', async function (assert) { + assert.expect(3); + instances.target.update({ hostSetIds: [] }); + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, 0); + await click('.rose-layout-page-actions a') + assert.equal(currentURL(), urls.targetAddHostSets); + // Click three times to select, unselect, then reselect (for coverage) + await click('tbody label'); + await click('tbody label'); + await click('tbody label'); + await click('form [type="submit"]'); + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, 1); + }); + + test('select and cancel host sets to add', async function (assert) { + assert.expect(4); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + // first, remove a target host set (otherwise none would be available to add) + await click('tbody tr .rose-dropdown-button-danger'); + assert.equal(findAll('tbody tr').length, targetHostSetCount - 1); + await click('.rose-layout-page-actions a') + assert.equal(currentURL(), urls.targetAddHostSets); + await click('tbody label'); + await click('form [type="button"]'); + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount - 1); + }); + + test('adding a target host set which errors displays error messages', async function (assert) { + assert.expect(4); + this.server.post('/targets/:idMethod', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: {}, + } + ); + }); + instances.target.update({ hostSetIds: [] }); + await visit(urls.targetAddHostSets); + assert.equal(instances.target.hostSets.length, 0); + assert.equal(currentURL(), urls.targetAddHostSets); + await click('tbody label'); + await click('form [type="submit"]'); + assert.equal(currentURL(), urls.targetAddHostSets); + assert.equal(instances.target.hostSets.length, 0); + }); + +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/targets/target/add-host-sets-test.js b/ui/core/tests/unit/routes/scopes/scope/targets/target/add-host-sets-test.js new file mode 100644 index 000000000..c07db35c9 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/targets/target/add-host-sets-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/targets/target/add-host-sets', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/targets/target/add-host-sets'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/targets/target/host-sets-test.js b/ui/core/tests/unit/routes/scopes/scope/targets/target/host-sets-test.js new file mode 100644 index 000000000..1309e5b03 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/targets/target/host-sets-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/targets/target/host-sets', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/targets/target/host-sets'); + assert.ok(route); + }); +});