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: