Skip to content

Commit

Permalink
[New] jsx-sort-props: support multiline prop groups
Browse files Browse the repository at this point in the history
Fixes #3170.
  • Loading branch information
duhamelgm authored and ljharb committed Feb 6, 2022
1 parent cfb4d6b commit 446c5a3
Show file tree
Hide file tree
Showing 4 changed files with 437 additions and 20 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -11,6 +11,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
* [`jsx-curly-brace-presence`]: add "propElementValues" config option ([#3191][] @ljharb)
* add [`iframe-missing-sandbox`] rule ([#2753][] @tosmolka @ljharb)
* [`no-did-mount-set-state`], [`no-did-update-set-state`]: no-op with react >= 16.3 ([#1754][] @ljharb)
* [`jsx-sort-props`]: support multiline prop groups ([#3198][] @duhamelgm)

### Fixed
* [`prop-types`], `propTypes`: add support for exported type inference ([#3163][] @vedadeepta)
Expand All @@ -31,6 +32,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
* [Docs] [`forbid-foreign-prop-types`]: document `allowInPropTypes` option ([#1815][] @ljharb)
* [Refactor] [`jsx-sort-default-props`]: remove unnecessary code ([#1817][] @ljharb)

[#3198]: https://github.com/yannickcr/eslint-plugin-react/pull/3198
[#3195]: https://github.com/yannickcr/eslint-plugin-react/pull/3195
[#3191]: https://github.com/yannickcr/eslint-plugin-react/pull/3191
[#3190]: https://github.com/yannickcr/eslint-plugin-react/pull/3190
Expand Down
37 changes: 37 additions & 0 deletions docs/rules/jsx-sort-props.md
Expand Up @@ -29,6 +29,7 @@ Examples of **correct** code for this rule:
"callbacksLast": <boolean>,
"shorthandFirst": <boolean>,
"shorthandLast": <boolean>,
"multiline": "ignore" | "first" | "last",
"ignoreCase": <boolean>,
"noSortAlphabetically": <boolean>,
"reservedFirst": <boolean>|<array<string>>,
Expand Down Expand Up @@ -70,6 +71,42 @@ When `true`, short hand props must be listed after all other props (unless `call
<Hello name="John" tel={5555555} active validate />
```

### `multiline`

Enforced sorting for multiline props

* `ignore`: Multiline props will not be taken in consideration for sorting.

* `first`: Multiline props must be listed before all other props (unless `shorthandFirst` is set), but still respecting the alphabetical order.

* `last`: Multiline props must be listed after all other props (unless either `callbacksLast` or `shorthandLast` are set), but still respecting the alphabetical order.

Defaults to `ignore`.

```jsx
// 'jsx-sort-props': [1, { multiline: 'first' }]
<Hello
classes={{
greetings: classes.greetings,
}}
active
validate
name="John"
tel={5555555}
/>

// 'jsx-sort-props': [1, { multiline: 'last' }]
<Hello
active
validate
name="John"
tel={5555555}
classes={{
greetings: classes.greetings,
}}
/>
```

### `noSortAlphabetically`

When `true`, alphabetical order is **not** enforced:
Expand Down
112 changes: 92 additions & 20 deletions lib/rules/jsx-sort-props.js
Expand Up @@ -18,13 +18,19 @@ function isCallbackPropName(name) {
return /^on[A-Z]/.test(name);
}

function isMultilineProp(node) {
return node.loc.start.line !== node.loc.end.line;
}

const messages = {
noUnreservedProps: 'A customized reserved first list must only contain a subset of React reserved props. Remove: {{unreservedWords}}',
listIsEmpty: 'A customized reserved first list must not be empty',
listReservedPropsFirst: 'Reserved props must be listed before all other props',
listCallbacksLast: 'Callbacks must be listed after all other props',
listShorthandFirst: 'Shorthand props must be listed before all other props',
listShorthandLast: 'Shorthand props must be listed after all other props',
listMultilineFirst: 'Multiline props must be listed before all other props',
listMultilineLast: 'Multiline props must be listed after all other props',
sortPropsByAlpha: 'Props should be sorted alphabetically',
};

Expand Down Expand Up @@ -75,6 +81,18 @@ function contextCompare(a, b, options) {
}
}

if (options.multiline !== 'ignore') {
const multilineSign = options.multiline === 'first' ? -1 : 1;
const aIsMultiline = isMultilineProp(a);
const bIsMultiline = isMultilineProp(b);
if (aIsMultiline && !bIsMultiline) {
return multilineSign;
}
if (!aIsMultiline && bIsMultiline) {
return -multilineSign;
}
}

if (options.noSortAlphabetically) {
return 0;
}
Expand Down Expand Up @@ -127,6 +145,7 @@ const generateFixerFunction = (node, context, reservedList) => {
const callbacksLast = configuration.callbacksLast || false;
const shorthandFirst = configuration.shorthandFirst || false;
const shorthandLast = configuration.shorthandLast || false;
const multiline = configuration.multiline || 'ignore';
const noSortAlphabetically = configuration.noSortAlphabetically || false;
const reservedFirst = configuration.reservedFirst || false;

Expand All @@ -138,6 +157,7 @@ const generateFixerFunction = (node, context, reservedList) => {
callbacksLast,
shorthandFirst,
shorthandLast,
multiline,
noSortAlphabetically,
reservedFirst,
reservedList,
Expand Down Expand Up @@ -213,6 +233,34 @@ function validateReservedFirstConfig(context, reservedFirst) {
}
}

const reportedNodeAttributes = new WeakMap();
/**
* Check if the current node attribute has already been reported with the same error type
* if that's the case then we don't report a new error
* otherwise we report the error
* @param {Object} nodeAttribute The node attribute to be reported
* @param {string} errorType The error type to be reported
* @param {Object} node The parent node for the node attribute
* @param {Object} context The context of the rule
* @param {Array<String>} reservedList The list of reserved props
*/
function reportNodeAttribute(nodeAttribute, errorType, node, context, reservedList) {
const errors = reportedNodeAttributes.get(nodeAttribute) || [];

if (errors.includes(errorType)) {
return;
}

errors.push(errorType);

reportedNodeAttributes.set(nodeAttribute, errors);

report(context, messages[errorType], errorType, {
node: nodeAttribute.name,
fix: generateFixerFunction(node, context, reservedList),
});
}

module.exports = {
meta: {
docs: {
Expand Down Expand Up @@ -241,6 +289,11 @@ module.exports = {
shorthandLast: {
type: 'boolean',
},
// Whether multiline properties should be listed first or last
multiline: {
enum: ['ignore', 'first', 'last'],
default: 'ignore',
},
ignoreCase: {
type: 'boolean',
},
Expand All @@ -262,6 +315,7 @@ module.exports = {
const callbacksLast = configuration.callbacksLast || false;
const shorthandFirst = configuration.shorthandFirst || false;
const shorthandLast = configuration.shorthandLast || false;
const multiline = configuration.multiline || 'ignore';
const noSortAlphabetically = configuration.noSortAlphabetically || false;
const reservedFirst = configuration.reservedFirst || false;
const reservedFirstError = validateReservedFirstConfig(context, reservedFirst);
Expand All @@ -285,6 +339,8 @@ module.exports = {
const currentValue = decl.value;
const previousIsCallback = isCallbackPropName(previousPropName);
const currentIsCallback = isCallbackPropName(currentPropName);
const previousIsMultiline = isMultilineProp(memo);
const currentIsMultiline = isMultilineProp(decl);

if (ignoreCase) {
previousPropName = previousPropName.toLowerCase();
Expand All @@ -304,10 +360,8 @@ module.exports = {
return decl;
}
if (!previousIsReserved && currentIsReserved) {
report(context, messages.listReservedPropsFirst, 'listReservedPropsFirst', {
node: decl.name,
fix: generateFixerFunction(node, context, reservedList),
});
reportNodeAttribute(decl, 'listReservedPropsFirst', node, context, reservedList);

return memo;
}
}
Expand All @@ -319,10 +373,8 @@ module.exports = {
}
if (previousIsCallback && !currentIsCallback) {
// Encountered a non-callback prop after a callback prop
report(context, messages.listCallbacksLast, 'listCallbacksLast', {
node: memo.name,
fix: generateFixerFunction(node, context, reservedList),
});
reportNodeAttribute(memo, 'listCallbacksLast', node, context, reservedList);

return memo;
}
}
Expand All @@ -332,10 +384,8 @@ module.exports = {
return decl;
}
if (!currentValue && previousValue) {
report(context, messages.listShorthandFirst, 'listShorthandFirst', {
node: memo.name,
fix: generateFixerFunction(node, context, reservedList),
});
reportNodeAttribute(decl, 'listShorthandFirst', node, context, reservedList);

return memo;
}
}
Expand All @@ -345,10 +395,34 @@ module.exports = {
return decl;
}
if (currentValue && !previousValue) {
report(context, messages.listShorthandLast, 'listShorthandLast', {
node: memo.name,
fix: generateFixerFunction(node, context, reservedList),
});
reportNodeAttribute(memo, 'listShorthandLast', node, context, reservedList);

return memo;
}
}

if (multiline === 'first') {
if (previousIsMultiline && !currentIsMultiline) {
// Exiting the multiline prop section
return decl;
}
if (!previousIsMultiline && currentIsMultiline) {
// Encountered a non-multiline prop before a multiline prop
reportNodeAttribute(decl, 'listMultilineFirst', node, context, reservedList);

return memo;
}
}

if (multiline === 'last') {
if (!previousIsMultiline && currentIsMultiline) {
// Entering the multiline prop section
return decl;
}
if (previousIsMultiline && !currentIsMultiline) {
// Encountered a non-multiline prop after a multiline prop
reportNodeAttribute(memo, 'listMultilineLast', node, context, reservedList);

return memo;
}
}
Expand All @@ -361,10 +435,8 @@ module.exports = {
: previousPropName > currentPropName
)
) {
report(context, messages.sortPropsByAlpha, 'sortPropsByAlpha', {
node: decl.name,
fix: generateFixerFunction(node, context, reservedList),
});
reportNodeAttribute(decl, 'sortPropsByAlpha', node, context, reservedList);

return memo;
}

Expand Down

0 comments on commit 446c5a3

Please sign in to comment.