Merge pull request #130 from hashicorp/feature-host-selections

Feature host selections
This commit is contained in:
Susmitha Girumala authored and GitHub committed 2020-08-24 11:55:25 -04:00
commit 1dc99833ac
19 files changed
+295 -26

No files matched your search

@@ -25,6 +25,7 @@ Renders a form input checkbox.
<Rose::Form::Checkbox
@name={{name}}
@label={{label}}
@description={{description}}
@checked={{checked}}
@error={{error}}
@disabled={{disabled}}
@@ -32,6 +33,7 @@ Renders a form input checkbox.
`,
context: {
label: text('text', 'Checkbox 🛎'),
description: text('description', 'This is an optional description paragraph.'),
name: text('name', 'checkbox-1'),
checked: boolean('checked', false),
error: boolean('error', false),
@@ -2,6 +2,7 @@
@use '../../utilities/type';
.rose-form-checkbox {
@include type.type(m);
position: relative;
margin-bottom: sizing.rems(xs);
color: var(--ui-gray);
@@ -15,7 +16,15 @@
// label styles
@include type.type(s, semibold);
color: currentColor;
padding-left: sizing.rems(l);
&,
.rose-form-checkbox-label-description {
padding-left: sizing.rems(l);
}
.rose-form-checkbox-label-description {
margin-top: sizing.rems(xs);
}
// custom checkbox styles
&::before {
@@ -23,6 +23,7 @@ $radio-svg: (
$data-svg-prefix: 'data:image/svg+xml;utf-8,';
.rose-form-radio {
@include type.type(m);
position: relative;
margin-bottom: sizing.rems(xs);
color: var(--ui-gray);
@@ -45,8 +46,8 @@ $data-svg-prefix: 'data:image/svg+xml;utf-8,';
&::after {
position: absolute;
top: -#{sizing.rems(xxs)};
left: -#{sizing.rems(xxxs) + sizing.rems(xxxxs)};
top: -#{sizing.rems(xxxxs)};
left: -#{sizing.rems(xxxs)};
display: block;
width: sizing.rems(l);
height: sizing.rems(l);
@@ -7,7 +7,7 @@
box-shadow: inset 0 -#{sizing.rems(xxxxs)} 0 var(--ui-gray-subtler-2);
.rose-nav-link {
--color: var(--ui-gray);
--color: var(--ui-gray-starker-1);
--background: transparent;
--box-shadow-color: transparent;
@@ -21,7 +21,7 @@
&:hover {
--box-shadow-color: var(--ui-gray-subtler-2);
--background: var(--ui-gray-subtler-4);
--background: var(--ui-gray-subtler-5);
}
&:active,
@@ -11,7 +11,10 @@
aria-describedby="{{if @error (concat 'errors-' this.id)}}" />
<label class="rose-form-checkbox-label" for={{this.id}}>
{{@label}}
<span class="rose-form-checkbox-label-text">{{@label}}</span>
{{#if @description}}
<p class="rose-form-checkbox-label-description">{{@description}}</p>
{{/if}}
</label>
{{yield (hash
@@ -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`<Rose::Form::Checkbox @label="Label" />`);
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`<Rose::Form::Checkbox @label="Label" @description="Hello world" />`);
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) {
@@ -0,0 +1,50 @@
<Rose::Form
class="full-width"
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@error={{@model.errors.base}}
as |form|
>
<form.actions
@disabled={{if @model.cannotSave @model.cannotSave}}
@submitText={{t "actions.save"}}
@cancelText={{t "actions.cancel"}} />
{{!--
{{#if @model.errors.host_ids}}
<input.errors as |errors|>
{{#each @model.errors.host_ids as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</input.errors>
{{/if}}
--}}
<Rose::Table as |table|>
<table.header as |header|>
<header.row as |row|>
<row.headerCell>{{t 'form.label.id'}}</row.headerCell>
<row.headerCell>{{t 'form.label.name'}}</row.headerCell>
<row.headerCell>{{t 'form.label.address'}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
{{#each this.options as |option|}}
<body.row as |row|>
<row.cell>
<form.checkbox
@label={{option.host.id}}
@description={{option.host.description}}
@checked={{option.selected}}
onChange={{fn @toggleHost option.host}} />
</row.cell>
<row.cell>{{option.host.name}}</row.cell>
<row.cell>{{option.host.address}}</row.cell>
</body.row>
{{/each}}
</table.body>
</Rose::Table>
</Rose::Form>
@@ -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))
}));
}
}
+3 -1
View File
@@ -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() {
@@ -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 } };
}
}
@@ -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 });
}
}
}
+18 -1
View File
@@ -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;
}
}
}
@@ -2,4 +2,7 @@
<nav.link @route='scopes.scope.projects.project.host-catalogs.host-catalog.host-sets.host-set.index'>
{{t 'titles.details'}}
</nav.link>
</Rose::Nav::Tabs>
<nav.link @route='scopes.scope.projects.project.host-catalogs.host-catalog.host-sets.host-set.hosts'>
{{t 'resources.hosts'}}
</nav.link>
</Rose::Nav::Tabs>
@@ -0,0 +1,23 @@
{{#if @model.hosts}}
<Form::HostSet::Hosts
@model={{@model.hostSet}}
@hosts={{@model.hosts}}
@submit={{route-action "save" @model.hostSet}}
@cancel={{route-action "cancel" @model.hostSet}}
@toggleHost={{route-action "toggleHost" @model.hostSet}}
/>
{{/if}}
{{#unless @model.hosts}}
<Rose::Layout::Centered>
<Rose::Message @title={{t "titles.no-host-set-hosts"}} as |message|>
<message.description>
{{t "descriptions.no-host-set-hosts"}}
</message.description>
<message.link @route="scopes.scope.projects.project.host-catalogs.host-catalog.hosts.new">
<Rose::Icon @name="plus-circle-outline" />
{{t "titles.new"}}
</message.link>
</Rose::Message>
</Rose::Layout::Centered>
{{/unless}}
+3 -1
View File
@@ -1,3 +1,5 @@
import factory from '../generated/factories/host';
export default factory.extend();
export default factory.extend({
id: (i) => `host-id-${i}`
});
@@ -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);
});
});
@@ -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`
<Form::HostSet::Hosts
@model={{this.model}}
@hosts={{this.hosts}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
assert.ok(find('form'));
});
});
@@ -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);
});
});
+4 -1
View File
@@ -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: