Skip to content

Commit 0642f74

Browse files
Merge pull request #2413 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-builtin-form-components
Extract rule: template-no-builtin-form-components
2 parents e7569a0 + 29fe7f6 commit 0642f74

4 files changed

Lines changed: 344 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ rules in templates can be disabled with eslint directives with mustache or html
211211
| [template-no-bare-strings](docs/rules/template-no-bare-strings.md) | disallow bare strings in templates (require translation/localization) | | | |
212212
| [template-no-bare-yield](docs/rules/template-no-bare-yield.md) | disallow templates whose only meaningful content is a bare {{yield}} | | | |
213213
| [template-no-block-params-for-html-elements](docs/rules/template-no-block-params-for-html-elements.md) | disallow block params on HTML elements | | | |
214+
| [template-no-builtin-form-components](docs/rules/template-no-builtin-form-components.md) | disallow usage of built-in form components | | | |
214215
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
215216
| [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | |
216217
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# ember/template-no-builtin-form-components
2+
3+
<!-- end auto-generated rule header -->
4+
5+
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.
6+
7+
## Examples
8+
9+
This rule **forbids** the following:
10+
11+
```gjs
12+
<template><Input @type="text" @value={{this.name}} /></template>
13+
```
14+
15+
```gjs
16+
<template><Textarea @value={{this.body}}></Textarea></template>
17+
```
18+
19+
This rule **allows** the following:
20+
21+
```gjs
22+
<template><input type="text" value={{this.name}} {{on "input" this.handleInput}} /></template>
23+
```
24+
25+
```gjs
26+
<template><textarea {{on "input" this.handleInput}}>{{this.body}}</textarea></template>
27+
```
28+
29+
## Migration
30+
31+
Many forms may be simplified by switching to a light one-way data approach.
32+
33+
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.
34+
35+
```js
36+
import Component from '@glimmer/component';
37+
import { tracked } from '@glimmer/tracking';
38+
import { action } from '@ember/object';
39+
40+
export default class MyComponent extends Component {
41+
@tracked userInput = {};
42+
43+
@action
44+
handleInput(event) {
45+
const formData = new FormData(event.currentTarget);
46+
this.userInput = Object.fromEntries(formData.entries());
47+
}
48+
}
49+
```
50+
51+
```hbs
52+
<form {{on 'input' this.handleInput}}>
53+
<label>
54+
Name
55+
<input name='name' />
56+
</label>
57+
</form>
58+
```
59+
60+
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.
61+
62+
In the following example the initial value of a field is controlled by a local tracked property, which is updated by an event listener.
63+
64+
```js
65+
import Component from '@glimmer/component';
66+
import { tracked } from '@glimmer/tracking';
67+
import { action } from '@ember/object';
68+
69+
export default class MyComponent extends Component {
70+
@tracked name;
71+
72+
@action
73+
updateName(event) {
74+
this.name = event.target.value;
75+
}
76+
}
77+
```
78+
79+
```hbs
80+
<input type='text' value={{this.name}} {{on 'input' this.updateName}} />
81+
```
82+
83+
## Related Rules
84+
85+
- [no-mut-helper](template-no-mut-helper.md)
86+
87+
## References
88+
89+
- [Ember Built-in Components](https://guides.emberjs.com/release/components/built-in-components/)
90+
- [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)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow usage of built-in form components',
7+
category: 'Best Practices',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-builtin-form-components.md',
9+
templateMode: 'both',
10+
},
11+
fixable: null,
12+
schema: [],
13+
messages: {
14+
noInput:
15+
'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.',
16+
noTextarea:
17+
'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.',
18+
},
19+
originallyFrom: {
20+
name: 'ember-template-lint',
21+
rule: 'lib/rules/no-builtin-form-components.js',
22+
docs: 'docs/rule/no-builtin-form-components.md',
23+
tests: 'test/unit/rules/no-builtin-form-components-test.js',
24+
},
25+
},
26+
27+
create(context) {
28+
const MESSAGE_IDS = {
29+
Input: 'noInput',
30+
Textarea: 'noTextarea',
31+
};
32+
33+
const filename = context.filename ?? context.getFilename();
34+
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
35+
36+
// local name → original name ('Input' | 'Textarea')
37+
// Only populated in GJS/GTS files via ImportDeclaration
38+
const importedComponents = new Map();
39+
40+
return {
41+
ImportDeclaration(node) {
42+
if (node.source.value === '@ember/component') {
43+
for (const specifier of node.specifiers) {
44+
if (specifier.type === 'ImportSpecifier') {
45+
const original = specifier.imported.name;
46+
if (original === 'Input' || original === 'Textarea') {
47+
importedComponents.set(specifier.local.name, original);
48+
}
49+
}
50+
}
51+
}
52+
},
53+
54+
GlimmerElementNode(node) {
55+
const tag = node.tag;
56+
if (isStrictMode) {
57+
// In GJS/GTS: only flag if explicitly imported from @ember/component
58+
const original = importedComponents.get(tag);
59+
if (original) {
60+
context.report({ node, messageId: MESSAGE_IDS[original] });
61+
}
62+
} else {
63+
// In HBS: flag by canonical name (no import context available)
64+
const messageId = MESSAGE_IDS[tag];
65+
if (messageId) {
66+
context.report({ node, messageId });
67+
}
68+
}
69+
},
70+
71+
// Catch usages as a value: {{yield Input}}, (component Input), @field={{Input}}, etc.
72+
GlimmerPathExpression(node) {
73+
const name = node.original;
74+
if (isStrictMode) {
75+
const original = importedComponents.get(name);
76+
if (original) {
77+
context.report({ node, messageId: MESSAGE_IDS[original] });
78+
}
79+
} else {
80+
const messageId = MESSAGE_IDS[name];
81+
if (messageId) {
82+
context.report({ node, messageId });
83+
}
84+
}
85+
},
86+
};
87+
},
88+
};
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
const rule = require('../../../lib/rules/template-no-builtin-form-components');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const ruleTester = new RuleTester({
5+
parser: require.resolve('ember-eslint-parser'),
6+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
7+
});
8+
9+
ruleTester.run('template-no-builtin-form-components', rule, {
10+
valid: [
11+
// Native HTML elements are always fine
12+
{ filename: 'test.gjs', code: '<template><input type="text" /></template>' },
13+
{ filename: 'test.gjs', code: '<template><input type="checkbox" /></template>' },
14+
{ filename: 'test.gjs', code: '<template><input type="radio" /></template>' },
15+
{ filename: 'test.gjs', code: '<template><textarea></textarea></template>' },
16+
{ filename: 'test.gjs', code: '<template><div></div></template>' },
17+
18+
// In GJS without an import from @ember/component, <Input>/<Textarea> are not the builtins
19+
{ filename: 'test.gjs', code: '<template><Input /></template>' },
20+
{ filename: 'test.gjs', code: '<template><Textarea></Textarea></template>' },
21+
22+
// Importing from a different source is fine
23+
{
24+
filename: 'test.gjs',
25+
code: "import { Input } from './my-components'; <template><Input /></template>",
26+
},
27+
{
28+
filename: 'test.gjs',
29+
code: "import { Textarea } from './my-components'; <template><Textarea></Textarea></template>",
30+
},
31+
],
32+
invalid: [
33+
{
34+
filename: 'test.gjs',
35+
code: "import { Input } from '@ember/component'; <template><Input /></template>",
36+
output: null,
37+
errors: [{ messageId: 'noInput' }],
38+
},
39+
{
40+
filename: 'test.gjs',
41+
code: 'import { Input } from \'@ember/component\'; <template><Input type="text" /></template>',
42+
output: null,
43+
errors: [{ messageId: 'noInput' }],
44+
},
45+
{
46+
// Aliased import must still be flagged
47+
filename: 'test.gjs',
48+
code: "import { Input as EmberInput } from '@ember/component'; <template><EmberInput /></template>",
49+
output: null,
50+
errors: [{ messageId: 'noInput' }],
51+
},
52+
{
53+
filename: 'test.gjs',
54+
code: "import { Textarea } from '@ember/component'; <template><Textarea></Textarea></template>",
55+
output: null,
56+
errors: [{ messageId: 'noTextarea' }],
57+
},
58+
{
59+
filename: 'test.gjs',
60+
code: "import { Textarea } from '@ember/component'; <template><Textarea @value={{this.body}}></Textarea></template>",
61+
output: null,
62+
errors: [{ messageId: 'noTextarea' }],
63+
},
64+
{
65+
// Aliased Textarea import must still be flagged
66+
filename: 'test.gjs',
67+
code: "import { Textarea as EmberTextarea } from '@ember/component'; <template><EmberTextarea></EmberTextarea></template>",
68+
output: null,
69+
errors: [{ messageId: 'noTextarea' }],
70+
},
71+
// Yielded as a value
72+
{
73+
filename: 'test.gjs',
74+
code: "import { Input } from '@ember/component'; <template>{{yield Input}}</template>",
75+
output: null,
76+
errors: [{ messageId: 'noInput' }],
77+
},
78+
{
79+
filename: 'test.gjs',
80+
code: "import { Input as EmberInput } from '@ember/component'; <template>{{yield EmberInput}}</template>",
81+
output: null,
82+
errors: [{ messageId: 'noInput' }],
83+
},
84+
{
85+
filename: 'test.gjs',
86+
code: "import { Textarea } from '@ember/component'; <template>{{yield Textarea}}</template>",
87+
output: null,
88+
errors: [{ messageId: 'noTextarea' }],
89+
},
90+
// Used in helpers / passed as argument
91+
{
92+
filename: 'test.gjs',
93+
code: "import { Input } from '@ember/component'; <template><MyForm @field={{Input}} /></template>",
94+
output: null,
95+
errors: [{ messageId: 'noInput' }],
96+
},
97+
{
98+
filename: 'test.gjs',
99+
code: "import { Input } from '@ember/component'; <template>{{component Input}}</template>",
100+
output: null,
101+
errors: [{ messageId: 'noInput' }],
102+
},
103+
],
104+
});
105+
106+
const hbsRuleTester = new RuleTester({
107+
parser: require.resolve('ember-eslint-parser/hbs'),
108+
parserOptions: {
109+
ecmaVersion: 2022,
110+
sourceType: 'module',
111+
},
112+
});
113+
114+
hbsRuleTester.run('template-no-builtin-form-components (hbs)', rule, {
115+
valid: [
116+
'<input type="text" />',
117+
'<input type="checkbox" />',
118+
'<input type="radio" />',
119+
'<textarea></textarea>',
120+
],
121+
invalid: [
122+
{
123+
code: '<Input />',
124+
output: null,
125+
errors: [{ messageId: 'noInput' }],
126+
},
127+
{
128+
code: '<Input type="text" />',
129+
output: null,
130+
errors: [{ messageId: 'noInput' }],
131+
},
132+
{
133+
code: '<Textarea></Textarea>',
134+
output: null,
135+
errors: [{ messageId: 'noTextarea' }],
136+
},
137+
{
138+
code: '<Textarea @value={{this.body}}></Textarea>',
139+
output: null,
140+
errors: [{ messageId: 'noTextarea' }],
141+
},
142+
// Yielded as a value
143+
{
144+
code: '{{yield Input}}',
145+
output: null,
146+
errors: [{ messageId: 'noInput' }],
147+
},
148+
{
149+
code: '{{yield Textarea}}',
150+
output: null,
151+
errors: [{ messageId: 'noTextarea' }],
152+
},
153+
// Used in helpers / passed as argument
154+
{
155+
code: '<MyForm @field={{Input}} />',
156+
output: null,
157+
errors: [{ messageId: 'noInput' }],
158+
},
159+
{
160+
code: '{{component Input}}',
161+
output: null,
162+
errors: [{ messageId: 'noInput' }],
163+
},
164+
],
165+
});

0 commit comments

Comments
 (0)