diff --git a/docs/rules/sort-jsx-props.md b/docs/rules/sort-jsx-props.md index e21f66bcb..50b16b402 100644 --- a/docs/rules/sort-jsx-props.md +++ b/docs/rules/sort-jsx-props.md @@ -87,14 +87,17 @@ If you use the [`jsx-sort-props`](https://github.com/jsx-eslint/eslint-plugin-re This rule accepts an options object with the following properties: ```ts +type Group = + | 'multiline' + | 'shorthand' + | 'unknown' + interface Options { type?: 'alphabetical' | 'natural' | 'line-length' order?: 'asc' | 'desc' 'ignore-case'?: boolean - 'always-on-top'?: string[] - callback?: 'first' | 'ignore' | 'last' - multiline?: 'first' | 'ignore' | 'last' - shorthand?: 'first' | 'ignore' | 'last' + groups?: (Group | Group[])[] + 'custom-groups': { [key in T[number]]: string[] | string } } ``` @@ -119,35 +122,27 @@ interface Options { Only affects alphabetical and natural sorting. When `true` the rule ignores the case-sensitivity of the order. -### always-on-top +### groups (default: `[]`) -You can set a list of property names that will always go at the beginning of the JSX element. - -### callback - -(default: `'ignore'`) - -- `first` - enforce callback JSX props to be at the top of the list -- `ignore` - sort callback props in general order -- `last` - enforce callback JSX props to be at the end of the list - -### multiline +You can set up a list of JSX props groups for sorting. Groups can be combined. There are predefined groups: `'multiline'`, `'shorthand'`. -(default: `'ignore'`) +### custom-groups -- `first` - enforce multiline JSX props to be at the top of the list -- `ignore` - sort multiline props in general order -- `last` - enforce multiline JSX props to be at the end of the list +(default: `{}`) -### shorthand +You can define your own groups for JSX props. The [minimatch](https://github.com/isaacs/minimatch) library is used for pattern matching. -(default: `'ignore'`) +Example: -- `first` - enforce shorthand JSX props to be at the top of the list -- `ignore` - sort shorthand props in general order -- `last` - enforce shorthand JSX props to be at the end of the list +``` +{ + "custom-groups": { + "callback": "on*" + } +} +``` ## ⚙️ Usage @@ -163,10 +158,11 @@ You can set a list of property names that will always go at the beginning of the { "type": "natural", "order": "asc", - "always-on-top": ["id", "name"], - "shorthand": "last", - "multiline": "first", - "callback": "ignore" + "groups": [ + "multiline", + "unknown", + "shorthand" + ] } ] } @@ -188,10 +184,11 @@ export default [ { type: 'natural', order: 'asc', - 'always-on-top': ['id', 'name'], - shorthand: 'last', - multiline: 'first', - callback: 'ignore', + groups: [ + 'multiline', + 'unknown', + 'shorthand', + ], }, ], }, diff --git a/rules/sort-jsx-props.ts b/rules/sort-jsx-props.ts index 0043790cd..42e1c8ece 100644 --- a/rules/sort-jsx-props.ts +++ b/rules/sort-jsx-props.ts @@ -1,6 +1,7 @@ import type { TSESTree } from '@typescript-eslint/types' import { AST_NODE_TYPES } from '@typescript-eslint/types' +import { minimatch } from 'minimatch' import type { SortingNode } from '../typings' @@ -11,27 +12,25 @@ import { makeFixes } from '../utils/make-fixes' import { sortNodes } from '../utils/sort-nodes' import { pairwise } from '../utils/pairwise' import { complete } from '../utils/complete' -import { groupBy } from '../utils/group-by' import { compare } from '../utils/compare' type MESSAGE_ID = 'unexpectedJSXPropsOrder' -export enum Position { - 'exception' = 'exception', - 'ignore' = 'ignore', - 'first' = 'first', - 'last' = 'last', -} +type Group = + | 'multiline' + | 'shorthand' + | 'unknown' + | T[number] -type SortingNodeWithPosition = SortingNode & { position: Position } +type SortingNodeWithGroup = SortingNode & { + group: Group +} -type Options = [ +type Options = [ Partial<{ - 'always-on-top': string[] + 'custom-groups': { [key in T[number]]: string[] | string } + groups: (Group[] | Group)[] 'ignore-case': boolean - multiline: Position - shorthand: Position - callback: Position order: SortOrder type: SortType }>, @@ -39,7 +38,7 @@ type Options = [ export const RULE_NAME = 'sort-jsx-props' -export default createEslintRule({ +export default createEslintRule, MESSAGE_ID>({ name: RULE_NAME, meta: { type: 'suggestion', @@ -52,6 +51,9 @@ export default createEslintRule({ { type: 'object', properties: { + 'custom-groups': { + type: 'object', + }, type: { enum: [ SortType.alphabetical, @@ -64,7 +66,7 @@ export default createEslintRule({ enum: [SortOrder.asc, SortOrder.desc], default: SortOrder.asc, }, - 'always-on-top': { + groups: { type: 'array', default: [], }, @@ -72,15 +74,6 @@ export default createEslintRule({ type: 'boolean', default: false, }, - shorthand: { - enum: [Position.first, Position.last, Position.ignore], - }, - callback: { - enum: [Position.first, Position.last, Position.ignore], - }, - multiline: { - enum: [Position.first, Position.last, Position.ignore], - }, }, additionalProperties: false, }, @@ -100,20 +93,18 @@ export default createEslintRule({ if (node.openingElement.attributes.length > 1) { let options = complete(context.options.at(0), { type: SortType.alphabetical, - shorthand: Position.ignore, - multiline: Position.ignore, - callback: Position.ignore, - 'always-on-top': [], 'ignore-case': false, order: SortOrder.asc, + 'custom-groups': {}, + groups: [], }) let source = context.getSourceCode() - let parts: SortingNodeWithPosition[][] = + let parts: SortingNodeWithGroup[][] = node.openingElement.attributes.reduce( ( - accumulator: SortingNodeWithPosition[][], + accumulator: SortingNodeWithGroup[][], attribute: TSESTree.JSXSpreadAttribute | TSESTree.JSXAttribute, ) => { if (attribute.type === AST_NODE_TYPES.JSXSpreadAttribute) { @@ -121,44 +112,44 @@ export default createEslintRule({ return accumulator } - let position: Position = Position.ignore + let name = attribute.name.type === AST_NODE_TYPES.JSXNamespacedName + ? `${attribute.name.namespace.name}:${attribute.name.name.name}` + : attribute.name.name - if ( - attribute.name.type === AST_NODE_TYPES.JSXIdentifier && - options['always-on-top'].includes(attribute.name.name) - ) { - position = Position.exception - } else { - if ( - options.shorthand !== Position.ignore && - attribute.value === null - ) { - position = options.shorthand + let group: Group | undefined + + let defineGroup = (nodeGroup: Group) => { + if (!group && options.groups.flat().includes(nodeGroup)) { + group = nodeGroup } + } + for (let [key, pattern] of Object.entries(options['custom-groups'])) { if ( - options.callback !== Position.ignore && - attribute.name.type === AST_NODE_TYPES.JSXIdentifier && - attribute.name.name.indexOf('on') === 0 && - attribute.value !== null + Array.isArray(pattern) && + pattern.some(patternValue => minimatch(name, patternValue)) ) { - position = options.callback - } else if ( - options.multiline !== Position.ignore && - attribute.loc.start.line !== attribute.loc.end.line - ) { - position = options.multiline + defineGroup(key) + } + + if (typeof pattern === 'string' && minimatch(name, pattern)) { + defineGroup(key) } } + if (attribute.value === null) { + defineGroup('shorthand') + } + + if (attribute.loc.start.line !== attribute.loc.end.line) { + defineGroup('multiline') + } + let jsxNode = { - name: - attribute.name.type === AST_NODE_TYPES.JSXNamespacedName - ? `${attribute.name.namespace.name}:${attribute.name.name.name}` - : attribute.name.name, size: rangeToDiff(attribute.range), + group: group ?? 'unknown', node: attribute, - position, + name, } accumulator.at(-1)!.push(jsxNode) @@ -168,32 +159,27 @@ export default createEslintRule({ [[]], ) - for (let nodes of parts) { - pairwise(nodes, (left, right) => { - let comparison: boolean + let getGroupNumber = (nodeWithGroup: SortingNodeWithGroup): number => { + for (let i = 0, max = options.groups.length; i < max; i++) { + let currentGroup = options.groups[i] if ( - left.position === Position.exception && - right.position === Position.exception + nodeWithGroup.group === currentGroup || + (Array.isArray(currentGroup) && currentGroup.includes(nodeWithGroup.group)) ) { - comparison = - options['always-on-top'].indexOf(left.name) > - options['always-on-top'].indexOf(right.name) - } else if (left.position === right.position) { - comparison = compare(left, right, options) - } else { - let positionPower = { - [Position.exception]: 2, - [Position.first]: 1, - [Position.ignore]: 0, - [Position.last]: -1, - } - - comparison = - positionPower[left.position] < positionPower[right.position] + return i } + } + return options.groups.length + } - if (comparison) { + for (let nodes of parts) { + pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(left) + let rightNum = getGroupNumber(right) + + if ((leftNum > rightNum || + (leftNum === rightNum && compare(left, right, options)))) { context.report({ messageId: 'unexpectedJSXPropsOrder', data: { @@ -202,24 +188,31 @@ export default createEslintRule({ }, node: right.node, fix: fixer => { - let groups = groupBy(nodes, ({ position }) => position) - - let getGroup = (index: string) => - index in groups ? groups[index] : [] - - let sortedNodes = [ - getGroup(Position.exception).sort( - (aNode, bNode) => - options['always-on-top'].indexOf(aNode.name) - - options['always-on-top'].indexOf(bNode.name), - ), - sortNodes(getGroup(Position.first), options), - sortNodes(getGroup(Position.ignore), options), - sortNodes(getGroup(Position.last), options), - ].flat() + let grouped: { + [key: string]: SortingNodeWithGroup[] + } = {} + + for (let currentNode of nodes) { + let groupNum = getGroupNumber(currentNode) + + if (!(groupNum in grouped)) { + grouped[groupNum] = [currentNode] + } else { + grouped[groupNum] = sortNodes( + [...grouped[groupNum], currentNode], + options, + ) + } + } + + let sortedNodes: SortingNode[] = [] + + for(let group of Object.keys(grouped).sort()) { + sortedNodes.push(...sortNodes(grouped[group], options)) + } return makeFixes(fixer, nodes, sortedNodes, source) - }, + } }) } }) diff --git a/test/sort-jsx-props.test.ts b/test/sort-jsx-props.test.ts index 9b04e7f49..77a025551 100644 --- a/test/sort-jsx-props.test.ts +++ b/test/sort-jsx-props.test.ts @@ -2,7 +2,7 @@ import { ESLintUtils } from '@typescript-eslint/utils' import { describe, it } from 'vitest' import { dedent } from 'ts-dedent' -import rule, { RULE_NAME, Position } from '../rules/sort-jsx-props' +import rule, { RULE_NAME } from '../rules/sort-jsx-props' import { SortOrder, SortType } from '../typings' describe(RULE_NAME, () => { @@ -247,8 +247,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['unknown', 'shorthand'], type: SortType.alphabetical, - shorthand: Position.last, order: SortOrder.asc, }, ], @@ -278,8 +278,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['unknown', 'shorthand'], type: SortType.alphabetical, - shorthand: Position.last, order: SortOrder.asc, }, ], @@ -311,8 +311,9 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { callback: 'on*' }, + groups: ['unknown', 'callback'], type: SortType.alphabetical, - callback: Position.last, order: SortOrder.asc, }, ], @@ -338,8 +339,9 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { callback: 'on*' }, + groups: ['unknown', 'callback'], type: SortType.alphabetical, - callback: Position.last, order: SortOrder.asc, }, ], @@ -377,8 +379,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: [['multiline'], 'unknown'], type: SortType.alphabetical, - multiline: Position.first, order: SortOrder.asc, }, ], @@ -416,8 +418,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['multiline', 'unknown'], type: SortType.alphabetical, - multiline: Position.first, order: SortOrder.asc, }, ], @@ -458,9 +460,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, type: SortType.alphabetical, + groups: ['top', 'unknown'], order: SortOrder.asc, - 'always-on-top': ['id', 'name'], }, ], }, @@ -489,9 +492,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, type: SortType.alphabetical, + groups: ['top', 'unknown'], order: SortOrder.asc, - 'always-on-top': ['id', 'name'], }, ], errors: [ @@ -526,9 +530,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id'] }, type: SortType.alphabetical, + groups: ['top', 'unknown'], order: SortOrder.asc, - 'always-on-top': ['id', 'name'], }, ], errors: [ @@ -556,9 +561,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id'] }, type: SortType.alphabetical, + groups: ['top', 'unknown'], order: SortOrder.asc, - 'always-on-top': ['id'], }, ], errors: [ @@ -808,8 +814,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['unknown', 'shorthand'], type: SortType.natural, - shorthand: Position.last, order: SortOrder.asc, }, ], @@ -839,8 +845,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['unknown', 'shorthand'], type: SortType.natural, - shorthand: Position.last, order: SortOrder.asc, }, ], @@ -872,8 +878,9 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { callback: 'on*' }, + groups: ['unknown', 'callback'], type: SortType.natural, - callback: Position.last, order: SortOrder.asc, }, ], @@ -899,8 +906,9 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { callback: 'on*' }, + groups: ['unknown', 'callback'], type: SortType.natural, - callback: Position.last, order: SortOrder.asc, }, ], @@ -938,8 +946,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: [['multiline'], 'unknown'], type: SortType.natural, - multiline: Position.first, order: SortOrder.asc, }, ], @@ -977,8 +985,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['multiline', 'unknown'], type: SortType.natural, - multiline: Position.first, order: SortOrder.asc, }, ], @@ -1019,9 +1027,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, + groups: ['top', 'unknown'], type: SortType.natural, order: SortOrder.asc, - 'always-on-top': ['id', 'name'], }, ], }, @@ -1050,9 +1059,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, + groups: ['top', 'unknown'], type: SortType.natural, order: SortOrder.asc, - 'always-on-top': ['id', 'name'], }, ], errors: [ @@ -1087,9 +1097,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id'] }, + groups: ['top', 'unknown'], type: SortType.natural, order: SortOrder.asc, - 'always-on-top': ['id', 'name'], }, ], errors: [ @@ -1117,9 +1128,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id'] }, + groups: ['top', 'unknown'], type: SortType.natural, order: SortOrder.asc, - 'always-on-top': ['id'], }, ], errors: [ @@ -1181,8 +1193,8 @@ describe(RULE_NAME, () => { let Odokawa = () => ( Pew-pew @@ -1369,8 +1381,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['unknown', 'shorthand'], type: SortType['line-length'], - shorthand: Position.last, order: SortOrder.desc, }, ], @@ -1391,8 +1403,8 @@ describe(RULE_NAME, () => { output: dedent` let Spike = () => ( @@ -1400,9 +1412,9 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['unknown', 'shorthand'], type: SortType['line-length'], order: SortOrder.desc, - shorthand: Position.last, }, ], errors: [ @@ -1440,8 +1452,9 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { callback: 'on*' }, + groups: ['unknown', 'callback'], type: SortType['line-length'], - callback: Position.last, order: SortOrder.desc, }, ], @@ -1467,8 +1480,9 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { callback: 'on*' }, + groups: ['unknown', 'callback'], type: SortType['line-length'], - callback: Position.last, order: SortOrder.desc, }, ], @@ -1506,8 +1520,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: [['multiline'], 'unknown'], type: SortType['line-length'], - multiline: Position.first, order: SortOrder.desc, }, ], @@ -1545,8 +1559,8 @@ describe(RULE_NAME, () => { `, options: [ { + groups: ['multiline', 'unknown'], type: SortType['line-length'], - multiline: Position.first, order: SortOrder.desc, }, ], @@ -1587,9 +1601,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, type: SortType['line-length'], + groups: ['top', 'unknown'], order: SortOrder.desc, - 'always-on-top': ['id', 'name'], }, ], }, @@ -1618,9 +1633,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, type: SortType['line-length'], + groups: ['top', 'unknown'], order: SortOrder.desc, - 'always-on-top': ['id', 'name'], }, ], errors: [ @@ -1662,9 +1678,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, type: SortType['line-length'], + groups: ['top', 'unknown'], order: SortOrder.desc, - 'always-on-top': ['id', 'name'], }, ], errors: [ @@ -1692,9 +1709,10 @@ describe(RULE_NAME, () => { `, options: [ { + 'custom-groups': { top: ['id', 'name'] }, type: SortType['line-length'], + groups: ['top', 'unknown'], order: SortOrder.desc, - 'always-on-top': ['id'], }, ], errors: [