Skip to content

Commit

Permalink
Support asynchronous abilities
Browse files Browse the repository at this point in the history
  • Loading branch information
anehx committed Jul 24, 2024
1 parent 2aecb71 commit 22499c4
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 9 deletions.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,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.
Expand Down
4 changes: 2 additions & 2 deletions ember-can/src/helpers/can.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface CanSignature {
Positional: [abilityString: string, model?: unknown];
Named: Record<string, unknown>;
};
Return: boolean;
Return: boolean | Promise<boolean>;
}

export default class CanHelper extends Helper<CanSignature> {
Expand All @@ -16,7 +16,7 @@ export default class CanHelper extends Helper<CanSignature> {
compute(
[abilityString, model]: CanSignature['Args']['Positional'],
properties: CanSignature['Args']['Named'] = {},
): boolean {
): boolean | Promise<boolean> {
return this.abilities.can(abilityString ?? '', model, properties);
}
}
4 changes: 2 additions & 2 deletions ember-can/src/helpers/cannot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface CannotSignature {
Positional: [abilityString: string, model?: unknown];
Named: Record<string, unknown>;
};
Return: boolean;
Return: boolean | Promise<boolean>;
}

export default class CannotHelper extends Helper<CannotSignature> {
Expand All @@ -16,7 +16,7 @@ export default class CannotHelper extends Helper<CannotSignature> {
compute(
[abilityString, model]: CannotSignature['Args']['Positional'],
properties: CannotSignature['Args']['Named'] = {},
): boolean {
): boolean | Promise<boolean> {
return this.abilities.cannot(abilityString ?? '', model, properties);
}
}
34 changes: 29 additions & 5 deletions ember-can/src/services/abilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ 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<Boolean>} value of ability converted to boolean (might be a promise)
*/
#getValue(
abilityString: string,
model?: unknown,
properties?: Record<string, unknown>,
invert?: boolean,
): boolean | Promise<boolean> {
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));
}

return invert ? !result : !!result;
}

/**
* Returns `true` if ability is permitted
* @public
Expand All @@ -81,9 +106,8 @@ export default class AbilitiesService extends Service {
abilityString: string,
model?: unknown,
properties?: Record<string, unknown>,
): boolean {
const { propertyName, abilityName } = this.parse(abilityString);
return !!this.valueFor(propertyName, abilityName, model, properties);
): boolean | Promise<boolean> {
return this.#getValue(abilityString, model, properties, false);
}

/**
Expand All @@ -98,8 +122,8 @@ export default class AbilitiesService extends Service {
abilityString: string,
model?: unknown,
properties?: Record<string, unknown>,
): boolean {
return !this.can(abilityString, model, properties);
): boolean | Promise<boolean> {
return this.#getValue(abilityString, model, properties, true);
}
}

Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"ember-load-initializers": "^2.1.2",
"ember-modifier": "^4.1.0",
"ember-page-title": "^8.2.3",
"ember-promise-helpers": "^2.0.0",
"ember-qunit": "^8.0.2",
"ember-resolver": "^12.0.1",
"ember-source": "~5.8.0",
Expand Down
33 changes: 33 additions & 0 deletions test-app/tests/addon/helpers/can-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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|}}
<span data-test-is-pending>{{is-pending promise}}</span>
<span data-test-value>{{await promise}}</span>
{{/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');
});
});
});
33 changes: 33 additions & 0 deletions test-app/tests/addon/helpers/cannot-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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|}}
<span data-test-is-pending>{{is-pending promise}}</span>
<span data-test-value>{{await promise}}</span>
{{/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');
});
});
});
29 changes: 29 additions & 0 deletions test-app/tests/addon/services/abilities-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,33 @@ 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() {
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);
});
});
});

0 comments on commit 22499c4

Please sign in to comment.