[ui, helios] Toast Component (#16099)

* Template and styles

* @type to @color on flash messages

* Notifications service as wrapper

* Test cases updated for new notifs
This commit is contained in:
Phil Renaud 2023-03-02 13:52:16 -05:00 committed by GitHub
parent 0e1b554299
commit 93574ce085
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 150 additions and 185 deletions

3
.changelog/16099.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Restyles "toast" notifications in the web UI with the Helios Design System
```

View File

@ -9,6 +9,7 @@ import classic from 'ember-classic-decorator';
@tagName('')
export default class Title extends Component {
@service router;
@service notifications;
job = null;
title = null;
@ -34,12 +35,10 @@ export default class Title extends Component {
try {
const job = this.job;
yield job.purge();
this.flashMessages.add({
this.notifications.add({
title: 'Job Purged',
message: `You have purged ${this.job.name}`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
this.router.transitionTo('jobs');
} catch (err) {

View File

@ -4,7 +4,7 @@ import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default class PolicyEditorComponent extends Component {
@service flashMessages;
@service notifications;
@service router;
@service store;
@ -41,22 +41,19 @@ export default class PolicyEditorComponent extends Component {
await this.policy.save();
this.flashMessages.add({
this.notifications.add({
title: 'Policy Saved',
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
if (shouldRedirectAfterSave) {
this.router.transitionTo('policies.policy', this.policy.id);
}
} catch (error) {
this.flashMessages.add({
this.notifications.add({
title: `Error creating Policy ${this.policy.name}`,
message: error,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
}

View File

@ -24,7 +24,7 @@ const EMPTY_KV = {
const invalidKeyCharactersRegex = new RegExp(/[^_\p{Letter}\p{Number}]/gu);
export default class VariableFormComponent extends Component {
@service flashMessages;
@service notifications;
@service router;
@service store;
@ -240,23 +240,20 @@ export default class VariableFormComponent extends Component {
this.args.model.setAndTrimPath();
await this.args.model.save({ adapterOptions: { overwrite } });
this.flashMessages.add({
this.notifications.add({
title: 'Variable saved',
message: `${this.path} successfully saved`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
this.removeExitHandler();
this.router.transitionTo('variables.variable', this.args.model.id);
} catch (error) {
notifyConflict(this)(error);
if (!this.hasConflict) {
this.flashMessages.add({
this.notifications.add({
title: `Error saving ${this.path}`,
message: error,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
} else {

View File

@ -16,7 +16,7 @@ export default class ApplicationController extends Controller {
@service config;
@service system;
@service token;
@service flashMessages;
@service notifications;
/**
* @type {KeyboardService}

View File

@ -24,7 +24,7 @@ export default class ClientController extends Controller.extend(
Sortable,
Searchable
) {
@service flashMessages;
@service notifications;
queryParams = [
{
@ -316,21 +316,18 @@ export default class ClientController extends Controller.extend(
e.preventDefault();
await this.model.addMeta({ [key]: value });
this.flashMessages.add({
this.notifications.add({
title: 'Metadata added',
message: `${key} successfully saved`,
type: 'success',
destroyOnClick: false,
timeout: 3000,
color: 'success',
});
} catch (err) {
const error =
messageFromAdapterError(err) || 'Could not save new dynamic metadata';
this.flashMessages.add({
this.notifications.add({
title: `Error saving Metadata`,
message: error,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
}

View File

@ -4,7 +4,7 @@ import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class JobsRunTemplatesManageController extends Controller {
@service flashMessages;
@service notifications;
@service router;
get templates() {
@ -27,19 +27,16 @@ export default class JobsRunTemplatesManageController extends Controller {
@task(function* (model) {
try {
yield model.destroyRecord();
this.flashMessages.add({
this.notifications.add({
title: 'Job template deleted',
message: `${model.path} successfully deleted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
} catch (err) {
this.flashMessages.add({
this.notifications.add({
title: `Job template could not be deleted.`,
message: err,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
}

View File

@ -10,6 +10,7 @@ export default class JobsRunTemplatesNewController extends Controller {
@service system;
@tracked templateName = null;
@tracked templateNamespace = 'default';
@service notifications;
get namespaceOptions() {
const namespaces = this.store
@ -60,22 +61,18 @@ export default class JobsRunTemplatesNewController extends Controller {
try {
await this.model.save({ adapterOptions: { overwrite } });
this.flashMessages.add({
this.notifications.add({
title: 'Job template saved',
message: `${this.templateName} successfully saved`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
this.router.transitionTo('jobs.run.templates');
} catch (e) {
this.flashMessages.add({
this.notifications.add({
title: 'Job template cannot be saved.',
message: e,
type: 'error',
destroyOnClick: false,
timeout: 5000,
color: 'critical',
});
}
}

View File

@ -5,7 +5,7 @@ import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class JobsRunTemplatesController extends Controller {
@service flashMessages;
@service notifications;
@service router;
@service system;
@ -34,22 +34,18 @@ export default class JobsRunTemplatesController extends Controller {
try {
await this.model.save({ adapterOptions: { overwrite } });
this.flashMessages.add({
this.notifications.add({
title: 'Job template saved',
message: `${this.model.path} successfully editted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
this.router.transitionTo('jobs.run.templates');
} catch (e) {
this.flashMessages.add({
this.notifications.add({
title: 'Job template cannot be editted.',
message: e,
type: 'error',
destroyOnClick: false,
timeout: 5000,
color: 'critical',
});
}
}
@ -58,20 +54,17 @@ export default class JobsRunTemplatesController extends Controller {
try {
yield this.model.destroyRecord();
this.flashMessages.add({
this.notifications.add({
title: 'Job template deleted',
message: `${this.model.path} successfully deleted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
this.router.transitionTo('jobs.run.templates.manage');
} catch (err) {
this.flashMessages.add({
this.notifications.add({
title: `Job template could not be deleted.`,
message: err,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
}

View File

@ -7,7 +7,7 @@ import { alias } from '@ember/object/computed';
import { task } from 'ember-concurrency';
export default class PoliciesPolicyController extends Controller {
@service flashMessages;
@service notifications;
@service router;
@service store;
@ -37,19 +37,18 @@ export default class PoliciesPolicyController extends Controller {
try {
yield this.policy.deleteRecord();
yield this.policy.save();
this.flashMessages.add({
this.notifications.add({
title: 'Policy Deleted',
type: 'success',
color: 'success',
type: `success`,
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('policies');
} catch (err) {
this.flashMessages.add({
this.notifications.add({
title: `Error deleting Policy ${this.policy.name}`,
message: err,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
}
@ -75,11 +74,10 @@ export default class PoliciesPolicyController extends Controller {
});
yield newToken.save();
yield this.refreshTokens();
this.flashMessages.add({
const thing = this.notifications.add({
title: 'Example Token Created',
message: `${newToken.secret}`,
type: 'success',
destroyOnClick: false,
color: 'success',
timeout: 30000,
customAction: {
label: 'Copy to Clipboard',
@ -88,6 +86,7 @@ export default class PoliciesPolicyController extends Controller {
},
},
});
console.log('thing', thing);
} catch (err) {
this.error = {
title: 'Error creating new token',
@ -104,11 +103,9 @@ export default class PoliciesPolicyController extends Controller {
yield token.deleteRecord();
yield token.save();
yield this.refreshTokens();
this.flashMessages.add({
this.notifications.add({
title: 'Token successfully deleted',
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
} catch (err) {
this.error = {

View File

@ -13,6 +13,8 @@ export default class VariablesVariableIndexController extends Controller {
@tracked sortProperty = 'key';
@tracked sortDescending = true;
@service notifications;
get sortedKeyValues() {
const sorted = this.model.keyValues.sortBy(this.sortProperty);
return this.sortDescending ? sorted : sorted.reverse();
@ -39,19 +41,16 @@ export default class VariablesVariableIndexController extends Controller {
} else {
this.router.transitionTo('variables');
}
this.flashMessages.add({
this.notifications.add({
title: 'Variable deleted',
message: `${this.model.path} successfully deleted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
color: 'success',
});
} catch (err) {
this.flashMessages.add({
this.notifications.add({
title: `Error deleting ${this.model.path}`,
message: err,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
}

View File

@ -8,7 +8,7 @@ import {
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import notifyError from 'nomad-ui/utils/notify-error';
export default class AllocationRoute extends Route.extend(WithWatchers) {
@service flashMessages;
@service notifications;
@service router;
@service store;
@ -51,11 +51,10 @@ export default class AllocationRoute extends Route.extend(WithWatchers) {
} catch (e) {
const [allocId, transition] = arguments;
if (e?.errors[0]?.detail === 'alloc not found' && !!transition.from) {
this.flashMessages.add({
this.notifications.add({
title: `Error: Not Found`,
message: `Allocation of id: ${allocId} was not found.`,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});
this.goBackToReferrer(transition.from.name);

View File

@ -7,7 +7,7 @@ import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
@classic
export default class JobsRunIndexRoute extends Route {
@service can;
@service flashMessages;
@service notifications;
@service router;
@service store;
@service system;
@ -51,11 +51,10 @@ export default class JobsRunIndexRoute extends Route {
handle404(e) {
const error404 = e.errors?.find((err) => err.status === 404);
if (error404) {
this.flashMessages.add({
this.notifications.add({
title: `Error loading job template`,
message: error404.detail,
type: 'error',
destroyOnClick: false,
color: 'critical',
sticky: true,
});

View File

@ -0,0 +1,45 @@
// @ts-check
import { default as FlashService } from 'ember-cli-flash/services/flash-messages';
/**
* @typedef {Object} NotificationObject
* @property {string} title
* @property {string} [message]
* @property {string} [type]
* @property {string} [color = 'neutral']
* @property {boolean} [sticky = true]
* @property {boolean} [destroyOnClick = false]
* @property {number} [timeout = 5000]
*/
/**
* @class NotificationsService
* @extends FlashService
* A wrapper service around Ember Flash Messages, for adding notifications to the UI
*/
export default class NotificationsService extends FlashService {
/**
* @param {NotificationObject} notificationObject
* @returns {FlashService}
*/
add(notificationObject) {
// Set some defaults
if (!('type' in notificationObject)) {
notificationObject.type = notificationObject.color || 'neutral';
}
if (!('sticky' in notificationObject)) {
notificationObject.sticky = true;
}
if (!('destroyOnClick' in notificationObject)) {
notificationObject.destroyOnClick = false;
}
if (!('timeout' in notificationObject)) {
notificationObject.timeout = 5000;
}
return super.add(notificationObject);
}
}

View File

@ -16,7 +16,7 @@ export default class TokenService extends Service {
@service store;
@service system;
@service router;
@service flashMessages;
@service notifications;
aclEnabled = true;
@ -135,7 +135,7 @@ export default class TokenService extends Service {
// Let the user know at the 10 minute mark,
// or any time they refresh with under 10 minutes left
if (diff < 1000 * 60 * MINUTES_LEFT_AT_WARNING) {
const existingNotification = this.flashMessages.queue?.find(
const existingNotification = this.notifications.queue?.find(
(m) => m.title === EXPIRY_NOTIFICATION_TITLE
);
// For the sake of updating the "time left" message, we keep running the task down to the moment of expiration
@ -149,13 +149,12 @@ export default class TokenService extends Service {
);
} else {
if (!this.expirationNotificationDismissed) {
this.flashMessages.add({
this.notifications.add({
title: EXPIRY_NOTIFICATION_TITLE,
message: `Your token access expires ${moment(
this.selfToken.expirationTime
).fromNow()}`,
type: 'error',
destroyOnClick: false,
color: 'warning',
sticky: true,
customCloseAction: () => {
this.set('expirationNotificationDismissed', true);

View File

@ -1,66 +1,14 @@
$bonusRightPadding: 20px;
section.notifications {
position: fixed;
bottom: 10px;
right: 10px;
bottom: 20px;
right: 20px;
z-index: 100;
justify-items: right;
display: grid;
.flash-message {
width: 300px;
transition: all 700ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
padding: 1rem;
&:not(:last-child) {
margin-bottom: 1rem;
box-shadow: 1px 1px 4px 0px rgb(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
padding-right: $bonusRightPadding;
&.alert-success {
background-color: lighten($nomad-green, 50%);
}
&.alert-error {
background-color: lighten($danger, 45%);
}
h3 {
font-weight: bold;
}
span.close-button {
position: absolute;
top: 0;
right: 0;
padding: 10px;
line-height: 100%;
font-size: 1.5rem;
cursor: pointer;
}
.alert-progress {
width: 100%;
border-radius: 3px;
position: absolute;
bottom: 0;
left: 0;
.alert-progressBar {
background-color: $nomad-green;
height: 2px;
width: 0%;
}
}
&.active {
.alert-progress {
.alert-progressBar {
width: 100%;
}
}
}
.custom-action-button {
width: calc(100% + $bonusRightPadding - 1rem);
margin: 1.5rem 0 0;
}
}
}

View File

@ -7,28 +7,27 @@
<SvgPatterns />
<section class="notifications">
{{#each this.flashMessages.queue as |flash|}}
{{#each this.notifications.queue as |flash|}}
<FlashMessage @flash={{flash}} as |component flash close|>
<span class="close-button" role="button" {{on "click"
(queue
<Hds::Toast
@color={{or flash.color "neutral"}}
@onDismiss={{
queue
(action close)
(action (optional flash.customCloseAction))
)
}}>&times;</span>
}}
as |T|>
{{#if flash.title}}
<h3>{{flash.title}}</h3>
<T.Title>{{flash.title}}</T.Title>
{{/if}}
{{#if flash.message}}
<p>{{flash.message}}</p>
<T.Description>{{flash.message}}</T.Description>
{{/if}}
{{#if flash.customAction}}
<button type="button" class="button custom-action-button" {{on "click" (action flash.customAction.action)}}>{{flash.customAction.label}}</button>
{{/if}}
{{#if component.showProgressBar}}
<div class="alert-progress">
<div class="alert-progressBar" style={{component.progressDuration}}></div>
</div>
<T.Button @text={{flash.customAction.label}} @color="secondary" {{on "click" (action flash.customAction.action)}} />
{{/if}}
</Hds::Toast>
</FlashMessage>
{{/each}}
</section>

View File

@ -464,11 +464,11 @@ module('Acceptance | allocation detail', function (hooks) {
component.onClick();
await waitFor('.flash-message.alert-error');
await waitFor('.flash-message.alert-critical');
assert.verifySteps(['Transition dispatched.']);
assert
.dom('.flash-message.alert-error')
.dom('.flash-message.alert-critical')
.exists('A toast error message pops up.');
// Clean-up

View File

@ -352,7 +352,7 @@ module('Acceptance | job run', function (hooks) {
'We do not navigate away from the page if an error is returned by the API.'
);
assert
.dom('.flash-message.alert-error')
.dom('.flash-message.alert-critical')
.exists('A toast error message pops up.');
});

View File

@ -65,7 +65,7 @@ module('Acceptance | policies', function (hooks) {
await typeIn('[data-test-policy-name-input]', 'My Fun Policy');
await click('button[type="submit"]');
assert
.dom('.flash-message.alert-error')
.dom('.flash-message.alert-critical')
.exists('Doesnt let you save a bad name');
assert.equal(currentURL(), '/policies/new');
document.querySelector('[data-test-policy-name-input]').value = ''; // clear

View File

@ -213,10 +213,10 @@ module('Acceptance | tokens', function (hooks) {
// TTL Action
await Jobs.visit();
assert
.dom('.flash-message.alert-error button')
.dom('.flash-message.alert-warning button')
.exists('A global alert exists and has a clickable button');
await click('.flash-message.alert-error button');
await click('.flash-message.alert-warning button');
assert.equal(
currentURL(),
'/settings/tokens',
@ -313,7 +313,7 @@ module('Acceptance | tokens', function (hooks) {
// short-circuiting our Ember Concurrency loop.
setTimeout(() => {
assert
.dom('.flash-message.alert-error')
.dom('.flash-message.alert-warning')
.doesNotExist('No notification yet for a token with 10m5s left');
notificationNotRendered();
setTimeout(async () => {
@ -322,7 +322,7 @@ module('Acceptance | tokens', function (hooks) {
});
assert
.dom('.flash-message.alert-error')
.dom('.flash-message.alert-warning')
.exists('Notification is rendered at the 10m mark');
notificationRendered();
run.cancelTimers();

View File

@ -362,9 +362,9 @@ module('Acceptance | variables', function (hooks) {
assert.equal(currentURL(), '/variables/new');
await typeIn('.path-input', 'foo/bar');
await click('button[type="submit"]');
assert.dom('.flash-message.alert-error').exists();
await click('.flash-message.alert-error .close-button');
assert.dom('.flash-message.alert-error').doesNotExist();
assert.dom('.flash-message.alert-critical').exists();
await click('.flash-message.alert-critical .hds-dismiss-button');
assert.dom('.flash-message.alert-critical').doesNotExist();
await typeIn('.key-value label:nth-child(1) input', 'myKey');
await typeIn('.key-value label:nth-child(2) input', 'superSecret');