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
17 changes: 17 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Migration guide

## 22.0 to 22.1

Version 22.1 enables `require-ion-error-text` in the recommended preset. By default, the rule checks the six Ionic controls that support `errorText` only when they bind Angular Signal Forms with `[formField]`. A non-empty static `errorText` or a property-bound `[errorText]` satisfies the rule.

Applications using `KitIonicFormField` from `@rdlabo/ionic-angular-kit/forms` may opt in to adapter-aware linting after every relevant standalone component imports both Angular's `FormField` and the kit adapter:

```js
{
files: ['**/*.html'],
rules: {
'@rdlabo/rules/require-ion-error-text': ['error', { formFieldProvidesErrorText: true }],
},
}
```

Set `checkAll: true` only when the application requires error text on supported controls that do not bind `[formField]`; filters and settings controls are otherwise intentionally outside the default scope. `ignoreReadonly: true` applies only with `checkAll` and only to literal `readonly` attributes on `ion-input` and `ion-textarea`. Dynamic `[readonly]` bindings remain checked.

## 21.x to 22.x

Version 22 targets Angular 21 and 22 with Ionic Framework 9. Ionic 8 applications must remain on version 21 of this plugin.
Expand Down
4 changes: 4 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ Each rule page in this documentation contains options and correct/incorrect exam
## Typed rules

Enable `parserOptions.projectService` for rules that inspect TypeScript types. Without typed linting, `restrict-try-block` still performs syntax-based checks but skips type-dependent Promise and RxJS detection.

## Ionic validation

- [`require-ion-error-text`](./rules/require-ion-error-text.md) requires validation controls to provide Ionic error text.
20 changes: 20 additions & 0 deletions docs/rules/require-ion-error-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# @rdlabo/rules/require-ion-error-text

> Require Ionic validation controls to provide an errorText source.

Requires Ionic controls participating in Angular Signal Forms validation to have a source for `errorText`.

By default the rule checks `ion-input`, `ion-textarea`, `ion-select`, `ion-checkbox`, `ion-radio-group`, and `ion-toggle` only when they bind `[formField]`. Static non-empty `errorText` and property-bound `[errorText]` are accepted. `[attr.errorText]` is not accepted because Ionic exposes a property input.

## Options

- `formFieldProvidesErrorText` (default `false`): set to `true` only after every relevant component imports `KitIonicFormField` and the application installs `provideKitIonicSignalForms()`.
- `checkAll` (default `false`): also checks supported controls without `[formField]`. This is opt-in because filters and settings controls are not necessarily validation fields.
- `ignoreReadonly` (default `false`): with `checkAll`, ignores `ion-input` and `ion-textarea` carrying a literal `readonly` attribute. A dynamic `[readonly]` binding is still checked.

The rule reports only and does not autofix application validation policy.

## Implementation

- [Rule source](../../src/rules/require-ion-error-text.ts)
- [Test source](../../tests/rules/require-ion-error-text.ts)
1 change: 1 addition & 0 deletions src/configs/recommended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const recommended: Linter.Config[] = [
},
],
'@rdlabo/rules/prefer-disable-handler': 'error',
'@rdlabo/rules/require-ion-error-text': 'error',
'@rdlabo/rules/require-ion-item-group': 'error',
},
},
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import notemplatedrivenforms from './rules/no-template-driven-forms';
import preferdisablehandler from './rules/prefer-disable-handler';
import preferionicstandalone from './rules/prefer-ionic-standalone';
import prefermodallauncher from './rules/prefer-modal-launcher';
import requireionerrortext from './rules/require-ion-error-text';
import requireionitemgroup from './rules/require-ion-item-group';
import requireviewmodel from './rules/require-viewmodel';
import restricttryblock from './rules/restrict-try-block';
Expand All @@ -40,6 +41,7 @@ export = {
'prefer-disable-handler': preferdisablehandler,
'prefer-ionic-standalone': preferionicstandalone,
'prefer-modal-launcher': prefermodallauncher,
'require-ion-error-text': requireionerrortext,
'require-ion-item-group': requireionitemgroup,
'require-viewmodel': requireviewmodel,
'restrict-try-block': restricttryblock,
Expand Down
80 changes: 80 additions & 0 deletions src/rules/require-ion-error-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { TSESLint } from '@typescript-eslint/utils';
import type { TSESTree } from '@typescript-eslint/utils';
import { isRenderedElement, type TemplateAstNode, visitTemplateChildren } from './template-ast-utils';

type Options = [{ formFieldProvidesErrorText?: boolean; checkAll?: boolean; ignoreReadonly?: boolean }];
type MessageIds = 'requireIonErrorText';

const SUPPORTED = new Set(['ion-input', 'ion-textarea', 'ion-select', 'ion-checkbox', 'ion-radio-group', 'ion-toggle']);
const READONLY_SUPPORTED = new Set(['ion-input', 'ion-textarea']);

interface Attribute {
name?: string;
value?: unknown;
keySpan?: { toString(): string };
}
type Element = TemplateAstNode & { attributes?: Attribute[]; inputs?: Attribute[] };

const hasInput = (element: Element, name: string) =>
(element.inputs as Attribute[] | undefined)?.some((input) => input.name === name && input.keySpan?.toString() !== `attr.${name}`) ?? false;
const attribute = (element: Element, name: string) => element.attributes?.find((item) => item.name === name);
const hasExplicitErrorTextSyntax = (element: Element) => hasInput(element, 'errorText') || attribute(element, 'errorText') !== undefined;
const hasErrorText = (element: Element) => {
const staticError = attribute(element, 'errorText');
return hasInput(element, 'errorText') || (typeof staticError?.value === 'string' && staticError.value.trim().length > 0);
};

const rule: TSESLint.RuleModule<MessageIds, Options> = {
defaultOptions: [{ formFieldProvidesErrorText: false, checkAll: false, ignoreReadonly: false }],
meta: {
docs: {
description: 'Require Ionic validation controls to provide an errorText source.',
url: '',
},
messages: {
requireIonErrorText: 'Provide errorText or [errorText] for this Ionic validation control.',
},
schema: [
{
type: 'object',
properties: {
formFieldProvidesErrorText: { type: 'boolean' },
checkAll: { type: 'boolean' },
ignoreReadonly: { type: 'boolean' },
},
additionalProperties: false,
},
],
type: 'problem',
},
create(context) {
const [options] = context.options;
const config = { formFieldProvidesErrorText: false, checkAll: false, ignoreReadonly: false, ...options };

const visit = (nodes: TemplateAstNode[] | undefined): void => {
for (const node of nodes ?? []) {
if (isRenderedElement(node) && SUPPORTED.has(node.name ?? '')) {
const element = node as Element;
const hasFormField = hasInput(element, 'formField');
const ignoredReadonly =
config.checkAll && config.ignoreReadonly && READONLY_SUPPORTED.has(node.name ?? '') && attribute(element, 'readonly') !== undefined;
const inScope = !ignoredReadonly && (config.checkAll || hasFormField);
const providedByAdapter = hasFormField && config.formFieldProvidesErrorText && !hasExplicitErrorTextSyntax(element);
if (inScope && !providedByAdapter && !hasErrorText(element)) {
context.report({ node: node as unknown as TSESTree.Node, loc: node.loc, messageId: 'requireIonErrorText' });
}
}
visitTemplateChildren(node, visit);
}
};

return {
Program(node) {
if (!context.filename.includes('.html') || context.filename.includes('.spec')) return;
visit((node as unknown as { templateNodes?: TemplateAstNode[] }).templateNodes);
},
};
},
};

export = rule;
45 changes: 45 additions & 0 deletions tests/rules/require-ion-error-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { RuleTester } from '@angular-eslint/test-utils';
import rule from '../../src/rules/require-ion-error-text';

const tester = new RuleTester({
languageOptions: {
// eslint-disable-next-line @typescript-eslint/no-require-imports
parser: require('@angular-eslint/template-parser'),
},
});

const html = (code: string, options?: [{ formFieldProvidesErrorText?: boolean; checkAll?: boolean; ignoreReadonly?: boolean }]) =>
options ? { code, filename: 'template.html', options } : { code, filename: 'template.html' };

tester.run('require-ion-error-text', rule, {
valid: [
html('<ion-select></ion-select>'),
html('<ion-input [formField]="field" errorText="Required"></ion-input>'),
html('<ion-input [formField]="field" [errorText]="message"></ion-input>'),
html('<ion-input [formField]="field"></ion-input>', [{ formFieldProvidesErrorText: true }]),
html('<ion-toggle errorText="Required"></ion-toggle>', [{ checkAll: true }]),
html('<ion-input readonly></ion-input>', [{ checkAll: true, ignoreReadonly: true }]),
html('<ion-searchbar [formField]="field"></ion-searchbar>', [{ checkAll: true }]),
],
invalid: [
{ ...html('<ion-input [formField]="field"></ion-input>'), errors: [{ messageId: 'requireIonErrorText' as const }] },
{ ...html('<ion-textarea [formField]="field" errorText=" "></ion-textarea>'), errors: [{ messageId: 'requireIonErrorText' as const }] },
{ ...html('<ion-select [formField]="field" [attr.errorText]="message"></ion-select>'), errors: [{ messageId: 'requireIonErrorText' as const }] },
{
...html('<ion-input [readonly]="locked"></ion-input>', [{ checkAll: true, ignoreReadonly: true }]),
errors: [{ messageId: 'requireIonErrorText' as const }],
},
{
...html('<ion-input [formField]="field"></ion-input>', [{ formFieldProvidesErrorText: false }]),
errors: [{ messageId: 'requireIonErrorText' as const }],
},
{
...html('<ion-input [formField]="field" errorText=" "></ion-input>', [{ formFieldProvidesErrorText: true }]),
errors: [{ messageId: 'requireIonErrorText' as const }],
},
...['ion-input', 'ion-textarea', 'ion-select', 'ion-checkbox', 'ion-radio-group', 'ion-toggle'].map((tag) => ({
...html(`<${tag}></${tag}>`, [{ checkAll: true }]),
errors: [{ messageId: 'requireIonErrorText' as const }],
})),
],
});