diff --git a/ui/core/app/components/pending-confirmations/index.hbs b/ui/core/app/components/pending-confirmations/index.hbs
new file mode 100644
index 000000000..fccad7b1c
--- /dev/null
+++ b/ui/core/app/components/pending-confirmations/index.hbs
@@ -0,0 +1,6 @@
+{{#each this.confirm.pending as |confirmation|}}
+ {{yield
+ confirmation.text
+ (fn this.accept confirmation)
+ (fn this.deny confirmation)}}
+{{/each}}
diff --git a/ui/core/app/components/pending-confirmations/index.js b/ui/core/app/components/pending-confirmations/index.js
new file mode 100644
index 000000000..2c7e447df
--- /dev/null
+++ b/ui/core/app/components/pending-confirmations/index.js
@@ -0,0 +1,40 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+/**
+ * A component that yields for each pending confirmation in the confirmations
+ * service, passing the confirmation instance, a confirm action, and a dismiss
+ * action. Useful for rendering confirm modals and awaiting user action.
+ */
+export default class PendingConfirmationsComponent extends Component {
+
+ // =services
+
+ @service confirm;
+
+ // =actions
+
+ /**
+ * Accepts the passed confirmation.
+ * @param {Confirmation} confirmation
+ * @return {Confirmation}
+ */
+ @action
+ accept(confirmation) {
+ confirmation.confirm();
+ return confirmation;
+ }
+
+ /**
+ * Denies the passed confirmation.
+ * @param {Confirmation} confirmation
+ * @return {Confirmation}
+ */
+ @action
+ deny(confirmation) {
+ confirmation.dismiss();
+ return confirmation;
+ }
+
+}
diff --git a/ui/core/app/services/confirm.js b/ui/core/app/services/confirm.js
index d3c0e4fe8..2fe1e659f 100644
--- a/ui/core/app/services/confirm.js
+++ b/ui/core/app/services/confirm.js
@@ -1,10 +1,20 @@
import Service from '@ember/service';
-import { getOwner } from '@ember/application';
-import { bind } from '@ember/runloop';
-import { Promise, resolve } from 'rsvp';
+import { A } from '@ember/array';
+import { tracked } from '@glimmer/tracking';
+import { defer, resolve } from 'rsvp';
+import { filterBy } from '@ember/object/computed';
import config from '../config/environment';
-export default class ConfirmService extends Service {
+/**
+ * A simple service that emits Confirmation instances and
+ * exposes a `pending` property to discover pending
+ * confirmations.
+ * @augments Service
+ *
+ * @example
+ * const confirmation = confirmationService.getConfirmation();
+ */
+export default class ConfirmationsService extends Service {
// =attributes
@@ -15,18 +25,15 @@ export default class ConfirmService extends Service {
enabled = config.enableConfirmService;
/**
- * The window's confirm function, bound to the window scope.
- * @type {Function}
+ * @type {Array}
*/
- get confirmFn() {
- // The Ember way of accessing globals...
- const document =
- getOwner(this).lookup('service:-document').documentElement;
- // defaultView === window, but without using globals directly
- const windowRef = document.parentNode.defaultView;
- const { confirm } = windowRef;
- return bind(windowRef, confirm);
- }
+ confirmations = A();
+
+ /**
+ * An array of pending confirmations (those where `done === false`).
+ * @type {Array}
+ */
+ @filterBy('confirmations', 'done', false) pending;
// =methods
@@ -39,15 +46,103 @@ export default class ConfirmService extends Service {
*/
confirm(text) {
if (this.enabled) {
- return new Promise((resolve, reject) => {
- if (this.confirmFn(text)) {
- return resolve();
- } else {
- return reject();
- }
- });
+ const confirmation = new Confirmation(text);
+ this.confirmations.addObject(confirmation);
+ return confirmation;
} else {
return resolve();
}
}
+
+}
+
+/**
+ * A promise-like object that is either "confirmed" or "dismissed", represented
+ * internally by a promise that resolves or rejects. Confirmations may be used
+ * as promises via `then`, `catch`, and `finally`.
+ *
+ * Not intended for instantiation directly. Use the confirmation service
+ * to create confirmation instances.
+ *
+ * @example
+ * const confirmation = new Confirmation();
+ * confirmation.confirm();
+ * // confirmation.done now equals true
+ * confirmation
+ * .then(() => { }) // do something
+ * .catch(() => { }) // not called, since the confirmation is confirmed
+ * .finally(() => { }); // do something
+ *
+ * @example
+ * const confirmation = new Confirmation();
+ * confirmation.dismiss();
+ * // confirmation.done now equals true
+ * confirmation
+ * .then(() => { }) // not called, since the confirmation is dismissed
+ * .catch(() => { }) // do something
+ * .finally(() => { }); // do something
+ */
+class Confirmation {
+
+ // =properties
+
+ /**
+ * Indicates whether the confirmation has been confirmed or dismissed.
+ * Automatically set to `true` after `confirm` or `dismiss` are called.
+ * @type {Boolean}
+ */
+ @tracked done = false;
+
+ #deferred = null;
+
+ /**
+ * Sets the type field to the specified type and sets up a defer.
+ * @param {String} type An optional type annotation for convenience.
+ * Has no effect on confirmation.
+ */
+ constructor(text) {
+ this.text = text;
+ this.#deferred = defer();
+ }
+
+ /**
+ * Sets the confirmation to done and resolves the defer.
+ */
+ confirm() {
+ this.done = true;
+ this.#deferred.resolve();
+ }
+
+ /**
+ * Sets the confirmation to done and rejects the defer.
+ */
+ dismiss() {
+ this.done = true;
+ this.#deferred.reject();
+ }
+
+ /**
+ * Delegates to the internal defer promise, allowing the
+ * confirmation to be treated like a promise.
+ */
+ then() {
+ return this.#deferred.promise.then(...arguments);
+ }
+
+ /**
+ * Delegates to the internal defer promise, allowing the
+ * confirmation to be treated like a promise.
+ */
+ catch() {
+ return this.#deferred.promise.catch(...arguments);
+ }
+
+ /**
+ * Delegates to the internal defer promise, allowing the
+ * confirmation to be treated like a promise.
+ */
+ finally() {
+ return this.#deferred.promise.finally(...arguments);
+ }
+
}
diff --git a/ui/core/app/templates/application.hbs b/ui/core/app/templates/application.hbs
index 8d4852ea5..bf13afa0d 100644
--- a/ui/core/app/templates/application.hbs
+++ b/ui/core/app/templates/application.hbs
@@ -83,3 +83,23 @@
{{message.text}}
+
+
+
+
+ {{text}}
+
+
+
+ {{t "actions.ok"}}
+
+
+ {{t "actions.cancel"}}
+
+
+
+
diff --git a/ui/core/package.json b/ui/core/package.json
index d48d748b7..624e0ad40 100644
--- a/ui/core/package.json
+++ b/ui/core/package.json
@@ -62,6 +62,7 @@
"ember-exam": "^5.0.1",
"ember-export-application-global": "^2.0.1",
"ember-fetch": "^8.0.1",
+ "ember-focus-trap": "^0.4.0",
"ember-inline-svg": "^1.0.0",
"ember-intl": "^5.1.1",
"ember-load-initializers": "^2.1.1",
diff --git a/ui/core/tests/acceptance/projects-test.js b/ui/core/tests/acceptance/projects-test.js
index 9a3830c40..0073540ac 100644
--- a/ui/core/tests/acceptance/projects-test.js
+++ b/ui/core/tests/acceptance/projects-test.js
@@ -4,6 +4,7 @@ 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 { Response } from 'miragejs';
+import { resolve, reject } from 'rsvp';
import {
authenticateSession,
// These are left here intentionally for future reference.
@@ -146,22 +147,22 @@ module('Acceptance | projects', function (hooks) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
- window.confirm = sinon.fake.returns(true);
+ confirmService.confirm = sinon.fake.returns(resolve());
await visit(existingProjectURL);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.equal(getProjectScopesCount(), initialProjectScopesCount - 1);
- assert.ok(window.confirm.calledOnce);
+ assert.ok(confirmService.confirm.calledOnce);
});
test('cannot delete project with confirmation denial', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
- window.confirm = sinon.fake.returns(false);
+ confirmService.confirm = sinon.fake.returns(reject());
await visit(existingProjectURL);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.equal(getProjectScopesCount(), initialProjectScopesCount);
- assert.ok(window.confirm.calledOnce);
+ assert.ok(confirmService.confirm.calledOnce);
});
test('saving an existing project with invalid fields displays error messages', async function (assert) {
diff --git a/ui/core/tests/integration/components/pending-confirmations-test.js b/ui/core/tests/integration/components/pending-confirmations-test.js
new file mode 100644
index 000000000..8c51630f4
--- /dev/null
+++ b/ui/core/tests/integration/components/pending-confirmations-test.js
@@ -0,0 +1,59 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, findAll, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | pending-confirmations', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders pending confirmations', async function(assert) {
+ assert.expect(2);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ await render(hbs`
+
+ Pending
+
+ `);
+ assert.equal(findAll('.pending').length, 0, 'No pending confirmations');
+ service.confirm();
+ await render(hbs`
+
+ Pending
+
+ `);
+ assert.equal(findAll('.pending').length, 1, 'One pending confirmations');
+ });
+
+ test('it exposes a confirm action that confirms the confirmation', async function(assert) {
+ assert.expect(3);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ const confirmation = service.confirm();
+ confirmation.then(() => assert.ok(true, 'confirmation.then was called'));
+ await render(hbs`
+
+
+
+ `);
+ assert.notOk(confirmation.done, 'Confirmation is not done');
+ await click('button');
+ assert.ok(confirmation.done, 'Confirmation is done');
+ });
+
+ test('it exposes a dismiss action that dismisses the confirmation', async function(assert) {
+ assert.expect(3);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ const confirmation = service.confirm();
+ confirmation.catch(() => assert.ok(true, 'confirmation.catch was called'));
+ await render(hbs`
+
+
+
+ `);
+ assert.notOk(confirmation.done, 'Confirmation is not done');
+ await click('button');
+ assert.ok(confirmation.done, 'Confirmation is done');
+ });
+});
diff --git a/ui/core/tests/unit/services/confirm-test.js b/ui/core/tests/unit/services/confirm-test.js
index c573fc2ed..0fb7f20d3 100644
--- a/ui/core/tests/unit/services/confirm-test.js
+++ b/ui/core/tests/unit/services/confirm-test.js
@@ -4,9 +4,68 @@ import { setupTest } from 'ember-qunit';
module('Unit | Service | confirm', function(hooks) {
setupTest(hooks);
- // Replace this with your real tests.
- test('it exists', function(assert) {
- let service = this.owner.lookup('service:confirm');
- assert.ok(service);
+ test('`confirm()` emits promise-like instances', function(assert) {
+ assert.expect(3);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ const confirmation = service.confirm();
+ assert.ok(confirmation.then, 'confirmation has a then method');
+ assert.ok(confirmation.catch, 'confirmation has a catch method');
+ assert.ok(confirmation.finally, 'confirmation has a finally method');
+ });
+
+ test('`pending` is an array of pending confirmations', function(assert) {
+ assert.expect(3);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ assert.equal(service.pending.length, 0, 'No pending confirmations yet');
+ const confirmation = service.confirm();
+ assert.equal(service.pending.length, 1, 'One pending confirmation created');
+ confirmation.confirm();
+ assert.equal(service.pending.length, 0, 'Confirmation was confirmed');
+ });
+
+ test('confirmations may be confirmed or dismissed', function(assert) {
+ assert.expect(4);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ let confirmation = service.confirm();
+ assert.notOk(confirmation.done, 'Confirmation is pending');
+ confirmation.confirm();
+ assert.ok(confirmation.done, 'Confirmation is done via confirm');
+ confirmation = service.confirm();
+ assert.notOk(confirmation.done, 'Confirmation is pending');
+ confirmation.dismiss();
+ assert.ok(confirmation.done, 'Confirmation is done via dismiss');
+ });
+
+ test('confirmations resolve on confirm', function(assert) {
+ assert.expect(1);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ const confirmation = service.confirm();
+ confirmation.then(() => assert.ok(true, 'Confirmation confirmed'));
+ confirmation.confirm();
+ });
+
+ test('confirmations reject on dismiss', function(assert) {
+ assert.expect(1);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ const confirmation = service.confirm();
+ confirmation.catch(() => assert.ok(true, 'Confirmation dismissed'));
+ confirmation.dismiss();
+ });
+
+ test('confirmations have a finally method', function(assert) {
+ assert.expect(2);
+ const service = this.owner.lookup('service:confirm');
+ service.enabled = true;
+ let confirmation = service.confirm();
+ confirmation.finally(() => assert.ok(true, 'Confirmation finally called'));
+ confirmation.confirm();
+ confirmation = service.confirm();
+ confirmation.finally(() => assert.ok(true, 'Confirmation finally called'));
+ confirmation.dismiss();
});
});
diff --git a/ui/core/translations/actions/en-us.yaml b/ui/core/translations/actions/en-us.yaml
index b12ee2825..810f0ca85 100644
--- a/ui/core/translations/actions/en-us.yaml
+++ b/ui/core/translations/actions/en-us.yaml
@@ -5,6 +5,9 @@ remove: Remove
dismiss: Dismiss
save: Save
manage: Manage
+confirm: Confirm
+ok: OK
+deny: Deny
help: Need help?
back: Back
get-topic-help: Get help for this topic