Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support asynchronous abilities #182

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
36 changes: 31 additions & 5 deletions ember-can/src/services/abilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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))
.catch(() => (invert ? true : false));
}

return invert ? !result : !!result;
}

/**
* Returns `true` if ability is permitted
* @public
Expand All @@ -81,9 +108,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 +124,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');
});
});
});
44 changes: 44 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,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 }),
);
});
});
});