diff --git a/ui/core/app/routes/application.js b/ui/core/app/routes/application.js index dc409c70e..804c2896e 100644 --- a/ui/core/app/routes/application.js +++ b/ui/core/app/routes/application.js @@ -18,6 +18,7 @@ export default class ApplicationRoute extends Route.extend( // =services @service session; + @service confirm; // =attributes @@ -90,4 +91,28 @@ export default class ApplicationRoute extends Route.extend( } return true; } + + /** + * If user attempts to navigate away from unsaved changes, the user is + * asked to confirm that they would like to discard the changes. If the + * user chooses discard, changes on the model are rolled back and the + * transition is retried. If the user cancels discard, the transition is + * aborted. + * @param {Transition} transition + */ + @action + async willTransition(transition) { + const maybeModel = transition?.from?.attributes; + if (maybeModel?.hasDirtyAttributes) { + transition.abort(); + try { + await this.confirm.confirm('abandon', { isAbandonConfirm: true }); + maybeModel?.rollbackAttributes(); + transition.retry(); + } catch (e) { + // if user denies, do nothing + } + } + } + } diff --git a/ui/core/app/services/confirm.js b/ui/core/app/services/confirm.js index 2fe1e659f..ed9ea3283 100644 --- a/ui/core/app/services/confirm.js +++ b/ui/core/app/services/confirm.js @@ -12,7 +12,7 @@ import config from '../config/environment'; * @augments Service * * @example - * const confirmation = confirmationService.getConfirmation(); + * const confirmation = confirmationService.confirm('Text', { type: 'discard' }); */ export default class ConfirmationsService extends Service { @@ -42,11 +42,12 @@ export default class ConfirmationsService extends Service { * accepts the confirmation, otherwise rejects. If the service is disabled, * this method always returns a resolving promise. * @param {string} text + * @param {object} options * @return {Promise} */ - confirm(text) { + confirm(text, options) { if (this.enabled) { - const confirmation = new Confirmation(text); + const confirmation = new Confirmation(text, options); this.confirmations.addObject(confirmation); return confirmation; } else { @@ -65,7 +66,7 @@ export default class ConfirmationsService extends Service { * to create confirmation instances. * * @example - * const confirmation = new Confirmation(); + * const confirmation = new Confirmation('Text', { type: 'discard' }); * confirmation.confirm(); * // confirmation.done now equals true * confirmation @@ -74,7 +75,7 @@ export default class ConfirmationsService extends Service { * .finally(() => { }); // do something * * @example - * const confirmation = new Confirmation(); + * const confirmation = new Confirmation('Text', { type: 'discard' }); * confirmation.dismiss(); * // confirmation.done now equals true * confirmation @@ -100,8 +101,9 @@ class Confirmation { * @param {String} type An optional type annotation for convenience. * Has no effect on confirmation. */ - constructor(text) { + constructor(text, options={}) { this.text = text; + this.options = options; this.#deferred = defer(); } diff --git a/ui/core/app/templates/application.hbs b/ui/core/app/templates/application.hbs index fac3c4ac0..e8c486787 100644 --- a/ui/core/app/templates/application.hbs +++ b/ui/core/app/templates/application.hbs @@ -85,24 +85,47 @@ - - -

{{confirmation.text}}

-
- - - {{t "actions.ok"}} - - - {{t "actions.cancel"}} - - -
+ {{#if confirmation.options.isAbandonConfirm}} + + +

{{t "questions.abandon-confirm"}}

+
+ + + {{t "actions.discard"}} + + + {{t "actions.cancel"}} + + +
+ {{else}} + + +

{{confirmation.text}}

+
+ + + {{t "actions.ok"}} + + + {{t "actions.cancel"}} + + +
+ {{/if}}
diff --git a/ui/core/tests/acceptance/projects-test.js b/ui/core/tests/acceptance/projects-test.js index 0073540ac..068323951 100644 --- a/ui/core/tests/acceptance/projects-test.js +++ b/ui/core/tests/acceptance/projects-test.js @@ -136,6 +136,44 @@ module('Acceptance | projects', function (hooks) { assert.equal(find('[name="name"]').value, existingProject.name); }); + test('can discard unsaved changes via dialog', async function (assert) { + assert.expect(5); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + assert.notEqual(existingProject.name, 'random string'); + await visit(existingProjectURL); + await click('form [type="button"]', 'Activate edit mode'); + await fillIn('[name="name"]', 'random string'); + assert.equal(currentURL(), existingProjectURL); + try { + await visit(projectsURL); + } catch (e) { + assert.ok(find('.rose-dialog')); + await click('.rose-dialog-footer button:first-child'); + assert.equal(currentURL(), projectsURL); + assert.notEqual(getFirstProjectScope().name, 'random string'); + } + }); + + test('can cancel discard unsaved changes via dialog', async function (assert) { + assert.expect(5); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + assert.notEqual(existingProject.name, 'random string'); + await visit(existingProjectURL); + await click('form [type="button"]', 'Activate edit mode'); + await fillIn('[name="name"]', 'random string'); + assert.equal(currentURL(), existingProjectURL); + try { + await visit(projectsURL); + } catch (e) { + assert.ok(find('.rose-dialog')); + await click('.rose-dialog-footer button:last-child'); + assert.equal(currentURL(), existingProjectURL); + assert.notEqual(getFirstProjectScope().name, 'random string'); + } + }); + test('can delete project', async function (assert) { assert.expect(1); await visit(existingProjectURL); diff --git a/ui/core/translations/actions/en-us.yaml b/ui/core/translations/actions/en-us.yaml index 810f0ca85..a6cadbe2e 100644 --- a/ui/core/translations/actions/en-us.yaml +++ b/ui/core/translations/actions/en-us.yaml @@ -2,6 +2,7 @@ add: Add cancel: Cancel delete: Delete remove: Remove +discard: Discard dismiss: Dismiss save: Save manage: Manage diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index 4441f333c..22fd19edb 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -17,8 +17,10 @@ titles: global: Global actions: Actions documentation: Documentation + abandon-confirm: Discard unsaved changes? descriptions: empty-set: There are no items to display yet. You may be able to add items or try back later. questions: delete-confirm: Are you sure you want to delete this resource? remove-confirm: Are you sure you want to remove this association? + abandon-confirm: Are you sure you want to navigate away? Your unsaved changes will be lost.