diff --git a/docs/rules/jsx-sort-props.md b/docs/rules/jsx-sort-props.md index b51209f32e..f47afeec55 100644 --- a/docs/rules/jsx-sort-props.md +++ b/docs/rules/jsx-sort-props.md @@ -35,6 +35,7 @@ Examples of **correct** code for this rule: "ignoreCase": , "noSortAlphabetically": , "reservedFirst": |>, + "customPropsFirst": >, "locale": "auto" | "any valid locale" }] ... @@ -138,6 +139,24 @@ With `reservedFirst: ["key"]`, the following will **not** warn: ``` +### `customPropsFirst` + +This can only be an array option. + +When `customPropsFirst` is defined, the specified custom props must be listed before all other props, but still respecting the alphabetical order: + +```jsx +// 'jsx-sort-props': [1, { customPropsFirst: ["className", 'theme'] }] + +``` + +If both `reservedFirst` and `customPropsFirst` are defined, reserved props are listed first, followed by custom props, and then all other props, but still respecting the alphabetical order: + +```jsx +// 'jsx-sort-props': [1, { reservedFirst: true, customPropsFirst: ["className", 'theme'] }] + +``` + ### `locale` Defaults to `"auto"`, meaning, the locale of the current environment. diff --git a/lib/rules/jsx-sort-props.js b/lib/rules/jsx-sort-props.js index ec9d869b74..028b8e0d84 100644 --- a/lib/rules/jsx-sort-props.js +++ b/lib/rules/jsx-sort-props.js @@ -28,8 +28,10 @@ function isMultilineProp(node) { 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', + reservedListIsEmpty: 'A customized reserved first list must not be empty', + customPropsListIsEmpty: 'Custom props first list must not be empty', listReservedPropsFirst: 'Reserved props must be listed before all other props', + listCustomPropsFirst: 'Custom 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', @@ -45,7 +47,7 @@ const RESERVED_PROPS_LIST = [ 'ref', ]; -function isReservedPropName(name, list) { +function isPropNameInList(name, list) { return list.indexOf(name) >= 0; } @@ -71,8 +73,8 @@ function contextCompare(a, b, options) { } if (options.reservedFirst) { - const aIsReserved = isReservedPropName(aProp, options.reservedList); - const bIsReserved = isReservedPropName(bProp, options.reservedList); + const aIsReserved = isPropNameInList(aProp, options.reservedList); + const bIsReserved = isPropNameInList(bProp, options.reservedList); if (aIsReserved && !bIsReserved) { return -1; } @@ -81,6 +83,17 @@ function contextCompare(a, b, options) { } } + if (options.customPropsList) { + const aIsCustom = isPropNameInList(aProp, options.customPropsList); + const bIsCustom = isPropNameInList(bProp, options.customPropsList); + if (aIsCustom && !bIsCustom) { + return -1; + } + if (!aIsCustom && bIsCustom) { + return 1; + } + } + if (options.callbacksLast) { const aIsCallback = propTypesSortUtil.isCallbackPropName(aProp); const bIsCallback = propTypesSortUtil.isCallbackPropName(bProp); @@ -212,7 +225,7 @@ function getGroupsOfSortableAttributes(attributes, context) { return sortableAttributeGroups; } -function generateFixerFunction(node, context, reservedList) { +function generateFixerFunction(node, context, reservedList, customPropsList) { const attributes = node.attributes.slice(0); const configuration = context.options[0] || {}; const ignoreCase = configuration.ignoreCase || false; @@ -222,11 +235,9 @@ function generateFixerFunction(node, context, reservedList) { const multiline = configuration.multiline || 'ignore'; const noSortAlphabetically = configuration.noSortAlphabetically || false; const reservedFirst = configuration.reservedFirst || false; + const customPropsFirst = configuration.customPropsFirst || false; const locale = configuration.locale || 'auto'; - // Sort props according to the context. Only supports ignoreCase. - // Since we cannot safely move JSXSpreadAttribute (due to potential variable overrides), - // we only consider groups of sortable attributes. const options = { ignoreCase, callbacksLast, @@ -236,8 +247,11 @@ function generateFixerFunction(node, context, reservedList) { noSortAlphabetically, reservedFirst, reservedList, + customPropsFirst, + customPropsList, locale, }; + const sortableAttributeGroups = getGroupsOfSortableAttributes(attributes, context); const sortedAttributeGroups = sortableAttributeGroups .slice(0) @@ -284,14 +298,14 @@ function validateReservedFirstConfig(context, reservedFirst) { if (reservedFirst) { if (Array.isArray(reservedFirst)) { // Only allow a subset of reserved words in customized lists - const nonReservedWords = reservedFirst.filter((word) => !isReservedPropName( + const nonReservedWords = reservedFirst.filter((word) => !isPropNameInList( word, RESERVED_PROPS_LIST )); if (reservedFirst.length === 0) { return function Report(decl) { - report(context, messages.listIsEmpty, 'listIsEmpty', { + report(context, messages.reservedListIsEmpty, 'reservedListIsEmpty', { node: decl, }); }; @@ -310,6 +324,27 @@ function validateReservedFirstConfig(context, reservedFirst) { } } +/** + * Checks if the `customPropsFirst` option is valid + * @param {Object} context The context of the rule + * @param {boolean | string[]} customPropsFirst The `customPropsFirst` option + * @return {Function | undefined} If an error is detected, a function to generate the error message, otherwise, `undefined` + */ +// eslint-disable-next-line consistent-return +function validateCustomPropsFirstConfig(context, customPropsFirst) { + if (customPropsFirst) { + if (Array.isArray(customPropsFirst)) { + if (customPropsFirst.length === 0) { + return function Report(decl) { + report(context, messages.customPropsListIsEmpty, 'customPropsListIsEmpty', { + node: decl, + }); + }; + } + } + } +} + const reportedNodeAttributes = new WeakMap(); /** * Check if the current node attribute has already been reported with the same error type @@ -320,8 +355,9 @@ const reportedNodeAttributes = new WeakMap(); * @param {Object} node The parent node for the node attribute * @param {Object} context The context of the rule * @param {Array} reservedList The list of reserved props + * @param {Array} customPropsList The list of custom props */ -function reportNodeAttribute(nodeAttribute, errorType, node, context, reservedList) { +function reportNodeAttribute(nodeAttribute, errorType, node, context, reservedList, customPropsList) { const errors = reportedNodeAttributes.get(nodeAttribute) || []; if (includes(errors, errorType)) { @@ -334,7 +370,7 @@ function reportNodeAttribute(nodeAttribute, errorType, node, context, reservedLi report(context, messages[errorType], errorType, { node: nodeAttribute.name, - fix: generateFixerFunction(node, context, reservedList), + fix: generateFixerFunction(node, context, reservedList, customPropsList), }); } @@ -382,6 +418,9 @@ module.exports = { reservedFirst: { type: ['array', 'boolean'], }, + customPropsFirst: { + type: ['array', 'boolean'], + }, locale: { type: 'string', default: 'auto', @@ -402,6 +441,9 @@ module.exports = { const reservedFirst = configuration.reservedFirst || false; const reservedFirstError = validateReservedFirstConfig(context, reservedFirst); const reservedList = Array.isArray(reservedFirst) ? reservedFirst : RESERVED_PROPS_LIST; + const customPropsFirst = configuration.customPropsFirst || false; + const customPropsFirstError = validateCustomPropsFirstConfig(context, customPropsFirst); + const customPropsList = Array.isArray(customPropsFirst) ? customPropsFirst : []; const locale = configuration.locale || 'auto'; return { @@ -436,14 +478,33 @@ module.exports = { return memo; } - const previousIsReserved = isReservedPropName(previousPropName, nodeReservedList); - const currentIsReserved = isReservedPropName(currentPropName, nodeReservedList); + const previousIsReserved = isPropNameInList(previousPropName, nodeReservedList); + const currentIsReserved = isPropNameInList(currentPropName, nodeReservedList); if (previousIsReserved && !currentIsReserved) { return decl; } if (!previousIsReserved && currentIsReserved) { - reportNodeAttribute(decl, 'listReservedPropsFirst', node, context, nodeReservedList); + reportNodeAttribute(decl, 'listReservedPropsFirst', node, context, nodeReservedList, customPropsList); + + return memo; + } + } + + if (customPropsFirst) { + if (customPropsFirstError) { + customPropsFirstError(decl); + return memo; + } + + const previousIsCustom = isPropNameInList(propName(memo), customPropsList); + const currentIsCustom = isPropNameInList(propName(decl), customPropsList); + + if (previousIsCustom && !currentIsCustom) { + return decl; + } + if (!previousIsCustom && currentIsCustom) { + reportNodeAttribute(decl, 'listCustomPropsFirst', node, context, nodeReservedList, customPropsList); return memo; } @@ -456,7 +517,7 @@ module.exports = { } if (previousIsCallback && !currentIsCallback) { // Encountered a non-callback prop after a callback prop - reportNodeAttribute(memo, 'listCallbacksLast', node, context, nodeReservedList); + reportNodeAttribute(memo, 'listCallbacksLast', node, context, nodeReservedList, customPropsList); return memo; } @@ -467,7 +528,7 @@ module.exports = { return decl; } if (!currentValue && previousValue) { - reportNodeAttribute(decl, 'listShorthandFirst', node, context, nodeReservedList); + reportNodeAttribute(decl, 'listShorthandFirst', node, context, nodeReservedList, customPropsList); return memo; } @@ -478,7 +539,7 @@ module.exports = { return decl; } if (currentValue && !previousValue) { - reportNodeAttribute(memo, 'listShorthandLast', node, context, nodeReservedList); + reportNodeAttribute(memo, 'listShorthandLast', node, context, nodeReservedList, customPropsList); return memo; } @@ -493,7 +554,7 @@ module.exports = { } if (!previousIsMultiline && currentIsMultiline) { // Encountered a non-multiline prop before a multiline prop - reportNodeAttribute(decl, 'listMultilineFirst', node, context, nodeReservedList); + reportNodeAttribute(decl, 'listMultilineFirst', node, context, nodeReservedList, customPropsList); return memo; } @@ -504,7 +565,7 @@ module.exports = { } if (previousIsMultiline && !currentIsMultiline) { // Encountered a non-multiline prop after a multiline prop - reportNodeAttribute(memo, 'listMultilineLast', node, context, nodeReservedList); + reportNodeAttribute(memo, 'listMultilineLast', node, context, nodeReservedList, customPropsList); return memo; } @@ -518,7 +579,7 @@ module.exports = { : previousPropName > currentPropName ) ) { - reportNodeAttribute(decl, 'sortPropsByAlpha', node, context, nodeReservedList); + reportNodeAttribute(decl, 'sortPropsByAlpha', node, context, nodeReservedList, customPropsList); return memo; } diff --git a/tests/lib/rules/jsx-sort-props.js b/tests/lib/rules/jsx-sort-props.js index 797dc26650..b3e63c934f 100644 --- a/tests/lib/rules/jsx-sort-props.js +++ b/tests/lib/rules/jsx-sort-props.js @@ -59,12 +59,19 @@ const expectedReservedFirstError = { type: 'JSXIdentifier', }; const expectedEmptyReservedFirstError = { - messageId: 'listIsEmpty', + messageId: 'reservedListIsEmpty', }; const expectedInvalidReservedFirstError = { messageId: 'noUnreservedProps', data: { unreservedWords: 'notReserved' }, }; +const expectedCustomPropsFirstError = { + messageId: 'listCustomPropsFirst', + type: 'JSXIdentifier', +}; +const expectedEmptyCustomPropsFirstError = { + messageId: 'customPropsListIsEmpty', +}; const callbacksLastArgs = [{ callbacksLast: true }]; const ignoreCaseAndCallbackLastArgs = [ { @@ -105,6 +112,11 @@ const reservedFirstWithShorthandLast = [ ]; const reservedFirstAsEmptyArrayArgs = [{ reservedFirst: [] }]; const reservedFirstAsInvalidArrayArgs = [{ reservedFirst: ['notReserved'] }]; +const customPropsFirstArgs = [{ customPropsFirst: ['className', 'theme'] }]; +const customPropsFirstWithNoSortAlphabeticallyArgs = [{ noSortAlphabetically: true, customPropsFirst: ['className', 'theme'] }]; +const customPropsFirstWithShorthandLast = [{ customPropsFirst: ['className', 'theme'], shorthandLast: true }]; +const customPropsFirstWithReservedFirst = [{ reservedFirst: true, customPropsFirst: ['className', 'theme'] }]; +const customPropsFirstAsEmptyArrayArgs = [{ customPropsFirst: [] }]; const multilineFirstArgs = [{ multiline: 'first' }]; const multilineAndShorthandFirstArgs = [ { @@ -278,6 +290,23 @@ ruleTester.run('jsx-sort-props', rule, { code: '', options: reservedFirstWithShorthandLast, }, + // customPropsFirst + { + code: '', + options: customPropsFirstArgs, + }, + { + code: '', + options: customPropsFirstWithNoSortAlphabeticallyArgs, + }, + { + code: '', + options: customPropsFirstWithShorthandLast, + }, + { + code: '', + options: customPropsFirstWithReservedFirst, + }, { code: ` ;', options: reservedFirstAndCallbacksLastArgs, errors: [expectedCallbackError], - // multiline first }, + // customPropsFirst + { + code: '', + options: customPropsFirstArgs, + errors: [expectedCustomPropsFirstError], + output: '', + }, + { + code: '', + options: customPropsFirstArgs, + errors: [expectedCustomPropsFirstError], + output: '', + }, + { + code: '', + options: customPropsFirstArgs, + output: '', + errors: [expectedError], + }, + { + code: '', + options: customPropsFirstArgs, + output: '', + errors: [expectedError], + }, + { + code: '', + options: customPropsFirstArgs, + errors: [expectedError], + output: '', + }, + { + code: '', + options: customPropsFirstWithNoSortAlphabeticallyArgs, + errors: [expectedCustomPropsFirstError], + output: '', + }, + { + code: '', + options: customPropsFirstWithReservedFirst, + errors: [expectedReservedFirstError], + output: '', + }, + { + code: '', + options: customPropsFirstAsEmptyArrayArgs, + errors: [expectedEmptyCustomPropsFirstError], + }, + // multiline first { code: `