diff --git a/addons/rose/addon/components/rose/form/checkbox.stories.mdx b/addons/rose/addon/components/rose/form/checkbox.stories.mdx index 3a15884dc..539f531c0 100644 --- a/addons/rose/addon/components/rose/form/checkbox.stories.mdx +++ b/addons/rose/addon/components/rose/form/checkbox.stories.mdx @@ -25,6 +25,7 @@ Renders a form input checkbox. {{yield (hash diff --git a/addons/rose/tests/integration/components/rose/form/checkbox-test.js b/addons/rose/tests/integration/components/rose/form/checkbox-test.js index 6ceb4abf9..53f5ef87b 100644 --- a/addons/rose/tests/integration/components/rose/form/checkbox-test.js +++ b/addons/rose/tests/integration/components/rose/form/checkbox-test.js @@ -7,9 +7,19 @@ module('Integration | Component | rose/form/checkbox', function (hooks) { setupRenderingTest(hooks); test('it renders', async function (assert) { + assert.expect(3); await render(hbs``); assert.equal(await find('label').textContent.trim(), 'Label'); assert.ok(await find('input')); + assert.notOk(await find('.rose-form-checkbox-label-description')); + }); + + test('it renders optional description paragraph', async function (assert) { + assert.expect(3); + await render(hbs``); + assert.equal(await find('.rose-form-checkbox-label-text').textContent.trim(), 'Label'); + assert.equal(await find('.rose-form-checkbox-label-description').textContent.trim(), 'Hello world'); + assert.ok(await find('input')); }); test('it displays optional errors', async function (assert) { diff --git a/ui/core/app/components/form/host-set/hosts/index.hbs b/ui/core/app/components/form/host-set/hosts/index.hbs new file mode 100644 index 000000000..1943e26aa --- /dev/null +++ b/ui/core/app/components/form/host-set/hosts/index.hbs @@ -0,0 +1,50 @@ + + + + + {{!-- + {{#if @model.errors.host_ids}} + + {{#each @model.errors.host_ids as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + --}} + + + + + {{t 'form.label.id'}} + {{t 'form.label.name'}} + {{t 'form.label.address'}} + + + + {{#each this.options as |option|}} + + + + + {{option.host.name}} + {{option.host.address}} + + {{/each}} + + + + diff --git a/ui/core/app/components/form/host-set/hosts/index.js b/ui/core/app/components/form/host-set/hosts/index.js new file mode 100644 index 000000000..1e420869a --- /dev/null +++ b/ui/core/app/components/form/host-set/hosts/index.js @@ -0,0 +1,23 @@ +import Component from '@glimmer/component'; +import { computed } from '@ember/object'; + +export default class FormHostSetHostsComponent extends Component { + + // =attributes + + /** + * An array of wrapped `HostModel` instances where the wrapper includes + * a `selected` boolean field indicating if the host is represented + * in `model.host_ids`. + * @type {[{host: HostModel, selected: boolean}]} + */ + @computed('args.{hosts.[],model.host_ids.[]}') + get options() { + const { hosts, model: hostSet } = this.args; + return hosts.map(host => ({ + host, + selected: Boolean(hostSet.host_ids.findBy('value', host.id)) + })); + } + +} diff --git a/ui/core/app/router.js b/ui/core/app/router.js index f0f18253c..13e46eb06 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -24,7 +24,9 @@ Router.map(function () { this.route('new'); this.route('host-catalog', { path: ':host_catalog_id' }, function() { this.route('host-sets', function() { - this.route('host-set', { path: ':host_set_id' }, function() {}); + this.route('host-set', { path: ':host_set_id' }, function() { + this.route('hosts'); + }); this.route('new'); }); this.route('hosts', function() { diff --git a/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets.js b/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets.js index 74f1a2783..4fbec613f 100644 --- a/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets.js +++ b/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets.js @@ -8,6 +8,19 @@ export default class ScopesScopeProjectsProjectHostCatalogsHostCatalogHostSetsRo @service intl; @service notify; + // =attributes + + /** + * Configure adapter options using current host catalog and + * the current project scope. + * @return {object} + */ + get adapterOptions() { + const scopeID = this.modelFor('scopes.scope.projects.project').id; + const hostCatalogID = this.modelFor('scopes.scope.projects.project.host-catalogs.host-catalog').id; + return { adapterOptions : { scopeID, hostCatalogID } }; + } + // =methods /** @@ -15,7 +28,8 @@ export default class ScopesScopeProjectsProjectHostCatalogsHostCatalogHostSetsRo * @return {Promise{[HostSetModel]}} */ async model() { - return this.store.findAll('host-set', this.adapterOptions()); + this.store.unloadAll('host-set'); + return this.store.findAll('host-set', this.adapterOptions); } // =actions @@ -40,8 +54,7 @@ export default class ScopesScopeProjectsProjectHostCatalogsHostCatalogHostSetsRo async save(hostSet) { try { const { isNew } = hostSet; - await hostSet.save(this.adapterOptions()); - this.refresh(); + await hostSet.save(this.adapterOptions); this.notify.success( this.intl.t(isNew ? 'notify.create-success' : 'notify.save-success') ); @@ -59,8 +72,7 @@ export default class ScopesScopeProjectsProjectHostCatalogsHostCatalogHostSetsRo @action async delete(hostSet) { try { - await hostSet.destroyRecord(this.adapterOptions()); - this.refresh(); + await hostSet.destroyRecord(this.adapterOptions); this.notify.success(this.intl.t('notify.delete-success')); this.transitionTo('scopes.scope.projects.project.host-catalogs.host-catalog.host-sets'); } catch (error) { @@ -68,14 +80,4 @@ export default class ScopesScopeProjectsProjectHostCatalogsHostCatalogHostSetsRo this.notify.error(error.message, { closeAfter: null }); } } - - /** - * Configure adapter options using current host catalog scope and it's parent project scope. - * @return {object} - */ - adapterOptions() { - const scopeID = this.modelFor('scopes.scope.projects.project').id; - const hostCatalogID = this.modelFor('scopes.scope.projects.project.host-catalogs.host-catalog').id; - return { adapterOptions : { scopeID, hostCatalogID } }; - } } diff --git a/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.js b/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.js new file mode 100644 index 000000000..b7bc79f0e --- /dev/null +++ b/ui/core/app/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.js @@ -0,0 +1,70 @@ +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 ScopesScopeProjectsProjectHostCatalogsHostCatalogHostSetsHostSetHostsRoute extends Route { + + // =services + + @service intl; + @service notify; + + // =methods + + /** + * Loads all hosts under the current host catalog. + * @return {Promise{[HostModel]}} + */ + async model() { + const scopeID = this.modelFor('scopes.scope.projects.project').id; + const hostCatalogID = this.modelFor('scopes.scope.projects.project.host-catalogs.host-catalog').id; + const hostSet = this.modelFor('scopes.scope.projects.project.host-catalogs.host-catalog.host-sets.host-set'); + this.store.unloadAll('host'); + return hash({ + hostSet, + hosts: this.store.findAll('host', { adapterOptions: { scopeID, hostCatalogID } }) + }); + } + + // =actions + + /** + * Toggles the presence of the passed host ID in the passed host set's + * `host_ids` field. + * @param {HostSetModel} hostSet + * @param {HostModel} host + */ + @action + toggleHost(hostSet, host) { + const isHostSelected = Boolean(hostSet.host_ids.findBy('value', host.id)); + const fragment = hostSet.host_ids.findBy('value', host.id); + if (isHostSelected) { + // if host is already selected, filter it out + hostSet.host_ids.removeObject(fragment); + } else { + // if host is not selected, add it + hostSet.host_ids.addObject({ value: host.id }); + } + } + + /** + * Saves host IDs on the host set. + * @param {HostSetModel} hostSet + */ + @action + async save(hostSet) { + try { + const hostCatalogID = this.modelFor('scopes.scope.projects.project.host-catalogs.host-catalog').id; + const options = { adapterOptions : { hostCatalogID } }; + await hostSet.saveHostIDs(options); + this.notify.success( + this.intl.t('notify.save-success') + ); + } catch (error) { + // TODO: replace with translated strings + this.notify.error(error.message, { closeAfter: null }); + } + } + +} diff --git a/ui/core/app/styles/app.scss b/ui/core/app/styles/app.scss index 0aaf60c5b..ba08e2b36 100644 --- a/ui/core/app/styles/app.scss +++ b/ui/core/app/styles/app.scss @@ -13,7 +13,9 @@ } .rose-form { - width: 66%; + &:not(.full-width) { + width: 66%; + } .rose-form-input { width: 100%; @@ -23,3 +25,18 @@ } } } + +.rose-table { + p { + margin-bottom: 0; + } + + .rose-form-checkbox { + margin-bottom: 0; + + .rose-form-checkbox-label-text { + color: var(--stark); + text-decoration: underline; + } + } +} diff --git a/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/-navigation.hbs b/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/-navigation.hbs index 62f603a0b..8e2795027 100644 --- a/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/-navigation.hbs +++ b/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/-navigation.hbs @@ -2,4 +2,7 @@ {{t 'titles.details'}} - \ No newline at end of file + + {{t 'resources.hosts'}} + + diff --git a/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs b/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs new file mode 100644 index 000000000..cc1964bf8 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs @@ -0,0 +1,23 @@ +{{#if @model.hosts}} + +{{/if}} + +{{#unless @model.hosts}} + + + + {{t "descriptions.no-host-set-hosts"}} + + + + {{t "titles.new"}} + + + +{{/unless}} diff --git a/ui/core/mirage/factories/host.js b/ui/core/mirage/factories/host.js index eb463f1f9..f322cb4d4 100644 --- a/ui/core/mirage/factories/host.js +++ b/ui/core/mirage/factories/host.js @@ -1,3 +1,5 @@ import factory from '../generated/factories/host'; -export default factory.extend(); +export default factory.extend({ + id: (i) => `host-id-${i}` +}); diff --git a/ui/core/tests/acceptance/host-sets-test.js b/ui/core/tests/acceptance/host-sets-test.js index b018fc1d8..a5bac11c9 100644 --- a/ui/core/tests/acceptance/host-sets-test.js +++ b/ui/core/tests/acceptance/host-sets-test.js @@ -58,4 +58,17 @@ module('Acceptance | host sets', function (hooks) { test('saving an existing host set with invalid fields displays error messages', async function (assert) { assert.expect(0); }); + + test('can view hosts associated with a host set', async function (assert) { + assert.expect(0); + }); + + test('can add/remove host associations to a host set and save', async function (assert) { + assert.expect(0); + }); + + test('can cancel add/remove host associations to a host set', async function (assert) { + assert.expect(0); + }); + }); diff --git a/ui/core/tests/integration/components/form/host-set/hosts-test.js b/ui/core/tests/integration/components/form/host-set/hosts-test.js new file mode 100644 index 000000000..e99b6a33e --- /dev/null +++ b/ui/core/tests/integration/components/form/host-set/hosts-test.js @@ -0,0 +1,25 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, find } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | form/host-set/hosts', function(hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + this.submit = () => {}; + this.cancel = () => {}; + this.model = { host_ids: [] }; + this.hosts = []; + + await render(hbs` + `); + + assert.ok(find('form')); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts-test.js b/ui/core/tests/unit/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts-test.js new file mode 100644 index 000000000..e7f3a700d --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts'); + assert.ok(route); + }); +}); diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index d9aa82900..69b41f0cc 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -57,8 +57,8 @@ resources: auth-methods: Auth Methods host-set: Host Set host-sets: Host Sets - hosts: Hosts host: Host + hosts: Hosts target: Target targets: Targets titles: @@ -86,6 +86,7 @@ titles: hosts: Hosts targets: Targets global: Global + no-host-set-hosts: No Hosts 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. @@ -98,6 +99,7 @@ descriptions: groups-welcome: Groups are collections of users. auth-methods-welcome: Auth methods are configurations to authenticate users. targets-welcome: Targets provide Users the means to connect to and interact networked services. A Target contains a collection of Hosts and a collection of Credentials. + no-host-set-hosts: Before associating hosts with this host set, create hosts within the host catalog. states: info: Info success: Success @@ -114,6 +116,7 @@ form: manage: Manage grant: Grant principal: Principal + address: Address protocol: Protocol errors: titles: