@ethlete/eslint-plugin 1.0.0-next.18 → 1.0.0-next.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/configs/recommended.js +2 -0
- package/src/index.js +6 -0
- package/src/rules/no-cdk-import.js +154 -0
- package/src/rules/no-legacy-query-import.js +136 -0
- package/src/rules/require-form-submit.js +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.19
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3055](https://github.com/ethlete-io/ethdk/pull/3055) [`2fcb8e3`](https://github.com/ethlete-io/ethdk/commit/2fcb8e3799169956571534b75fdc152acc983877) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `require-form-submit` (a form handles its own submission, a submit control reaches a form) plus the opt-in `no-cdk-import` and `no-legacy-query-import`, which name each legacy symbol's successor in the message.
|
|
8
|
+
|
|
3
9
|
## 1.0.0-next.18
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -422,6 +422,8 @@ const recommendedTemplate = {
|
|
|
422
422
|
// (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
|
|
423
423
|
// which a template rule cannot verify)
|
|
424
424
|
'ethlete/prefer-static-boolean-properties': 'warn',
|
|
425
|
+
// A form must handle its own submission, and a type="submit" control must reach a form
|
|
426
|
+
'ethlete/require-form-submit': 'error',
|
|
425
427
|
},
|
|
426
428
|
};
|
|
427
429
|
|
package/src/index.js
CHANGED
|
@@ -55,6 +55,9 @@ const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
|
|
|
55
55
|
const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
|
|
56
56
|
const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
|
|
57
57
|
const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
|
|
58
|
+
const requireFormSubmit = require('./rules/require-form-submit');
|
|
59
|
+
const noCdkImport = require('./rules/no-cdk-import');
|
|
60
|
+
const noLegacyQueryImport = require('./rules/no-legacy-query-import');
|
|
58
61
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
59
62
|
|
|
60
63
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -118,6 +121,9 @@ const plugin = {
|
|
|
118
121
|
'prefer-present-tense-output': preferPresentTenseOutput,
|
|
119
122
|
'prefer-static-boolean-properties': preferStaticBooleanProperties,
|
|
120
123
|
'no-impure-top-level-provider': noImpureTopLevelProvider,
|
|
124
|
+
'require-form-submit': requireFormSubmit,
|
|
125
|
+
'no-cdk-import': noCdkImport,
|
|
126
|
+
'no-legacy-query-import': noLegacyQueryImport,
|
|
121
127
|
},
|
|
122
128
|
};
|
|
123
129
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createRequire } = require('node:module');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Disallows importing from `@ethlete/cdk`, the maintenance-mode predecessor of `@ethlete/components`,
|
|
10
|
+
* and names the successor of each imported symbol in the message.
|
|
11
|
+
*
|
|
12
|
+
* import { ButtonComponent } from '@ethlete/cdk';
|
|
13
|
+
* → `ButtonComponent` is legacy @ethlete/cdk. Use `ButtonComponent` from @ethlete/components
|
|
14
|
+
* (https://…/components/button): a real button system - variant, size and color inputs plus
|
|
15
|
+
* theming instead of CSS-only classes.
|
|
16
|
+
*
|
|
17
|
+
* The successors come from `migration-map.json`, which ships inside the `@ethlete/cdk` package - so
|
|
18
|
+
* the advice is always the installed version's, and this rule holds no copy of it. Without the map on
|
|
19
|
+
* disk the rule still reports, pointing at the migration guide instead of a specific symbol.
|
|
20
|
+
*
|
|
21
|
+
* Off by default: it is only useful once an app has decided to leave the cdk behind.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const CDK_PACKAGE = '@ethlete/cdk';
|
|
25
|
+
const MIGRATION_MAP = '@ethlete/cdk/migration-map.json';
|
|
26
|
+
const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
|
|
27
|
+
|
|
28
|
+
/** @type {Map<string, Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }> | null>} */
|
|
29
|
+
const mapCache = new Map();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string | undefined} mapPath
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
*/
|
|
35
|
+
const loadMigrationMap = (mapPath, cwd) => {
|
|
36
|
+
const cacheKey = mapPath ? path.resolve(cwd, mapPath) : MIGRATION_MAP;
|
|
37
|
+
|
|
38
|
+
if (mapCache.has(cacheKey)) return mapCache.get(cacheKey) ?? null;
|
|
39
|
+
|
|
40
|
+
let map;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
map = mapPath
|
|
44
|
+
? JSON.parse(fs.readFileSync(cacheKey, 'utf8'))
|
|
45
|
+
: createRequire(path.join(cwd, 'noop.js'))(MIGRATION_MAP);
|
|
46
|
+
} catch {
|
|
47
|
+
map = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mapCache.set(cacheKey, map);
|
|
51
|
+
|
|
52
|
+
return map;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** @param {any} specifier */
|
|
56
|
+
const importedName = (specifier) => {
|
|
57
|
+
if (specifier.type !== 'ImportSpecifier') return null;
|
|
58
|
+
|
|
59
|
+
return specifier.imported.name ?? specifier.imported.value ?? null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
63
|
+
const noCdkImport = {
|
|
64
|
+
meta: {
|
|
65
|
+
type: 'suggestion',
|
|
66
|
+
docs: {
|
|
67
|
+
description: 'Disallow importing from the maintenance-mode @ethlete/cdk; name the @ethlete/components successor.',
|
|
68
|
+
},
|
|
69
|
+
schema: [
|
|
70
|
+
{
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
/** Where to read the migration map from, when `@ethlete/cdk` is not resolvable from the linted project. */
|
|
74
|
+
migrationMapPath: { type: 'string' },
|
|
75
|
+
/** Base URL the migration map's doc paths are appended to. */
|
|
76
|
+
docsBaseUrl: { type: 'string' },
|
|
77
|
+
},
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
messages: {
|
|
82
|
+
successor: '`{{ name }}` is legacy {{ cdk }}. Use `{{ to }}` from {{ package }} instead ({{ docs }}).{{ note }}',
|
|
83
|
+
noSuccessor: '`{{ name }}` is legacy {{ cdk }} and has no successor{{ note }} - see {{ docs }}.',
|
|
84
|
+
unmapped: '`{{ name }}` is legacy {{ cdk }}. Move it to @ethlete/components - see {{ docs }}.',
|
|
85
|
+
module: 'Do not depend on the legacy {{ cdk }} - move to @ethlete/components, see {{ docs }}.',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
const options = context.options[0] ?? {};
|
|
90
|
+
const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
|
|
91
|
+
const migrationDocs = `${docsBaseUrl}/cdk/migration`;
|
|
92
|
+
const map = loadMigrationMap(options.migrationMapPath, context.cwd);
|
|
93
|
+
|
|
94
|
+
/** @param {string} name */
|
|
95
|
+
const entryFor = (name) => map?.[name] ?? null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ImportDeclaration(node) {
|
|
99
|
+
const declaration = /** @type {any} */ (node);
|
|
100
|
+
const source = declaration.source.value;
|
|
101
|
+
|
|
102
|
+
if (typeof source !== 'string' || (source !== CDK_PACKAGE && !source.startsWith(`${CDK_PACKAGE}/`))) return;
|
|
103
|
+
|
|
104
|
+
const named = declaration.specifiers.filter(/** @param {any} s */ (s) => importedName(s));
|
|
105
|
+
|
|
106
|
+
if (!named.length) {
|
|
107
|
+
context.report({ node, messageId: 'module', data: { cdk: CDK_PACKAGE, docs: migrationDocs } });
|
|
108
|
+
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const specifier of named) {
|
|
113
|
+
const name = /** @type {string} */ (importedName(specifier));
|
|
114
|
+
const entry = entryFor(name);
|
|
115
|
+
|
|
116
|
+
if (!entry) {
|
|
117
|
+
context.report({
|
|
118
|
+
node: specifier,
|
|
119
|
+
messageId: 'unmapped',
|
|
120
|
+
data: { name, cdk: CDK_PACKAGE, docs: migrationDocs },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!entry.to) {
|
|
127
|
+
context.report({
|
|
128
|
+
node: specifier,
|
|
129
|
+
messageId: 'noSuccessor',
|
|
130
|
+
data: { name, cdk: CDK_PACKAGE, note: entry.note ? ` (${entry.note})` : '', docs: migrationDocs },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
context.report({
|
|
137
|
+
node: specifier,
|
|
138
|
+
messageId: 'successor',
|
|
139
|
+
data: {
|
|
140
|
+
name,
|
|
141
|
+
cdk: CDK_PACKAGE,
|
|
142
|
+
to: entry.to,
|
|
143
|
+
package: entry.package ?? '@ethlete/components',
|
|
144
|
+
docs: entry.docs ? `${docsBaseUrl}${entry.docs}` : migrationDocs,
|
|
145
|
+
note: entry.note ? ` ${entry.note}.` : '',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
module.exports = noCdkImport;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows importing the legacy (v2) query system from `@ethlete/query`, and names the current-system
|
|
6
|
+
* API in the message.
|
|
7
|
+
*
|
|
8
|
+
* import { V2QueryClient, filterSuccess } from '@ethlete/query';
|
|
9
|
+
* → `V2QueryClient` is the legacy (v2) query system. Use `createQueryClient` instead (…/query/queries).
|
|
10
|
+
* → `filterSuccess` … Use `query.response()` instead - the query is already signals (…/query/queries).
|
|
11
|
+
*
|
|
12
|
+
* Two things are matched: every `V2`/`AnyV2`-prefixed export - the prefix the library gives the legacy
|
|
13
|
+
* system's half of a colliding name - and the legacy APIs that never collided, which carry their
|
|
14
|
+
* successor from the migration guide. `createLegacyQueryCreator` is deliberately **not** matched: it is
|
|
15
|
+
* the sanctioned interop seam a migration leans on until its call sites are converted.
|
|
16
|
+
*
|
|
17
|
+
* Off by default, and deliberately not type-aware: it names successors rather than repeating the
|
|
18
|
+
* `@deprecated` tag every legacy export already carries. For the whole deprecated surface (including
|
|
19
|
+
* the types this rule leaves alone), enable `@typescript-eslint/no-deprecated` alongside it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const QUERY_PACKAGE = '@ethlete/query';
|
|
23
|
+
const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The legacy APIs whose names never collided with the current system, and what the migration guide
|
|
27
|
+
* (`/query/legacy#migrating-to-the-current-system`) maps each one to.
|
|
28
|
+
*
|
|
29
|
+
* @type {Record<string, { to: string, docs: string }>}
|
|
30
|
+
*/
|
|
31
|
+
const LEGACY_SYMBOLS = {
|
|
32
|
+
def: { to: 'the type parameter of a current creator - `createGetQuery(client)<TArgs>(route)`', docs: '/query/http' },
|
|
33
|
+
BasicAuthProvider: { to: 'no equivalent - the current system authenticates through `createBearerAuthProvider`', docs: '/query/auth' }, // prettier-ignore
|
|
34
|
+
CustomHeaderAuthProvider: { to: '`headers` on `createQueryClient`, which re-reads a function form per request', docs: '/query/queries#the-query-client' }, // prettier-ignore
|
|
35
|
+
EntityStore: { to: 'nothing directly - caching dedupes by request, and shared state derives from signals', docs: '/query/caching' }, // prettier-ignore
|
|
36
|
+
InfinityQuery: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
37
|
+
InfinityQueryDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
38
|
+
InfinityQueryTriggerDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
39
|
+
createInfinityQueryConfig: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
40
|
+
QueryDirective: { to: "the query's own signals, read directly in the template", docs: '/query/migrating-from-v2#templates-read-signals-not-directives' }, // prettier-ignore
|
|
41
|
+
filterSuccess: { to: '`query.response()`, or `query.response.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
42
|
+
filterFailure: { to: '`query.error()`, or `query.error.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
43
|
+
switchQueryState: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
44
|
+
takeUntilResponse: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
45
|
+
toQuerySignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' },
|
|
46
|
+
queryStateSignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
47
|
+
queryStateResponseSignal: { to: '`query.response()`', docs: '/query/queries#the-query-object' },
|
|
48
|
+
queryStateErrorSignal: { to: '`query.error()`', docs: '/query/queries#the-query-object' },
|
|
49
|
+
queryStateLoadingSignal: { to: '`query.loading()`', docs: '/query/queries#the-query-object' },
|
|
50
|
+
validateWithV2Query: { to: '`validateWithQuery`', docs: '/query/errors#validating-against-the-server-as-the-user-types' }, // prettier-ignore
|
|
51
|
+
provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query-devtools`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The current-system counterparts of the names the legacy system had to give up its half of. */
|
|
55
|
+
const V2_SUCCESSORS = {
|
|
56
|
+
V2QueryClient: { to: '`createQueryClient`', docs: '/query/queries#the-query-client' },
|
|
57
|
+
V2QueryClientConfig: { to: 'the config of `createQueryClient`', docs: '/query/queries#the-query-client' },
|
|
58
|
+
V2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
|
|
59
|
+
AnyV2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
|
|
60
|
+
V2BearerAuthProvider: { to: '`createBearerAuthProvider` plus the secure creator templates', docs: '/query/auth' },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** @param {any} specifier */
|
|
64
|
+
const importedName = (specifier) => {
|
|
65
|
+
if (specifier.type !== 'ImportSpecifier') return null;
|
|
66
|
+
|
|
67
|
+
return specifier.imported.name ?? specifier.imported.value ?? null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** @param {string} name */
|
|
71
|
+
const isV2Symbol = (name) => /^(V2|AnyV2)[A-Z]/.test(name);
|
|
72
|
+
|
|
73
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
74
|
+
const noLegacyQueryImport = {
|
|
75
|
+
meta: {
|
|
76
|
+
type: 'suggestion',
|
|
77
|
+
docs: {
|
|
78
|
+
description: 'Disallow importing the legacy (v2) query system; name the current-system API instead.',
|
|
79
|
+
},
|
|
80
|
+
schema: [
|
|
81
|
+
{
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
/** Base URL the guide paths in the messages are appended to. */
|
|
85
|
+
docsBaseUrl: { type: 'string' },
|
|
86
|
+
},
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
messages: {
|
|
91
|
+
successor: '`{{ name }}` is the legacy (v2) query system. Use {{ to }} instead ({{ docs }}).',
|
|
92
|
+
legacySystem:
|
|
93
|
+
'`{{ name }}` is the legacy (v2) query system. Migrate to the current one - see {{ docs }}, and run `nx g @ethlete/query:migrate-to-query-v3` for the mechanical parts.',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
create(context) {
|
|
97
|
+
const options = context.options[0] ?? {};
|
|
98
|
+
const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ImportDeclaration(node) {
|
|
102
|
+
const declaration = /** @type {any} */ (node);
|
|
103
|
+
|
|
104
|
+
if (declaration.source.value !== QUERY_PACKAGE) return;
|
|
105
|
+
|
|
106
|
+
for (const specifier of declaration.specifiers) {
|
|
107
|
+
const name = importedName(specifier);
|
|
108
|
+
|
|
109
|
+
if (!name) continue;
|
|
110
|
+
|
|
111
|
+
const successor = V2_SUCCESSORS[name] ?? LEGACY_SYMBOLS[name];
|
|
112
|
+
|
|
113
|
+
if (successor) {
|
|
114
|
+
context.report({
|
|
115
|
+
node: specifier,
|
|
116
|
+
messageId: 'successor',
|
|
117
|
+
data: { name, to: successor.to, docs: `${docsBaseUrl}${successor.docs}` },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isV2Symbol(name)) continue;
|
|
124
|
+
|
|
125
|
+
context.report({
|
|
126
|
+
node: specifier,
|
|
127
|
+
messageId: 'legacySystem',
|
|
128
|
+
data: { name, docs: `${docsBaseUrl}/query/migrating-from-v2` },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
module.exports = noLegacyQueryImport;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks the two ends of the same wire: that a `<form>` handles its own submission, and that a
|
|
6
|
+
* submit control reaches a form at all.
|
|
7
|
+
*
|
|
8
|
+
* <form (ngSubmit)="save()"> ✔
|
|
9
|
+
* <form> ✘ pressing Enter in a field does nothing, or reloads the page
|
|
10
|
+
*
|
|
11
|
+
* <form …><button type="submit"> ✔
|
|
12
|
+
* <button type="submit" form="edit"> ✔ associated by id, outside the form's subtree
|
|
13
|
+
* <button type="submit"> ✘ submits nothing
|
|
14
|
+
*
|
|
15
|
+
* A form declaring native submission (`action`, `ngNoForm`, `method="dialog"`) is left alone - it is
|
|
16
|
+
* handled by the platform rather than by a handler.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @param {any} node @param {string} name */
|
|
20
|
+
const attribute = (node, name) => node.attributes?.find(/** @param {any} a */ (a) => a.name === name);
|
|
21
|
+
|
|
22
|
+
/** @param {any} node @param {string} name */
|
|
23
|
+
const hasBinding = (node, name) =>
|
|
24
|
+
node.inputs?.some(/** @param {any} i */ (i) => i.name === name) ||
|
|
25
|
+
node.attributes?.some(/** @param {any} a */ (a) => a.name === name);
|
|
26
|
+
|
|
27
|
+
/** @param {any} node */
|
|
28
|
+
const handlesSubmit = (node) =>
|
|
29
|
+
node.outputs?.some(/** @param {any} o */ (o) => o.name === 'submit' || o.name === 'ngSubmit');
|
|
30
|
+
|
|
31
|
+
/** @param {any} node */
|
|
32
|
+
const submitsNatively = (node) =>
|
|
33
|
+
hasBinding(node, 'action') || hasBinding(node, 'ngNoForm') || attribute(node, 'method')?.value === 'dialog';
|
|
34
|
+
|
|
35
|
+
/** @param {any} node */
|
|
36
|
+
const isSubmitControl = (node) =>
|
|
37
|
+
(node.name === 'button' || node.name === 'input') && attribute(node, 'type')?.value === 'submit';
|
|
38
|
+
|
|
39
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
40
|
+
const requireFormSubmit = {
|
|
41
|
+
meta: {
|
|
42
|
+
type: 'problem',
|
|
43
|
+
docs: {
|
|
44
|
+
description: 'Require a `<form>` to handle its own submission, and a submit control to reach a form.',
|
|
45
|
+
},
|
|
46
|
+
schema: [],
|
|
47
|
+
messages: {
|
|
48
|
+
missingSubmitHandler:
|
|
49
|
+
'This `<form>` handles no submission - pressing Enter in a field either does nothing or reloads the page. Bind `(ngSubmit)` (reactive forms) or `(submit)`, or use a plain element if this is not a form.',
|
|
50
|
+
submitOutsideForm:
|
|
51
|
+
'A `type="submit"` control outside a `<form>` submits nothing. Put it inside the form, or associate it with one by id: `form="the-form-id"`.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
|
|
56
|
+
|
|
57
|
+
if (!parserServices?.convertNodeSourceSpanToLoc) return {};
|
|
58
|
+
|
|
59
|
+
let formDepth = 0;
|
|
60
|
+
|
|
61
|
+
/** @param {any} node */
|
|
62
|
+
const report = (node, messageId) =>
|
|
63
|
+
context.report({
|
|
64
|
+
loc: parserServices.convertNodeSourceSpanToLoc(node.startSourceSpan ?? node.sourceSpan),
|
|
65
|
+
messageId,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
/** @param {any} node */
|
|
70
|
+
Element(node) {
|
|
71
|
+
if (node.name === 'form') {
|
|
72
|
+
formDepth++;
|
|
73
|
+
|
|
74
|
+
if (!handlesSubmit(node) && !submitsNatively(node)) report(node, 'missingSubmitHandler');
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (formDepth > 0 || !isSubmitControl(node) || hasBinding(node, 'form')) return;
|
|
80
|
+
|
|
81
|
+
report(node, 'submitOutsideForm');
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** @param {any} node */
|
|
85
|
+
'Element:exit'(node) {
|
|
86
|
+
if (node.name === 'form') formDepth--;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = requireFormSubmit;
|