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');