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

feat: Add ability helper #121

Open
wants to merge 2 commits 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
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,53 @@ Cannot helper is a negation of `can` helper with the same API.
{{cannot "doSth in myModel" model extraProperties}}
```

### `ability`

Ability helper will return the ability property as is instead of as a boolean like with can/cannot.
It allows you to return objects in your abilitie's property as long as you include a 'can' property that represents the usual boolean ability you would return in a more classic scenario.

```js
// app/abilities/post.js

import { computed } from '@ember/object';
import { Ability } from 'ember-can';

export default Ability.extend({
// only an admin can edit a post, if and only the post is editable
canEdit: computed('user.isAdmin', 'model.isNotEditable', function() {
if (!this.get('model.isNotEditable')) {
return {
can: false,
reason: 'This post cannot be edited'
}
}

if (!this.get('user.isAdmin')) {
return {
can: false,
reason: 'You need to be an admin to edit a post'
}
}

return true;
})
});
```

```hbs
{{ability "write post" post}}
{{!-- returns { can: ..., reason: ... } or true --}}
{{ability "write post:reason" post}}
{{!-- returns 'This post cannot be edited', 'You need to be an admin to edit a post' or undefined --}}

{{#if (can "write post" post)}}
<p>A post</p>
{{else}}
{{#with (ability "write post:reason" post) as |cannotEditPostReason|}}
{{cannotEditPostReason}}
{{/with}}
{{/if}}
```

## Abilities

Expand Down
43 changes: 43 additions & 0 deletions addon/helpers/ability.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { addObserver, removeObserver } from '@ember/object/observers';
import { get, setProperties } from '@ember/object';

export default Helper.extend({
can: service(),

ability: null,
propertyName: null,

compute([abilityString, model], properties) {
let { abilityName, propertyName, subProperty } = this.can.parse(abilityString);
let ability = this.can.abilityFor(abilityName, model, properties);

propertyName = ability.parseProperty(propertyName);

this._removeAbilityObserver();
this._addAbilityObserver(ability, propertyName);

if (!subProperty) {
return ability[propertyName];
}

return get(ability[propertyName], subProperty);
},

destroy() {
this._removeAbilityObserver();
return this._super(...arguments);
},

_addAbilityObserver(ability, propertyName) {
setProperties(this, { ability, propertyName });
addObserver(this, `ability.${propertyName}`, this, 'recompute');
},

_removeAbilityObserver() {
removeObserver(this, `ability.${this.propertyName}`, this, 'recompute');
this.ability && this.ability.destroy();
setProperties(this, { ability: null, propertyName: null });
}
});
46 changes: 12 additions & 34 deletions addon/helpers/can.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,17 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { addObserver, removeObserver } from '@ember/object/observers';
import { setProperties } from '@ember/object';
import AbilityHelper from 'ember-can/helpers/ability';
import { assert } from '@ember/debug';

export default Helper.extend({
can: service(),
export default AbilityHelper.extend({
compute([abilityString]) {
let { abilityName, propertyName, subProperty } = this.can.parse(abilityString);

ability: null,
propertyName: null,
assert(`Using 'abilityString:subProperty' syntax is forbidden in can and cannot helpers, use ability helper instead`, !subProperty);

compute([abilityString, model], properties) {
let { abilityName, propertyName } = this.can.parse(abilityString);
let ability = this.can.abilityFor(abilityName, model, properties);

propertyName = ability.parseProperty(propertyName);

this._removeAbilityObserver();
this._addAbilityObserver(ability, propertyName);

return ability[propertyName];
},

destroy() {
this._removeAbilityObserver();
return this._super(...arguments);
},

_addAbilityObserver(ability, propertyName) {
setProperties(this, { ability, propertyName });
addObserver(this, `ability.${propertyName}`, this, 'recompute');
},

_removeAbilityObserver() {
removeObserver(this, `ability.${this.propertyName}`, this, 'recompute');
this.ability && this.ability.destroy();
setProperties(this, { ability: null, propertyName: null });
let result = this._super(...arguments);
if (typeof result === 'object') {
assert(`Ability property ${propertyName} in '${abilityName}' is an object and must have a 'can' property`, 'can' in result);
return result.can
}
return result;
}
});
26 changes: 20 additions & 6 deletions addon/services/can.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Service from '@ember/service';
import Ability from 'ember-can/ability';
import { get } from '@ember/object';
import { assert } from '@ember/debug';
import { getOwner } from '@ember/application';
import { assign } from '@ember/polyfills';
Expand Down Expand Up @@ -44,19 +45,24 @@ export default Service.extend({
/**
* Returns a value for requested ability in specified ability class
* @public
* @param {String} propertyName name of ability, eg `createProjects`
* @param {String} abilityName name of ability class
* @param {[type]} abilityString eg. 'create projects in account'
* @param {*} model
* @param {Object} properties extra properties (to be set on the ability instance)
* @return {*} value of ability
*/
valueFor(propertyName, abilityName, model, properties) {
valueFor(abilityString, model, properties) {
let { abilityName, propertyName, subProperty } = this.parse(abilityString);

let ability = this.abilityFor(abilityName, model, properties);
let result = ability.getAbility(propertyName);

ability.destroy();

return result;
if (!subProperty) {
return result;
}

return get(result, subProperty);
},

/**
Expand All @@ -68,8 +74,16 @@ export default Service.extend({
* @return {Boolean} value of ability converted to boolean
*/
can(abilityString, model, properties) {
let { propertyName, abilityName } = this.parse(abilityString);
return !!this.valueFor(propertyName, abilityName, model, properties);
let { abilityName, propertyName, subProperty } = this.parse(abilityString);

assert(`Using 'abilityString:subProperty' syntax is forbidden in can and cannot helpers, use ability helper instead`, !subProperty);

let result = this.valueFor(abilityString, model, properties);
if (typeof result === 'object') {
assert(`Ability property ${propertyName} in '${abilityName}' is an object and must have a 'can' property`, 'can' in result);
return !!result.can
}
return !!result;
},

/**
Expand Down
6 changes: 4 additions & 2 deletions addon/utils/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ const stopWords = ['of', 'in', 'for', 'to', 'from', 'on', 'as'];
* @return {Object} extracted propertyName and abilityName
*/
export default function(string) {
let parts = string.split(' ');
let [abilityString, subProperty] = string.split(':').map(s => s.trim());

let parts = abilityString.split(' ');
let abilityName = singularize(parts.pop());
let last = parts[parts.length - 1];

Expand All @@ -21,5 +23,5 @@ export default function(string) {

let propertyName = camelize(parts.join(' '));

return { propertyName, abilityName };
return { subProperty, propertyName, abilityName };
}
1 change: 1 addition & 0 deletions app/helpers/ability.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-can/helpers/ability';
153 changes: 153 additions & 0 deletions tests/integration/helpers/ability-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { Ability } from 'ember-can';
import { computed } from '@ember/object';
import Service from '@ember/service';
import { inject as service } from '@ember/service';
import { run } from '@ember/runloop';

module('Integration | Helper | ability', function(hooks) {
setupRenderingTest(hooks);

module('with subproperty access', function() {
test('it works with custom property parser', async function(assert) {
assert.expect(1);

this.owner.register('ability:post', Ability.extend({
worksWell: computed('model', function() {
return { can: true, subProperty: 'prop' };
}),

parseProperty(propertyName) {
return propertyName; // without `can` prefix
}
}));

await render(hbs`{{ability "works well post:subProperty"}}`);
assert.dom(this.element).hasText('prop');
});

test('it works without model', async function(assert) {
assert.expect(1);

this.owner.register('ability:post', Ability.extend({
canWrite: computed('model', function() {
return { can: true, subProperty: 'prop' };
}),
}));

await render(hbs`{{ability "write post:subProperty"}}`);
assert.dom(this.element).hasText('prop');
});

test('it can receives model', async function(assert) {
assert.expect(4);

this.owner.register('ability:post', Ability.extend({
canWrite: computed('model.write', function() {
return { can: this.get('model.write'), subProperty: 'prop' };
}),
}));

await render(hbs`{{ability "write post:subProperty" model}}`);
assert.dom(this.element).hasText('prop');

this.set('model', { write: false });
assert.dom(this.element).hasText('prop');

this.set('model', { write: true });
assert.dom(this.element).hasText('prop');

this.set('model', null);
assert.dom(this.element).hasText('prop');
});

test('it works with default model', async function(assert) {
assert.expect(4);

this.owner.register('ability:post', Ability.extend({
// eslint-disable-next-line ember/avoid-leaking-state-in-ember-objects
model: { write: true },

canWrite: computed('model.write', function() {
return { can: this.get('model.write'), subProperty: 'prop' };
}).readOnly(),
}));

await render(hbs`{{ability "write post:subProperty" model}}`);
assert.dom(this.element).hasText('prop');

this.set('model', undefined);
assert.dom(this.element).hasText('prop');

this.set('model', null);
assert.dom(this.element).hasText('prop');

this.set('model', { write: false });
assert.dom(this.element).hasText('prop');
});

test('it can receives properties', async function(assert) {
assert.expect(2);

this.owner.register('ability:post', Ability.extend({
canWrite: computed('write', function() {
return { can: this.get('write'), subProperty: 'prop' };
}).readOnly(),
}));

this.set('write', false);
await render(hbs`{{ability "write post:subProperty" write=write}}`);
assert.dom(this.element).hasText('prop');

this.set('write', true);
assert.dom(this.element).hasText('prop');
});

test('it can receives model and properties', async function(assert) {
assert.expect(2);

this.owner.register('ability:post', Ability.extend({
canWrite: computed('model.write', 'write', function() {
return { can: this.get('model.write') && this.get('write'), subProperty: 'prop' };
}).readOnly(),
}));

this.set('write', false);
this.set('model', { write: false });

await render(hbs`{{ability "write post:subProperty" model write=this.write}}`);

assert.dom(this.element).hasText('prop');

this.set('write', true);
this.set('model', { write: true });

assert.dom(this.element).hasText('prop');
});

test('it reacts on ability change', async function(assert) {
assert.expect(2);

this.owner.register('service:session', Service.extend({
isLoggedIn: false
}));

this.owner.register('ability:post', Ability.extend({
session: service(),

canWrite: computed('session.isLoggedIn', function() {
return { can: this.get('session.isLoggedIn'), subProperty: 'prop' };
})
}));

await render(hbs`{{ability "write post:subProperty"}}`);
assert.dom(this.element).hasText('prop');

run(() => this.owner.lookup('service:session').set('isLoggedIn', true));
assert.dom(this.element).hasText('prop');
});
});
});
Loading