diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d9a3dbbd..0a2dedda31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Fixed * [`no-unused-state`]: avoid a crash on a class field gDSFP ([#3236][] @ljharb) +* [`boolean-prop-naming`]: handle React.FC, intersection, union types ([#3241][] @ljharb) +[#3241]: https://github.com/yannickcr/eslint-plugin-react/pull/3241 [#3236]: https://github.com/yannickcr/eslint-plugin-react/issues/3236 ## [7.29.3] - 2022.03.03 diff --git a/lib/rules/boolean-prop-naming.js b/lib/rules/boolean-prop-naming.js index 5e0a420b7a..dff7cd2a04 100644 --- a/lib/rules/boolean-prop-naming.js +++ b/lib/rules/boolean-prop-naming.js @@ -228,6 +228,47 @@ module.exports = { args.filter((arg) => arg.type === 'ObjectExpression').forEach((object) => validatePropNaming(node, object.properties)); } + function getTypeAnnotation(component) { + // If this is a functional component that uses a global type, check it + if ( + (component.node.type === 'FunctionDeclaration' || component.node.type === 'ArrowFunctionExpression') + && component.node.params + && component.node.params.length > 0 + && component.node.params[0].typeAnnotation + ) { + return component.node.params[0].typeAnnotation.typeAnnotation; + } + + if ( + component.node.parent + && component.node.parent.type === 'VariableDeclarator' + && component.node.parent.id + && component.node.parent.id.type === 'Identifier' + && component.node.parent.id.typeAnnotation + && component.node.parent.id.typeAnnotation.typeAnnotation + && component.node.parent.id.typeAnnotation.typeAnnotation.typeParameters + && component.node.parent.id.typeAnnotation.typeAnnotation.typeParameters.type === 'TSTypeParameterInstantiation' + ) { + return component.node.parent.id.typeAnnotation.typeAnnotation.typeParameters.params.find( + (param) => param.type === 'TSTypeReference' + ); + } + } + + function findTypeAnnotations(identifier, node) { + if (node.type === 'TSTypeLiteral') { + const currentNode = [].concat( + objectTypeAnnotations.get(identifier.name) || [], + node + ); + objectTypeAnnotations.set(identifier.name, currentNode); + } else if (node.type === 'TSIntersectionType' || node.type === 'TSUnionType') { + node.types.forEach((type) => { + findTypeAnnotations(identifier, type); + }); + } + } + // -------------------------------------------------------------------------- // Public // -------------------------------------------------------------------------- @@ -299,9 +340,7 @@ module.exports = { }, TSTypeAliasDeclaration(node) { - if (node.typeAnnotation.type === 'TSTypeLiteral') { - objectTypeAnnotations.set(node.id.name, node.typeAnnotation); - } + findTypeAnnotations(node.id, node.typeAnnotation); }, // eslint-disable-next-line object-shorthand @@ -311,19 +350,11 @@ module.exports = { } const list = components.list(); + Object.keys(list).forEach((component) => { - // If this is a functional component that uses a global type, check it - if ( - ( - list[component].node.type === 'FunctionDeclaration' - || list[component].node.type === 'ArrowFunctionExpression' - ) - && list[component].node.params - && list[component].node.params.length - && list[component].node.params[0].typeAnnotation - ) { - const typeNode = list[component].node.params[0].typeAnnotation; - const annotation = typeNode.typeAnnotation; + const annotation = getTypeAnnotation(list[component]); + + if (annotation) { let propType; if (annotation.type === 'GenericTypeAnnotation') { propType = objectTypeAnnotations.get(annotation.id.name); @@ -334,10 +365,12 @@ module.exports = { } if (propType) { - validatePropNaming( - list[component].node, - propType.properties || propType.members - ); + [].concat(propType).forEach((prop) => { + validatePropNaming( + list[component].node, + prop.properties || prop.members + ); + }); } } diff --git a/tests/lib/rules/boolean-prop-naming.js b/tests/lib/rules/boolean-prop-naming.js index b9a797a657..2e2be01e5d 100644 --- a/tests/lib/rules/boolean-prop-naming.js +++ b/tests/lib/rules/boolean-prop-naming.js @@ -417,6 +417,86 @@ ruleTester.run('boolean-prop-naming', rule, { features: ['ts'], errors: [], }, + { + code: ` + type Props = { + isEnabled: boolean + } & OtherProps + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^is[A-Z]([A-Za-z0-9]?)+' }], + features: ['types'], + errors: [], + }, + { + code: ` + type Props = { + isEnabled: boolean + } & { + hasLOL: boolean + } & OtherProps + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types'], + errors: [], + }, + { + code: ` + type Props = { + isEnabled: boolean + } + + const HelloNew: React.FC = (props) => { return
}; + `, + options: [{ rule: '^is[A-Z]([A-Za-z0-9]?)+' }], + features: ['types'], + errors: [], + }, + { + code: ` + type Props = { + isEnabled: boolean + } & { + hasLOL: boolean + } + + const HelloNew: React.FC = (props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types'], + errors: [], + }, + { + code: ` + type Props = { + isEnabled: boolean + } | { + hasLOL: boolean + } + + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types'], + errors: [], + }, + { + code: ` + type Props = { + isEnabled: boolean + } & ({ + hasLOL: boolean + } | { + isLOL: boolean + }) + + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types'], + errors: [], + }, ]), invalid: parsers.all([ @@ -1050,5 +1130,114 @@ ruleTester.run('boolean-prop-naming', rule, { }, ], }, + { + code: ` + type Props = { + enabled: boolean + } & OtherProps + + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^is[A-Z]([A-Za-z0-9]?)+' }], + features: ['types', 'no-ts-old'], + errors: [ + { + message: 'Prop name (enabled) doesn\'t match rule (^is[A-Z]([A-Za-z0-9]?)+)', + }, + ], + }, + { + code: ` + type Props = { + enabled: boolean + } & { + hasLOL: boolean + } & OtherProps + + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types', 'no-ts-old'], + errors: [ + { + message: 'Prop name (enabled) doesn\'t match rule (^(is|has)[A-Z]([A-Za-z0-9]?)+)', + }, + ], + }, + { + code: ` + type Props = { + enabled: boolean + } + + const HelloNew: React.FC = (props) => { return
}; + `, + options: [{ rule: '^is[A-Z]([A-Za-z0-9]?)+' }], + features: ['types', 'no-ts-old'], + errors: [ + { + message: 'Prop name (enabled) doesn\'t match rule (^is[A-Z]([A-Za-z0-9]?)+)', + }, + ], + }, + { + code: ` + type Props = { + enabled: boolean + } & { + hasLOL: boolean + } + + const HelloNew: React.FC = (props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types', 'no-ts-old'], + errors: [ + { + message: 'Prop name (enabled) doesn\'t match rule (^(is|has)[A-Z]([A-Za-z0-9]?)+)', + }, + ], + }, + { + code: ` + type Props = { + enabled: boolean + } | { + hasLOL: boolean + } + + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types', 'no-ts-old'], + errors: [ + { + message: 'Prop name (enabled) doesn\'t match rule (^(is|has)[A-Z]([A-Za-z0-9]?)+)', + }, + ], + }, + { + code: ` + type Props = { + enabled: boolean + } & ({ + hasLOL: boolean + } | { + lol: boolean + }) + + const HelloNew = (props: Props) => { return
}; + `, + options: [{ rule: '^(is|has)[A-Z]([A-Za-z0-9]?)+' }], + features: ['types', 'no-ts-old'], + errors: [ + { + message: 'Prop name (enabled) doesn\'t match rule (^(is|has)[A-Z]([A-Za-z0-9]?)+)', + }, + { + message: 'Prop name (lol) doesn\'t match rule (^(is|has)[A-Z]([A-Za-z0-9]?)+)', + }, + ], + }, ]), });