From 9fbba4637e29045689168283b12c1e64aacdfe85 Mon Sep 17 00:00:00 2001 From: nickbullock Date: Thu, 8 Jul 2021 15:58:00 +0800 Subject: [PATCH 1/9] Attributes order rule WIP --- packages/eslint-plugin-template/src/index.ts | 5 + .../src/rules/attributes-order.ts | 264 ++++++++++++++++++ .../tests/rules/attributes-order.test.ts | 30 ++ 3 files changed, 299 insertions(+) create mode 100644 packages/eslint-plugin-template/src/rules/attributes-order.ts create mode 100644 packages/eslint-plugin-template/tests/rules/attributes-order.test.ts diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts index bfa6c43b6..0d123f62b 100644 --- a/packages/eslint-plugin-template/src/index.ts +++ b/packages/eslint-plugin-template/src/index.ts @@ -61,6 +61,10 @@ import useTrackByFunction, { RULE_NAME as useTrackByFunctionRuleName, } from './rules/use-track-by-function'; +import attributesOrder, { + RULE_NAME as attributesOrderRuleName, +} from './rules/attributes-order'; + export default { configs: { all, @@ -92,5 +96,6 @@ export default { [noNegatedAsyncRuleName]: noNegatedAsync, [noPositiveTabindexRuleName]: noPositiveTabindex, [useTrackByFunctionRuleName]: useTrackByFunction, + [attributesOrderRuleName]: attributesOrder }, }; 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..7f4067c9b --- /dev/null +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -0,0 +1,264 @@ +import type { TmplAstElement } from '@angular/compiler'; +import { + BindingType, + TmplAstBoundAttribute, + TmplAstBoundEvent, + TmplAstReference, + TmplAstTextAttribute, +} from '@angular/compiler'; +import { createESLintRule, getTemplateParserServices } from '../utils/create-eslint-rule'; +import { Template } from '@angular/compiler/src/render3/r3_ast'; + +enum OrderAttributeType { + TEMPLATE_REFERENCE = 1, + STRUCTURAL, + CLASS, + STYLE, + ID, + NAME, + DATA, + SRC, + FOR, + TYPE, + HREF, + VALUE, + TITLE, + ALT, + ROLE, + ARIA, + INPUT, + OUTPUT, + BANANA, + ANIMATION, + OTHER_ATTRIBUTES +} + +type Options = [ + { + order: OrderAttributeType[] + }, +]; + +interface HasOrderType { + orderType: OrderAttributeType; +} + +interface HasParent { + parent: any; +} + +interface HasOriginalType { + __originalType: BindingType +} + +type ExtendedTmplAstBoundAttribute = TmplAstBoundAttribute & HasOrderType & HasParent & HasOriginalType; +type ExtendedTmplAstBoundEvent = TmplAstBoundEvent & HasOrderType & HasParent; +type ExtendedTmplAstTextAttribute = TmplAstTextAttribute & HasOrderType & HasParent; +type ExtendedTmplAstReference = TmplAstReference & HasOrderType & HasParent; +type ExtendedTmplAstElement = TmplAstElement & HasParent; +type OrderAttributeNode = + ExtendedTmplAstBoundAttribute + | ExtendedTmplAstBoundEvent + | ExtendedTmplAstTextAttribute + | ExtendedTmplAstReference +export type MessageIds = 'attributesOrder'; +export const RULE_NAME = 'attributes-order'; + +const defaultOptions: Options[number] = { + order: [ + OrderAttributeType.TEMPLATE_REFERENCE, + OrderAttributeType.STRUCTURAL, + OrderAttributeType.ID, + OrderAttributeType.CLASS, + OrderAttributeType.NAME, + OrderAttributeType.DATA, + OrderAttributeType.SRC, + OrderAttributeType.FOR, + OrderAttributeType.TYPE, + OrderAttributeType.HREF, + OrderAttributeType.VALUE, + OrderAttributeType.TITLE, + OrderAttributeType.STYLE, + OrderAttributeType.ALT, + OrderAttributeType.ROLE, + OrderAttributeType.ARIA, + OrderAttributeType.INPUT, + OrderAttributeType.OUTPUT, + OrderAttributeType.BANANA, + OrderAttributeType.ANIMATION, + OrderAttributeType.OTHER_ATTRIBUTES, + ], +}; + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Ensures that html attributes are sorted correctly', + category: 'Possible Errors', + recommended: false, + }, + schema: [ + { + type: 'object', + properties: { + order: { + type: 'array', + of: 'string', + }, + }, + additionalProperties: false, + }, + ], + messages: { + attributesOrder: 'Attributes order is incorrect', + }, + }, + defaultOptions: [defaultOptions], + create(context, [{ order }]) { + const parserServices = getTemplateParserServices(context); + + return { + Element(element: TmplAstElement) { + const { attributes, inputs, outputs, references } = element; + + const structuralAttrs = extractTemplateAttrs(element as ExtendedTmplAstElement); + const attrs = [...attributes, ...inputs, ...outputs, ...references] as OrderAttributeNode[]; + const typedAttrs = attrs.map(attr => ({ + ...attr, + orderType: getOrderAttributeType(attr), + })) as OrderAttributeNode[]; + const sortedTypedAttrs = sortAttrs([...typedAttrs, ...structuralAttrs]); + + if (sortedTypedAttrs.length < 2) { + return; + } + + const filteredOrder = order.filter(orderType => sortedTypedAttrs.some(attr => attr.orderType === orderType)); + + let expectedOrderTypeStartIndex = 0; + let expectedOrderTypeEndIndex = 1; + const getExpectedOrderTypes = () => filteredOrder.slice(expectedOrderTypeStartIndex, expectedOrderTypeEndIndex); + + sortedTypedAttrs.forEach((attr, index) => { + if (index === 1) { + expectedOrderTypeEndIndex += 1; + } else { + const orderTypes = getExpectedOrderTypes(); + + if (!orderTypes.length) { + return; + } + + if (orderTypes.includes(attr.orderType)) { + if (attr.orderType !== filteredOrder[expectedOrderTypeStartIndex]) { + expectedOrderTypeStartIndex += 1; + expectedOrderTypeEndIndex += 1; + } + } else { + const loc = parserServices.convertNodeSourceSpanToLoc(attr.sourceSpan); + + context.report({ + loc, + messageId: 'attributesOrder', + }); + + return; + } + } + }); + }, + }; + }, +}); + +function sortAttrs( + list: OrderAttributeNode[], +): OrderAttributeNode[] { + return list.sort((a, b) => { + if (a.sourceSpan.start.line != b.sourceSpan.start.line) { + return a.sourceSpan.start.line - b.sourceSpan.start.line; + } + + return a.sourceSpan.start.col - b.sourceSpan.start.col; + }); +} + +function getOrderAttributeType(node: OrderAttributeNode): OrderAttributeType { + switch (node.constructor.name) { + case TmplAstTextAttribute.name: + return getTextAttributeOrderType(node as ExtendedTmplAstTextAttribute); + case TmplAstBoundAttribute.name: + return getBoundAttributeOrderType(node as ExtendedTmplAstBoundAttribute); + case TmplAstBoundEvent.name: + return OrderAttributeType.OUTPUT; + case TmplAstReference.name: + return OrderAttributeType.TEMPLATE_REFERENCE; + } + + return OrderAttributeType.OTHER_ATTRIBUTES; +} + +function getTextAttributeOrderType( + node: ExtendedTmplAstTextAttribute | ExtendedTmplAstBoundAttribute, +): OrderAttributeType { + if (node.name.startsWith('data-')) { + return OrderAttributeType.DATA; + } + if (node.name.startsWith('aria-')) { + return OrderAttributeType.ARIA; + } + switch (node.name) { + case 'id': + return OrderAttributeType.ID; + case 'class': + return OrderAttributeType.CLASS; + case 'style': + return OrderAttributeType.STYLE; + case 'src': + return OrderAttributeType.SRC; + case 'type': + return OrderAttributeType.TYPE; + case 'for': + return OrderAttributeType.FOR; + case 'href': + return OrderAttributeType.HREF; + case 'value': + return OrderAttributeType.VALUE; + case 'title': + return OrderAttributeType.TITLE; + case 'alt': + return OrderAttributeType.ALT; + case 'role': + return OrderAttributeType.ROLE; + default: + return OrderAttributeType.OTHER_ATTRIBUTES; + } +} + +function getBoundAttributeOrderType(node: ExtendedTmplAstBoundAttribute): OrderAttributeType { + switch (node.__originalType) { + case BindingType.Class: + return OrderAttributeType.CLASS; + case BindingType.Style: + return OrderAttributeType.STYLE; + case BindingType.Animation: + return OrderAttributeType.ANIMATION; + case BindingType.Attribute: + return getTextAttributeOrderType(node); + case BindingType.Property: + return getTextAttributeOrderType(node); + default: + return OrderAttributeType.INPUT; + } +} + +function extractTemplateAttrs(node: ExtendedTmplAstElement): (ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute)[] { + if (node.parent.constructor.name === Template.name && node.name === node.parent.tagName && node.parent.templateAttrs.length) { + return node.parent.templateAttrs.map((attr: any) => ({ ...attr, orderType: OrderAttributeType.STRUCTURAL })); + } + + return []; +} diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts b/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts new file mode 100644 index 000000000..5c738495d --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts @@ -0,0 +1,30 @@ +import { + convertAnnotatedSourceToFailureCase, + RuleTester, +} from '@angular-eslint/utils'; +import type { MessageIds } from '../../src/rules/attributes-order'; +import rule, { RULE_NAME } from '../../src/rules/attributes-order'; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: '@angular-eslint/template-parser', +}); + +const messageId: MessageIds = 'attributesOrder'; + +ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if structural directive is not first', + annotatedSource: ` + + ~~~~~~~~~~~ + `, + }), + ], +}); From d53d3726824f0395431dc04d099ae24030be3939 Mon Sep 17 00:00:00 2001 From: nickbullock Date: Wed, 21 Jul 2021 14:06:04 +0800 Subject: [PATCH 2/9] feat(eslint-plugin-template): [attributes-order] added rule (#127) --- .../src/rules/attributes-order.ts | 372 ++++++++++-------- .../tests/rules/attributes-order.test.ts | 99 ++++- 2 files changed, 307 insertions(+), 164 deletions(-) diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts index 7f4067c9b..278d91ef7 100644 --- a/packages/eslint-plugin-template/src/rules/attributes-order.ts +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -1,41 +1,31 @@ -import type { TmplAstElement } from '@angular/compiler'; -import { +import type { + TmplAstElement, BindingType, + TmplAstTextAttribute, +} from '@angular/compiler'; +import { TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstReference, - TmplAstTextAttribute, } from '@angular/compiler'; -import { createESLintRule, getTemplateParserServices } from '../utils/create-eslint-rule'; +import { + createESLintRule, + getTemplateParserServices, +} from '../utils/create-eslint-rule'; import { Template } from '@angular/compiler/src/render3/r3_ast'; -enum OrderAttributeType { - TEMPLATE_REFERENCE = 1, - STRUCTURAL, - CLASS, - STYLE, - ID, - NAME, - DATA, - SRC, - FOR, - TYPE, - HREF, - VALUE, - TITLE, - ALT, - ROLE, - ARIA, - INPUT, - OUTPUT, - BANANA, - ANIMATION, - OTHER_ATTRIBUTES +export enum OrderAttributeType { + TEMPLATE_REFERENCE = 'TEMPLATE_REFERENCE', + STRUCTURAL_DIRECTIVE = 'STRUCTURAL_DIRECTIVE', + ATTRIBUTE_BINDING = 'ATTRIBUTE_BINDING', + INPUT_BINDING = 'INPUT_BINDING', + OUTPUT_BINDING = 'OUTPUT_BINDING', + TWO_WAY_BINDING = 'TWO_WAY_BINDING', } type Options = [ { - order: OrderAttributeType[] + order: OrderAttributeType[]; }, ]; @@ -43,51 +33,45 @@ interface HasOrderType { orderType: OrderAttributeType; } -interface HasParent { - parent: any; +interface HasTemplateParent { + parent: Template; } interface HasOriginalType { - __originalType: BindingType + __originalType: BindingType; } -type ExtendedTmplAstBoundAttribute = TmplAstBoundAttribute & HasOrderType & HasParent & HasOriginalType; -type ExtendedTmplAstBoundEvent = TmplAstBoundEvent & HasOrderType & HasParent; -type ExtendedTmplAstTextAttribute = TmplAstTextAttribute & HasOrderType & HasParent; -type ExtendedTmplAstReference = TmplAstReference & HasOrderType & HasParent; -type ExtendedTmplAstElement = TmplAstElement & HasParent; +type InputsOutputsHash = Record< + string, + { input: ExtendedTmplAstBoundAttribute; output?: ExtendedTmplAstBoundEvent } +>; + +type ExtendedTmplAstBoundAttribute = TmplAstBoundAttribute & + HasOrderType & + HasOriginalType; +type ExtendedTmplAstBoundEvent = TmplAstBoundEvent & HasOrderType; +type ExtendedTmplAstTextAttribute = TmplAstTextAttribute & HasOrderType; +type ExtendedTmplAstReference = TmplAstReference & HasOrderType; +type ExtendedTmplAstElement = TmplAstElement & HasTemplateParent; type OrderAttributeNode = - ExtendedTmplAstBoundAttribute + | ExtendedTmplAstBoundAttribute | ExtendedTmplAstBoundEvent | ExtendedTmplAstTextAttribute - | ExtendedTmplAstReference + | ExtendedTmplAstReference; export type MessageIds = 'attributesOrder'; export const RULE_NAME = 'attributes-order'; +const DEFAULT_ORDER = [ + OrderAttributeType.STRUCTURAL_DIRECTIVE, + OrderAttributeType.TEMPLATE_REFERENCE, + OrderAttributeType.ATTRIBUTE_BINDING, + OrderAttributeType.INPUT_BINDING, + OrderAttributeType.TWO_WAY_BINDING, + OrderAttributeType.OUTPUT_BINDING, +]; + const defaultOptions: Options[number] = { - order: [ - OrderAttributeType.TEMPLATE_REFERENCE, - OrderAttributeType.STRUCTURAL, - OrderAttributeType.ID, - OrderAttributeType.CLASS, - OrderAttributeType.NAME, - OrderAttributeType.DATA, - OrderAttributeType.SRC, - OrderAttributeType.FOR, - OrderAttributeType.TYPE, - OrderAttributeType.HREF, - OrderAttributeType.VALUE, - OrderAttributeType.TITLE, - OrderAttributeType.STYLE, - OrderAttributeType.ALT, - OrderAttributeType.ROLE, - OrderAttributeType.ARIA, - OrderAttributeType.INPUT, - OrderAttributeType.OUTPUT, - OrderAttributeType.BANANA, - OrderAttributeType.ANIMATION, - OrderAttributeType.OTHER_ATTRIBUTES, - ], + order: [...DEFAULT_ORDER], }; export default createESLintRule({ @@ -95,8 +79,7 @@ export default createESLintRule({ meta: { type: 'problem', docs: { - description: - 'Ensures that html attributes are sorted correctly', + description: 'Ensures that html attributes are sorted correctly', category: 'Possible Errors', recommended: false, }, @@ -106,14 +89,19 @@ export default createESLintRule({ properties: { order: { type: 'array', - of: 'string', + uniqueItems: true, + minimum: 6, + items: { + enum: [...DEFAULT_ORDER], + }, }, }, additionalProperties: false, }, ], messages: { - attributesOrder: 'Attributes order is incorrect', + attributesOrder: + 'Attributes order is incorrect, expected {{types}} at this position', }, }, defaultOptions: [defaultOptions], @@ -124,59 +112,85 @@ export default createESLintRule({ Element(element: TmplAstElement) { const { attributes, inputs, outputs, references } = element; - const structuralAttrs = extractTemplateAttrs(element as ExtendedTmplAstElement); - const attrs = [...attributes, ...inputs, ...outputs, ...references] as OrderAttributeNode[]; - const typedAttrs = attrs.map(attr => ({ + const structuralAttrs = extractTemplateAttrs( + element as ExtendedTmplAstElement, + ); + const attrs = [...attributes, ...references] as OrderAttributeNode[]; + const typedAttrs = attrs.map((attr) => ({ ...attr, orderType: getOrderAttributeType(attr), })) as OrderAttributeNode[]; - const sortedTypedAttrs = sortAttrs([...typedAttrs, ...structuralAttrs]); + const { extractedBananaBoxes, extractedInputs, extractedOutputs } = + extractBananaBoxes( + inputs as ExtendedTmplAstBoundAttribute[], + outputs as ExtendedTmplAstBoundEvent[], + ); + const sortedTypedAttrs = sortAttrsByLocation([ + ...structuralAttrs, + ...typedAttrs, + ...extractedInputs, + ...extractedOutputs, + ...extractedBananaBoxes, + ]); if (sortedTypedAttrs.length < 2) { return; } - const filteredOrder = order.filter(orderType => sortedTypedAttrs.some(attr => attr.orderType === orderType)); + const filteredOrder = order.filter((orderType) => + sortedTypedAttrs.some((attr) => attr.orderType === orderType), + ); let expectedOrderTypeStartIndex = 0; let expectedOrderTypeEndIndex = 1; - const getExpectedOrderTypes = () => filteredOrder.slice(expectedOrderTypeStartIndex, expectedOrderTypeEndIndex); - - sortedTypedAttrs.forEach((attr, index) => { - if (index === 1) { - expectedOrderTypeEndIndex += 1; - } else { - const orderTypes = getExpectedOrderTypes(); - - if (!orderTypes.length) { - return; - } - - if (orderTypes.includes(attr.orderType)) { - if (attr.orderType !== filteredOrder[expectedOrderTypeStartIndex]) { - expectedOrderTypeStartIndex += 1; - expectedOrderTypeEndIndex += 1; - } - } else { - const loc = parserServices.convertNodeSourceSpanToLoc(attr.sourceSpan); - - context.report({ - loc, - messageId: 'attributesOrder', - }); - - return; - } + let prevType; + + console.log( + '>>>> inspect', + filteredOrder, + 'attrs:', + sortedTypedAttrs.map((attr) => attr.name).join(', '), + ); + + for (let i = 0; i < sortedTypedAttrs.length; i++) { + const expectedOrderTypes = filteredOrder.slice( + expectedOrderTypeStartIndex, + expectedOrderTypeEndIndex, + ); + + if (!expectedOrderTypes.length) { + return; } - }); + + const attr = sortedTypedAttrs[i]; + + if (!expectedOrderTypes.includes(attr.orderType)) { + const loc = parserServices.convertNodeSourceSpanToLoc( + attr.sourceSpan, + ); + + context.report({ + loc, + messageId: 'attributesOrder', + data: { types: expectedOrderTypes.join(' or ') }, + }); + break; + } + + if (i === 0) { + expectedOrderTypeEndIndex++; + } else if (attr.orderType !== prevType) { + expectedOrderTypeStartIndex++; + expectedOrderTypeEndIndex++; + } + prevType = attr.orderType; + } }, }; }, }); -function sortAttrs( - list: OrderAttributeNode[], -): OrderAttributeNode[] { +function sortAttrsByLocation(list: OrderAttributeNode[]): OrderAttributeNode[] { return list.sort((a, b) => { if (a.sourceSpan.start.line != b.sourceSpan.start.line) { return a.sourceSpan.start.line - b.sourceSpan.start.line; @@ -188,77 +202,115 @@ function sortAttrs( function getOrderAttributeType(node: OrderAttributeNode): OrderAttributeType { switch (node.constructor.name) { - case TmplAstTextAttribute.name: - return getTextAttributeOrderType(node as ExtendedTmplAstTextAttribute); case TmplAstBoundAttribute.name: - return getBoundAttributeOrderType(node as ExtendedTmplAstBoundAttribute); + return OrderAttributeType.INPUT_BINDING; case TmplAstBoundEvent.name: - return OrderAttributeType.OUTPUT; + return OrderAttributeType.OUTPUT_BINDING; case TmplAstReference.name: return OrderAttributeType.TEMPLATE_REFERENCE; + default: + return OrderAttributeType.ATTRIBUTE_BINDING; } - - return OrderAttributeType.OTHER_ATTRIBUTES; } -function getTextAttributeOrderType( - node: ExtendedTmplAstTextAttribute | ExtendedTmplAstBoundAttribute, -): OrderAttributeType { - if (node.name.startsWith('data-')) { - return OrderAttributeType.DATA; - } - if (node.name.startsWith('aria-')) { - return OrderAttributeType.ARIA; - } - switch (node.name) { - case 'id': - return OrderAttributeType.ID; - case 'class': - return OrderAttributeType.CLASS; - case 'style': - return OrderAttributeType.STYLE; - case 'src': - return OrderAttributeType.SRC; - case 'type': - return OrderAttributeType.TYPE; - case 'for': - return OrderAttributeType.FOR; - case 'href': - return OrderAttributeType.HREF; - case 'value': - return OrderAttributeType.VALUE; - case 'title': - return OrderAttributeType.TITLE; - case 'alt': - return OrderAttributeType.ALT; - case 'role': - return OrderAttributeType.ROLE; - default: - return OrderAttributeType.OTHER_ATTRIBUTES; +function extractTemplateAttrs( + node: ExtendedTmplAstElement, +): (ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute)[] { + if ( + node.parent.constructor.name === Template.name && + node.name === node.parent.tagName && + node.parent.templateAttrs.length + ) { + return node.parent.templateAttrs.map( + (attr) => + ({ + ...attr, + orderType: OrderAttributeType.STRUCTURAL_DIRECTIVE, + } as ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute), + ); } + + return []; } -function getBoundAttributeOrderType(node: ExtendedTmplAstBoundAttribute): OrderAttributeType { - switch (node.__originalType) { - case BindingType.Class: - return OrderAttributeType.CLASS; - case BindingType.Style: - return OrderAttributeType.STYLE; - case BindingType.Animation: - return OrderAttributeType.ANIMATION; - case BindingType.Attribute: - return getTextAttributeOrderType(node); - case BindingType.Property: - return getTextAttributeOrderType(node); - default: - return OrderAttributeType.INPUT; - } +function extractBananaBoxes( + inputs: ExtendedTmplAstBoundAttribute[], + outputs: ExtendedTmplAstBoundEvent[], +): any { + const extractedBananaBoxes: ExtendedTmplAstBoundAttribute[] = []; + const extractedInputs: ExtendedTmplAstBoundAttribute[] = []; + const extractedOutputs: ExtendedTmplAstBoundEvent[] = []; + + const { hash, notMatchedOutputs } = getInputsOutputsHash(inputs, outputs); + const extractedOutputsFromHash = notMatchedOutputs.map((output) => { + return { + ...output, + orderType: OrderAttributeType.OUTPUT_BINDING, + } as ExtendedTmplAstBoundEvent; + }); + + extractedOutputs.push(...extractedOutputsFromHash); + + Object.keys(hash).forEach((inputKey) => { + const { input, output } = hash[inputKey]; + + if (!output) { + extractedInputs.push({ + ...input, + orderType: OrderAttributeType.INPUT_BINDING, + } as ExtendedTmplAstBoundAttribute); + return; + } + + if ((input.value as any).location === (output.handler as any).location) { + extractedBananaBoxes.push({ + ...input, + orderType: OrderAttributeType.TWO_WAY_BINDING, + } as ExtendedTmplAstBoundAttribute); + } else { + extractedInputs.push({ + ...input, + orderType: OrderAttributeType.INPUT_BINDING, + } as ExtendedTmplAstBoundAttribute); + extractedOutputs.push({ + ...output, + orderType: OrderAttributeType.OUTPUT_BINDING, + } as ExtendedTmplAstBoundEvent); + } + }); + + return { + extractedBananaBoxes, + extractedInputs, + extractedOutputs, + }; } -function extractTemplateAttrs(node: ExtendedTmplAstElement): (ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute)[] { - if (node.parent.constructor.name === Template.name && node.name === node.parent.tagName && node.parent.templateAttrs.length) { - return node.parent.templateAttrs.map((attr: any) => ({ ...attr, orderType: OrderAttributeType.STRUCTURAL })); - } +function getInputsOutputsHash( + inputs: ExtendedTmplAstBoundAttribute[], + outputs: ExtendedTmplAstBoundEvent[], +): { hash: InputsOutputsHash; notMatchedOutputs: ExtendedTmplAstBoundEvent[] } { + const hash: InputsOutputsHash = {}; + const notMatchedOutputs: ExtendedTmplAstBoundEvent[] = []; - return []; + inputs.forEach((input) => { + hash[input.name] = { input }; + }); + outputs.forEach((output) => { + if (!output.name.endsWith('Change')) { + notMatchedOutputs.push(output); + return; + } + + const name = output.name.substring(0, output.name.lastIndexOf('Change')); + + if (!hash[name]) { + notMatchedOutputs.push(output); + return; + } + + hash[name].output = output; + }); + + return { hash, notMatchedOutputs }; } diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts b/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts index 5c738495d..cc97edf20 100644 --- a/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts +++ b/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts @@ -3,7 +3,10 @@ import { RuleTester, } from '@angular-eslint/utils'; import type { MessageIds } from '../../src/rules/attributes-order'; -import rule, { RULE_NAME } from '../../src/rules/attributes-order'; +import rule, { + OrderAttributeType, + RULE_NAME, +} from '../../src/rules/attributes-order'; //------------------------------------------------------------------------------ // Tests @@ -16,15 +19,103 @@ const ruleTester = new RuleTester({ const messageId: MessageIds = 'attributesOrder'; ruleTester.run(RULE_NAME, rule, { - valid: [], + valid: [ + ``, + ``, + ``, + ``, + ], invalid: [ + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if two way binding is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~ + `, + data: { + types: [ + OrderAttributeType.ATTRIBUTE_BINDING, + OrderAttributeType.INPUT_BINDING, + ].join(' or '), + }, + }), convertAnnotatedSourceToFailureCase({ messageId, description: 'should fail if structural directive is not first', annotatedSource: ` - - ~~~~~~~~~~~ + + ~~~~~~~~~ + `, + data: { types: OrderAttributeType.STRUCTURAL_DIRECTIVE }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if attribute is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~ + `, + data: { + types: [ + OrderAttributeType.STRUCTURAL_DIRECTIVE, + OrderAttributeType.TEMPLATE_REFERENCE, + ].join(' or '), + }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if output is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { + types: [ + OrderAttributeType.ATTRIBUTE_BINDING, + OrderAttributeType.INPUT_BINDING, + ].join(' or '), + }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail if input is in wrong place', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~ + `, + data: { + types: [ + OrderAttributeType.STRUCTURAL_DIRECTIVE, + OrderAttributeType.ATTRIBUTE_BINDING, + ].join(' or '), + }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should work with custom order', + annotatedSource: ` + , + ~~~~~~~~~~~~~~~~~~~ `, + data: { + types: [ + OrderAttributeType.INPUT_BINDING, + OrderAttributeType.OUTPUT_BINDING, + ].join(' or '), + }, + options: [ + { + order: [ + OrderAttributeType.TEMPLATE_REFERENCE, + OrderAttributeType.STRUCTURAL_DIRECTIVE, + OrderAttributeType.ATTRIBUTE_BINDING, + OrderAttributeType.INPUT_BINDING, + OrderAttributeType.OUTPUT_BINDING, + OrderAttributeType.TWO_WAY_BINDING, + ], + }, + ], }), ], }); From 25cf8f3993d7eea772bd7b58361a9ddcec76b806 Mon Sep 17 00:00:00 2001 From: nickbullock Date: Wed, 21 Jul 2021 14:12:34 +0800 Subject: [PATCH 3/9] feat(eslint-plugin-template): [attributes-order] updated readme (#127) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 053f510a6..572d9c3b3 100644 --- a/README.md +++ b/README.md @@ -815,6 +815,6 @@ If you see a rule below that has **no status** against it, then please feel free -[`pr559`]: https://api.github.com/repos/angular-eslint/angular-eslint/pulls/559 +[`pr594`]: https://api.github.com/repos/angular-eslint/angular-eslint/pulls/594 From 373b3c49b2362822a5b61b6d356f22f269955afe Mon Sep 17 00:00:00 2001 From: nickbullock Date: Wed, 21 Jul 2021 14:14:24 +0800 Subject: [PATCH 4/9] feat(eslint-plugin-template): [attributes-order] run prettier (#127) --- packages/eslint-plugin-template/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts index 0d123f62b..ce4c1b23f 100644 --- a/packages/eslint-plugin-template/src/index.ts +++ b/packages/eslint-plugin-template/src/index.ts @@ -96,6 +96,6 @@ export default { [noNegatedAsyncRuleName]: noNegatedAsync, [noPositiveTabindexRuleName]: noPositiveTabindex, [useTrackByFunctionRuleName]: useTrackByFunction, - [attributesOrderRuleName]: attributesOrder + [attributesOrderRuleName]: attributesOrder, }, }; From 2e868f4dd0eb123f6251555c2f673217a3f7b67c Mon Sep 17 00:00:00 2001 From: Phil McCloghry-Laing Date: Tue, 21 Jun 2022 16:05:20 +1000 Subject: [PATCH 5/9] feat(eslint-plugin-template): [attributes-order] Add fixer + code review suggestions --- .../src/configs/all.json | 5 +- packages/eslint-plugin-template/src/index.ts | 17 +- .../src/rules/attributes-order.ts | 473 ++++++++++-------- .../src/utils/is-on-same-location.ts | 14 + .../tests/rules/attributes-order.test.ts | 121 ----- .../tests/rules/attributes-order/cases.ts | 198 ++++++++ .../tests/rules/attributes-order/spec.ts | 12 + 7 files changed, 494 insertions(+), 346 deletions(-) create mode 100644 packages/eslint-plugin-template/src/utils/is-on-same-location.ts delete mode 100644 packages/eslint-plugin-template/tests/rules/attributes-order.test.ts create mode 100644 packages/eslint-plugin-template/tests/rules/attributes-order/cases.ts create mode 100644 packages/eslint-plugin-template/tests/rules/attributes-order/spec.ts 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 6f42e5fdb..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,13 +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'; - -import attributesOrder, { - RULE_NAME as attributesOrderRuleName, -} from './rules/attributes-order'; export default { configs: { @@ -84,7 +83,9 @@ export default { accessibilityLabelHasAssociatedControl, [accessibilityTableScopeRuleName]: accessibilityTableScope, [accessibilityValidAriaRuleName]: accessibilityValidAria, + [attributesOrderRuleName]: attributesOrder, [bananaInBoxRuleName]: bananaInBox, + [buttonHasTypeRuleName]: buttonHasType, [conditionalComplexityRuleName]: conditionalComplexity, [clickEventsHaveKeyEventsRuleName]: clickEventsHaveKeyEvents, [cyclomaticComplexityRuleName]: cyclomaticComplexity, @@ -99,7 +100,5 @@ export default { [noNegatedAsyncRuleName]: noNegatedAsync, [noPositiveTabindexRuleName]: noPositiveTabindex, [useTrackByFunctionRuleName]: useTrackByFunction, - [attributesOrderRuleName]: attributesOrder, - [buttonHasTypeRuleName]: buttonHasType, }, }; diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts index 278d91ef7..808f47cc7 100644 --- a/packages/eslint-plugin-template/src/rules/attributes-order.ts +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -1,99 +1,104 @@ import type { TmplAstElement, - BindingType, - TmplAstTextAttribute, -} from '@angular/compiler'; -import { TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstReference, -} from '@angular/compiler'; + 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'; -import { Template } from '@angular/compiler/src/render3/r3_ast'; - -export enum OrderAttributeType { - TEMPLATE_REFERENCE = 'TEMPLATE_REFERENCE', - STRUCTURAL_DIRECTIVE = 'STRUCTURAL_DIRECTIVE', - ATTRIBUTE_BINDING = 'ATTRIBUTE_BINDING', - INPUT_BINDING = 'INPUT_BINDING', - OUTPUT_BINDING = 'OUTPUT_BINDING', - TWO_WAY_BINDING = 'TWO_WAY_BINDING', +import { isOnSameLocation } from '../utils/is-on-same-location'; + +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 = [ { - order: OrderAttributeType[]; + readonly alphabetical: boolean; + readonly order: readonly OrderType[]; }, ]; -interface HasOrderType { - orderType: OrderAttributeType; -} +type HasOrderType = Readonly<{ + orderType: Type; +}>; interface HasTemplateParent { - parent: Template; + parent: TmplAstTemplate; } -interface HasOriginalType { - __originalType: BindingType; -} - -type InputsOutputsHash = Record< - string, - { input: ExtendedTmplAstBoundAttribute; output?: ExtendedTmplAstBoundEvent } ->; - type ExtendedTmplAstBoundAttribute = TmplAstBoundAttribute & - HasOrderType & - HasOriginalType; -type ExtendedTmplAstBoundEvent = TmplAstBoundEvent & HasOrderType; -type ExtendedTmplAstTextAttribute = TmplAstTextAttribute & HasOrderType; -type ExtendedTmplAstReference = TmplAstReference & HasOrderType; + HasOrderType< + | OrderType.InputBinding + | OrderType.TwoWayBinding + | OrderType.StructuralDirective + >; +type ExtendedTmplAstBoundEvent = TmplAstBoundEvent & + HasOrderType; +type ExtendedTmplAstTextAttribute = TmplAstTextAttribute & + HasOrderType; +type ExtendedTmplAstReference = TmplAstReference & + HasOrderType; type ExtendedTmplAstElement = TmplAstElement & HasTemplateParent; -type OrderAttributeNode = +type ExtendedAttribute = | ExtendedTmplAstBoundAttribute | ExtendedTmplAstBoundEvent | ExtendedTmplAstTextAttribute | ExtendedTmplAstReference; + export type MessageIds = 'attributesOrder'; export const RULE_NAME = 'attributes-order'; const DEFAULT_ORDER = [ - OrderAttributeType.STRUCTURAL_DIRECTIVE, - OrderAttributeType.TEMPLATE_REFERENCE, - OrderAttributeType.ATTRIBUTE_BINDING, - OrderAttributeType.INPUT_BINDING, - OrderAttributeType.TWO_WAY_BINDING, - OrderAttributeType.OUTPUT_BINDING, + OrderType.StructuralDirective, + OrderType.TemplateReferenceVariable, + OrderType.AttributeBinding, + OrderType.InputBinding, + OrderType.TwoWayBinding, + OrderType.OutputBinding, ]; -const defaultOptions: Options[number] = { +const DEFAULT_OPTIONS: Options[number] = { + alphabetical: false, order: [...DEFAULT_ORDER], }; export default createESLintRule({ name: RULE_NAME, meta: { - type: 'problem', + type: 'layout', docs: { description: 'Ensures that html attributes are sorted correctly', - category: 'Possible Errors', recommended: false, }, + fixable: 'code', schema: [ { type: 'object', properties: { + alphabetical: { + type: 'boolean', + default: DEFAULT_OPTIONS.alphabetical, + }, order: { type: 'array', - uniqueItems: true, - minimum: 6, items: { - enum: [...DEFAULT_ORDER], + enum: DEFAULT_OPTIONS.order, }, + default: DEFAULT_OPTIONS.order, + minItems: DEFAULT_OPTIONS.order.length, + uniqueItems: true, }, }, additionalProperties: false, @@ -101,216 +106,256 @@ export default createESLintRule({ ], messages: { attributesOrder: - 'Attributes order is incorrect, expected {{types}} at this position', + 'Attributes order is incorrect, expected {{expected}} instead of {{actual}}', }, }, - defaultOptions: [defaultOptions], - create(context, [{ order }]) { + defaultOptions: [DEFAULT_OPTIONS], + create(context, [{ alphabetical, order }]) { const parserServices = getTemplateParserServices(context); - return { - Element(element: TmplAstElement) { - const { attributes, inputs, outputs, references } = element; + function getLocation(attr: ExtendedAttribute): TSESTree.SourceLocation { + const loc = parserServices.convertNodeSourceSpanToLoc(attr.sourceSpan); - const structuralAttrs = extractTemplateAttrs( - element as ExtendedTmplAstElement, - ); - const attrs = [...attributes, ...references] as OrderAttributeNode[]; - const typedAttrs = attrs.map((attr) => ({ - ...attr, - orderType: getOrderAttributeType(attr), - })) as OrderAttributeNode[]; + 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 } = - extractBananaBoxes( - inputs as ExtendedTmplAstBoundAttribute[], - outputs as ExtendedTmplAstBoundEvent[], + normalizeInputsOutputs( + inputs.map(toInputBindingOrderType), + outputs.map(toOutputBindingOrderType), ); - const sortedTypedAttrs = sortAttrsByLocation([ - ...structuralAttrs, - ...typedAttrs, + const allAttributes = [ + ...extractTemplateAttrs(node), + ...attributes.map(toAttributeBindingOrderType), + ...references.map(toTemplateReferenceVariableOrderType), + ...extractedBananaBoxes, ...extractedInputs, ...extractedOutputs, - ...extractedBananaBoxes, - ]); + ] as const; - if (sortedTypedAttrs.length < 2) { + if (allAttributes.length < 2) { return; } - const filteredOrder = order.filter((orderType) => - sortedTypedAttrs.some((attr) => attr.orderType === orderType), - ); + const sortedAttributes = [...allAttributes].sort(byLocation); - let expectedOrderTypeStartIndex = 0; - let expectedOrderTypeEndIndex = 1; - let prevType; - - console.log( - '>>>> inspect', - filteredOrder, - 'attrs:', - sortedTypedAttrs.map((attr) => attr.name).join(', '), + const expectedAttributes = [...allAttributes].sort( + byOrder(order, alphabetical), ); - for (let i = 0; i < sortedTypedAttrs.length; i++) { - const expectedOrderTypes = filteredOrder.slice( - expectedOrderTypeStartIndex, - expectedOrderTypeEndIndex, - ); + let errorRange: [number, number] | undefined; - if (!expectedOrderTypes.length) { - return; + for (let i = 0; i < sortedAttributes.length; i++) { + if (sortedAttributes[i] !== expectedAttributes[i]) { + errorRange = [errorRange?.[0] ?? i, i]; } + } - const attr = sortedTypedAttrs[i]; - - if (!expectedOrderTypes.includes(attr.orderType)) { - const loc = parserServices.convertNodeSourceSpanToLoc( - attr.sourceSpan, - ); - - context.report({ - loc, - messageId: 'attributesOrder', - data: { types: expectedOrderTypes.join(' or ') }, - }); - break; + 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; } - if (i === 0) { - expectedOrderTypeEndIndex++; - } else if (attr.orderType !== prevType) { - expectedOrderTypeStartIndex++; - expectedOrderTypeEndIndex++; - } - prevType = attr.orderType; + 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 sortAttrsByLocation(list: OrderAttributeNode[]): OrderAttributeNode[] { - return list.sort((a, b) => { - if (a.sourceSpan.start.line != b.sourceSpan.start.line) { - return a.sourceSpan.start.line - b.sourceSpan.start.line; +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 a.sourceSpan.start.col - b.sourceSpan.start.col; - }); + return orderComparison; + }; } -function getOrderAttributeType(node: OrderAttributeNode): OrderAttributeType { - switch (node.constructor.name) { - case TmplAstBoundAttribute.name: - return OrderAttributeType.INPUT_BINDING; - case TmplAstBoundEvent.name: - return OrderAttributeType.OUTPUT_BINDING; - case TmplAstReference.name: - return OrderAttributeType.TEMPLATE_REFERENCE; - default: - return OrderAttributeType.ATTRIBUTE_BINDING; - } +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)[] { - if ( - node.parent.constructor.name === Template.name && - node.name === node.parent.tagName && - node.parent.templateAttrs.length - ) { - return node.parent.templateAttrs.map( - (attr) => - ({ - ...attr, - orderType: OrderAttributeType.STRUCTURAL_DIRECTIVE, - } as ExtendedTmplAstBoundAttribute | ExtendedTmplAstTextAttribute), - ); - } - - return []; + return isTmplAstTemplate(node.parent) + ? node.parent.templateAttrs.map(toStructuralDirectiveOrderType) + : []; } -function extractBananaBoxes( - inputs: ExtendedTmplAstBoundAttribute[], - outputs: ExtendedTmplAstBoundEvent[], -): any { - const extractedBananaBoxes: ExtendedTmplAstBoundAttribute[] = []; - const extractedInputs: ExtendedTmplAstBoundAttribute[] = []; - const extractedOutputs: ExtendedTmplAstBoundEvent[] = []; - - const { hash, notMatchedOutputs } = getInputsOutputsHash(inputs, outputs); - const extractedOutputsFromHash = notMatchedOutputs.map((output) => { - return { - ...output, - orderType: OrderAttributeType.OUTPUT_BINDING, - } as ExtendedTmplAstBoundEvent; - }); - - extractedOutputs.push(...extractedOutputsFromHash); - - Object.keys(hash).forEach((inputKey) => { - const { input, output } = hash[inputKey]; - - if (!output) { - extractedInputs.push({ - ...input, - orderType: OrderAttributeType.INPUT_BINDING, - } as ExtendedTmplAstBoundAttribute); - return; - } - - if ((input.value as any).location === (output.handler as any).location) { - extractedBananaBoxes.push({ - ...input, - orderType: OrderAttributeType.TWO_WAY_BINDING, - } as ExtendedTmplAstBoundAttribute); - } else { - extractedInputs.push({ - ...input, - orderType: OrderAttributeType.INPUT_BINDING, - } as ExtendedTmplAstBoundAttribute); - extractedOutputs.push({ - ...output, - orderType: OrderAttributeType.OUTPUT_BINDING, - } as ExtendedTmplAstBoundEvent); - } - }); +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, - }; + return { extractedBananaBoxes, extractedInputs, extractedOutputs } as const; } -function getInputsOutputsHash( - inputs: ExtendedTmplAstBoundAttribute[], - outputs: ExtendedTmplAstBoundEvent[], -): { hash: InputsOutputsHash; notMatchedOutputs: ExtendedTmplAstBoundEvent[] } { - const hash: InputsOutputsHash = {}; - const notMatchedOutputs: ExtendedTmplAstBoundEvent[] = []; - - inputs.forEach((input) => { - hash[input.name] = { input }; - }); - outputs.forEach((output) => { - if (!output.name.endsWith('Change')) { - notMatchedOutputs.push(output); - return; - } - - const name = output.name.substring(0, output.name.lastIndexOf('Change')); +function isTmplAstTemplate(node: TmplAstNode): node is TmplAstTemplate { + return node instanceof TmplAstTemplate; +} - if (!hash[name]) { - notMatchedOutputs.push(output); - return; - } +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; + } +} - hash[name].output = output; - }); +function getStartPos(expected: ExtendedAttribute): number { + switch (expected.orderType) { + case OrderType.StructuralDirective: + return expected.sourceSpan.start.offset - 1; + default: + return expected.sourceSpan.start.offset; + } +} - return { hash, notMatchedOutputs }; +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/src/utils/is-on-same-location.ts b/packages/eslint-plugin-template/src/utils/is-on-same-location.ts new file mode 100644 index 000000000..7620a2b4f --- /dev/null +++ b/packages/eslint-plugin-template/src/utils/is-on-same-location.ts @@ -0,0 +1,14 @@ +import type { + TmplAstBoundAttribute, + TmplAstBoundEvent, +} from '@angular-eslint/bundled-angular-compiler'; + +export function isOnSameLocation( + input: TmplAstBoundAttribute, + output: TmplAstBoundEvent, +) { + return ( + input.sourceSpan.start === output.sourceSpan.start && + input.sourceSpan.end === output.sourceSpan.end + ); +} diff --git a/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts b/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts deleted file mode 100644 index cc97edf20..000000000 --- a/packages/eslint-plugin-template/tests/rules/attributes-order.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - convertAnnotatedSourceToFailureCase, - RuleTester, -} from '@angular-eslint/utils'; -import type { MessageIds } from '../../src/rules/attributes-order'; -import rule, { - OrderAttributeType, - RULE_NAME, -} from '../../src/rules/attributes-order'; - -//------------------------------------------------------------------------------ -// Tests -//------------------------------------------------------------------------------ - -const ruleTester = new RuleTester({ - parser: '@angular-eslint/template-parser', -}); - -const messageId: MessageIds = 'attributesOrder'; - -ruleTester.run(RULE_NAME, rule, { - valid: [ - ``, - ``, - ``, - ``, - ], - invalid: [ - convertAnnotatedSourceToFailureCase({ - messageId, - description: 'should fail if two way binding is in wrong place', - annotatedSource: ` - - ~~~~~~~~~~~~~~~~~~~ - `, - data: { - types: [ - OrderAttributeType.ATTRIBUTE_BINDING, - OrderAttributeType.INPUT_BINDING, - ].join(' or '), - }, - }), - convertAnnotatedSourceToFailureCase({ - messageId, - description: 'should fail if structural directive is not first', - annotatedSource: ` - - ~~~~~~~~~ - `, - data: { types: OrderAttributeType.STRUCTURAL_DIRECTIVE }, - }), - convertAnnotatedSourceToFailureCase({ - messageId, - description: 'should fail if attribute is in wrong place', - annotatedSource: ` - - ~~~~~~~~~~~~~~~~~ - `, - data: { - types: [ - OrderAttributeType.STRUCTURAL_DIRECTIVE, - OrderAttributeType.TEMPLATE_REFERENCE, - ].join(' or '), - }, - }), - convertAnnotatedSourceToFailureCase({ - messageId, - description: 'should fail if output is in wrong place', - annotatedSource: ` - - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - `, - data: { - types: [ - OrderAttributeType.ATTRIBUTE_BINDING, - OrderAttributeType.INPUT_BINDING, - ].join(' or '), - }, - }), - convertAnnotatedSourceToFailureCase({ - messageId, - description: 'should fail if input is in wrong place', - annotatedSource: ` - - ~~~~~~~~~~~~~~~~ - `, - data: { - types: [ - OrderAttributeType.STRUCTURAL_DIRECTIVE, - OrderAttributeType.ATTRIBUTE_BINDING, - ].join(' or '), - }, - }), - convertAnnotatedSourceToFailureCase({ - messageId, - description: 'should work with custom order', - annotatedSource: ` - , - ~~~~~~~~~~~~~~~~~~~ - `, - data: { - types: [ - OrderAttributeType.INPUT_BINDING, - OrderAttributeType.OUTPUT_BINDING, - ].join(' or '), - }, - options: [ - { - order: [ - OrderAttributeType.TEMPLATE_REFERENCE, - OrderAttributeType.STRUCTURAL_DIRECTIVE, - OrderAttributeType.ATTRIBUTE_BINDING, - OrderAttributeType.INPUT_BINDING, - OrderAttributeType.OUTPUT_BINDING, - OrderAttributeType.TWO_WAY_BINDING, - ], - }, - ], - }), - ], -}); 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, +}); From 56f46d472a6e9c8644e9b0fc1e45dcd69b52dbbd Mon Sep 17 00:00:00 2001 From: Phil McCloghry-Laing Date: Sun, 18 Sep 2022 19:49:53 +1000 Subject: [PATCH 6/9] Update packages/eslint-plugin-template/src/rules/attributes-order.ts Co-authored-by: James Henry --- packages/eslint-plugin-template/src/rules/attributes-order.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts index 808f47cc7..d7cc7ec78 100644 --- a/packages/eslint-plugin-template/src/rules/attributes-order.ts +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -79,7 +79,7 @@ export default createESLintRule({ meta: { type: 'layout', docs: { - description: 'Ensures that html attributes are sorted correctly', + description: 'Ensures that HTML attributes and Angular bindings are sorted based on an expected order', recommended: false, }, fixable: 'code', From efb4595513a39f772f7d3d4736028d3e93f97fee Mon Sep 17 00:00:00 2001 From: Phil McCloghry-Laing Date: Sun, 18 Sep 2022 19:50:06 +1000 Subject: [PATCH 7/9] Update packages/eslint-plugin-template/src/rules/attributes-order.ts Co-authored-by: James Henry --- packages/eslint-plugin-template/src/rules/attributes-order.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts index d7cc7ec78..f014f8b08 100644 --- a/packages/eslint-plugin-template/src/rules/attributes-order.ts +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -106,7 +106,7 @@ export default createESLintRule({ ], messages: { attributesOrder: - 'Attributes order is incorrect, expected {{expected}} instead of {{actual}}', + 'The element's attributes/bindings did not match the expected order: expected {{expected}} instead of {{actual}}', }, }, defaultOptions: [DEFAULT_OPTIONS], From 065cb2f80a8f998c46189c43b763f692769c4ae7 Mon Sep 17 00:00:00 2001 From: Phil McCloghry-Laing Date: Mon, 19 Sep 2022 09:54:22 +1000 Subject: [PATCH 8/9] Fixed quotes and moved isOnSameLocation to attributes-order.ts --- .../src/rules/attributes-order.ts | 17 +++++++++++++---- .../src/utils/is-on-same-location.ts | 14 -------------- 2 files changed, 13 insertions(+), 18 deletions(-) delete mode 100644 packages/eslint-plugin-template/src/utils/is-on-same-location.ts diff --git a/packages/eslint-plugin-template/src/rules/attributes-order.ts b/packages/eslint-plugin-template/src/rules/attributes-order.ts index f014f8b08..d52b353f5 100644 --- a/packages/eslint-plugin-template/src/rules/attributes-order.ts +++ b/packages/eslint-plugin-template/src/rules/attributes-order.ts @@ -12,7 +12,6 @@ import { createESLintRule, getTemplateParserServices, } from '../utils/create-eslint-rule'; -import { isOnSameLocation } from '../utils/is-on-same-location'; export const enum OrderType { TemplateReferenceVariable = 'TEMPLATE_REFERENCE', @@ -79,7 +78,8 @@ export default createESLintRule({ meta: { type: 'layout', docs: { - description: 'Ensures that HTML attributes and Angular bindings are sorted based on an expected order', + description: + 'Ensures that HTML attributes and Angular bindings are sorted based on an expected order', recommended: false, }, fixable: 'code', @@ -105,8 +105,7 @@ export default createESLintRule({ }, ], messages: { - attributesOrder: - 'The element's attributes/bindings did not match the expected order: expected {{expected}} instead of {{actual}}', + attributesOrder: `The element's attributes/bindings did not match the expected order: expected {{expected}} instead of {{actual}}`, }, }, defaultOptions: [DEFAULT_OPTIONS], @@ -325,6 +324,16 @@ 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: diff --git a/packages/eslint-plugin-template/src/utils/is-on-same-location.ts b/packages/eslint-plugin-template/src/utils/is-on-same-location.ts deleted file mode 100644 index 7620a2b4f..000000000 --- a/packages/eslint-plugin-template/src/utils/is-on-same-location.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { - TmplAstBoundAttribute, - TmplAstBoundEvent, -} from '@angular-eslint/bundled-angular-compiler'; - -export function isOnSameLocation( - input: TmplAstBoundAttribute, - output: TmplAstBoundEvent, -) { - return ( - input.sourceSpan.start === output.sourceSpan.start && - input.sourceSpan.end === output.sourceSpan.end - ); -} From 52675618bc42694e6c643e86784f5522e63a2c3b Mon Sep 17 00:00:00 2001 From: Phil McCloghry-Laing Date: Tue, 27 Sep 2022 09:18:11 +1000 Subject: [PATCH 9/9] chore: update rule docs --- .../docs/rules/attributes-order.md | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 packages/eslint-plugin-template/docs/rules/attributes-order.md 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 + +``` + +
+ +