ICU 5247 detailed worker view and ICU 5248 edit worker view (#1246)

* feat: 🎸 Add list view for workers

✅ Closes: ICU-5251

* feat: 🎸 Add route for worker detail and include links in table

* refactor: 💡 Updated id display in table and fixed worker route

* test: 💍 Add workers list test

* feat: 🎸 Add worker detailed view and edit view

Added worker detailed view and editable form view for name and
description of worker

✅ Closes: ICU-5247 ICU-5248

* refactor: 💡 fixed conflict merge

fixed merge conflicts for scope/workers/worker

✅ Closes: ICU-5247

* Update ui/admin/app/routes/scopes/scope/workers.js

Co-authored-by: Dharini Jeeva <dharini@hashicorp.com>

* Update ui/admin/app/templates/scopes/scope/workers/worker/index.hbs

Co-authored-by: Dharini Jeeva <dharini@hashicorp.com>

* test: 💍 Test for worker/update-test.js

Created update-test.js and refactored worker/form.hbs for correct
display of last_seen

✅ Closes: ICU-5247

* Update ui/admin/app/routes/scopes/scope/workers.js

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* Update ui/admin/app/templates/scopes/scope/workers/worker/index.hbs

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* Update ui/admin/tests/acceptance/workers/read-test.js

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* refactor: 💡 code refactor and test creation

Refactored code for detailed worker view, created controller for
detailew worker view to display worker name in breadcrumbs, added test
for `delete`, `update`.

✅ Closes: ICU-5247

* refactor: 💡 Refactor test

Refactor update-test.js and clean up code.

✅ Closes: ICU-5247

* refactor: 💡 fixed workers/update-test.js

added local variable for better test coverage for cancel test in
workers/update-test.js

✅ Closes: ICU-5247

* refactor: 💡 fix test

Refactored yaml file for `last_seen.description`

✅ Closes: ICU-5247

* Update ui/admin/app/routes/scopes/scope/workers.js

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* removed `type` from  `<form.textarea/>` component

* Update ui/admin/app/controllers/scopes/scope/workers/worker.js

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* spacing for linter

* refactor: 💡 Refactor test

Refactored `worker/read-test.js` to to test correct authorization

✅ Closes: ICU-5247

* ci: 🎡 Fixed lint

fixed lint

✅ Closes: ICU-5247

* refactor: 💡 Added custom relatvie date to field

Added custom relative date to `last_status_time` input for worker view.

✅ Closes: ICU-5247

* refactor: 💡 refactored yaml file

changed delete dropdown for worker to `actions.delete`

✅ Closes: ICU-5247

* Removed unused services from workers.js

* merge changes from main

* fix lint

* fix lint

* Update index.hbs

lint issues

* refactor: 💡 fixed lint for hbs

fixed linting issues for hbs files

✅ Closes: ICU-5247 ICU-5249

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
Co-authored-by: Dharini Jeeva <dharini@hashicorp.com>
This commit is contained in:
authored and GitHub committed 2022-08-29 10:24:41 -04:00
1 parent 0dff900795
commit bc7ed3f0ed
13 files changed
+505 -7

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ import { attr } from '@ember-data/model';
export default class WorkerModel extends GeneratedWorkerModel {
// =attributes
/**
/**
* Names are optional on models in our API. Thus we need to fallback on ID
* for display purposes.
* @type {string}
@@ -10,8 +10,9 @@ export default Factory.extend({
description: () => faker.random.words(),
created_time: () => faker.date.recent(),
updated_time: () => faker.date.recent(),
version: () => faker.datatype.number(),
address: () => `${faker.internet.ipv4()}:${faker.internet.port()}`,
version: () => faker.datatype.number({ max: 4 }),
address: () =>
`${faker.datatype.uuid()}.proxy.boundary.hashicorp.cloud:${faker.internet.port()}`,
last_status_time: () => faker.date.recent(),
active_connection_count: () => faker.datatype.number({ max: 10 }),
});
+11
View File
@@ -41,6 +41,17 @@ session_max_seconds:
session_connection_limit:
label: Maximum Connections
help: The maximum number of connections allowed per session. For unlimited, specify "-1".
worker_version:
label: Version
worker_authorized_actions:
label: Authorized Actions
worker_last_seen:
label: Last Seen
help: Last time the worker reported status.
worker_address:
help: URL and port for this worker.
worker_type:
help: Type of worker
worker_filter:
label: Worker Filter
help: Optional boolean expression to filter the workers that are allowed to satisfy this request.
@@ -404,7 +404,7 @@ credential:
password:
label: Password
help: The password to use when signing in with this credential store.
private_key:
private_key:
label: SSH Private Key
help: The private key to use when connecting with this credential store.
passphrase:
@@ -438,6 +438,7 @@ worker:
description: Workers are the server components that perform the session handling.
actions:
register: Register Worker
delete: Remove Worker
messages:
welcome:
title: Welcome to Workers
@@ -0,0 +1,89 @@
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
{{#unless form.isEditable}}
<form.input
@name='type'
@value={{@model.type}}
@label={{t 'form.type.label'}}
@helperText={{t 'form.worker_type.help'}}
readonly={{true}}
/>
{{/unless}}
<form.input
@name='name'
@type='name'
@value={{@model.name}}
@label={{t 'form.name.label'}}
@helperText={{t 'form.name.help'}}
@error={{@model.errors.name}}
readonly={{false}}
as |field|
>
{{#if @model.errors.name}}
<field.errors as |errors|>
{{#each @model.errors.name as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
<form.textarea
@name='description'
@value={{@model.description}}
@label={{t 'form.description.label'}}
@helperText={{t 'form.description.help'}}
@error={{@model.errors.description}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.description}}
<field.errors as |errors|>
{{#each @model.errors.description as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
{{#unless form.isEditable}}
<form.input
@name='address'
@type='url'
@value={{@model.address}}
@label={{t 'form.address.label'}}
@helperText={{t 'form.worker_address.help'}}
readonly={{true}}
/>
{{#let
(relative-datetime-live @model.last_status_time)
(format-date @model.last_status_time format='yymmddt')
as |relativeDate customFormat|
}}
<form.input
@name='last seen'
@type='datetime'
@value={{concat relativeDate ', ' customFormat}}
@label={{t 'form.worker_last_seen.label'}}
@helperText={{t 'form.worker_last_seen.help'}}
readonly={{true}}
/>
{{/let}}
{{/unless}}
{{#if (can 'save worker' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -0,0 +1,13 @@
import Controller from '@ember/controller';
export default class ScopesScopeWorkersWorkerController extends Controller {
// =attributes
/**
* A worker breadcrumb
* @type {string}
*/
get breadCrumb() {
return this.model.displayName;
}
}
+8
View File
@@ -21,6 +21,14 @@ export default {
minute: 'numeric',
second: 'numeric',
},
yymmddt: {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
},
},
number: {
compact: { notation: 'compact' },
+42 -1
View File
@@ -1,10 +1,15 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { confirm } from 'core/decorators/confirm';
import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeWorkersRoute extends Route {
// =services
@service can
@service session;
@service router;
@@ -24,4 +29,40 @@ export default class ScopesScopeWorkersRoute extends Route {
refresh() {
return super.refresh(...arguments);
}
/**
* Save a worker in current scope.
* @param {WorkerModel} worker
* @param {Event} e
*/
@action
@loading
@notifyError(({ message }) => message)
@notifySuccess(({ isNew }) =>
isNew ? 'notifications.create-success' : 'notifications.save-success'
)
async save(worker) {
await worker.save();
if (this.can.can('read model', worker)) {
await this.router.transitionTo('scopes.scope.workers.worker', worker);
} else {
await this.router.transitionTo('scopes.scope.workers');
}
this.refresh();
}
/**
* Delete worker in current scope and redirect to index.
* @param {UserModel} worker
*/
@action
@loading
@confirm('questions.delete-confirm')
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.delete-success')
async delete(worker) {
await worker.destroyRecord();
await this.router.replaceWith('scopes.scope.workers');
this.refresh();
}
}
@@ -1,2 +1,49 @@
{{page-title 'Worker'}}
{{outlet}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.worker.title'}}
<DocLink @doc='worker' @iconSize='large' />
</h2>
<p>{{t 'resources.worker.description'}}</p>
<Copyable
@text={{@model.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<code>{{@model.id}}</code>
</Copyable>
</page.header>
<page.actions>
<Rose::Dropdown
@text='{{t 'actions.manage'}}'
@dropdownRight={{true}}
as |dropdown|
>
{{#if (can 'delete worker' @model)}}
<dropdown.button
@style='danger'
@disabled={{@model.canSave}}
{{on 'click' (route-action 'delete' @model)}}
>
{{t 'resources.worker.actions.delete'}}
</dropdown.button>
{{/if}}
</Rose::Dropdown>
</page.actions>
<page.body>
<Rose::Separator />
<Form::Worker
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,108 @@
import { module, test } from 'qunit';
import { visit, find, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
import { resolve, reject } from 'rsvp';
import sinon from 'sinon';
import {
authenticateSession,
// These are left here intentionally for future reference.
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
module('Acceptance | workers | delete', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
let getWorkerCount;
const instances = {
scopes: {
global: null,
},
};
const urls = {
globalScope: null,
workers: null,
worker: null,
};
hooks.beforeEach(function () {
// Generate resources
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.worker = this.server.create('worker', {
scope: instances.scopes.global,
});
urls.globalScope = `/scopes/global`;
urls.workers = `${urls.globalScope}/workers`;
urls.worker = `${urls.workers}/${instances.worker.id}`;
getWorkerCount = () => this.server.schema.workers.all().models.length;
authenticateSession({});
});
test('can delete a worker', async function (assert) {
assert.expect(1);
const count = getWorkerCount();
await visit(urls.worker);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.strictEqual(getWorkerCount(), count - 1);
});
test('can accept delete worker via dialog', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
confirmService.confirm = sinon.fake.returns(resolve());
const count = getWorkerCount();
await visit(urls.worker);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.strictEqual(getWorkerCount(), count - 1);
assert.ok(confirmService.confirm.calledOnce);
});
test('can cancel delete worker via dialog', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
confirmService.confirm = sinon.fake.returns(reject());
const count = getWorkerCount();
await visit(urls.worker);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.strictEqual(getWorkerCount(), count);
assert.ok(confirmService.confirm.calledOnce);
});
test('cannot delete worker without proper authorization', async function (assert) {
assert.expect(1);
instances.worker.authorized_actions =
instances.worker.authorized_actions.filter((item) => item !== 'delete');
await visit(urls.worker);
assert
.dom('.rose-layout-page-actions .rose-dropdown-button-danger')
.isNotVisible();
});
test('deleting a worker which errors displays error messages', async function (assert) {
assert.expect(1);
this.server.del('/workers/:id', () => {
return new Response(
490,
{},
{
status: 490,
code: 'error',
message: 'Oops.',
}
);
});
await visit(urls.worker);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.ok(find('[role="alert"]').textContent.trim(), 'Oops.');
});
});
@@ -0,0 +1,68 @@
import { module, test } from 'qunit';
import { visit } 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 {
authenticateSession,
// These are left here intentionally for future reference.
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
module('Acceptance | workers | read', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
const instances = {
scopes: {
global: null,
},
worker: null,
};
const urls = {
globalScope: null,
workers: null,
worker: null,
};
hooks.beforeEach(function () {
//Generate the resources
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.worker = this.server.create('worker', {
scope: instances.scopes.global,
});
// Generate route URLs for resources
urls.globalScope = '/scopes/global';
urls.workers = `${urls.globalScope}/workers`;
urls.worker = `${urls.workers}/${instances.worker.id}`;
authenticateSession({});
});
test('visiting workers', async function (assert) {
assert.expect(1);
await visit(urls.workers);
await a11yAudit();
console.log(urls.workers);
assert.dom(`[href="${urls.workers}"]`).isVisible();
});
test('cannot navigate to an worker form without proper authorization', async function (assert) {
assert.expect(1);
instances.worker.authorized_actions =
instances.worker.authorized_actions.filter((itm) => itm !== 'read');
await visit(urls.workers);
await a11yAudit();
assert
.dom('main tbody .rose-table-header-cell:nth-child(1) a')
.isNotVisible();
});
test('can navigate to an worker form with proper authorization', async function (assert) {
assert.expect(1);
await visit(urls.workers);
await a11yAudit();
assert.dom('main tbody .rose-table-header-cell:nth-child(1) a').isVisible();
});
});
@@ -0,0 +1,98 @@
import { module, test } from 'qunit';
import { visit, click, find, fillIn } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
import {
authenticateSession,
// These are left here intentionally for future reference.
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
module('Acceptance | workers | update', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
const instances = {
scopes: {
global: null,
},
};
const urls = {
globalScope: null,
workers: null,
worker: null,
};
hooks.beforeEach(function () {
//Generate the resources
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.worker = this.server.create('worker', {
scope: instances.scopes.global,
});
// Generate route URLs for resources
urls.globalScope = '/scopes/global';
urls.workers = `${urls.globalScope}/workers`;
(urls.worker = `${urls.workers}/${instances.worker.id}`),
authenticateSession({});
});
test('can save changes to an existing worker', async function (assert) {
assert.expect(2);
await visit(urls.worker);
await click('form [type="button"]', 'Click edit mode');
await fillIn('[name="name"]', 'Updated worker name');
await click('.rose-form-actions [type="submit"]');
assert.dom(`[href="${urls.worker}"]`).isVisible();
await assert.dom('input[name="name"]').hasValue('Updated worker name');
});
test('can cancel changes to an existing worker', async function (assert) {
assert.expect(1);
const name = instances.worker.name;
await visit(urls.worker);
await click('form [type="button"]', 'Click edit mode');
await fillIn('[name="name"]', `Updated Worker Name`);
await click('.rose-form-actions [type="button"]');
await assert.dom('input[name="name"]').hasValue(`${name}`);
});
test('saving an existing worker with invalid fields displays error messages', async function (assert) {
assert.expect(2);
this.server.patch('/workers/:id', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'The request was invalid.',
details: {
request_fields: [
{
name: 'name',
description: 'Name is required',
},
],
},
}
);
});
await visit(urls.worker);
await click('.rose-form-actions [type="button"]', 'Click edit mode');
await fillIn('[name="name"]', 'Worker Name');
await click('[type="submit"]');
assert.strictEqual(
find('.rose-notification-body').textContent.trim(),
'The request was invalid.',
'Displays primary error message.'
);
assert.strictEqual(
find('.rose-form-error-message').textContent.trim(),
'Name is required',
'Displays field-level errors.'
);
});
});
@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | scopes/scope/users/user', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/workers/worker'
);
assert.ok(controller);
});
});