From 3b49ff3b7a5bc6a305e730b76e184630014d77be Mon Sep 17 00:00:00 2001 From: David Ratier Date: Sat, 25 Jul 2020 16:53:16 +0200 Subject: [PATCH 1/2] Add subproperty feature --- addon/helpers/ability.js | 43 ++++++ addon/helpers/can.js | 46 ++----- addon/services/can.js | 26 +++- addon/utils/normalize.js | 6 +- app/helpers/ability.js | 1 + tests/integration/helpers/ability-test.js | 153 ++++++++++++++++++++++ tests/unit/services/can-test.js | 18 ++- tests/unit/utils/normalize-test.js | 8 ++ 8 files changed, 257 insertions(+), 44 deletions(-) create mode 100644 addon/helpers/ability.js create mode 100644 app/helpers/ability.js create mode 100644 tests/integration/helpers/ability-test.js diff --git a/addon/helpers/ability.js b/addon/helpers/ability.js new file mode 100644 index 0000000..3fa9959 --- /dev/null +++ b/addon/helpers/ability.js @@ -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 }); + } +}); diff --git a/addon/helpers/can.js b/addon/helpers/can.js index c2c0c24..d571b91 100644 --- a/addon/helpers/can.js +++ b/addon/helpers/can.js @@ -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; } }); diff --git a/addon/services/can.js b/addon/services/can.js index 233bd7d..01dce20 100644 --- a/addon/services/can.js +++ b/addon/services/can.js @@ -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'; @@ -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); }, /** @@ -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; }, /** diff --git a/addon/utils/normalize.js b/addon/utils/normalize.js index bb74466..7094a02 100644 --- a/addon/utils/normalize.js +++ b/addon/utils/normalize.js @@ -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]; @@ -21,5 +23,5 @@ export default function(string) { let propertyName = camelize(parts.join(' ')); - return { propertyName, abilityName }; + return { subProperty, propertyName, abilityName }; } diff --git a/app/helpers/ability.js b/app/helpers/ability.js new file mode 100644 index 0000000..4bc4e76 --- /dev/null +++ b/app/helpers/ability.js @@ -0,0 +1 @@ +export { default } from 'ember-can/helpers/ability'; diff --git a/tests/integration/helpers/ability-test.js b/tests/integration/helpers/ability-test.js new file mode 100644 index 0000000..f7191e7 --- /dev/null +++ b/tests/integration/helpers/ability-test.js @@ -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'); + }); + }); +}); diff --git a/tests/unit/services/can-test.js b/tests/unit/services/can-test.js index 042bd5b..473cf85 100644 --- a/tests/unit/services/can-test.js +++ b/tests/unit/services/can-test.js @@ -7,16 +7,30 @@ module('Unit | Service | can', function(hooks) { setupTest(hooks); test('parse', function(assert) { - assert.expect(2); + assert.expect(4); let service = this.owner.lookup('service:can'); assert.deepEqual(service.parse('manage members in project'), { + subProperty: undefined, propertyName: 'manageMembers', abilityName: 'project' }); assert.deepEqual(service.parse('add tags to post'), { + subProperty: undefined, + propertyName: 'addTags', + abilityName: 'post' + }); + + assert.deepEqual(service.parse('manage members in project:prop'), { + subProperty: 'prop', + propertyName: 'manageMembers', + abilityName: 'project' + }); + + assert.deepEqual(service.parse('add tags to post:prop'), { + subProperty: 'prop', propertyName: 'addTags', abilityName: 'post' }); @@ -49,7 +63,7 @@ module('Unit | Service | can', function(hooks) { }), })); - assert.equal(service.valueFor('touchThis', 'superModel', { yeah: 'Yeah!' }), 'Yeah!'); + assert.equal(service.valueFor('touchThis in superModel', { yeah: 'Yeah!' }), 'Yeah!'); }); test('can', function(assert) { diff --git a/tests/unit/utils/normalize-test.js b/tests/unit/utils/normalize-test.js index ab5efd5..118405c 100644 --- a/tests/unit/utils/normalize-test.js +++ b/tests/unit/utils/normalize-test.js @@ -43,4 +43,12 @@ module('Unit | Util | normalize', function() { assert.equal("comment", norm.propertyName); assert.equal("issue", norm.abilityName); }); + + test('returns subproperty as well', function(assert) { + let norm = normalize("edit posts:prop"); + + assert.equal("prop", norm.subProperty); + assert.equal("edit", norm.propertyName); + assert.equal("post", norm.abilityName); + }); }); From 28d8373787dc03ee0895507514b154ead0f13a00 Mon Sep 17 00:00:00 2001 From: David Ratier Date: Sun, 26 Jul 2020 19:25:42 +0200 Subject: [PATCH 2/2] Add documentation for subproperty feature --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index be22c75..d2d88ef 100644 --- a/README.md +++ b/README.md @@ -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)}} +

A post

+{{else}} + {{#with (ability "write post:reason" post) as |cannotEditPostReason|}} + {{cannotEditPostReason}} + {{/with}} +{{/if}} +``` ## Abilities