Repository: ember-template-lint/ember-template-lint
Pull request #853 (2019-10-09 19:57:09)

Add `no-action` rule
#835 add no `{{action}}` rule


Patch:
diff --git a/docs/rule/no-action-modifiers.md b/docs/rule/no-action-modifiers.md
index 15526006..39980aea 100644
--- a/docs/rule/no-action-modifiers.md
+++ b/docs/rule/no-action-modifiers.md
@@ -32,4 +32,5 @@ The following values are valid configuration:
 
 ### Related Rules
 
+* [no-action](no-action.md)
 * [no-element-event-actions](no-element-event-actions.md)
diff --git a/docs/rule/no-action.md b/docs/rule/no-action.md
new file mode 100644
index 00000000..2fc9079e
--- /dev/null
+++ b/docs/rule/no-action.md
@@ -0,0 +1,108 @@
+## no-action
+
+What's wrong with `{{action}}`?
+
+"Action" is an overloaded term in Ember parlance. Actions are:
+
+1.) Methods on the `actions` hash
+```js
+actions: {
+  helloWorld() {}
+}
+```
+
+2.) But also element modifiers that setup event handlers?
+```hbs
+<div {{action 'helloWorld'}}></div>
+```
+
+3.) Oh, and they are partial applied functions too:
+```hbs
+{{some-component onClick=(action 'addNumbers' 1 2 3)}}
+```
+
+4.) Also, they usually receive strings, but can also receive functions?
+```hbs
+<div {{action this.someMethod}}></div>
+```
+
+They're something we pass downward, but also something we send back up (as in Data Down, Actions Up). They are the way you interact with the DOM, except when you need to actually access the event itself, or when you use the `click` method on a classic component class. The point is, if you try to ask a group of experienced Ember devs to define what an "action" is, you may get a few contradicting/overlapping opinions.
+
+Actions have served many different purposes over the years, and this makes them difficult to learn, to teach, and to repurpose.
+
+```hbs
+<button {{action "increment" 5}}>Click</button>
+<button {{action this.increment 5}}>Click</button>
+<button onclick={{action "increment" 5}}>Click</button>
+<button onclick={{action this.increment 5}}>Click</button>
+<button {{action (action "increment" 5)}}>Click</button>
+<button {{action (action this.increment 5)}}>Click</button>
+```
+
+We wanted to separate out the different responsibilities here, and the `@action` decorator was the first part of the puzzle, and the other two pieces are the `{{fn}}` helper and `{{on}}` modifier.
+
+### Examples
+
+This rule **forbids** the following:
+
+
+```hbs
+<button onclick={{action 'foo'}}></button>
+```
+
+```hbs
+<button {{action 'submit'}}>Submit</button>
+```
+
+```hbs
+<FooBar @baz={{action 'submit'}} />
+```
+
+```hbs
+{{yield (action 'foo')}}
+```
+
+```hbs
+{{yield (action this.foo)}}
+```
+
+This rule **allows** the following:
+
+```hbs
+<button {{on "submit" @action}}>Click Me</button>
+```
+
+```hbs
+<button {{on "submit" this.action}}>Click Me</button>
+```
+
+### Migration
+
+```hbs
+<select onchange={{action this.updateSelection this.options}}>
+  {{#each this.options as |opt|}}
+    <option>{{opt.value}}<option>
+  {{/each}}
+</select>
+```
+to
+
+```hbs
+<select {{on 'change' (fn this.updateSelection this.options)}}>
+  {{#each this.options as |opt|}}
+    <option>{{opt.value}}<option>
+  {{/each}}
+</select>
+```
+
+### Related Rules
+
+* [no-action-modifiers](no-action-modifiers.md)
+* [no-element-event-actions](no-element-event-actions.md)
+
+### References
+
+* [Ember Octane Update: What's up with `@action`?](https://www.pzuraq.com/ember-octane-update-action/)
+* [RFC-471 `on` modifier](https://github.com/emberjs/rfcs/blob/master/text/0471-on-modifier.md)
+* [RFC-470 `fn` helper](https://github.com/emberjs/rfcs/blob/master/text/0470-fn-helper.md)
+* [Intent to RFC: Deprecate `{{action}}`](https://github.com/emberjs/rfcs/issues/537)
diff --git a/docs/rule/no-element-event-actions.md b/docs/rule/no-element-event-actions.md
index f0f32155..3599b431 100644
--- a/docs/rule/no-element-event-actions.md
+++ b/docs/rule/no-element-event-actions.md
@@ -32,4 +32,5 @@ This rule **allows** the following:
 
 ### Related Rules
 
+* [no-action](no-action.md)
 * [no-action-modifiers](no-action-modifiers.md)
diff --git a/docs/rules.md b/docs/rules.md
index b85a4cd6..335be933 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -10,6 +10,7 @@
 * [link-href-attributes](rule/link-href-attributes.md)
 * [link-rel-noopener](rule/link-rel-noopener.md)
 * [no-abstract-roles](rule/no-abstract-roles.md)
+* [no-action](rule/no-action.md)
 * [no-action-modifiers](rule/no-action-modifiers.md)
 * [no-attrs-in-components](rule/no-attrs-in-components.md)
 * [no-bare-strings](rule/no-bare-strings.md)
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 0dcacf54..0dc992a0 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -16,6 +16,7 @@ module.exports = {
   'no-abstract-roles': require('./lint-no-abstract-roles'),
   'no-action-modifiers': require('./lint-no-action-modifiers'),
   'no-attrs-in-components': require('./lint-no-attrs-in-components'),
+  'no-action': require('./lint-no-action'),
   'no-bare-strings': require('./lint-no-bare-strings'),
   'no-curly-component-invocation': require('./lint-no-curly-component-invocation'),
   'no-debugger': require('./lint-no-debugger'),
diff --git a/lib/rules/lint-no-action.js b/lib/rules/lint-no-action.js
new file mode 100644
index 00000000..e538a8f4
--- /dev/null
+++ b/lib/rules/lint-no-action.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const Rule = require('./base');
+
+const ERROR_MESSAGE = 'Do not use `action` as %. Instead, use the `on` modifier and `fn` helper.';
+
+module.exports = class NoAction extends Rule {
+  visitor() {
+    const isLocal = this.isLocal.bind(this);
+    const log = this.log.bind(this);
+    const sourceForNode = this.sourceForNode.bind(this);
+    let closestTag = null;
+
+    function detectAction(node, usageContext) {
+      if (isLocal(node.path)) {
+        return;
+      }
+      let maybeAction = node.path.original;
+      if (maybeAction !== 'action') {
+        return;
+      }
+      if (node.path.data === true || node.path.this === true) {
+        return;
+      }
+      log({
+        message: ERROR_MESSAGE.replace('%', usageContext),
+        line: node.loc && node.loc.start.line,
+        column: node.loc && node.loc.start.column,
+        source: sourceForNode(node),
+      });
+    }
+
+    return {
+      SubExpression: node => {
+        detectAction(node, '(action ...)');
+      },
+      MustacheStatement: node => {
+        detectAction(node, '{{action ...}}');
+      },
+      ElementNode: node => {
+        closestTag = node.tag;
+      },
+      ElementModifierStatement: node => {
+        detectAction(node, `<${closestTag} {{action ...}} />`);
+      },
+    };
+  }
+};
+
+module.exports.ERROR_MESSAGE = ERROR_MESSAGE;


Test patch:
diff --git a/lib/helpers/rule-test-harness.js b/lib/helpers/rule-test-harness.js
index 608b1c14..4a413e33 100644
--- a/lib/helpers/rule-test-harness.js
+++ b/lib/helpers/rule-test-harness.js
@@ -165,7 +165,7 @@ module.exports = function generateRuleTests({
     });
 
     good.forEach(function(goodItem) {
-      let template = goodItem.template;
+      let template = typeof goodItem === 'string' ? goodItem : goodItem.template;
 
       testMethod(`passes when given \`${template}\``, function() {
         meta = parseMeta(goodItem);
diff --git a/test/unit/rules/lint-no-action-test.js b/test/unit/rules/lint-no-action-test.js
new file mode 100644
index 00000000..ec5bfa30
--- /dev/null
+++ b/test/unit/rules/lint-no-action-test.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const generateRuleTests = require('../../helpers/rule-test-harness');
+const ERROR_MESSAGE = require('../../../lib/rules/lint-no-action').ERROR_MESSAGE;
+
+generateRuleTests({
+  name: 'no-action',
+
+  config: true,
+
+  good: [
+    '{{#let (fn this.foo bar) as |action|}}<Component @baz={{action}} />{{/let}}',
+    '<MyScope as |action|><Component @baz={{action}} /></MyScope>',
+    '<button {{on "submit" @action}}>Click Me</button>',
+    '<button {{on "submit" this.action}}>Click Me</button>',
+  ],
+
+  bad: [
+    {
+      template: '<button onclick={{action "foo"}}></button>',
+      result: {
+        message: ERROR_MESSAGE.replace('%', '{{action ...}}'),
+        moduleId: 'layout.hbs',
+        source: '{{action "foo"}}',
+        line: 1,
+        column: 16,
+      },
+    },
+    {
+      template: '<button {{action "submit"}}>Submit</button>',
+      result: {
+        message: ERROR_MESSAGE.replace('%', '<button {{action ...}} />'),
+        moduleId: 'layout.hbs',
+        source: '{{action "submit"}}',
+        line: 1,
+        column: 8,
+      },
+    },
+    {
+      template: '<FooBar @baz={{action "submit"}} />',
+      result: {
+        message: ERROR_MESSAGE.replace('%', '{{action ...}}'),
+        moduleId: 'layout.hbs',
+        source: '{{action "submit"}}',
+        line: 1,
+        column: 13,
+      },
+    },
+    {
+      template: '{{yield (action "foo")}}',
+      result: {
+        message: ERROR_MESSAGE.replace('%', '(action ...)'),
+        moduleId: 'layout.hbs',
+        source: '(action "foo")',
+        line: 1,
+        column: 8,
+      },
+    },
+    {
+      template: '{{yield (action this.foo)}}',
+      result: {
+        message: ERROR_MESSAGE.replace('%', '(action ...)'),
+        moduleId: 'layout.hbs',
+        source: '(action this.foo)',
+        line: 1,
+        column: 8,
+      },
+    },
+  ],
+});