Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-bare-strings](docs/rules/template-no-bare-strings.md) | disallow bare strings in templates (require translation/localization) | | | |
| [template-no-bare-yield](docs/rules/template-no-bare-yield.md) | disallow templates whose only meaningful content is a bare {{yield}} | | | |
| [template-no-block-params-for-html-elements](docs/rules/template-no-block-params-for-html-elements.md) | disallow block params on HTML elements | | | |
| [template-no-builtin-form-components](docs/rules/template-no-builtin-form-components.md) | disallow usage of built-in form components | | | |
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
| [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | |
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
Expand Down
90 changes: 90 additions & 0 deletions docs/rules/template-no-builtin-form-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ember/template-no-builtin-form-components

<!-- end auto-generated rule header -->

Disallow usage of Ember's built-in `<Input>` and `<Textarea>` components. These components use two-way binding to mutate values, which is considered an anti-pattern. Use native HTML `<input>` and `<textarea>` elements instead.

## Examples

This rule **forbids** the following:

```gjs
<template><Input @type="text" @value={{this.name}} /></template>
```

```gjs
<template><Textarea @value={{this.body}}></Textarea></template>
```

This rule **allows** the following:

```gjs
<template><input type="text" value={{this.name}} {{on "input" this.handleInput}} /></template>
```

```gjs
<template><textarea {{on "input" this.handleInput}}>{{this.body}}</textarea></template>
```

## Migration

Many forms may be simplified by switching to a light one-way data approach.

For example – vanilla JavaScript has everything we need to handle form data, de-sync it from our source data and collect all user input in a single object.

```js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class MyComponent extends Component {
@tracked userInput = {};

@action
handleInput(event) {
const formData = new FormData(event.currentTarget);
this.userInput = Object.fromEntries(formData.entries());
}
}
```

```hbs
<form {{on 'input' this.handleInput}}>
<label>
Name
<input name='name' />
</label>
</form>
```

Another option would is to "control" the field's value by replacing the built-in form component with a native HTML element and binding an event listener to handle user input.

In the following example the initial value of a field is controlled by a local tracked property, which is updated by an event listener.

```js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class MyComponent extends Component {
@tracked name;

@action
updateName(event) {
this.name = event.target.value;
}
}
```

```hbs
<input type='text' value={{this.name}} {{on 'input' this.updateName}} />
```

## Related Rules

- [no-mut-helper](template-no-mut-helper.md)

## References

- [Ember Built-in Components](https://guides.emberjs.com/release/components/built-in-components/)
- [ember-template-lint no-builtin-form-components](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-builtin-form-components.md)
88 changes: 88 additions & 0 deletions lib/rules/template-no-builtin-form-components.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow usage of built-in form components',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-builtin-form-components.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
noInput:
'Do not use the `Input` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.',
noTextarea:
'Do not use the `Textarea` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-builtin-form-components.js',
docs: 'docs/rule/no-builtin-form-components.md',
tests: 'test/unit/rules/no-builtin-form-components-test.js',
},
},

create(context) {
const MESSAGE_IDS = {
Input: 'noInput',
Textarea: 'noTextarea',
};

const filename = context.filename ?? context.getFilename();
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');

// local name → original name ('Input' | 'Textarea')
// Only populated in GJS/GTS files via ImportDeclaration
const importedComponents = new Map();

return {
ImportDeclaration(node) {
if (node.source.value === '@ember/component') {
for (const specifier of node.specifiers) {
if (specifier.type === 'ImportSpecifier') {
const original = specifier.imported.name;
if (original === 'Input' || original === 'Textarea') {
importedComponents.set(specifier.local.name, original);
}
}
}
}
},

GlimmerElementNode(node) {
const tag = node.tag;
if (isStrictMode) {
// In GJS/GTS: only flag if explicitly imported from @ember/component
const original = importedComponents.get(tag);
if (original) {
context.report({ node, messageId: MESSAGE_IDS[original] });
}
} else {
// In HBS: flag by canonical name (no import context available)
const messageId = MESSAGE_IDS[tag];
if (messageId) {
context.report({ node, messageId });
}
}
},

// Catch usages as a value: {{yield Input}}, (component Input), @field={{Input}}, etc.
GlimmerPathExpression(node) {
const name = node.original;
if (isStrictMode) {
const original = importedComponents.get(name);
if (original) {
context.report({ node, messageId: MESSAGE_IDS[original] });
}
} else {
const messageId = MESSAGE_IDS[name];
if (messageId) {
context.report({ node, messageId });
}
}
},
};
},
};
165 changes: 165 additions & 0 deletions tests/lib/rules/template-no-builtin-form-components.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
const rule = require('../../../lib/rules/template-no-builtin-form-components');
const RuleTester = require('eslint').RuleTester;

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

ruleTester.run('template-no-builtin-form-components', rule, {
valid: [
// Native HTML elements are always fine
{ filename: 'test.gjs', code: '<template><input type="text" /></template>' },
{ filename: 'test.gjs', code: '<template><input type="checkbox" /></template>' },
{ filename: 'test.gjs', code: '<template><input type="radio" /></template>' },
{ filename: 'test.gjs', code: '<template><textarea></textarea></template>' },
{ filename: 'test.gjs', code: '<template><div></div></template>' },

// In GJS without an import from @ember/component, <Input>/<Textarea> are not the builtins
{ filename: 'test.gjs', code: '<template><Input /></template>' },
{ filename: 'test.gjs', code: '<template><Textarea></Textarea></template>' },

// Importing from a different source is fine
{
filename: 'test.gjs',
code: "import { Input } from './my-components'; <template><Input /></template>",
},
{
filename: 'test.gjs',
code: "import { Textarea } from './my-components'; <template><Textarea></Textarea></template>",
},
],
invalid: [
{
filename: 'test.gjs',
code: "import { Input } from '@ember/component'; <template><Input /></template>",
output: null,
errors: [{ messageId: 'noInput' }],
},
{
filename: 'test.gjs',
code: 'import { Input } from \'@ember/component\'; <template><Input type="text" /></template>',
output: null,
errors: [{ messageId: 'noInput' }],
},
{
// Aliased import must still be flagged
filename: 'test.gjs',
code: "import { Input as EmberInput } from '@ember/component'; <template><EmberInput /></template>",
output: null,
errors: [{ messageId: 'noInput' }],
},
{
filename: 'test.gjs',
code: "import { Textarea } from '@ember/component'; <template><Textarea></Textarea></template>",
output: null,
errors: [{ messageId: 'noTextarea' }],
},
{
filename: 'test.gjs',
code: "import { Textarea } from '@ember/component'; <template><Textarea @value={{this.body}}></Textarea></template>",
output: null,
errors: [{ messageId: 'noTextarea' }],
},
{
// Aliased Textarea import must still be flagged
filename: 'test.gjs',
code: "import { Textarea as EmberTextarea } from '@ember/component'; <template><EmberTextarea></EmberTextarea></template>",
output: null,
errors: [{ messageId: 'noTextarea' }],
},
// Yielded as a value
{
filename: 'test.gjs',
code: "import { Input } from '@ember/component'; <template>{{yield Input}}</template>",
output: null,
errors: [{ messageId: 'noInput' }],
},
{
filename: 'test.gjs',
code: "import { Input as EmberInput } from '@ember/component'; <template>{{yield EmberInput}}</template>",
output: null,
errors: [{ messageId: 'noInput' }],
},
{
filename: 'test.gjs',
code: "import { Textarea } from '@ember/component'; <template>{{yield Textarea}}</template>",
output: null,
errors: [{ messageId: 'noTextarea' }],
},
// Used in helpers / passed as argument
{
filename: 'test.gjs',
code: "import { Input } from '@ember/component'; <template><MyForm @field={{Input}} /></template>",
output: null,
errors: [{ messageId: 'noInput' }],
},
{
filename: 'test.gjs',
code: "import { Input } from '@ember/component'; <template>{{component Input}}</template>",
output: null,
errors: [{ messageId: 'noInput' }],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

hbsRuleTester.run('template-no-builtin-form-components (hbs)', rule, {
valid: [
'<input type="text" />',
'<input type="checkbox" />',
'<input type="radio" />',
'<textarea></textarea>',
],
invalid: [
{
code: '<Input />',
output: null,
errors: [{ messageId: 'noInput' }],
},
{
code: '<Input type="text" />',
output: null,
errors: [{ messageId: 'noInput' }],
},
{
code: '<Textarea></Textarea>',
output: null,
errors: [{ messageId: 'noTextarea' }],
},
{
code: '<Textarea @value={{this.body}}></Textarea>',
output: null,
errors: [{ messageId: 'noTextarea' }],
},
// Yielded as a value
{
code: '{{yield Input}}',
output: null,
errors: [{ messageId: 'noInput' }],
},
{
code: '{{yield Textarea}}',
output: null,
errors: [{ messageId: 'noTextarea' }],
},
// Used in helpers / passed as argument
{
code: '<MyForm @field={{Input}} />',
output: null,
errors: [{ messageId: 'noInput' }],
},
{
code: '{{component Input}}',
output: null,
errors: [{ messageId: 'noInput' }],
},
],
});
Loading