feat: 🎸 apply confirmation modal to deletes
This commit is contained in:
9 files changed
+314
-30
No files matched your search
@@ -0,0 +1,6 @@
|
||||
{{#each this.confirm.pending as |confirmation|}}
|
||||
{{yield
|
||||
confirmation.text
|
||||
(fn this.accept confirmation)
|
||||
(fn this.deny confirmation)}}
|
||||
{{/each}}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+117
-22
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -83,3 +83,23 @@
|
||||
{{message.text}}
|
||||
</Rose::Notification>
|
||||
</EmberNotify>
|
||||
|
||||
<PendingConfirmations as |text accept deny|>
|
||||
<Rose::Dialog
|
||||
@heading={{t "actions.confirm"}}
|
||||
@modal={{true}}
|
||||
@dismiss={{deny}}
|
||||
as |dialog|>
|
||||
<dialog.body>
|
||||
<p>{{text}}</p>
|
||||
</dialog.body>
|
||||
<dialog.footer>
|
||||
<Rose::Button @style="primary" {{on "click" accept}}>
|
||||
{{t "actions.ok"}}
|
||||
</Rose::Button>
|
||||
<Rose::Button @style="secondary" {{on "click" deny}}>
|
||||
{{t "actions.cancel"}}
|
||||
</Rose::Button>
|
||||
</dialog.footer>
|
||||
</Rose::Dialog>
|
||||
</PendingConfirmations>
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
<PendingConfirmations as |confirmations|>
|
||||
<div class="pending">Pending</div>
|
||||
</PendingConfirmations>
|
||||
`);
|
||||
assert.equal(findAll('.pending').length, 0, 'No pending confirmations');
|
||||
service.confirm();
|
||||
await render(hbs`
|
||||
<PendingConfirmations as |confirmations|>
|
||||
<div class="pending">Pending</div>
|
||||
</PendingConfirmations>
|
||||
`);
|
||||
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`
|
||||
<PendingConfirmations as |confirmations confirm|>
|
||||
<button {{on "click" confirm}}>Confirm</button>
|
||||
</PendingConfirmations>
|
||||
`);
|
||||
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`
|
||||
<PendingConfirmations as |confirmations confirm dismiss|>
|
||||
<button {{on "click" dismiss}}>Dismiss</button>
|
||||
</PendingConfirmations>
|
||||
`);
|
||||
assert.notOk(confirmation.done, 'Confirmation is not done');
|
||||
await click('button');
|
||||
assert.ok(confirmation.done, 'Confirmation is done');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user