Skip to content

Commit

Permalink
add action decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
Chris Garrett committed Feb 8, 2019
1 parent 0fe6002 commit d3a6a24
Show file tree
Hide file tree
Showing 4 changed files with 335 additions and 1 deletion.
95 changes: 95 additions & 0 deletions packages/@ember/object/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { EMBER_NATIVE_DECORATOR_SUPPORT } from '@ember/canary-features';
import { assert } from '@ember/debug';
import { assign } from '@ember/polyfills';

/**
Decorator that turns the target function into an Action
Adds an `actions` object to the target object and creates a passthrough
function that calls the original. This means the function still exists
on the original object, and can be used directly.
```js
export default class ActionDemoComponent extends Component {
@action
foo() {
// do something
}
}
```
```hbs
<!-- template.hbs -->
<button onclick={{action "foo"}}>Execute foo action</button>
```
It also binds the function directly to the instance, so it can be used in any
context:
```hbs
<!-- template.hbs -->
<button onclick={{this.foo}}>Execute foo action</button>
```
@method computed
@for @ember/object
@static
@param {ElementDescriptor} elementDesc the descriptor of the element to decorate
@return {ElementDescriptor} the decorated descriptor
@private
*/
export let action;

if (EMBER_NATIVE_DECORATOR_SUPPORT) {
let BINDINGS_MAP = new WeakMap();

action = function action(elementDesc) {
assert(
'The @action decorator must be applied to methods',
elementDesc &&
elementDesc.kind === 'method' &&
elementDesc.descriptor &&
typeof elementDesc.descriptor.value === 'function'
);

let actionFn = elementDesc.descriptor.value;

elementDesc.descriptor = {
get() {
let bindings = BINDINGS_MAP.get(this);

if (bindings === undefined) {
bindings = new Map();
BINDINGS_MAP.set(this, bindings);
}

let fn = bindings.get(actionFn);

if (fn === undefined) {
fn = actionFn.bind(this);
bindings.set(actionFn, fn);
}

return fn;
},
};

elementDesc.finisher = target => {
let { key } = elementDesc;
let { prototype } = target;

if (typeof target.proto === 'function') {
target.proto();
}

if (!prototype.hasOwnProperty('actions')) {
let parentActions = prototype.actions;
// we need to assign because of the way mixins copy actions down when inheriting
prototype.actions = parentActions ? assign({}, parentActions) : {};
}

prototype.actions[key] = actionFn;

return target;
};

return elementDesc;
};
}
234 changes: 234 additions & 0 deletions packages/@ember/object/tests/action_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { EMBER_NATIVE_DECORATOR_SUPPORT } from '@ember/canary-features';
import { Component } from '@ember/-internals/glimmer';
import { Object as EmberObject } from '@ember/-internals/runtime';
import { moduleFor, RenderingTestCase, strip } from 'internal-test-helpers';

import { action } from '../index';

if (EMBER_NATIVE_DECORATOR_SUPPORT) {
moduleFor(
'@action decorator',
class extends RenderingTestCase {
'@test action decorator works with ES6 class'(assert) {
class FooComponent extends Component {
@action
foo() {
assert.ok(true, 'called!');
}
}

this.registerComponent('foo-bar', {
ComponentClass: FooComponent,
template: "<button {{action 'foo'}}>Click Me!</button>",
});

this.render('{{foo-bar}}');

this.$('button').click();
}

'@test action decorator does not add actions to superclass'(assert) {
class Foo extends EmberObject {
@action
foo() {
// Do nothing
}
}

class Bar extends Foo {
@action
bar() {
assert.ok(false, 'called');
}
}

let foo = Foo.create();
let bar = Bar.create();

assert.equal(typeof foo.actions.foo, 'function', 'foo has foo action');
assert.equal(typeof foo.actions.bar, 'undefined', 'foo does not have bar action');

assert.equal(typeof bar.actions.foo, 'function', 'bar has foo action');
assert.equal(typeof bar.actions.bar, 'function', 'bar has bar action');
}

'@test actions are properly merged through traditional and ES6 prototype hierarchy'(assert) {
assert.expect(4);

let FooComponent = Component.extend({
actions: {
foo() {
assert.ok(true, 'foo called!');
},
},
});

class BarComponent extends FooComponent {
@action
bar() {
assert.ok(true, 'bar called!');
}
}

let BazComponent = BarComponent.extend({
actions: {
baz() {
assert.ok(true, 'baz called!');
},
},
});

class QuxComponent extends BazComponent {
@action
qux() {
assert.ok(true, 'qux called!');
}
}

this.registerComponent('qux-component', {
ComponentClass: QuxComponent,
template: strip`
<button {{action 'foo'}}>Click Foo!</button>
<button {{action 'bar'}}>Click Bar!</button>
<button {{action 'baz'}}>Click Baz!</button>
<button {{action 'qux'}}>Click Qux!</button>
`,
});

this.render('{{qux-component}}');

this.$('button').click();
}

'@test action decorator super works with native class methods'(assert) {
class FooComponent extends Component {
foo() {
assert.ok(true, 'called!');
}
}

class BarComponent extends FooComponent {
@action
foo() {
super.foo();
}
}

this.registerComponent('bar-bar', {
ComponentClass: BarComponent,
template: "<button {{action 'foo'}}>Click Me!</button>",
});

this.render('{{bar-bar}}');

this.$('button').click();
}

'@test action decorator super works with traditional class methods'(assert) {
let FooComponent = Component.extend({
foo() {
assert.ok(true, 'called!');
},
});

class BarComponent extends FooComponent {
@action
foo() {
super.foo();
}
}

this.registerComponent('bar-bar', {
ComponentClass: BarComponent,
template: "<button {{action 'foo'}}>Click Me!</button>",
});

this.render('{{bar-bar}}');

this.$('button').click();
}

'@test action decorator works with parent native class actions'(assert) {
class FooComponent extends Component {
@action
foo() {
assert.ok(true, 'called!');
}
}

class BarComponent extends FooComponent {
@action
foo() {
super.foo();
}
}

this.registerComponent('bar-bar', {
ComponentClass: BarComponent,
template: "<button {{action 'foo'}}>Click Me!</button>",
});

this.render('{{bar-bar}}');

this.$('button').click();
}

'@test action decorator binds functions'(assert) {
class FooComponent extends Component {
bar = 'some value';

@action
foo() {
assert.equal(this.bar, 'some value', 'context bound correctly');
}
}

this.registerComponent('foo-bar', {
ComponentClass: FooComponent,
template: '<button onclick={{this.foo}}>Click Me!</button>',
});

this.render('{{foo-bar}}');

this.$('button').click();
}

'@test action decorator super works correctly when bound'(assert) {
class FooComponent extends Component {
bar = 'some value';

@action
foo() {
assert.equal(this.bar, 'some value', 'context bound correctly');
}
}

class BarComponent extends FooComponent {
@action
foo() {
super.foo();
}
}

this.registerComponent('bar-bar', {
ComponentClass: BarComponent,
template: '<button onclick={{this.foo}}>Click Me!</button>',
});

this.render('{{bar-bar}}');

this.$('button').click();
}

'@test action decorator throws an error if applied to non-methods'() {
expectAssertion(() => {
class TestObject extends EmberObject {
@action foo = 'bar';
}

new TestObject();
}, /The @action decorator must be applied to methods/);
}
}
);
}
4 changes: 4 additions & 0 deletions packages/ember/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
} from '@ember/string';
import Service, { inject as injectService } from '@ember/service';

import { action } from '@ember/object';

import {
and,
bool,
Expand Down Expand Up @@ -433,6 +435,8 @@ Ember._ProxyMixin = _ProxyMixin;
Ember.RSVP = RSVP;
Ember.Namespace = Namespace;

Ember._action = action;

computed.empty = empty;
computed.notEmpty = notEmpty;
computed.none = none;
Expand Down
3 changes: 2 additions & 1 deletion packages/ember/tests/reexports_test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Ember from '../index';
import { FEATURES } from '@ember/canary-features';
import { FEATURES, EMBER_NATIVE_DECORATOR_SUPPORT } from '@ember/canary-features';
import { confirmExport } from 'internal-test-helpers';
import { moduleFor, AbstractTestCase } from 'internal-test-helpers';
import { jQueryDisabled, jQuery } from '@ember/-internals/views';
Expand Down Expand Up @@ -269,6 +269,7 @@ let allExports = [
'@ember/-internals/metal',
{ get: 'isNamespaceSearchDisabled', set: 'setNamespaceSearchDisabled' },
],
EMBER_NATIVE_DECORATOR_SUPPORT ? ['_action', '@ember/object', 'action'] : null,
['computed.empty', '@ember/object/computed', 'empty'],
['computed.notEmpty', '@ember/object/computed', 'notEmpty'],
['computed.none', '@ember/object/computed', 'none'],
Expand Down

0 comments on commit d3a6a24

Please sign in to comment.