diff --git a/.changelog/16099.txt b/.changelog/16099.txt
new file mode 100644
index 000000000..df365db3d
--- /dev/null
+++ b/.changelog/16099.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Restyles "toast" notifications in the web UI with the Helios Design System
+```
diff --git a/ui/app/components/job-page/parts/title.js b/ui/app/components/job-page/parts/title.js
index 07e3dc7c3..09a9bb24b 100644
--- a/ui/app/components/job-page/parts/title.js
+++ b/ui/app/components/job-page/parts/title.js
@@ -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) {
diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js
index d37ef1287..83862e133 100644
--- a/ui/app/components/policy-editor.js
+++ b/ui/app/components/policy-editor.js
@@ -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,
});
}
diff --git a/ui/app/components/variable-form.js b/ui/app/components/variable-form.js
index 0ac54bf8b..4e1666482 100644
--- a/ui/app/components/variable-form.js
+++ b/ui/app/components/variable-form.js
@@ -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 {
diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js
index 4781831fa..24a3a9909 100644
--- a/ui/app/controllers/application.js
+++ b/ui/app/controllers/application.js
@@ -16,7 +16,7 @@ export default class ApplicationController extends Controller {
@service config;
@service system;
@service token;
- @service flashMessages;
+ @service notifications;
/**
* @type {KeyboardService}
diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js
index 8c3839383..1efb5e484 100644
--- a/ui/app/controllers/clients/client/index.js
+++ b/ui/app/controllers/clients/client/index.js
@@ -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,
});
}
diff --git a/ui/app/controllers/jobs/run/templates/manage.js b/ui/app/controllers/jobs/run/templates/manage.js
index 8851e01eb..6048f5ba3 100644
--- a/ui/app/controllers/jobs/run/templates/manage.js
+++ b/ui/app/controllers/jobs/run/templates/manage.js
@@ -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,
});
}
diff --git a/ui/app/controllers/jobs/run/templates/new.js b/ui/app/controllers/jobs/run/templates/new.js
index 274c82bca..29b3a0f84 100644
--- a/ui/app/controllers/jobs/run/templates/new.js
+++ b/ui/app/controllers/jobs/run/templates/new.js
@@ -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',
});
}
}
diff --git a/ui/app/controllers/jobs/run/templates/template.js b/ui/app/controllers/jobs/run/templates/template.js
index 0c435898b..58b0a5cab 100644
--- a/ui/app/controllers/jobs/run/templates/template.js
+++ b/ui/app/controllers/jobs/run/templates/template.js
@@ -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,
});
}
diff --git a/ui/app/controllers/policies/policy.js b/ui/app/controllers/policies/policy.js
index b504e5c1e..20c97173c 100644
--- a/ui/app/controllers/policies/policy.js
+++ b/ui/app/controllers/policies/policy.js
@@ -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 = {
diff --git a/ui/app/controllers/variables/variable/index.js b/ui/app/controllers/variables/variable/index.js
index d12c5879a..78c0e4725 100644
--- a/ui/app/controllers/variables/variable/index.js
+++ b/ui/app/controllers/variables/variable/index.js
@@ -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,
});
}
diff --git a/ui/app/routes/allocations/allocation.js b/ui/app/routes/allocations/allocation.js
index c2e900bd7..f54641168 100644
--- a/ui/app/routes/allocations/allocation.js
+++ b/ui/app/routes/allocations/allocation.js
@@ -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);
diff --git a/ui/app/routes/jobs/run/index.js b/ui/app/routes/jobs/run/index.js
index 664a688c3..c96412b5d 100644
--- a/ui/app/routes/jobs/run/index.js
+++ b/ui/app/routes/jobs/run/index.js
@@ -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,
});
diff --git a/ui/app/services/notifications.js b/ui/app/services/notifications.js
new file mode 100644
index 000000000..970cbfd51
--- /dev/null
+++ b/ui/app/services/notifications.js
@@ -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);
+ }
+}
diff --git a/ui/app/services/token.js b/ui/app/services/token.js
index 5e49e4502..a50dee895 100644
--- a/ui/app/services/token.js
+++ b/ui/app/services/token.js
@@ -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);
diff --git a/ui/app/styles/core/notifications.scss b/ui/app/styles/core/notifications.scss
index dffd0f106..daef87d15 100644
--- a/ui/app/styles/core/notifications.scss
+++ b/ui/app/styles/core/notifications.scss
@@ -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;
- 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;
+ &:not(:last-child) {
+ margin-bottom: 1rem;
}
}
}
diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs
index fdd437bac..153bea4bb 100644
--- a/ui/app/templates/application.hbs
+++ b/ui/app/templates/application.hbs
@@ -7,28 +7,27 @@
- {{#each this.flashMessages.queue as |flash|}}
+ {{#each this.notifications.queue as |flash|}}
- ×
- {{#if flash.title}}
- {{flash.title}}
- {{/if}}
- {{#if flash.message}}
- {{flash.message}}
- {{/if}}
- {{#if flash.customAction}}
-
- {{/if}}
- {{#if component.showProgressBar}}
-
- {{/if}}
+
+ {{#if flash.title}}
+ {{flash.title}}
+ {{/if}}
+ {{#if flash.message}}
+ {{flash.message}}
+ {{/if}}
+ {{#if flash.customAction}}
+
+ {{/if}}
+
{{/each}}
diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js
index 3503e45d0..cf31ddccb 100644
--- a/ui/tests/acceptance/allocation-detail-test.js
+++ b/ui/tests/acceptance/allocation-detail-test.js
@@ -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
diff --git a/ui/tests/acceptance/job-run-test.js b/ui/tests/acceptance/job-run-test.js
index 39be3d21d..c0e28a949 100644
--- a/ui/tests/acceptance/job-run-test.js
+++ b/ui/tests/acceptance/job-run-test.js
@@ -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.');
});
diff --git a/ui/tests/acceptance/policies-test.js b/ui/tests/acceptance/policies-test.js
index db03c33e1..0931f2466 100644
--- a/ui/tests/acceptance/policies-test.js
+++ b/ui/tests/acceptance/policies-test.js
@@ -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
diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js
index 6c6d4ff3a..63ab965f9 100644
--- a/ui/tests/acceptance/token-test.js
+++ b/ui/tests/acceptance/token-test.js
@@ -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();
diff --git a/ui/tests/acceptance/variables-test.js b/ui/tests/acceptance/variables-test.js
index 8e4d1aefc..d0250eeff 100644
--- a/ui/tests/acceptance/variables-test.js
+++ b/ui/tests/acceptance/variables-test.js
@@ -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');