Repository: yannickcr/eslint-plugin-react
Pull request #890 (2016-10-06 04:33:43)

Add forbid-elements rule
May specify a list of forbidden elements and their desired replacement
elements.

addresses https://github.com/yannickcr/eslint-plugin-react/issues/887


kentor: @ljharb I've made the suggested schema changes. I've left out the auto fixing capability for now and I could add it in later.
kentor: I'm no longer interested in having this merged. If no one else wants this rule it's fine to close it imo.
ljharb: @kentor sorry this fell through the cracks - I still think it's a great rule to add.
TheXardas: Will it work for named imports like:
```js
import MyBlock from './Block.jsx';

class MyComponent extends React.Component {
    render() {
        return <MyBlock />;
    }
}
```
with forbidden `Block` element?

ljharb: @TheXardas no, there is no block element in that file.
TheXardas: But there is!
It is aliased as MyBlock, but it's still a Block component.
ljharb: @TheXardas right but there's no way static analysis can determine that within that file. The only way a linter rule can determine what kind of component a thing is is its name in the file. If someone intentionally names it MyBlock to get around the prohibition, there's nothing a linter rule could do about it (just like if they used an eslint override comment)

Patch:
diff --git a/README.md b/README.md
index 01897602..21deb179 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,7 @@ Finally, enable all of the rules that you would like to use.  Use [our preset](#
 
 * [react/display-name](docs/rules/display-name.md): Prevent missing `displayName` in a React component definition
 * [react/forbid-component-props](docs/rules/forbid-component-props.md): Forbid certain props on Components
+* [react/forbid-elements](docs/rules/forbid-elements.md): Forbid certain elements
 * [react/forbid-prop-types](docs/rules/forbid-prop-types.md): Forbid certain propTypes
 * [react/no-array-index-key](docs/rules/no-array-index-key.md): Prevent using Array index in `key` props
 * [react/no-children-prop](docs/rules/no-children-prop.md): Prevent passing children as props
@@ -111,7 +112,6 @@ Finally, enable all of the rules that you would like to use.  Use [our preset](#
 * [react/sort-comp](docs/rules/sort-comp.md): Enforce component methods order
 * [react/sort-prop-types](docs/rules/sort-prop-types.md): Enforce propTypes declarations alphabetical sorting
 * [react/style-prop-object](docs/rules/style-prop-object.md): Enforce style prop value being an object
-* [react/void-dom-elements-no-children](docs/rules/void-dom-elements-no-children.md): Prevent void DOM elements (e.g. `<img />`, `<br />`) from receiving children
 
 ## JSX-specific rules
 
diff --git a/docs/rules/forbid-elements.md b/docs/rules/forbid-elements.md
new file mode 100644
index 00000000..16c4c62d
--- /dev/null
+++ b/docs/rules/forbid-elements.md
@@ -0,0 +1,58 @@
+# Forbid certain elements (forbid-elements)
+
+You may want to forbid usage of certain elements in favor of others, (e.g. forbid all `<div />` and use `<Box />` instead). This rule allows you to configure a list of forbidden elements and to specify their desired replacements.
+
+## Rule Details
+
+This rule checks all JSX elements and `React.createElement` calls and verifies that no forbidden elements are used. This rule is off by default. If on, no elements are forbidden by default.
+
+## Rule Options
+
+```js
+...
+"forbid-elements": [<enabled>, { "forbid": [<string|object>] }]
+...
+```
+
+### `forbid`
+
+An array of strings and/or objects. An object in this array may have the following properties: 
+
+* `element` (required): the name of the forbidden element (e.g. `'button'`, `'Modal'`)
+* `message`: additional message that gets reported
+
+A string item in the array is a shorthand for `{ element: string }`. 
+
+The following patterns are not considered warnings:
+
+```jsx
+// [1, { "forbid": ["button"] }]
+<Button />
+
+// [1, { "forbid": [{ "element": "button" }] }]
+<Button />
+```
+
+The following patterns are considered warnings:
+
+```jsx
+// [1, { "forbid": ["button"] }]
+<button />
+React.createElement('button');
+
+// [1, { "forbid": ["Modal"] }]
+<Modal />
+React.createElement(Modal);
+
+// [1, { "forbid": ["Namespaced.Element"] }]
+<Namespaced.Element />
+React.createElement(Namespaced.Element);
+
+// [1, { "forbid": [{ "element": "button", "message": "use <Button> instead" }, "input"] }]
+<div><button /><input /></div>
+React.createElement('div', {}, React.createElemet('button', {}, React.createElement('input')));
+```
+
+## When not to use
+
+If you don't want to forbid any elements.
diff --git a/docs/rules/void-dom-elements-no-children.md b/docs/rules/void-dom-elements-no-children.md
deleted file mode 100644
index 9bbdda7f..00000000
--- a/docs/rules/void-dom-elements-no-children.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# Prevent void DOM elements (e.g. `<img />`, `<br />`) from receiving children
-
-There are some HTML elements that are only self-closing (e.g. `img`, `br`, `hr`). These are collectively known as void DOM elements. If you try to give these children, React will give you a warning like:
-
-> Invariant Violation: img is a void element tag and must neither have `children` nor use `dangerouslySetInnerHTML`.
-
-
-## Rule Details
-
-The following patterns are considered warnings:
-
-```jsx
-<br>Children</br>
-<br children='Children' />
-<br dangerouslySetInnerHTML={{ __html: 'HTML' }} />
-React.createElement('br', undefined, 'Children')
-React.createElement('br', { children: 'Children' })
-React.createElement('br', { dangerouslySetInnerHTML: { __html: 'HTML' } })
-```
-
-The following patterns are not considered warnings:
-
-```jsx
-<div>Children</div>
-<div children='Children' />
-<div dangerouslySetInnerHTML={{ __html: 'HTML' }} />
-React.createElement('div', undefined, 'Children')
-React.createElement('div', { children: 'Children' })
-React.createElement('div', { dangerouslySetInnerHTML: { __html: 'HTML' } })
-```
diff --git a/index.js b/index.js
index aac6284a..eb894a8f 100644
--- a/index.js
+++ b/index.js
@@ -42,6 +42,7 @@ var allRules = {
   'jsx-space-before-closing': require('./lib/rules/jsx-space-before-closing'),
   'no-direct-mutation-state': require('./lib/rules/no-direct-mutation-state'),
   'forbid-component-props': require('./lib/rules/forbid-component-props'),
+  'forbid-elements': require('./lib/rules/forbid-elements'),
   'forbid-prop-types': require('./lib/rules/forbid-prop-types'),
   'prefer-es6-class': require('./lib/rules/prefer-es6-class'),
   'jsx-key': require('./lib/rules/jsx-key'),
@@ -57,7 +58,6 @@ var allRules = {
   'style-prop-object': require('./lib/rules/style-prop-object'),
   'no-unused-prop-types': require('./lib/rules/no-unused-prop-types'),
   'no-children-prop': require('./lib/rules/no-children-prop'),
-  'void-dom-elements-no-children': require('./lib/rules/void-dom-elements-no-children'),
   'no-comment-textnodes': require('./lib/rules/no-comment-textnodes'),
   'require-extension': require('./lib/rules/require-extension'),
   'wrap-multilines': require('./lib/rules/wrap-multilines'),
diff --git a/lib/rules/forbid-elements.js b/lib/rules/forbid-elements.js
new file mode 100644
index 00000000..8cce882f
--- /dev/null
+++ b/lib/rules/forbid-elements.js
@@ -0,0 +1,112 @@
+/**
+ * @fileoverview Forbid certain elements
+ * @author Kenneth Chung
+ */
+'use strict';
+
+var has = require('has');
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Forbid certain elements',
+      category: 'Best Practices',
+      recommended: false
+    },
+
+    schema: [{
+      type: 'object',
+      properties: {
+        forbid: {
+          type: 'array',
+          items: {
+            anyOf: [
+              {type: 'string'},
+              {
+                type: 'object',
+                properties: {
+                  element: {type: 'string'},
+                  message: {type: 'string'}
+                },
+                required: ['element'],
+                additionalProperties: false
+              }
+            ]
+          }
+        }
+      },
+      additionalProperties: false
+    }]
+  },
+
+  create: function(context) {
+    var sourceCode = context.getSourceCode();
+    var configuration = context.options[0] || {};
+    var forbidConfiguration = configuration.forbid || [];
+
+    var indexedForbidConfigs = {};
+
+    forbidConfiguration.forEach(function(item) {
+      if (typeof item === 'string') {
+        indexedForbidConfigs[item] = {element: item};
+      } else {
+        indexedForbidConfigs[item.element] = item;
+      }
+    });
+
+    function errorMessageForElement(name) {
+      var message = '<' + name + '> is forbidden';
+      var additionalMessage = indexedForbidConfigs[name].message;
+
+      if (additionalMessage) {
+        message = message + ', ' + additionalMessage;
+      }
+
+      return message;
+    }
+
+    function isValidCreateElement(node) {
+      return node.callee
+        && node.callee.type === 'MemberExpression'
+        && node.callee.object.name === 'React'
+        && node.callee.property.name === 'createElement'
+        && node.arguments.length > 0;
+    }
+
+    function reportIfForbidden(element, node) {
+      if (has(indexedForbidConfigs, element)) {
+        context.report({
+          node: node,
+          message: errorMessageForElement(element)
+        });
+      }
+    }
+
+    return {
+      JSXOpeningElement: function(node) {
+        reportIfForbidden(sourceCode.getText(node.name), node.name);
+      },
+
+      CallExpression: function(node) {
+        if (!isValidCreateElement(node)) {
+          return;
+        }
+
+        var argument = node.arguments[0];
+        var argType = argument.type;
+
+        if (argType === 'Identifier' && /^[A-Z_]/.test(argument.name)) {
+          reportIfForbidden(argument.name, argument);
+        } else if (argType === 'Literal' && /^[a-z][^\.]*$/.test(argument.value)) {
+          reportIfForbidden(argument.value, argument);
+        } else if (argType === 'MemberExpression') {
+          reportIfForbidden(sourceCode.getText(argument), argument);
+        }
+      }
+    };
+  }
+};
diff --git a/lib/rules/void-dom-elements-no-children.js b/lib/rules/void-dom-elements-no-children.js
deleted file mode 100644
index 323dbed3..00000000
--- a/lib/rules/void-dom-elements-no-children.js
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * @fileoverview Prevent void elements (e.g. <img />, <br />) from receiving
- *   children
- * @author Joe Lencioni
- */
-'use strict';
-
-var find = require('array.prototype.find');
-var has = require('has');
-
-// ------------------------------------------------------------------------------
-// Helpers
-// ------------------------------------------------------------------------------
-
-// Using an object here to avoid array scan. We should switch to Set once
-// support is good enough.
-var VOID_DOM_ELEMENTS = {
-  area: true,
-  base: true,
-  br: true,
-  col: true,
-  embed: true,
-  hr: true,
-  img: true,
-  input: true,
-  keygen: true,
-  link: true,
-  menuitem: true,
-  meta: true,
-  param: true,
-  source: true,
-  track: true,
-  wbr: true
-};
-
-function isVoidDOMElement(elementName) {
-  return has(VOID_DOM_ELEMENTS, elementName);
-}
-
-function errorMessage(elementName) {
-  return 'Void DOM element <' + elementName + ' /> cannot receive children.';
-}
-
-// ------------------------------------------------------------------------------
-// Rule Definition
-// ------------------------------------------------------------------------------
-
-module.exports = {
-  meta: {
-    docs: {
-      description: 'Prevent passing of children to void DOM elements (e.g. <br />).',
-      category: 'Best Practices',
-      recommended: false
-    },
-    schema: []
-  },
-
-  create: function(context) {
-    return {
-      JSXElement: function(node) {
-        var elementName = node.openingElement.name.name;
-
-        if (!isVoidDOMElement(elementName)) {
-          // e.g. <div />
-          return;
-        }
-
-        if (node.children.length > 0) {
-          // e.g. <br>Foo</br>
-          context.report({
-            node: node,
-            message: errorMessage(elementName)
-          });
-        }
-
-        var attributes = node.openingElement.attributes;
-
-        var hasChildrenAttributeOrDanger = !!find(attributes, function(attribute) {
-          if (!attribute.name) {
-            return false;
-          }
-
-          return attribute.name.name === 'children' || attribute.name.name === 'dangerouslySetInnerHTML';
-        });
-
-        if (hasChildrenAttributeOrDanger) {
-          // e.g. <br children="Foo" />
-          context.report({
-            node: node,
-            message: errorMessage(elementName)
-          });
-        }
-      },
-
-      CallExpression: function(node) {
-        if (node.callee.type !== 'MemberExpression') {
-          return;
-        }
-
-        if (node.callee.property.name !== 'createElement') {
-          return;
-        }
-
-        var args = node.arguments;
-        var elementName = args[0].value;
-
-        if (!isVoidDOMElement(elementName)) {
-          // e.g. React.createElement('div');
-          return;
-        }
-
-        var firstChild = args[2];
-        if (firstChild) {
-          // e.g. React.createElement('br', undefined, 'Foo')
-          context.report({
-            node: node,
-            message: errorMessage(elementName)
-          });
-        }
-
-        var props = args[1].properties;
-
-        var hasChildrenPropOrDanger = !!find(props, function(prop) {
-          if (!prop.key) {
-            return false;
-          }
-
-          return prop.key.name === 'children' || prop.key.name === 'dangerouslySetInnerHTML';
-        });
-
-        if (hasChildrenPropOrDanger) {
-          // e.g. React.createElement('br', { children: 'Foo' })
-          context.report({
-            node: node,
-            message: errorMessage(elementName)
-          });
-        }
-      }
-    };
-  }
-};


Test patch:
diff --git a/tests/lib/rules/forbid-elements.js b/tests/lib/rules/forbid-elements.js
new file mode 100644
index 00000000..0ab7fcdc
--- /dev/null
+++ b/tests/lib/rules/forbid-elements.js
@@ -0,0 +1,230 @@
+/**
+ * @fileoverview Tests for forbid-elements
+ */
+'use strict';
+
+// -----------------------------------------------------------------------------
+// Requirements
+// -----------------------------------------------------------------------------
+
+var rule = require('../../../lib/rules/forbid-elements');
+var RuleTester = require('eslint').RuleTester;
+
+var parserOptions = {
+  ecmaVersion: 6,
+  ecmaFeatures: {
+    experimentalObjectRestSpread: true,
+    jsx: true
+  }
+};
+
+require('babel-eslint');
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+var ruleTester = new RuleTester();
+ruleTester.run('forbid-elements', rule, {
+  valid: [
+    {
+      code: '<button />',
+      options: [],
+      parserOptions: parserOptions
+    },
+    {
+      code: '<button />',
+      options: [{forbid: []}],
+      parserOptions: parserOptions
+    },
+    {
+      code: '<Button />',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: '<Button />',
+      options: [{forbid: [{element: 'button'}]}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement(button)',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'createElement("button")',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'NotReact.createElement("button")',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement("_thing")',
+      options: [{forbid: ['_thing']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement("Modal")',
+      options: [{forbid: ['Modal']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement("dotted.component")',
+      options: [{forbid: ['dotted.component']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement(function() {})',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement({})',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    },
+    {
+      code: 'React.createElement(1)',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions
+    }
+  ],
+
+  invalid: [
+    {
+      code: '<button />',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions,
+      errors: [{message: '<button> is forbidden'}]
+    },
+    {
+      code: '[<Modal />, <button />]',
+      options: [{forbid: ['button', 'Modal']}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<Modal> is forbidden'},
+        {message: '<button> is forbidden'}
+      ]
+    },
+    {
+      code: '<dotted.component />',
+      options: [{forbid: ['dotted.component']}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<dotted.component> is forbidden'}
+      ]
+    },
+    {
+      code: '<dotted.Component />',
+      options: [{forbid: [
+        {element: 'dotted.Component', message: 'that ain\'t cool'}
+      ]}],
+      parserOptions: parserOptions,
+      errors: [{message: '<dotted.Component> is forbidden, that ain\'t cool'}]
+    },
+    {
+      code: '<button />',
+      options: [{forbid: [
+        {element: 'button', message: 'use <Button> instead'}
+      ]}],
+      parserOptions: parserOptions,
+      errors: [{message: '<button> is forbidden, use <Button> instead'}]
+    },
+    {
+      code: '<button><input /></button>',
+      options: [{forbid: [{element: 'button'}, {element: 'input'}]}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<button> is forbidden'},
+        {message: '<input> is forbidden'}
+      ]
+    },
+    {
+      code: '<button><input /></button>',
+      options: [{forbid: [{element: 'button'}, 'input']}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<button> is forbidden'},
+        {message: '<input> is forbidden'}
+      ]
+    },
+    {
+      code: '<button><input /></button>',
+      options: [{forbid: ['input', {element: 'button'}]}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<button> is forbidden'},
+        {message: '<input> is forbidden'}
+      ]
+    },
+    {
+      code: '<button />',
+      options: [{forbid: [
+        {element: 'button', message: 'use <Button> instead'},
+        {element: 'button', message: 'use <Button2> instead'}
+      ]}],
+      parserOptions: parserOptions,
+      errors: [{message: '<button> is forbidden, use <Button2> instead'}]
+    },
+    {
+      code: 'React.createElement("button", {}, child)',
+      options: [{forbid: ['button']}],
+      parserOptions: parserOptions,
+      errors: [{message: '<button> is forbidden'}]
+    },
+    {
+      code: '[React.createElement(Modal), React.createElement("button")]',
+      options: [{forbid: ['button', 'Modal']}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<Modal> is forbidden'},
+        {message: '<button> is forbidden'}
+      ]
+    },
+    {
+      code: 'React.createElement(dotted.Component)',
+      options: [{forbid: [
+        {element: 'dotted.Component', message: 'that ain\'t cool'}
+      ]}],
+      parserOptions: parserOptions,
+      errors: [{message: '<dotted.Component> is forbidden, that ain\'t cool'}]
+    },
+    {
+      code: 'React.createElement(dotted.component)',
+      options: [{forbid: ['dotted.component']}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<dotted.component> is forbidden'}
+      ]
+    },
+    {
+      code: 'React.createElement(_comp)',
+      options: [{forbid: ['_comp']}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<_comp> is forbidden'}
+      ]
+    },
+    {
+      code: 'React.createElement("button")',
+      options: [{forbid: [
+        {element: 'button', message: 'use <Button> instead'}
+      ]}],
+      parserOptions: parserOptions,
+      errors: [{message: '<button> is forbidden, use <Button> instead'}]
+    },
+    {
+      code: 'React.createElement("button", {}, React.createElement("input"))',
+      options: [{forbid: [{element: 'button'}, {element: 'input'}]}],
+      parserOptions: parserOptions,
+      errors: [
+        {message: '<button> is forbidden'},
+        {message: '<input> is forbidden'}
+      ]
+    }
+  ]
+});
diff --git a/tests/lib/rules/void-dom-elements-no-children.js b/tests/lib/rules/void-dom-elements-no-children.js
deleted file mode 100644
index 022d8e87..00000000
--- a/tests/lib/rules/void-dom-elements-no-children.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @fileoverview Tests for void-dom-elements-no-children
- * @author Joe Lencioni
- */
-
-'use strict';
-
-// -----------------------------------------------------------------------------
-// Requirements
-// -----------------------------------------------------------------------------
-
-var rule = require('../../../lib/rules/void-dom-elements-no-children');
-var RuleTester = require('eslint').RuleTester;
-
-var parserOptions = {
-  ecmaVersion: 6,
-  ecmaFeatures: {
-    experimentalObjectRestSpread: true,
-    jsx: true
-  }
-};
-
-function errorMessage(elementName) {
-  return 'Void DOM element <' + elementName + ' /> cannot receive children.';
-}
-
-// -----------------------------------------------------------------------------
-// Tests
-// -----------------------------------------------------------------------------
-
-var ruleTester = new RuleTester();
-ruleTester.run('void-dom-elements-no-children', rule, {
-  valid: [
-    {
-      code: '<div>Foo</div>;',
-      parserOptions: parserOptions
-    },
-    {
-      code: '<div children="Foo" />;',
-      parserOptions: parserOptions
-    },
-    {
-      code: '<div dangerouslySetInnerHTML={{ __html: "Foo" }} />;',
-      parserOptions: parserOptions
-    },
-    {
-      code: 'React.createElement("div", {}, "Foo");',
-      parserOptions: parserOptions
-    },
-    {
-      code: 'React.createElement("div", { children: "Foo" });',
-      parserOptions: parserOptions
-    },
-    {
-      code: 'React.createElement("div", { dangerouslySetInnerHTML: { __html: "Foo" } });',
-      parserOptions: parserOptions
-    }
-  ],
-  invalid: [
-    {
-      code: '<br>Foo</br>;',
-      errors: [{message: errorMessage('br')}],
-      parserOptions: parserOptions
-    },
-    {
-      code: '<br children="Foo" />;',
-      errors: [{message: errorMessage('br')}],
-      parserOptions: parserOptions
-    },
-    {
-      code: '<img {...props} children="Foo" />;',
-      errors: [{message: errorMessage('img')}],
-      parserOptions: parserOptions
-    },
-    {
-      code: '<br dangerouslySetInnerHTML={{ __html: "Foo" }} />;',
-      errors: [{message: errorMessage('br')}],
-      parserOptions: parserOptions
-    },
-    {
-      code: 'React.createElement("br", {}, "Foo");',
-      errors: [{message: errorMessage('br')}],
-      parserOptions: parserOptions
-    },
-    {
-      code: 'React.createElement("br", { children: "Foo" });',
-      errors: [{message: errorMessage('br')}],
-      parserOptions: parserOptions
-    },
-    {
-      code: 'React.createElement("br", { dangerouslySetInnerHTML: { __html: "Foo" } });',
-      errors: [{message: errorMessage('br')}],
-      parserOptions: parserOptions
-    }
-  ]
-});