diff --git a/README.md b/README.md index 0bb7bbe..a5c2e61 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,49 @@ If you're using [engines](http://ember-engines.com/) and you want to access an _ export { default } from 'my-app/abilities/foo-bar'; ``` +## Asynchronous abilities + +Some abilities might have the need to run asynchronously e.g. to fetch some +information from a backend. With `ember-can` it is possible to implement an +ability that returns a promise instead of a boolean value. + +```js +// app/abilities/post.js + +import { Ability } from 'ember-can'; + +export default class PostAbility extends Ability { + async canWrite() { + const response = await fetch('/api/post/can-i-write'); + + return response.status === 200; + } +} +``` + +In order to use that async ability in a template, you need to install +`ember-promise-helpers` and await the `can` helper accordingly: + +```hbs +{{#let (can 'write post' post) as |hasPermission|}} + {{#if (is-pending hasPermission)}} + We don't know yet if you can write a post! + {{else if (await hasPermission)}} + You can write a post! + {{else}} + You can't write a post! + {{/if}} +{{/let}} +``` + +The usage in JS code is the same as handling regular promises: + +```js +if (await this.abilities.cannot('edit post', post)) { + alert("You can't write a post!"); +} +``` + ## Upgrade guide See [UPGRADING.md](https://github.com/minutebase/ember-can/blob/master/UPGRADING.md) for more details. diff --git a/ember-can/src/helpers/can.ts b/ember-can/src/helpers/can.ts index d27587a..d4a7ca5 100644 --- a/ember-can/src/helpers/can.ts +++ b/ember-can/src/helpers/can.ts @@ -7,7 +7,7 @@ interface CanSignature { Positional: [abilityString: string, model?: unknown]; Named: Record; }; - Return: boolean; + Return: boolean | Promise; } export default class CanHelper extends Helper { @@ -16,7 +16,7 @@ export default class CanHelper extends Helper { compute( [abilityString, model]: CanSignature['Args']['Positional'], properties: CanSignature['Args']['Named'] = {}, - ): boolean { + ): boolean | Promise { return this.abilities.can(abilityString ?? '', model, properties); } } diff --git a/ember-can/src/helpers/cannot.ts b/ember-can/src/helpers/cannot.ts index 62af9cb..d07fad8 100644 --- a/ember-can/src/helpers/cannot.ts +++ b/ember-can/src/helpers/cannot.ts @@ -7,7 +7,7 @@ interface CannotSignature { Positional: [abilityString: string, model?: unknown]; Named: Record; }; - Return: boolean; + Return: boolean | Promise; } export default class CannotHelper extends Helper { @@ -16,7 +16,7 @@ export default class CannotHelper extends Helper { compute( [abilityString, model]: CannotSignature['Args']['Positional'], properties: CannotSignature['Args']['Named'] = {}, - ): boolean { + ): boolean | Promise { return this.abilities.cannot(abilityString ?? '', model, properties); } } diff --git a/ember-can/src/services/abilities.ts b/ember-can/src/services/abilities.ts index f1cceec..ed145ba 100644 --- a/ember-can/src/services/abilities.ts +++ b/ember-can/src/services/abilities.ts @@ -69,6 +69,33 @@ export default class AbilitiesService extends Service { return result; } + /** + * Returns a value for a requested ability string. + * @private + * @param {[type]} abilityString eg. 'create projects in account' + * @param {*} model + * @param {[type]} properties extra properties (to be set on the ability instance) + * @param {[type]} invert invert the boolean result + * @return {Boolean|Promise} value of ability converted to boolean (might be a promise) + */ + #getValue( + abilityString: string, + model?: unknown, + properties?: Record, + invert?: boolean, + ): boolean | Promise { + const { propertyName, abilityName } = this.parse(abilityString); + const result = this.valueFor(propertyName, abilityName, model, properties); + + if (result instanceof Promise) { + return result + .then((value) => (invert ? !value : !!value)) + .catch(() => (invert ? true : false)); + } + + return invert ? !result : !!result; + } + /** * Returns `true` if ability is permitted * @public @@ -81,9 +108,8 @@ export default class AbilitiesService extends Service { abilityString: string, model?: unknown, properties?: Record, - ): boolean { - const { propertyName, abilityName } = this.parse(abilityString); - return !!this.valueFor(propertyName, abilityName, model, properties); + ): boolean | Promise { + return this.#getValue(abilityString, model, properties, false); } /** @@ -98,8 +124,8 @@ export default class AbilitiesService extends Service { abilityString: string, model?: unknown, properties?: Record, - ): boolean { - return !this.can(abilityString, model, properties); + ): boolean | Promise { + return this.#getValue(abilityString, model, properties, true); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aece9a..4fa4194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: ember-page-title: specifier: ^9.0.1 version: 9.0.1(6508b3c4df8fbac914e36403c1fb8214) + ember-promise-helpers: + specifier: ^2.0.0 + version: 2.0.0 ember-qunit: specifier: ^9.0.1 version: 9.0.2(9cbd2f138baa249bde1fdcdbeb334b8d) @@ -3487,6 +3490,10 @@ packages: peerDependencies: ember-source: '>= 4.2.0' + ember-promise-helpers@2.0.0: + resolution: {integrity: sha512-ZQlzSRjCdDgGBlXHtk+LKmxOlXWKalBBZrmYlr2X/kqNf7vaVUnlKQD1T+sRzwnsZZTQwL5PKfLmSWF3hFD69g==} + engines: {node: 12.* || 14.* || >= 16} + ember-qunit@9.0.2: resolution: {integrity: sha512-vAuumvTYgfFpP0LgK1uw2vBEnD7bv1JYh0JUwkFfBiAkCGV7HkjVWNcgQ2agwarfzxLgEaC0hWlOn9adwJmlNg==} peerDependencies: @@ -11771,6 +11778,13 @@ snapshots: transitivePeerDependencies: - supports-color + ember-promise-helpers@2.0.0: + dependencies: + ember-cli-babel: 7.26.11 + ember-cli-htmlbars: 5.7.2 + transitivePeerDependencies: + - supports-color + ember-qunit@9.0.2(9cbd2f138baa249bde1fdcdbeb334b8d): dependencies: '@ember/test-helpers': 5.2.1(90e8de4d7874ce691b35119c6b6acc13) diff --git a/test-app/package.json b/test-app/package.json index 359ad00..f6802a5 100644 --- a/test-app/package.json +++ b/test-app/package.json @@ -78,6 +78,7 @@ "ember-load-initializers": "^3.0.1", "ember-modifier": "^4.2.0", "ember-page-title": "^9.0.1", + "ember-promise-helpers": "^2.0.0", "ember-qunit": "^9.0.1", "ember-resolver": "^13.1.0", "ember-source": "^5.12.0", diff --git a/test-app/tests/addon/helpers/can-test.js b/test-app/tests/addon/helpers/can-test.js index 0d8b86a..1c20fea 100644 --- a/test-app/tests/addon/helpers/can-test.js +++ b/test-app/tests/addon/helpers/can-test.js @@ -214,4 +214,37 @@ module('Addon | Helper | can', function (hooks) { assert.dom(this.element).hasText('true'); }); + + module('async', function () { + test('it can handle promises', async function (assert) { + const promise = new Promise((resolve) => { + this._resolve = resolve; + }); + + this.owner.register( + 'ability:post', + class extends Ability { + async canWrite() { + return await promise; + } + }, + ); + + await render(hbs` + {{#let (can "write post") as |promise|}} + {{is-pending promise}} + {{await promise}} + {{/let}} + `); + + assert.dom('[data-test-is-pending]').hasText('true'); + assert.dom('[data-test-value]').hasText(''); + + await this._resolve(true); + await settled(); + + assert.dom('[data-test-is-pending]').hasText('false'); + assert.dom('[data-test-value]').hasText('true'); + }); + }); }); diff --git a/test-app/tests/addon/helpers/cannot-test.js b/test-app/tests/addon/helpers/cannot-test.js index 0843427..809f325 100644 --- a/test-app/tests/addon/helpers/cannot-test.js +++ b/test-app/tests/addon/helpers/cannot-test.js @@ -214,4 +214,37 @@ module('Addon | Helper | cannot', function (hooks) { assert.dom(this.element).hasText('true'); }); + + module('async', function () { + test('it can handle promises', async function (assert) { + const promise = new Promise((resolve) => { + this._resolve = resolve; + }); + + this.owner.register( + 'ability:post', + class extends Ability { + async canWrite() { + return await promise; + } + }, + ); + + await render(hbs` + {{#let (cannot "write post") as |promise|}} + {{is-pending promise}} + {{await promise}} + {{/let}} + `); + + assert.dom('[data-test-is-pending]').hasText('true'); + assert.dom('[data-test-value]').hasText(''); + + await this._resolve(true); + await settled(); + + assert.dom('[data-test-is-pending]').hasText('false'); + assert.dom('[data-test-value]').hasText('false'); + }); + }); }); diff --git a/test-app/tests/addon/services/abilities-test.js b/test-app/tests/addon/services/abilities-test.js index fe00929..5b97b1e 100644 --- a/test-app/tests/addon/services/abilities-test.js +++ b/test-app/tests/addon/services/abilities-test.js @@ -94,4 +94,48 @@ module('Unit | Service | abilities', function (hooks) { abilityName: 'post', }); }); + + module('async', function (hooks) { + hooks.beforeEach(function () { + this.owner.register( + 'ability:super-model', + class extends Ability { + async canTouchThis() { + if (this.model.fail) { + throw new Error(); + } + + return await this.model.yeah; + } + }, + ); + }); + + test('can', async function (assert) { + let service = this.owner.lookup('service:abilities'); + let can = service.can('touchThis in superModel', { yeah: true }); + + assert.true(can instanceof Promise); + assert.true(await can); + }); + + test('cannot', async function (assert) { + let service = this.owner.lookup('service:abilities'); + let cannot = service.cannot('touchThis in superModel', { yeah: false }); + + assert.true(cannot instanceof Promise); + assert.true(await cannot); + }); + + test('rejected promise', async function (assert) { + let service = this.owner.lookup('service:abilities'); + + assert.false( + await service.can('touchThis in superModel', { fail: true }), + ); + assert.true( + await service.cannot('touchThis in superModel', { fail: true }), + ); + }); + }); });