Skip to content

Commit

Permalink
feat(utils): add the ModalManager utility
Browse files Browse the repository at this point in the history
  • Loading branch information
sukima committed Sep 19, 2022
1 parent c457ba7 commit 2748f44
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 0 deletions.
108 changes: 108 additions & 0 deletions ember-resources/src/util/modal-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { tracked } from '@glimmer/tracking';

interface ValuedResult<TValue> {
reason: 'confirmed' | 'rejected';
value: TValue;
}

interface CancelledResult {
reason: 'cancelled';
}

type Result<TValue> = ValuedResult<TValue> | CancelledResult;

interface Controller {
open: () => void;
close: () => void;
}

const noop = () => {
return;
};

/**
* A utility to manage modal dialog boxes. It provides an isOpen tracked
* property that can be used in templates to show/hide a modal dialog.
*
* This utility helps the code logic by offering a promise that will result in
* the conclusion of the modal dialog. It also allows you to provide the dialog
* component a simple manager interface that it can use to confirm, cancel, and
* even reject based on user input. That result can then be used for control
* flow by that initiator or even passed around as a promise as needed.
*
* @example
* ```ts
* import Component from '@glimmer/component';
* import { ModalManager } from 'ember-resources/utils/modal-manager';
*
* class Demo extends Component {
* manager = new ModalManager();
* openAndManageModal = async () => {
* let { reason, value } = await this.manager.open();
* if (reason !== 'confirmed') return;
* doSomethingWith(value);
* }
* }
* ```
*
* ```hbs
* <button type="button" {{on "click" this.openAndManageModal}}></button>
* {{#if this.manager.isOpen}}
* <MyModal @manager={{this.manager}} />
* {{/if}}
* ```
*/
export class ModalManager<TValue = undefined> {
#openModal: () => void = noop;
#closeModal: () => void = noop;
#resolve: (result: Result<TValue>) => void = noop;
#reject: (error: Error) => void = noop;

@tracked _isOpen = false;

get isOpen() {
return this._isOpen;
}

open = () => {
return new Promise<Result<TValue>>((resolve, reject) => {
this._isOpen = true;
this.#resolve = resolve;
this.#reject = reject;
this.#openModal();
}).finally(() => {
this.#closeModal();
this._isOpen = true;
});
};

cancel = () => {
this.#resolve({ reason: 'cancelled' });
};

confirm = (value: TValue) => {
this.#resolve({ reason: 'confirmed', value });
};

reject = (value: TValue) => {
this.#resolve({ reason: 'rejected', value });
};

error = (error: Error) => {
this.#reject(error);
};

delegateTo(controller: Controller) {
this.#openModal = () => controller.open();
this.#closeModal = () => controller.close();
}

static withDelegate(factory: (manager: ModalManager) => Controller) {
let manager = new ModalManager();
let controller = factory(manager);

manager.delegateTo(controller);

return manager;
}
}
90 changes: 90 additions & 0 deletions testing/ember-app/tests/utils/modal-manager/js-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';

import { ModalManager } from 'ember-resources/util/modal-manager';

module('Utils | modal-manager | js', function (hooks) {
setupTest(hooks);

test('manages isOpen flag', async function(assert) {
let subject = new ModalManager();
assert.false(subject.isOpen, 'isOpen false');
let resultPromise = subject.open();
assert.true(subject.isOpen, 'isOpen troue');
subject.cancel();
await resultPromise;
assert.false(subject.isOpen, 'isOpen false');
});

test('can be cancelled', async function(assert) {
let subject = new ModalManager();
let resultPromise = subject.open();
subject.cancel();
let { reason } = await resultPromise;
assert.equal(reason, 'cancelled');
});

test('can be confirmed', async function(assert) {
let subject = new ModalManager();
let resultPromise = subject.open();
subject.confirm('test-value');
let { reason, value } = await resultPromise;
assert.equal(reason, 'confirmed');
assert.equal(value, 'test-value');
});

test('can be rejected', async function(assert) {
let subject = new ModalManager();
let resultPromise = subject.open();
subject.reject('test-value');
let { reason, value } = await resultPromise;
assert.equal(reason, 'rejected');
assert.equal(value, 'test-value');
});

test('can be errored', async function(assert) {
let subject = new ModalManager();
let resultPromise = subject.open();
subject.error(new Error('test-error'));
await assert.rejects(resultPromise, /test-error/);
});

test('can delegate opening and closing', async function(assert) {
class TestController {
open() {
assert.step('opened');
}
close() {
assert.step('closed');
}
}
let testController = new TestController();
let subject = new ModalManager();
subject.delegateTo(testController);
let resultPromise = subject.open();
subject.cancel();
await resultPromise;
assert.verifySteps(['opened', 'closed']);
});

test('provides a factory method for inline delegation', async function(assert) {
class TestController {
open() {
assert.step('opened');
}
close() {
assert.step('closed');
}
}
let calledWith: ModalManager;
let subject = ModalManager.withDelegate((manager: ModalManager) => {
calledWith = manager;
return new TestController();
});
let resultPromise = subject.open();
subject.cancel();
await resultPromise;
assert.verifySteps(['opened', 'closed']);
assert.strictEqual(calledWith, subject);
});
});
53 changes: 53 additions & 0 deletions testing/ember-app/tests/utils/modal-manager/rendering-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Component from '@glimmer/component';
import { setComponentTemplate } from '@ember/component';
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';

import { ModalManager } from 'ember-resources/util/modal-manager';

module('Utils | modal-manager | rendering', function (hooks) {
setupRenderingTest(hooks);

test('it works', async function (assert) {
class Test extends Component {
manager = new ModalManager();
openModal = async () => {
assert.step('modal opened');
let { result, value = '' } = await this.manager.open();
assert.step(`modal ${result} (${value})`);
};
}

setComponentTemplate<typeof Test>(
hbs`
<button id="open" type="button" {{on "click" this.openModal}}></button>
{{#if this.manager.isOpen}}
<button id="cancel" type="button" {{on "click" this.manager.cancel}}></button>
<button id="confirm" type="button" {{on "click" (fn this.manager.confirm "test-confirm")}}></button>
<button id="reject" type="button" {{on "click" (fn this.manager.reject "test-reject")}}></button>
{{/each}}
`,
Test
);

this.setProperties({ Test });
await render(hbs`<this.Test />`);
await click('#open');
await click('#cancel');
await click('#open');
await click('#confirm');
await click('#open');
await click('#reject');

assert.verifySteps([
'modal opened',
'modal cancelled ()',
'modal opened',
'modal confirmed (test-confirm)',
'modal opened',
'modal rejected (test-reject)',
]);
});
});

0 comments on commit 2748f44

Please sign in to comment.