From 896b09bffff630b4d2dcaa366c814e20f94b0b58 Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Wed, 14 Oct 2020 09:33:44 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20apply=20confirmation=20m?= =?UTF-8?q?odal=20to=20deletes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pending-confirmations/index.hbs | 6 + .../components/pending-confirmations/index.js | 40 +++++ ui/core/app/services/confirm.js | 139 +++++++++++++++--- ui/core/app/templates/application.hbs | 20 +++ ui/core/package.json | 1 + ui/core/tests/acceptance/projects-test.js | 9 +- .../components/pending-confirmations-test.js | 59 ++++++++ ui/core/tests/unit/services/confirm-test.js | 67 ++++++++- ui/core/translations/actions/en-us.yaml | 3 + 9 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 ui/core/app/components/pending-confirmations/index.hbs create mode 100644 ui/core/app/components/pending-confirmations/index.js create mode 100644 ui/core/tests/integration/components/pending-confirmations-test.js 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