diff --git a/packages/eslint-plugin-template/docs/rules/attributes-order.md b/packages/eslint-plugin-template/docs/rules/attributes-order.md new file mode 100644 index 000000000..382438bd1 --- /dev/null +++ b/packages/eslint-plugin-template/docs/rules/attributes-order.md @@ -0,0 +1,507 @@ + + +
+ +# `@angular-eslint/template/attributes-order` + +Ensures that HTML attributes and Angular bindings are sorted based on an expected order + +- Type: layout +- 🔧 Supports autofix (`--fix`) + +
+ +## Rule Options + +The rule accepts an options object with the following properties: + +```ts +interface Options { + alphabetical?: boolean; + /** + * Default: `["STRUCTURAL_DIRECTIVE","TEMPLATE_REFERENCE","ATTRIBUTE_BINDING","INPUT_BINDING","TWO_WAY_BINDING","OUTPUT_BINDING"]` + * + * @minItems 6 + */ + order?: [ + ( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + ), + ( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + ), + ( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + ), + ( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + ), + ( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + ), + ( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + ), + ...( + | "STRUCTURAL_DIRECTIVE" + | "TEMPLATE_REFERENCE" + | "ATTRIBUTE_BINDING" + | "INPUT_BINDING" + | "TWO_WAY_BINDING" + | "OUTPUT_BINDING" + )[] + ]; +} + +``` + +
+ +## Usage Examples + +> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit. + +
+ +
+❌ - Toggle examples of incorrect code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error", + { + "order": [] + } + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error", + { + "alphabetical": true, + "order": [] + } + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~ +``` + +
+ +
+ +--- + +
+ +
+✅ - Toggle examples of correct code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/attributes-order": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +
diff --git a/packages/eslint-plugin-template/src/configs/all.json b/packages/eslint-plugin-template/src/configs/all.json index 10af6f97f..b498369a4 100644 --- a/packages/eslint-plugin-template/src/configs/all.json +++ b/packages/eslint-plugin-template/src/configs/all.json @@ -7,7 +7,9 @@ "@angular-eslint/template/accessibility-label-has-associated-control": "error", "@angular-eslint/template/accessibility-table-scope": "error", "@angular-eslint/template/accessibility-valid-aria": "error", + "@angular-eslint/template/attributes-order": "error", "@angular-eslint/template/banana-in-box": "error", + "@angular-eslint/template/button-has-type": "error", "@angular-eslint/template/click-events-have-key-events": "error", "@angular-eslint/template/conditional-complexity": "error", "@angular-eslint/template/cyclomatic-complexity": "error", @@ -21,7 +23,6 @@ "@angular-eslint/template/no-duplicate-attributes": "error", "@angular-eslint/template/no-negated-async": "error", "@angular-eslint/template/no-positive-tabindex": "error", - "@angular-eslint/template/use-track-by-function": "error", - "@angular-eslint/template/button-has-type": "error" + "@angular-eslint/template/use-track-by-function": "error" } } diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts index 4f2deb265..f37b957ce 100644 --- a/packages/eslint-plugin-template/src/index.ts +++ b/packages/eslint-plugin-template/src/index.ts @@ -21,9 +21,15 @@ import accessibilityTableScope, { import accessibilityValidAria, { RULE_NAME as accessibilityValidAriaRuleName, } from './rules/accessibility-valid-aria'; +import attributesOrder, { + RULE_NAME as attributesOrderRuleName, +} from './rules/attributes-order'; import bananaInBox, { RULE_NAME as bananaInBoxRuleName, } from './rules/banana-in-box'; +import buttonHasType, { + RULE_NAME as buttonHasTypeRuleName, +} from './rules/button-has-type'; import clickEventsHaveKeyEvents, { RULE_NAME as clickEventsHaveKeyEventsRuleName, } from './rules/click-events-have-key-events'; @@ -60,9 +66,6 @@ import noPositiveTabindex, { import useTrackByFunction, { RULE_NAME as useTrackByFunctionRuleName, } from './rules/use-track-by-function'; -import buttonHasType, { - RULE_NAME as buttonHasTypeRuleName, -} from './rules/button-has-type'; export default { configs: { @@ -80,7 +83,9 @@ export default { accessibilityLabelHasAssociatedControl, [accessibilityTableScopeRuleName]: accessibilityTableScope, [accessibilityValidAriaRuleName]: accessibilityValidAria, + [attributesOrderRuleName]: attributesOrder, [bananaInBoxRuleName]: bananaInBox, + [buttonHasTypeRuleName]: buttonHasType, [conditionalComplexityRuleName]: conditionalComplexity, [clickEventsHaveKeyEventsRuleName]: clickEventsHaveKeyEvents, [cyclomaticComplexityRuleName]: cyclomaticComplexity, @@ -95,6 +100,5 @@ export default { [noNegatedAsyncRuleName]: noNegatedAsync, [noPositiveTabindexRuleName]: noPositiveTabindex, [useTrackByFunctionRuleName]: useTrackByFunction, - [buttonHasTypeRuleName]: buttonHasType, }, }; diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts new file mode 100644 index 000000000..d52b353f5 --- /dev/null +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -0,0 +1,370 @@ +import type { + TmplAstElement, + TmplAstBoundAttribute, + TmplAstBoundEvent, + TmplAstReference, + TmplAstTextAttribute, + TmplAstNode, +} from '@angular-eslint/bundled-angular-compiler'; +import { TmplAstTemplate } from '@angular-eslint/bundled-angular-compiler'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { + createESLintRule, + getTemplateParserServices, +} from '../utils/create-eslint-rule'; + +export const enum OrderType { + TemplateReferenceVariable = 'TEMPLATE_REFERENCE', + StructuralDirective = 'STRUCTURAL_DIRECTIVE', + AttributeBinding = 'ATTRIBUTE_BINDING', + InputBinding = 'INPUT_BINDING', + OutputBinding = 'OUTPUT_BINDING', + TwoWayBinding = 'TWO_WAY_BINDING', +} + +type Options = [ + { + readonly alphabetical: boolean; + readonly order: readonly OrderType[]; + }, +]; + +type HasOrderType = Readonly<{ + orderType: Type; +}>; + +interface HasTemplateParent { + parent: TmplAstTemplate; +} + +type ExtendedTmplAstBoundAttribute = TmplAstBoundAttribute & + HasOrderType< + | OrderType.InputBinding + | OrderType.TwoWayBinding + | OrderType.StructuralDirective + >; +type ExtendedTmplAstBoundEvent = TmplAstBoundEvent & + HasOrderType; +type ExtendedTmplAstTextAttribute = TmplAstTextAttribute & + HasOrderType; +type ExtendedTmplAstReference = TmplAstReference & + HasOrderType; +type ExtendedTmplAstElement = TmplAstElement & HasTemplateParent; +type ExtendedAttribute = + | ExtendedTmplAstBoundAttribute + | ExtendedTmplAstBoundEvent + | ExtendedTmplAstTextAttribute + | ExtendedTmplAstReference; + +export type MessageIds = 'attributesOrder'; +export const RULE_NAME = 'attributes-order'; + +const DEFAULT_ORDER = [ + OrderType.StructuralDirective, + OrderType.TemplateReferenceVariable, + OrderType.AttributeBinding, + OrderType.InputBinding, + OrderType.TwoWayBinding, + OrderType.OutputBinding, +]; + +const DEFAULT_OPTIONS: Options[number] = { + alphabetical: false, + order: [...DEFAULT_ORDER], +}; + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'layout', + docs: { + description: + 'Ensures that HTML attributes and Angular bindings are sorted based on an expected order', + recommended: false, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + alphabetical: { + type: 'boolean', + default: DEFAULT_OPTIONS.alphabetical, + }, + order: { + type: 'array', + items: { + enum: DEFAULT_OPTIONS.order, + }, + default: DEFAULT_OPTIONS.order, + minItems: DEFAULT_OPTIONS.order.length, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + messages: { + attributesOrder: `The element's attributes/bindings did not match the expected order: expected {{expected}} instead of {{actual}}`, + }, + }, + defaultOptions: [DEFAULT_OPTIONS], + create(context, [{ alphabetical, order }]) { + const parserServices = getTemplateParserServices(context); + + function getLocation(attr: ExtendedAttribute): TSESTree.SourceLocation { + const loc = parserServices.convertNodeSourceSpanToLoc(attr.sourceSpan); + + switch (attr.orderType) { + case OrderType.StructuralDirective: + return { + start: { + line: loc.start.line, + column: loc.start.column - 1, + }, + end: { + line: loc.end.line, + column: loc.end.column + 1, + }, + }; + default: + return loc; + } + } + + return { + Element$1(node: ExtendedTmplAstElement) { + const { attributes, inputs, outputs, references } = node; + const { extractedBananaBoxes, extractedInputs, extractedOutputs } = + normalizeInputsOutputs( + inputs.map(toInputBindingOrderType), + outputs.map(toOutputBindingOrderType), + ); + const allAttributes = [ + ...extractTemplateAttrs(node), + ...attributes.map(toAttributeBindingOrderType), + ...references.map(toTemplateReferenceVariableOrderType), + ...extractedBananaBoxes, + ...extractedInputs, + ...extractedOutputs, + ] as const; + + if (allAttributes.length < 2) { + return; + } + + const sortedAttributes = [...allAttributes].sort(byLocation); + + const expectedAttributes = [...allAttributes].sort( + byOrder(order, alphabetical), + ); + + let errorRange: [number, number] | undefined; + + for (let i = 0; i < sortedAttributes.length; i++) { + if (sortedAttributes[i] !== expectedAttributes[i]) { + errorRange = [errorRange?.[0] ?? i, i]; + } + } + + if (errorRange) { + const [startIndex, endIndex] = errorRange; + const sourceCode = context.getSourceCode(); + + const { start } = getLocation(sortedAttributes[startIndex]); + const { end } = getLocation(sortedAttributes[endIndex]); + const loc = { start, end }; + + const range = [ + getStartPos(sortedAttributes[startIndex]), + getEndPos(sortedAttributes[endIndex]), + ] as const; + + let replacementText = ''; + let lastPos = range[0]; + for (let i = startIndex; i <= endIndex; i++) { + const oldAttr = sortedAttributes[i]; + const oldStart = getStartPos(oldAttr); + const oldEnd = getEndPos(oldAttr); + const newAttr = expectedAttributes[i]; + const newStart = getStartPos(newAttr); + const newEnd = getEndPos(newAttr); + + replacementText += sourceCode.text.slice(lastPos, oldStart); + replacementText += sourceCode.text.slice(newStart, newEnd); + + lastPos = oldEnd; + } + + context.report({ + loc, + messageId: 'attributesOrder', + data: { + expected: expectedAttributes + .slice(startIndex, endIndex + 1) + .map((a) => `\`${getMessageName(a)}\``) + .join(', '), + actual: sortedAttributes + .slice(startIndex, endIndex + 1) + .map((a) => `\`${getMessageName(a)}\``) + .join(', '), + }, + fix: (fixer) => fixer.replaceTextRange(range, replacementText), + }); + } + }, + }; + }, +}); + +function byLocation(one: ExtendedAttribute, other: ExtendedAttribute) { + return one.sourceSpan.start.line === other.sourceSpan.start.line + ? one.sourceSpan.start.col - other.sourceSpan.start.col + : one.sourceSpan.start.line - other.sourceSpan.start.line; +} + +function byOrder(order: readonly OrderType[], alphabetical: boolean) { + return function (one: ExtendedAttribute, other: ExtendedAttribute) { + const orderComparison = + getOrderIndex(one, order) - getOrderIndex(other, order); + + if (alphabetical && orderComparison === 0) { + return one.name > other.name ? 1 : -1; + } + + return orderComparison; + }; +} + +function getOrderIndex(attr: ExtendedAttribute, order: readonly OrderType[]) { + return order.indexOf(attr.orderType); +} + +function toAttributeBindingOrderType(attribute: TmplAstTextAttribute) { + return { + ...attribute, + orderType: OrderType.AttributeBinding, + } as ExtendedTmplAstTextAttribute; +} +function toInputBindingOrderType(input: TmplAstBoundAttribute) { + return { + ...input, + orderType: OrderType.InputBinding, + } as ExtendedTmplAstBoundAttribute; +} +function toStructuralDirectiveOrderType( + attributeOrInput: TmplAstBoundAttribute | TmplAstTextAttribute, +) { + return { + ...attributeOrInput, + orderType: OrderType.StructuralDirective, + } as ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute; +} +function toOutputBindingOrderType(output: TmplAstBoundEvent) { + return { + ...output, + orderType: OrderType.OutputBinding, + } as ExtendedTmplAstBoundEvent; +} +function toTwoWayBindingOrderType(output: TmplAstBoundAttribute) { + return { + ...output, + orderType: OrderType.TwoWayBinding, + } as ExtendedTmplAstBoundAttribute; +} +function toTemplateReferenceVariableOrderType(reference: TmplAstReference) { + return { + ...reference, + orderType: OrderType.TemplateReferenceVariable, + } as ExtendedTmplAstReference; +} + +function extractTemplateAttrs( + node: ExtendedTmplAstElement, +): (ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute)[] { + return isTmplAstTemplate(node.parent) + ? node.parent.templateAttrs.map(toStructuralDirectiveOrderType) + : []; +} + +function normalizeInputsOutputs( + inputs: readonly TmplAstBoundAttribute[], + outputs: readonly TmplAstBoundEvent[], +) { + const extractedInputs: readonly ExtendedTmplAstBoundAttribute[] = inputs + .filter( + (input) => !outputs.some((output) => isOnSameLocation(input, output)), + ) + .map(toInputBindingOrderType); + const { extractedBananaBoxes, extractedOutputs } = outputs.reduce<{ + extractedOutputs: readonly ExtendedTmplAstBoundEvent[]; + extractedBananaBoxes: readonly ExtendedTmplAstBoundAttribute[]; + }>( + ({ extractedBananaBoxes, extractedOutputs }, output) => { + const boundInput = inputs.find((input) => + isOnSameLocation(input, output), + ); + + return { + extractedBananaBoxes: extractedBananaBoxes.concat( + boundInput ? toTwoWayBindingOrderType(boundInput) : [], + ), + extractedOutputs: extractedOutputs.concat( + boundInput ? [] : toOutputBindingOrderType(output), + ), + }; + }, + { extractedBananaBoxes: [], extractedOutputs: [] }, + ); + + return { extractedBananaBoxes, extractedInputs, extractedOutputs } as const; +} + +function isTmplAstTemplate(node: TmplAstNode): node is TmplAstTemplate { + return node instanceof TmplAstTemplate; +} + +function isOnSameLocation( + input: TmplAstBoundAttribute, + output: TmplAstBoundEvent, +) { + return ( + input.sourceSpan.start === output.sourceSpan.start && + input.sourceSpan.end === output.sourceSpan.end + ); +} + +function getMessageName(expected: ExtendedAttribute): string { + switch (expected.orderType) { + case OrderType.StructuralDirective: + return `*${expected.name}`; + case OrderType.TemplateReferenceVariable: + return `#${expected.name}`; + case OrderType.InputBinding: + return `[${expected.name}]`; + case OrderType.OutputBinding: + return `(${expected.name})`; + case OrderType.TwoWayBinding: + return `[(${expected.name})]`; + default: + return expected.name; + } +} + +function getStartPos(expected: ExtendedAttribute): number { + switch (expected.orderType) { + case OrderType.StructuralDirective: + return expected.sourceSpan.start.offset - 1; + default: + return expected.sourceSpan.start.offset; + } +} + +function getEndPos(expected: ExtendedAttribute): number { + switch (expected.orderType) { + case OrderType.StructuralDirective: + return expected.sourceSpan.end.offset + 1; + default: + return expected.sourceSpan.end.offset; + } +} diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts b/packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts new file mode 100644 index 000000000..533d68775 --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts @@ -0,0 +1,198 @@ +import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/utils'; +import type { MessageIds } from '../../../src/rules/attributes-order'; +import { OrderType } from '../../../src/rules/attributes-order'; + +const messageId: MessageIds = 'attributesOrder'; + +export const valid = [ + ``, + ``, + ``, + '', + ``, + '', +]; + +export const invalid = [ + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if structural directive is not first', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { expected: '`*ngIf`, `#inputRef`', actual: '`#inputRef`, `*ngIf`' }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if attribute is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { + expected: '`#inputRef`, `class`', + actual: '`class`, `#inputRef`', + }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if input is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { + expected: '`class`, `[binding]`', + actual: '`[binding]`, `class`', + }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if two way binding is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { + expected: '`[binding]`, `[(ngModel)]`', + actual: '`[(ngModel)]`, `[binding]`', + }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if output is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { + expected: '`[binding]`, `(output)`', + actual: '`(output)`, `[binding]`', + }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail if structural directive is in wrong place with custom order', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + options: [ + { + order: [ + OrderType.TemplateReferenceVariable, + OrderType.AttributeBinding, + OrderType.StructuralDirective, + OrderType.InputBinding, + OrderType.OutputBinding, + OrderType.TwoWayBinding, + ], + }, + ], + data: { + expected: '`class`, `*ngIf`', + actual: '`*ngIf`, `class`', + }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should work with custom order', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + options: [ + { + alphabetical: true, + order: [ + OrderType.TemplateReferenceVariable, + OrderType.StructuralDirective, + OrderType.AttributeBinding, + OrderType.InputBinding, + OrderType.OutputBinding, + OrderType.TwoWayBinding, + ], + }, + ], + data: { + expected: + '`#inputRef`, `*ngIf`, `class`, `id`, `[binding]`, `(output)`, `[(ngModel)]`', + actual: + '`*ngIf`, `[(ngModel)]`, `#inputRef`, `id`, `class`, `[binding]`, `(output)`', + }, + annotatedOutput: ` + + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should work for multi-line', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~ + `, + data: { + expected: + '`*ngIf`, `#inputRef`, `id`, `class`, `[binding]`, `[(ngModel)]`, `(output)`', + actual: + '`[(ngModel)]`, `*ngIf`, `#inputRef`, `id`, `class`, `(output)`, `[binding]`', + }, + annotatedOutput: ` + + + `, + }), +]; diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts b/packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts new file mode 100644 index 000000000..424155ee5 --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '@angular-eslint/utils'; +import rule, { RULE_NAME } from '../../../src/rules/attributes-order'; +import { invalid, valid } from './cases'; + +const ruleTester = new RuleTester({ + parser: '@angular-eslint/template-parser', +}); + +ruleTester.run(RULE_NAME, rule, { + valid, + invalid, +});