From 30732140d3d4beb4a51061698cc662a44721edec Mon Sep 17 00:00:00 2001 From: Bohdan Yefimenko Date: Wed, 21 Aug 2024 22:42:22 +0300 Subject: [PATCH] [New] `forbid-component-props`: add `allowedForPatterns`/`disallowedForPatterns` options --- CHANGELOG.md | 2 + docs/rules/forbid-component-props.md | 45 ++++- lib/rules/forbid-component-props.js | 75 ++++++++- tests/lib/rules/forbid-component-props.js | 193 ++++++++++++++++++++++ 4 files changed, 299 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f24ca7dd1..35bb82625a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,11 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Added * add type generation ([#3830][] @voxpelli) * [`no-unescaped-entities`]: add suggestions ([#3831][] @StyleShit) +* [`forbid-component-props`]: add `allowedForPatterns`/`disallowedForPatterns` options ([#3805][] @Efimenko) [#3831]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3831 [#3830]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3830 +[#3805]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3805 ## [7.36.1] - 2024.09.12 diff --git a/docs/rules/forbid-component-props.md b/docs/rules/forbid-component-props.md index 3d796c648d..209b6abba7 100644 --- a/docs/rules/forbid-component-props.md +++ b/docs/rules/forbid-component-props.md @@ -55,7 +55,17 @@ custom message, and a component allowlist: } ``` -For glob string patterns: +Use `disallowedFor` as an exclusion list to warn on props for specific components. `disallowedFor` must have at least one item. + +```js +{ + "propName": "someProp", + "disallowedFor": ["SomeComponent", "AnotherComponent"], + "message": "Avoid using someProp for SomeComponent and AnotherComponent" +} +``` + +For `propNamePattern` glob string patterns: ```js { @@ -65,23 +75,42 @@ For glob string patterns: } ``` -Use `disallowedFor` as an exclusion list to warn on props for specific components. `disallowedFor` must have at least one item. +```js +{ + "propNamePattern": '**-**', + "allowedForPatterns": ["*Component"], + "message": "Avoid using kebab-case except components that match the `*Component` pattern" +} +``` + +Use `allowedForPatterns` for glob string patterns: ```js { "propName": "someProp", - "disallowedFor": ["SomeComponent", "AnotherComponent"], - "message": "Avoid using someProp for SomeComponent and AnotherComponent" + "allowedForPatterns": ["*Component"], + "message": "Avoid using `someProp` except components that match the `*Component` pattern" +} +``` + +Use `disallowedForPatterns` for glob string patterns: + +```js +{ + "propName": "someProp", + "disallowedForPatterns": ["*Component"], + "message": "Avoid using `someProp` for components that match the `*Component` pattern" } ``` -For glob string patterns: +Combine several properties to cover more cases: ```js { - "propNamePattern": "**-**", - "disallowedFor": ["MyComponent"], - "message": "Avoid using kebab-case for MyComponent" + "propName": "someProp", + "allowedFor": ['div'], + "allowedForPatterns": ["*Component"], + "message": "Avoid using `someProp` except `div` and components that match the `*Component` pattern" } ``` diff --git a/lib/rules/forbid-component-props.js b/lib/rules/forbid-component-props.js index 20b11d9218..2dd4412b87 100644 --- a/lib/rules/forbid-component-props.js +++ b/lib/rules/forbid-component-props.js @@ -52,6 +52,11 @@ module.exports = { uniqueItems: true, items: { type: 'string' }, }, + allowedForPatterns: { + type: 'array', + uniqueItems: true, + items: { type: 'string' }, + }, message: { type: 'string' }, }, additionalProperties: false, @@ -66,12 +71,20 @@ module.exports = { minItems: 1, items: { type: 'string' }, }, + disallowedForPatterns: { + type: 'array', + uniqueItems: true, + minItems: 1, + items: { type: 'string' }, + }, message: { type: 'string' }, }, - required: ['disallowedFor'], + anyOf: [ + { required: ['disallowedFor'] }, + { required: ['disallowedForPatterns'] }, + ], additionalProperties: false, }, - { type: 'object', properties: { @@ -81,6 +94,11 @@ module.exports = { uniqueItems: true, items: { type: 'string' }, }, + allowedForPatterns: { + type: 'array', + uniqueItems: true, + items: { type: 'string' }, + }, message: { type: 'string' }, }, additionalProperties: false, @@ -95,9 +113,18 @@ module.exports = { minItems: 1, items: { type: 'string' }, }, + disallowedForPatterns: { + type: 'array', + uniqueItems: true, + minItems: 1, + items: { type: 'string' }, + }, message: { type: 'string' }, }, - required: ['disallowedFor'], + anyOf: [ + { required: ['disallowedFor'] }, + { required: ['disallowedForPatterns'] }, + ], additionalProperties: false, }, ], @@ -114,8 +141,10 @@ module.exports = { const propPattern = value.propNamePattern; const prop = propName || propPattern; const options = { - allowList: typeof value === 'string' ? [] : (value.allowedFor || []), - disallowList: typeof value === 'string' ? [] : (value.disallowedFor || []), + allowList: [].concat(value.allowedFor || []), + allowPatternList: [].concat(value.allowedForPatterns || []), + disallowList: [].concat(value.disallowedFor || []), + disallowPatternList: [].concat(value.disallowedForPatterns || []), message: typeof value === 'string' ? null : value.message, isPattern: !!value.propNamePattern, }; @@ -140,10 +169,40 @@ module.exports = { return false; } + function checkIsTagForbiddenByAllowOptions() { + if (options.allowList.indexOf(tagName) !== -1) { + return false; + } + + if (options.allowPatternList.length === 0) { + return true; + } + + return options.allowPatternList.every( + (pattern) => !minimatch(tagName, pattern) + ); + } + + function checkIsTagForbiddenByDisallowOptions() { + if (options.disallowList.indexOf(tagName) !== -1) { + return true; + } + + if (options.disallowPatternList.length === 0) { + return false; + } + + return options.disallowPatternList.some( + (pattern) => minimatch(tagName, pattern) + ); + } + + const hasDisallowOptions = options.disallowList.length > 0 || options.disallowPatternList.length > 0; + // disallowList should have a least one item (schema configuration) - const isTagForbidden = options.disallowList.length > 0 - ? options.disallowList.indexOf(tagName) !== -1 - : options.allowList.indexOf(tagName) === -1; + const isTagForbidden = hasDisallowOptions + ? checkIsTagForbiddenByDisallowOptions() + : checkIsTagForbiddenByAllowOptions(); // if the tagName is undefined (``), we assume it's a forbidden element return typeof tagName === 'undefined' || isTagForbidden; diff --git a/tests/lib/rules/forbid-component-props.js b/tests/lib/rules/forbid-component-props.js index d97299e1bd..566860d139 100644 --- a/tests/lib/rules/forbid-component-props.js +++ b/tests/lib/rules/forbid-component-props.js @@ -250,6 +250,78 @@ ruleTester.run('forbid-component-props', rule, { }, ], }, + { + code: ` + const rootElement = ( + + + + + + + + ); + `, + options: [ + { + forbid: [ + { + propName: 'className', + allowedForPatterns: ['*Icon', '*Svg', 'UI*'], + }, + ], + }, + ], + }, + { + code: ` + const rootElement = ( + + + + + + + + + ); + `, + options: [ + { + forbid: [ + { + propName: 'className', + allowedFor: ['ButtonLegacy'], + allowedForPatterns: ['*Icon', '*Svg', 'UI*'], + }, + ], + }, + ], + }, + { + code: ` + const rootElement = ( + + + + + + + + ); + `, + options: [ + { + forbid: [ + { + propName: 'className', + disallowedFor: ['Modal'], + disallowedForPatterns: ['*Legacy', 'Shared*'], + }, + ], + }, + ], + }, ]), invalid: parsers.all([ @@ -679,5 +751,126 @@ ruleTester.run('forbid-component-props', rule, { }, ], }, + { + code: ` + const rootElement = () => ( + + + + + ); + `, + options: [ + { + forbid: [ + { + propName: 'className', + message: 'className available only for icons', + allowedForPatterns: ['*Icon'], + }, + ], + }, + ], + errors: [ + { + message: 'className available only for icons', + line: 5, + column: 22, + type: 'JSXAttribute', + }, + ], + }, + { + code: ` + const rootElement = () => ( + + + + + + ); + `, + options: [ + { + forbid: [ + { + propName: 'className', + message: 'className available only for icons', + allowedForPatterns: ['*Icon'], + }, + { + propName: 'style', + message: 'style available only for SVGs', + allowedForPatterns: ['*Svg'], + }, + ], + }, + ], + errors: [ + { + message: 'style available only for SVGs', + line: 4, + column: 21, + type: 'JSXAttribute', + }, + { + message: 'className available only for icons', + line: 6, + column: 22, + type: 'JSXAttribute', + }, + ], + }, + { + code: ` + const rootElement = ( + + + + + + + + ); + `, + options: [ + { + forbid: [ + { + propName: 'className', + disallowedFor: ['SomeSvg'], + disallowedForPatterns: ['UI*', '*Icon'], + message: 'Avoid using className for SomeSvg and components that match the `UI*` and `*Icon` patterns', + }, + ], + }, + ], + errors: [ + { + message: 'Avoid using className for SomeSvg and components that match the `UI*` and `*Icon` patterns', + line: 4, + column: 23, + type: 'JSXAttribute', + }, + { + message: 'Avoid using className for SomeSvg and components that match the `UI*` and `*Icon` patterns', + line: 5, + column: 26, + type: 'JSXAttribute', + }, + { + message: 'Avoid using className for SomeSvg and components that match the `UI*` and `*Icon` patterns', + line: 6, + column: 22, + type: 'JSXAttribute', + }, + { + message: 'Avoid using className for SomeSvg and components that match the `UI*` and `*Icon` patterns', + line: 7, + column: 21, + type: 'JSXAttribute', + }, + ], + }, ]), });