Skip to content

Commit dc656eb

Browse files
committed
[New] jsx-sort-props: add sortFirst option
Fixes #3175. Fixes #3639. Fixes #3193. Closes #3853.
1 parent a116665 commit dc656eb

4 files changed

Lines changed: 227 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
1010
* [`jsx-props-no-multi-spaces`]: improve autofix for multi-line ([#3930][] @justisb)
1111
* [`jsx-handler-names`]: support namespaced component names ([#3943][] @takuji)
1212
* [`jsx-no-leaked-render`]: add `ignoreAttributes` option ([#3441][] @aleclarson)
13+
* [`jsx-sort-props`]: add `sortFirst` option ([#3965][] @loderunner)
1314

1415
### Fixed
1516
* [`no-unknown-property`]: allow `onLoad` on `body` ([#3923][] @DerekStapleton)
@@ -24,6 +25,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
2425
[#3978]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3978
2526
[#3958]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3958
2627
[#3980]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3980
28+
[#3965]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3965
2729
[#3943]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3943
2830
[#3942]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3942
2931
[#3930]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3930

docs/rules/jsx-sort-props.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ This rule checks all JSX components and verifies that all props are sorted alpha
1515
Examples of **incorrect** code for this rule:
1616

1717
```jsx
18-
<Hello lastName="Smith" firstName="John" />;
18+
<Hello lastName="Smith" firstName="John" />
1919
```
2020

2121
Examples of **correct** code for this rule:
@@ -37,6 +37,7 @@ Examples of **correct** code for this rule:
3737
"ignoreCase": <boolean>,
3838
"noSortAlphabetically": <boolean>,
3939
"reservedFirst": <boolean>|<array<string>>,
40+
"sortFirst": <array<string>>,
4041
"locale": "auto" | "any valid locale"
4142
}]
4243
...
@@ -49,7 +50,7 @@ When `true` the rule ignores the case-sensitivity of the props order.
4950
Examples of **correct** code for this rule
5051

5152
```jsx
52-
<Hello name="John" Number="2" />;
53+
<Hello name="John" Number="2" />
5354
```
5455

5556
### `callbacksLast`
@@ -140,6 +141,32 @@ With `reservedFirst: ["key"]`, the following will **not** warn:
140141
<Hello key={'uuid'} name="John" ref={johnRef} />
141142
```
142143

144+
### `sortFirst`
145+
146+
When `sortFirst` is defined as an array of prop names, those props must be listed before all other props, maintaining the exact order specified in the array. This option has the highest priority and takes precedence over all other sorting options (including `reservedFirst`, `shorthandFirst`, `callbacksLast`, and `multiline`).
147+
148+
The prop names in the array are matched case-sensitively by default, but respect the `ignoreCase` option when enabled.
149+
150+
Examples of **incorrect** code for this rule:
151+
152+
```jsx
153+
// 'jsx-sort-props': [1, { sortFirst: ['className'] }]
154+
<Hello name="John" className="test" />
155+
```
156+
157+
Examples of **correct** code for this rule:
158+
159+
```jsx
160+
// 'jsx-sort-props': [1, { sortFirst: ['className'] }]
161+
<Hello className="test" name="John" />
162+
163+
// 'jsx-sort-props': [1, { sortFirst: ['className', 'id'] }]
164+
<Hello className="test" id="test" name="John" />
165+
166+
// 'jsx-sort-props': [1, { sortFirst: ['className'], ignoreCase: true }]
167+
<Hello classname="test" name="John" />
168+
```
169+
143170
### `locale`
144171

145172
Defaults to `"auto"`, meaning, the locale of the current environment.

lib/rules/jsx-sort-props.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const messages = {
3535
listShorthandLast: 'Shorthand props must be listed after all other props',
3636
listMultilineFirst: 'Multiline props must be listed before all other props',
3737
listMultilineLast: 'Multiline props must be listed after all other props',
38+
listSortFirstPropsFirst: 'Props in sortFirst must be listed before all other props',
3839
sortPropsByAlpha: 'Props should be sorted alphabetically',
3940
};
4041

@@ -49,6 +50,17 @@ function isReservedPropName(name, list) {
4950
return list.indexOf(name) >= 0;
5051
}
5152

53+
function getSortFirstIndex(name, sortFirstList, ignoreCase) {
54+
const normalizedPropName = ignoreCase ? name.toLowerCase() : name;
55+
for (let i = 0; i < sortFirstList.length; i++) {
56+
const normalizedListName = ignoreCase ? sortFirstList[i].toLowerCase() : sortFirstList[i];
57+
if (normalizedPropName === normalizedListName) {
58+
return i;
59+
}
60+
}
61+
return -1;
62+
}
63+
5264
let attributeMap;
5365
// attributeMap = { end: endrange, hasComment: true||false if comment in between nodes exists, it needs to be sorted to end }
5466

@@ -70,6 +82,24 @@ function contextCompare(a, b, options) {
7082
return -1;
7183
}
7284

85+
if (options.sortFirst && options.sortFirst.length > 0) {
86+
const aSortFirstIndex = getSortFirstIndex(aProp, options.sortFirst, options.ignoreCase);
87+
const bSortFirstIndex = getSortFirstIndex(bProp, options.sortFirst, options.ignoreCase);
88+
if (aSortFirstIndex >= 0 && bSortFirstIndex >= 0) {
89+
// Both are in sortFirst, maintain their exact order
90+
if (aSortFirstIndex !== bSortFirstIndex) {
91+
return aSortFirstIndex - bSortFirstIndex;
92+
}
93+
return 0;
94+
}
95+
if (aSortFirstIndex >= 0 && bSortFirstIndex < 0) {
96+
return -1;
97+
}
98+
if (aSortFirstIndex < 0 && bSortFirstIndex >= 0) {
99+
return 1;
100+
}
101+
}
102+
73103
if (options.reservedFirst) {
74104
const aIsReserved = isReservedPropName(aProp, options.reservedList);
75105
const bIsReserved = isReservedPropName(bProp, options.reservedList);
@@ -222,6 +252,7 @@ function generateFixerFunction(node, context, reservedList) {
222252
const multiline = configuration.multiline || 'ignore';
223253
const noSortAlphabetically = configuration.noSortAlphabetically || false;
224254
const reservedFirst = configuration.reservedFirst || false;
255+
const sortFirst = configuration.sortFirst || [];
225256
const locale = configuration.locale || 'auto';
226257

227258
// Sort props according to the context. Only supports ignoreCase.
@@ -236,6 +267,7 @@ function generateFixerFunction(node, context, reservedList) {
236267
noSortAlphabetically,
237268
reservedFirst,
238269
reservedList,
270+
sortFirst,
239271
locale,
240272
};
241273
const sortableAttributeGroups = getGroupsOfSortableAttributes(attributes, context);
@@ -382,6 +414,13 @@ module.exports = {
382414
reservedFirst: {
383415
type: ['array', 'boolean'],
384416
},
417+
sortFirst: {
418+
type: 'array',
419+
items: {
420+
type: 'string',
421+
},
422+
uniqueItems: true,
423+
},
385424
locale: {
386425
type: 'string',
387426
default: 'auto',
@@ -402,6 +441,7 @@ module.exports = {
402441
const reservedFirst = configuration.reservedFirst || false;
403442
const reservedFirstError = validateReservedFirstConfig(context, reservedFirst);
404443
const reservedList = Array.isArray(reservedFirst) ? reservedFirst : RESERVED_PROPS_LIST;
444+
const sortFirst = configuration.sortFirst || [];
405445
const locale = configuration.locale || 'auto';
406446

407447
return {
@@ -425,6 +465,31 @@ module.exports = {
425465
const previousIsCallback = propTypesSortUtil.isCallbackPropName(previousPropName);
426466
const currentIsCallback = propTypesSortUtil.isCallbackPropName(currentPropName);
427467

468+
if (sortFirst && sortFirst.length > 0) {
469+
const previousSortFirstIndex = getSortFirstIndex(previousPropName, sortFirst, ignoreCase);
470+
const currentSortFirstIndex = getSortFirstIndex(currentPropName, sortFirst, ignoreCase);
471+
472+
if (previousSortFirstIndex >= 0 && currentSortFirstIndex >= 0) {
473+
// Both are in sortFirst, check their order
474+
if (previousSortFirstIndex > currentSortFirstIndex) {
475+
reportNodeAttribute(decl, 'listSortFirstPropsFirst', node, context, nodeReservedList);
476+
return memo;
477+
}
478+
return decl;
479+
}
480+
481+
if (previousSortFirstIndex >= 0 && currentSortFirstIndex < 0) {
482+
// Previous is in sortFirst, current is not - this is correct, continue to next prop
483+
return decl;
484+
}
485+
486+
if (previousSortFirstIndex < 0 && currentSortFirstIndex >= 0) {
487+
// Current is in sortFirst but previous is not - error
488+
reportNodeAttribute(decl, 'listSortFirstPropsFirst', node, context, nodeReservedList);
489+
return memo;
490+
}
491+
}
492+
428493
if (ignoreCase) {
429494
previousPropName = previousPropName.toLowerCase();
430495
currentPropName = currentPropName.toLowerCase();

tests/lib/rules/jsx-sort-props.js

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ const expectedReservedFirstError = {
5858
messageId: 'listReservedPropsFirst',
5959
type: 'JSXIdentifier',
6060
};
61+
const expectedSortFirstError = {
62+
messageId: 'listSortFirstPropsFirst',
63+
type: 'JSXIdentifier',
64+
};
6165
const expectedEmptyReservedFirstError = {
6266
messageId: 'listIsEmpty',
6367
};
@@ -120,6 +124,33 @@ const multilineAndShorthandAndCallbackLastArgs = [
120124
callbacksLast: true,
121125
},
122126
];
127+
const sortFirstArgs = [{ sortFirst: ['className'] }];
128+
const sortFirstMultipleArgs = [{ sortFirst: ['className', 'id'] }];
129+
const sortFirstWithIgnoreCaseArgs = [{ sortFirst: ['className'], ignoreCase: true }];
130+
const sortFirstWithReservedFirstArgs = [
131+
{
132+
sortFirst: ['className'],
133+
reservedFirst: true,
134+
},
135+
];
136+
const sortFirstWithShorthandFirstArgs = [
137+
{
138+
sortFirst: ['className'],
139+
shorthandFirst: true,
140+
},
141+
];
142+
const sortFirstWithCallbacksLastArgs = [
143+
{
144+
sortFirst: ['className'],
145+
callbacksLast: true,
146+
},
147+
];
148+
const sortFirstWithMultilineFirstArgs = [
149+
{
150+
sortFirst: ['className'],
151+
multiline: 'first',
152+
},
153+
];
123154

124155
ruleTester.run('jsx-sort-props', rule, {
125156
valid: parsers.all([].concat(
@@ -296,7 +327,29 @@ ruleTester.run('jsx-sort-props', rule, {
296327
/>
297328
`,
298329
options: [{ locale: 'sk-SK' }],
299-
} : []
330+
} : [],
331+
// sortFirst
332+
{ code: '<App className="test" name="John" />;', options: sortFirstArgs },
333+
{ code: '<App className="test" id="test" name="John" />;', options: sortFirstMultipleArgs },
334+
{ code: '<App className="test" id="test" />;', options: sortFirstMultipleArgs },
335+
{ code: '<App className="test" a b c />;', options: sortFirstArgs },
336+
{ code: '<App className="test" id="test" a b c />;', options: sortFirstMultipleArgs },
337+
{ code: '<App className="test" key={0} name="John" />;', options: sortFirstWithReservedFirstArgs },
338+
{ code: '<App className="test" a name="John" />;', options: sortFirstWithShorthandFirstArgs },
339+
{ code: '<App className="test" name="John" onClick={handleClick} />;', options: sortFirstWithCallbacksLastArgs },
340+
{
341+
code: `
342+
<App
343+
className="test"
344+
data={{
345+
test: 1,
346+
}}
347+
name="John"
348+
/>
349+
`,
350+
options: sortFirstWithMultilineFirstArgs,
351+
},
352+
{ code: '<App classname="test" a="test2" />;', options: sortFirstWithIgnoreCaseArgs }
300353
)),
301354
invalid: parsers.all([].concat(
302355
{
@@ -1101,6 +1154,83 @@ ruleTester.run('jsx-sort-props', rule, {
11011154
line: 11,
11021155
},
11031156
],
1157+
},
1158+
// sortFirst
1159+
{
1160+
code: '<App name="John" className="test" />;',
1161+
options: sortFirstArgs,
1162+
errors: [expectedSortFirstError],
1163+
output: '<App className="test" name="John" />;',
1164+
},
1165+
{
1166+
code: '<App id="test" className="test" name="John" />;',
1167+
options: sortFirstMultipleArgs,
1168+
errors: [expectedSortFirstError],
1169+
output: '<App className="test" id="test" name="John" />;',
1170+
},
1171+
{
1172+
code: '<App a className="test" b />;',
1173+
options: sortFirstArgs,
1174+
errors: [expectedSortFirstError],
1175+
output: '<App className="test" a b />;',
1176+
},
1177+
{
1178+
code: '<App key={0} className="test" name="John" />;',
1179+
options: sortFirstWithReservedFirstArgs,
1180+
errors: [expectedSortFirstError],
1181+
output: '<App className="test" key={0} name="John" />;',
1182+
},
1183+
{
1184+
code: '<App a className="test" name="John" />;',
1185+
options: sortFirstWithShorthandFirstArgs,
1186+
errors: [expectedSortFirstError],
1187+
output: '<App className="test" a name="John" />;',
1188+
},
1189+
{
1190+
code: '<App name="John" onClick={handleClick} className="test" />;',
1191+
options: sortFirstWithCallbacksLastArgs,
1192+
errors: [expectedSortFirstError],
1193+
output: '<App className="test" name="John" onClick={handleClick} />;',
1194+
},
1195+
{
1196+
code: `
1197+
<App
1198+
name="John"
1199+
className="test"
1200+
data={{
1201+
test: 1,
1202+
}}
1203+
/>
1204+
`,
1205+
options: sortFirstWithMultilineFirstArgs,
1206+
errors: [expectedSortFirstError, expectedMultilineFirstError],
1207+
output: `
1208+
<App
1209+
className="test"
1210+
data={{
1211+
test: 1,
1212+
}}
1213+
name="John"
1214+
/>
1215+
`,
1216+
},
1217+
{
1218+
code: '<App name="John" classname="test" />;',
1219+
options: sortFirstWithIgnoreCaseArgs,
1220+
errors: [expectedSortFirstError],
1221+
output: '<App classname="test" name="John" />;',
1222+
},
1223+
{
1224+
code: '<App className="test" id="test" tel={5555555} name="John" />;',
1225+
options: sortFirstMultipleArgs,
1226+
errors: [expectedError],
1227+
output: '<App className="test" id="test" name="John" tel={5555555} />;',
1228+
},
1229+
{
1230+
code: '<App id="test" className="test" id="test2" />;',
1231+
options: sortFirstMultipleArgs,
1232+
errors: [expectedSortFirstError],
1233+
output: '<App className="test" id="test" id="test2" />;',
11041234
}
11051235
)),
11061236
});

0 commit comments

Comments
 (0)