diff --git a/ember-resources/src/util/modal-manager.ts b/ember-resources/src/util/modal-manager.ts new file mode 100644 index 000000000..ee6f99fc0 --- /dev/null +++ b/ember-resources/src/util/modal-manager.ts @@ -0,0 +1,108 @@ +import { tracked } from '@glimmer/tracking'; + +interface ValuedResult { + reason: 'confirmed' | 'rejected'; + value: TValue; +} + +interface CancelledResult { + reason: 'cancelled'; +} + +type Result = ValuedResult | 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 + * + * {{#if this.manager.isOpen}} + * + * {{/if}} + * ``` + */ +export class ModalManager { + #openModal: () => void = noop; + #closeModal: () => void = noop; + #resolve: (result: Result) => void = noop; + #reject: (error: Error) => void = noop; + + @tracked _isOpen = false; + + get isOpen() { + return this._isOpen; + } + + open = () => { + return new Promise>((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; + } +} diff --git a/testing/ember-app/tests/utils/modal-manager/js-test.ts b/testing/ember-app/tests/utils/modal-manager/js-test.ts new file mode 100644 index 000000000..6377897ea --- /dev/null +++ b/testing/ember-app/tests/utils/modal-manager/js-test.ts @@ -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); + }); +}); diff --git a/testing/ember-app/tests/utils/modal-manager/rendering-test.ts b/testing/ember-app/tests/utils/modal-manager/rendering-test.ts new file mode 100644 index 000000000..5a9723cc2 --- /dev/null +++ b/testing/ember-app/tests/utils/modal-manager/rendering-test.ts @@ -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( + hbs` + + {{#if this.manager.isOpen}} + + + + {{/each}} + `, + Test + ); + + this.setProperties({ Test }); + await render(hbs``); + 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)', + ]); + }); +});