diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 23612721f11..2d3fb6a896d 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -328,6 +328,86 @@ it('shows tooltip on focus', async () => { ``` +### `@elastic/eui/button-group-no-invalid-children` + +Enforce that `EuiButtonGroup` children (when using the Children API) are valid button components. + +Valid direct children are: +- `variant="default"`: `EuiButton`, `EuiButtonEmpty`, and `EuiButtonIcon` + +Besides those button components, these three wrapper components are also allowed: `EuiPopover`, `EuiToolTip` and `EuiCopy`. + +#### Examples + +```tsx +// ✗ Bad - non-button elements + +
Not a button
+ ... +
+ +// ✓ Good - direct buttons + + Save + Cancel + + +// ✓ Good - icon button with tooltip + + Save + + + + + +// ✓ Good - EuiCopy with render prop (expression or block body) + + + {(copy) => Copy} + + + +// ✓ Good - .map() with expression or block body + + {buttons.map((b) => {b.label})} + + +// ✓ Good - EuiPopover with a button trigger + + More} + isOpen={isOpen} + closePopover={closePopover} + > + Panel content + + + +// ✓ Good - EuiPopover with an EuiToolTip-wrapped icon trigger + + + + + } + isOpen={isOpen} + closePopover={closePopover} + > + Panel content + + +``` + +#### Custom button wrapper components + +If a project-specific button component (e.g. ``) is used as a child and the rule cannot resolve it statically, it reports `invalidUnresolvableChild` which suggests suppressing the rule inline with a comment: + +```tsx +// eslint-disable-next-line @elastic/eui/button-group-no-invalid-children -- SaveButton returns EuiButton + +``` + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/changelogs/upcoming/9849.md b/packages/eslint-plugin/changelogs/upcoming/9849.md new file mode 100644 index 00000000000..e4b3a79d03c --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/9849.md @@ -0,0 +1 @@ +- Added `button-group-no-invalid-children` rule \ No newline at end of file diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 7d446d9b344..bc18365226c 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -27,6 +27,7 @@ import { EuiBadgeAccessibilityRules } from './rules/a11y/badge_accessibility_rul import { EuiIconAccessibilityRules } from './rules/a11y/icon_accessibility_rules'; import { TooltipNoInteractiveContent } from './rules/a11y/tooltip_no_interactive_content'; import { TooltipButtonIconWrap } from './rules/a11y/tooltip_button_icon_wrap'; +import { ButtonGroupNoInvalidChildren } from './rules/button_group_no_invalid_children'; const config = { rules: { @@ -52,6 +53,7 @@ const config = { 'require-href-for-link': RequireHrefForLink, 'tooltip-no-interactive-content': TooltipNoInteractiveContent, 'tooltip-button-icon-wrap': TooltipButtonIconWrap, + 'button-group-no-invalid-children': ButtonGroupNoInvalidChildren, }, configs: { recommended: { @@ -78,6 +80,7 @@ const config = { '@elastic/eui/require-href-for-link': 'warn', '@elastic/eui/tooltip-no-interactive-content': 'warn', '@elastic/eui/tooltip-button-icon-wrap': 'warn', + '@elastic/eui/button-group-no-invalid-children': 'warn', }, }, }, diff --git a/packages/eslint-plugin/src/rules/button_group_no_invalid_children.test.ts b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.test.ts new file mode 100644 index 00000000000..3d3be39ba6f --- /dev/null +++ b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.test.ts @@ -0,0 +1,1230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import dedent from 'dedent'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { ButtonGroupNoInvalidChildren } from './button_group_no_invalid_children'; + +const languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run( + 'button-group-no-invalid-children', + ButtonGroupNoInvalidChildren, + { + valid: [ + // Direct children + { + name: 'EuiButton child', + code: dedent` + + Save + + `, + languageOptions, + }, + { + name: 'EuiButtonEmpty child', + code: dedent` + + Cancel + + `, + languageOptions, + }, + { + name: 'EuiButtonIcon child', + code: dedent` + + + + `, + languageOptions, + }, + { + name: 'mixed valid direct children', + code: dedent` + + Save + Cancel + + + `, + languageOptions, + }, + + // Wrappers + { + name: 'EuiButtonIcon wrapped in EuiToolTip', + code: dedent` + + Save + + + + + `, + languageOptions, + }, + { + name: 'EuiButton wrapped in EuiToolTip', + code: dedent` + + + Save + + + `, + languageOptions, + }, + { + name: 'EuiPopover as direct child — JSX children (panel content) are not validated', + code: dedent` + + Normal + Open} isOpen={false} closePopover={() => {}}> + Panel content with non-button elements + + + `, + languageOptions, + }, + { + name: 'EuiPopover with EuiToolTip-wrapped trigger in button prop is accepted', + code: dedent` + + } + isOpen={false} + closePopover={() => {}} + > + Panel content + + + `, + languageOptions, + }, + { + name: 'EuiPopover button prop as a resolved variable with valid button is accepted', + code: dedent` + const trigger = Open; + + {}}> + Panel content + + + `, + languageOptions, + }, + { + name: 'EuiPopover button prop as a conditional with valid buttons is accepted', + code: dedent` + + : More} + isOpen={false} + closePopover={() => {}} + > + Panel content + + + `, + languageOptions, + }, + { + name: 'EuiCopy with valid render-prop button is accepted', + code: dedent` + + Normal + + {(copy) => Copy} + + + `, + languageOptions, + }, + { + name: 'EuiCopy block-body render prop with valid button is accepted', + code: dedent` + + + {(copy) => { return Copy; }} + + + `, + languageOptions, + }, + + // Fragments + { + name: 'shorthand fragment wrapping valid buttons', + code: dedent` + + <> + Save + Cancel + + + `, + languageOptions, + }, + { + name: ' wrapping valid buttons', + code: dedent` + + + Save + + + `, + languageOptions, + }, + { + name: ' wrapping valid buttons', + code: dedent` + + + Save + + + + `, + languageOptions, + }, + { + name: 'nested fragments wrapping valid buttons', + code: dedent` + + <> + + Save + + + + `, + languageOptions, + }, + { + name: 'fragment wrapping valid button inside EuiToolTip', + code: dedent` + + + <> + + + + + `, + languageOptions, + }, + { + name: 'conditional inside EuiToolTip with valid branches', + code: dedent` + + + {isIcon + ? + : Delete} + + + `, + languageOptions, + }, + { + name: 'variable inside EuiToolTip resolves to a valid button', + code: dedent` + const icon = ; + + + {icon} + + + `, + languageOptions, + }, + + // Text nodes + { + name: 'whitespace text nodes between buttons are silently ignored', + code: dedent` + + {' '} + Save + {' '} + + `, + languageOptions, + }, + + // Conditional rendering + { + name: '{condition && } is valid', + code: dedent` + + Save + {canDelete && } + + `, + languageOptions, + }, + { + name: 'ternary with both valid branches', + code: dedent` + + {isAdmin ? Delete : Cancel} + + `, + languageOptions, + }, + { + name: 'ternary with null alternate branch', + code: dedent` + + Save + {canDelete ? : null} + + `, + languageOptions, + }, + { + name: '{unresolvable || } — unresolvable left side is skipped, valid right side passes', + code: dedent` + + {fallback || Save} + + `, + languageOptions, + }, + { + name: '{unresolvable ?? } — unresolvable left side is skipped, valid right side passes', + code: dedent` + + {fallback ?? Save} + + `, + languageOptions, + }, + { + name: '{resolvedButton || } — valid resolved left side passes', + code: dedent` + const resolvedButton = Cancel; + + {resolvedButton || Save} + + `, + languageOptions, + }, + + // Array notation + { + name: 'array of valid buttons', + code: dedent` + + {[ + Save, + Cancel, + ]} + + `, + languageOptions, + }, + { + name: 'array containing a valid wrapper (EuiToolTip)', + code: dedent` + + {[ + Save, + + + , + ]} + + `, + languageOptions, + }, + + // Variable resolution + { + name: 'const variable holding a valid button is resolved and accepted', + code: dedent` + const button = Save; + + {button} + + `, + languageOptions, + }, + { + name: 'const variable holding a valid button array is resolved and accepted', + code: dedent` + const buttons = [ + Save, + Cancel, + ]; + + {buttons} + + `, + languageOptions, + }, + { + name: 'reassigned variable is skipped — cannot be safely resolved', + code: dedent` + let button = Not a button; + button = Save; + + {button} + + `, + languageOptions, + }, + + // Local arrow-function + { + name: 'local arrow-fn component with valid return is resolved transparently', + code: dedent` + const ActionButtons = () => Save; + + + + `, + languageOptions, + }, + { + name: 'local arrow-fn component returning a fragment of valid buttons', + code: dedent` + const ActionButtons = () => ( + <> + Save + Cancel + + ); + + + + `, + languageOptions, + }, + { + name: 'local block-body arrow-fn component with valid return is resolved and accepted', + code: dedent` + const ActionButtons = () => { return Save; }; + + + + `, + languageOptions, + }, + { + name: 'local block-body arrow-fn component with early return is resolved and accepted', + code: dedent` + const ActionButtons = ({ isIcon }) => { + if (isIcon) return ; + return Add; + }; + + + + `, + languageOptions, + }, + + // Static bail-outs (not resolvable at analysis time) + { + name: 'function call child — cannot statically resolve', + code: dedent` + + {renderButtons()} + + `, + languageOptions, + }, + { + name: 'options API (self-closing) is ignored', + code: dedent` + {}} + /> + `, + languageOptions, + }, + + // Variants + { + name: 'variant="default" with valid children is accepted', + code: dedent` + + Save + Cancel + + `, + languageOptions, + }, + { + name: 'variant="segmented" is not yet validated — invalid children pass through', + code: dedent` + +
Not a button
+
+ `, + languageOptions, + }, + { + name: 'variant="selection" is not yet validated — invalid children pass through', + code: dedent` + +
Not a button
+
+ `, + languageOptions, + }, + { + name: 'dynamic variant is conservatively skipped — invalid children pass through', + code: dedent` + +
Not a button
+
+ `, + languageOptions, + }, + { + name: 'spread props on child — cannot statically determine', + code: dedent` + + + + `, + languageOptions, + }, + { + name: 'spread props on wrapper child — cannot statically determine', + code: dedent` + + + + + + `, + languageOptions, + }, + { + name: 'map() with expression-body valid button is accepted', + code: dedent` + + {buttons.map((b) => {b.label})} + + `, + languageOptions, + }, + { + name: 'map() with expression-body spread props is skipped — cannot statically determine element identity', + code: dedent` + + {buttons.map((b) => )} + + `, + languageOptions, + }, + { + name: 'map() with block-body single return of valid button is accepted', + code: dedent` + + {buttons.map((b) => { + return {b.label}; + })} + + `, + languageOptions, + }, + { + name: 'map() with block-body early null return and valid button is accepted', + code: dedent` + + {buttons.map((b) => { + if (b.hidden) return null; + return {b.label}; + })} + + `, + languageOptions, + }, + { + name: 'map() with block-body if/else returning different valid buttons is accepted', + code: dedent` + + {buttons.map((b) => { + if (b.type === 'icon') return ; + return {b.label}; + })} + + `, + languageOptions, + }, + { + name: 'variable holding a map() result with valid buttons is accepted', + code: dedent` + const buttons = items.map((item) => {item.label}); + + {buttons} + + `, + languageOptions, + }, + { + name: 'member-expression child (e.g. namespace component) is skipped', + code: dedent` + + + + `, + languageOptions, + }, + + // Switch statement + { + name: 'local arrow-fn with switch returning valid buttons is accepted', + code: dedent` + const ActionButton = ({ variant }) => { + switch (variant) { + case 'icon': return ; + default: return Add; + } + }; + + + + `, + languageOptions, + }, + ], + + invalid: [ + // Direct children + { + name: 'plain div child', + code: dedent` + +
Not a button
+
+ `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'div' } }], + }, + { + name: 'EuiFlexGroup child', + code: dedent` + + + Save + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiFlexGroup' } }], + }, + { + name: 'multiple invalid children reported individually', + code: dedent` + + text + Valid + Also invalid + + `, + languageOptions, + errors: [ + { messageId: 'invalidChild', data: { name: 'span' } }, + { messageId: 'invalidChild', data: { name: 'EuiText' } }, + ], + }, + + // EuiCopy + { + name: 'invalid element returned by EuiCopy render prop', + code: dedent` + + + {(copy) => Not a button} + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiCopy' }, + }, + ], + }, + { + name: 'EuiCopy block-body render prop with invalid return is reported', + code: dedent` + + + {(copy) => { return Not a button; }} + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiCopy' }, + }, + ], + }, + { + name: 'EuiCopy block-body render prop with invalid branch in if/else is reported', + code: dedent` + + + {(copy) => { + if (isBad) return Not a button; + return Copy; + }} + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiCopy' }, + }, + ], + }, + + // EuiPopover + { + name: 'invalid element in EuiPopover button prop', + code: dedent` + + Not a button} isOpen={false} closePopover={() => {}}> + Panel content + + + `, + languageOptions, + errors: [ + { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } }, + ], + }, + { + name: 'HTML element in EuiPopover button prop', + code: dedent` + + Not a button} isOpen={false} closePopover={() => {}}> + Panel content + + + `, + languageOptions, + errors: [{ messageId: 'invalidPopoverButton', data: { name: 'div' } }], + }, + { + name: 'EuiToolTip in EuiPopover button prop with invalid child', + code: dedent` + + Not a button} + isOpen={false} + closePopover={() => {}} + > + Panel content + + + `, + languageOptions, + errors: [ + { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } }, + ], + }, + { + name: 'EuiPopover button prop as resolved variable with invalid element is reported', + code: dedent` + const trigger = Not a button; + + {}}> + Panel content + + + `, + languageOptions, + errors: [ + { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } }, + ], + }, + { + name: 'EuiPopover button prop as conditional with one invalid branch is reported', + code: dedent` + + : Bad} + isOpen={false} + closePopover={() => {}} + > + Panel content + + + `, + languageOptions, + errors: [ + { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } }, + ], + }, + + // Invalid wrapper children + { + name: 'non-button inside EuiToolTip', + code: dedent` + + + + + + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiFlexGroup', wrapper: 'EuiToolTip' }, + }, + ], + }, + + // EuiToolTip + { + name: 'conditional inside EuiToolTip with one invalid branch', + code: dedent` + + + {isIcon + ? + : Not a button} + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiToolTip' }, + }, + ], + }, + { + name: 'variable inside EuiToolTip resolves to an invalid element', + code: dedent` + const icon = Not a button; + + + {icon} + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiToolTip' }, + }, + ], + }, + + // Fragments + { + name: 'shorthand fragment wrapping invalid element', + code: dedent` + + <> + Save + Not allowed + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: ' wrapping invalid element', + code: dedent` + + +
Not a button
+
+
+ `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'div' } }], + }, + { + name: ' wrapping invalid element', + code: dedent` + + + Not allowed + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'fragment inside EuiToolTip wrapping invalid element', + code: dedent` + + + <> + Not a button + + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiToolTip' }, + }, + ], + }, + + // Conditional children + { + name: '{condition && } right side is flagged', + code: dedent` + + Save + {show && Not allowed} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'ternary with one invalid branch', + code: dedent` + + {isAdmin ? Delete : No access} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'ternary with both invalid branches reported separately', + code: dedent` + + {show ? A : B} + + `, + languageOptions, + errors: [ + { messageId: 'invalidChild', data: { name: 'EuiText' } }, + { messageId: 'invalidChild', data: { name: 'EuiBadge' } }, + ], + }, + + { + name: '{unresolvable || } invalid right side is flagged', + code: dedent` + + {fallback || Not a button} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: '{unresolvable ?? } invalid right side is flagged', + code: dedent` + + {fallback ?? Not a button} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: '{resolvedInvalidButton || } resolved invalid left side is flagged', + code: dedent` + const resolvedButton = Not a button; + + {resolvedButton || Save} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + + // Unresolvable custom component + { + name: 'unresolvable custom component inside EuiToolTip uses invalidUnresolvableWrapperChild', + code: dedent` + + + + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidUnresolvableWrapperChild', + data: { name: 'SaveButton', wrapper: 'EuiToolTip' }, + }, + ], + }, + { + name: 'unresolvable custom component inside EuiCopy render prop uses invalidUnresolvableWrapperChild', + code: dedent` + + + {(copy) => } + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidUnresolvableWrapperChild', + data: { name: 'SaveButton', wrapper: 'EuiCopy' }, + }, + ], + }, + + // Variable resolution + { + name: 'const variable holding an invalid element is resolved and reported', + code: dedent` + const button = Not a button; + + {button} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'const variable holding an array with an invalid element is resolved and reported', + code: dedent` + const buttons = [ + Save, + Not a button, + ]; + + {buttons} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + + // Local arrow-function + { + name: 'local arrow-fn component with invalid return is resolved and reported at the invalid element', + code: dedent` + const ActionButtons = () => Not a button; + + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'local block-body arrow-fn component with invalid return is resolved and reported at the invalid element', + code: dedent` + const ActionButtons = () => { return Not a button; }; + + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'local block-body arrow-fn component with invalid branch is resolved and reported', + code: dedent` + const ActionButtons = ({ isInvalid }) => { + if (isInvalid) return Bad; + return Good; + }; + + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'imported custom component cannot be resolved — uses invalidUnresolvableChild message', + code: dedent` + + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidUnresolvableChild', + data: { name: 'SaveButton' }, + }, + ], + }, + + // Array notation + { + name: 'array containing an invalid element', + code: dedent` + + {[ + Save, + Not allowed, + ]} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'array containing a wrapper (EuiToolTip) with an invalid inner element', + code: dedent` + + {[ + + Not a button + , + ]} + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiToolTip' }, + }, + ], + }, + + // .map() + { + name: 'map() with expression-body returning invalid element is reported', + code: dedent` + + {buttons.map((b) =>
{b.label}
)} +
+ `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'div' } }], + }, + { + name: 'map() with block-body returning invalid element is reported', + code: dedent` + + {buttons.map((b) => { + return
{b.label}
; + })} +
+ `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'div' } }], + }, + { + name: 'map() with block-body invalid branch is reported', + code: dedent` + + {buttons.map((b) => { + if (b.type === 'bad') return {b.label}; + return {b.label}; + })} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + { + name: 'variable holding map() result with invalid element is reported', + code: dedent` + const buttons = items.map((item) =>
{item.label}
); + + {buttons} + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'div' } }], + }, + + // Spread on wrapper + { + name: 'EuiToolTip with spread props still validates its children', + code: dedent` + + + Not a button + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiToolTip' }, + }, + ], + }, + { + name: 'EuiCopy with spread props still validates its render-prop child', + code: dedent` + + + {(copy) => Not a button} + + + `, + languageOptions, + errors: [ + { + messageId: 'invalidWrapperChild', + data: { name: 'EuiText', wrapper: 'EuiCopy' }, + }, + ], + }, + + // Switch statement + { + name: 'local arrow-fn with switch containing invalid branch is reported', + code: dedent` + const ActionButton = ({ variant }) => { + switch (variant) { + case 'bad': return Not a button; + default: return Good; + } + }; + + + + `, + languageOptions, + errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }], + }, + ], + } +); diff --git a/packages/eslint-plugin/src/rules/button_group_no_invalid_children.ts b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.ts new file mode 100644 index 00000000000..9d454250807 --- /dev/null +++ b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type TSESTree, type TSESLint, ESLintUtils } from '@typescript-eslint/utils'; +import { hasSpread } from '../utils/has_spread'; +import { flatMap } from '../utils/flat_map'; +import { getElementName } from '../utils/get_element_name'; +import { collectJsxChildren } from '../utils/collect_jsx_children'; +import { findAttrValue } from '../utils/get_attr_value'; + +const BUTTON_GROUP = 'EuiButtonGroup'; +const VALID_BUTTONS = new Set(['EuiButton', 'EuiButtonEmpty', 'EuiButtonIcon']); +const VALID_WRAPPERS = new Set(['EuiToolTip', 'EuiPopover', 'EuiCopy']); + +function joinSet(set: Set): string { + const items: string[] = []; + set.forEach((v) => items.push(v)); + return items.join(', '); +} + +const VALID_BUTTONS_LIST = joinSet(VALID_BUTTONS); +const VALID_WRAPPERS_LIST = joinSet(VALID_WRAPPERS); + +function isCustomComponent(name: string): boolean { + return name[0] === name[0].toUpperCase() && !name.startsWith('Eui'); +} + +function reportInvalidWrapperChildren< + TContext extends TSESLint.RuleContext +>( + wrapper: TSESTree.JSXElement, + wrapperName: string, + context: TContext +): void { + const children = flatMap(wrapper.children, (c) => + collectJsxChildren(c, context.sourceCode) + ); + for (const wc of children) { + if (hasSpread(wc.openingElement.attributes)) continue; + const wcName = getElementName(wc.openingElement); + if (wcName === null || VALID_BUTTONS.has(wcName)) continue; + context.report({ + node: wc.openingElement, + messageId: isCustomComponent(wcName) + ? 'invalidUnresolvableWrapperChild' + : 'invalidWrapperChild', + data: { name: wcName, wrapper: wrapperName }, + }); + } +} + +/* Rule */ + +export const ButtonGroupNoInvalidChildren = ESLintUtils.RuleCreator.withoutDocs( + { + create(context) { + return { + JSXElement(node) { + const { openingElement } = node; + + if ( + openingElement.name.type !== 'JSXIdentifier' || + openingElement.name.name !== BUTTON_GROUP + ) { + return; + } + + // Only validate when the variant is "default" (or absent — the default). + // Rules for "segmented" and "selection" are added in later chunks. + // Dynamic variant values (variables) are conservatively skipped. + const variant = findAttrValue( + context, + openingElement.attributes, + 'variant' + ); + if (variant !== undefined && variant !== 'default') return; + + const children = flatMap(node.children, (c) => + collectJsxChildren(c, context.sourceCode) + ); + if (children.length === 0) return; + + for (const child of children) { + const name = getElementName(child.openingElement); + if (name === null) continue; + + if (VALID_BUTTONS.has(name)) continue; + + if (VALID_WRAPPERS.has(name)) { + if (name === 'EuiToolTip') { + // Validate JSX children (expanding fragments/conditionals). + reportInvalidWrapperChildren(child, name, context); + } else if (name === 'EuiPopover') { + // The trigger is the `button` prop, not JSX children (panel + // content). The prop value may be a JSXExpressionContainer or + // a bare JSX element; collectJsxChildren handles both. + // EuiToolTip wrapping the trigger button is also supported. + const buttonProp = child.openingElement.attributes.find( + (attr): attr is TSESTree.JSXAttribute => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'button' + ); + + if (buttonProp?.value != null) { + const triggerElements = collectJsxChildren( + buttonProp.value as TSESTree.Node, + context.sourceCode + ); + + for (const te of triggerElements) { + if (hasSpread(te.openingElement.attributes)) continue; + + const teName = getElementName(te.openingElement); + + if (teName === null || VALID_BUTTONS.has(teName)) continue; + + if (teName === 'EuiToolTip') { + // EuiToolTip wrapping the trigger — validate its children. + const tooltipChildren = flatMap(te.children, (c) => + collectJsxChildren(c, context.sourceCode) + ); + + for (const ttc of tooltipChildren) { + if (hasSpread(ttc.openingElement.attributes)) continue; + + const ttcName = getElementName(ttc.openingElement); + + if (ttcName === null || VALID_BUTTONS.has(ttcName)) + continue; + + context.report({ + node: ttc.openingElement, + messageId: 'invalidPopoverButton', + data: { name: ttcName }, + }); + } + continue; + } + context.report({ + node: te.openingElement, + messageId: 'invalidPopoverButton', + data: { name: teName }, + }); + } + } + } else if (name === 'EuiCopy') { + // Children is a render prop: {(copy) => } + // ArrowFunctionExpression with expression body is expanded by + // collectJsxChildren, so validation works the same as EuiToolTip. + reportInvalidWrapperChildren(child, name, context); + } + continue; + } + + // For elements that aren't known valid buttons or wrappers, a spread + // prevents static classification; skip conservatively. + if (hasSpread(child.openingElement.attributes)) continue; + + context.report({ + node: child.openingElement, + messageId: isCustomComponent(name) + ? 'invalidUnresolvableChild' + : 'invalidChild', + data: { name }, + }); + } + }, + }; + }, + meta: { + type: 'problem', + docs: { + description: `Enforce that EuiButtonGroup children are ${VALID_BUTTONS_LIST}, or a supported wrapper (${VALID_WRAPPERS_LIST})`, + }, + schema: [], + messages: { + invalidChild: [ + `{{ name }} is not a valid child of EuiButtonGroup.`, + `Allowed children: ${VALID_BUTTONS_LIST}.`, + `Allowed wrappers: ${VALID_WRAPPERS_LIST}.`, + ].join(' '), + invalidUnresolvableChild: [ + `{{ name }} cannot be verified as a valid child of EuiButtonGroup.`, + `Allowed children: ${VALID_BUTTONS_LIST}.`, + `Allowed wrappers: ${VALID_WRAPPERS_LIST}.`, + `If {{ name }} is a shared button wrapper component only containing`, + `valid button children, suppress this rule inline with a comment`, + `explaining why it's valid.`, + `// eslint-disable-next-line @elastic/eui/button-group-no-invalid-children -- SaveButton only wraps EuiButton`, + ].join(' '), + invalidPopoverButton: [ + `{{ name }} is not a valid trigger button for EuiPopover in EuiButtonGroup.`, + `The \`button\` prop must be ${VALID_BUTTONS_LIST}.`, + ].join(' '), + invalidWrapperChild: [ + `{{ name }} inside {{ wrapper }} is not a valid button for EuiButtonGroup.`, + `The wrapper must contain ${VALID_BUTTONS_LIST}.`, + ].join(' '), + invalidUnresolvableWrapperChild: [ + `{{ name }} inside {{ wrapper }} cannot be verified as a valid button for EuiButtonGroup.`, + `The wrapper must contain ${VALID_BUTTONS_LIST}.`, + `If {{ name }} is a shared button wrapper component, suppress this rule inline`, + `with a comment explaining why it renders valid button children:`, + `// eslint-disable-next-line @elastic/eui/button-group-no-invalid-children -- SaveButton wraps EuiButton`, + ].join(' '), + }, + }, + defaultOptions: [], + } +); diff --git a/packages/eslint-plugin/src/utils/collect_jsx_children.ts b/packages/eslint-plugin/src/utils/collect_jsx_children.ts new file mode 100644 index 00000000000..471659b8fae --- /dev/null +++ b/packages/eslint-plugin/src/utils/collect_jsx_children.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type TSESTree, type TSESLint } from '@typescript-eslint/utils'; +import { flatMap } from './flat_map'; +import { isFragment } from './is_fragment'; + +// A variable is only safely resolvable if it is never reassigned. A second +// write means the value at the point of use can't be determined statically. +function isReassigned(variable: TSESLint.Scope.Variable): boolean { + return variable.references.filter((ref) => ref.isWrite()).length > 1; +} + +/** + * Resolves an expression-context Identifier to the node its variable was + * initialized with (e.g. the `` in `const btn = `). + * Returns null for anything that can't be resolved safely: imports, function + * parameters, reassigned variables, or missing initializers. + */ +export function resolveIdentifierValue( + sourceCode: TSESLint.SourceCode, + node: TSESTree.Identifier +): TSESTree.Node | null { + let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(node); + while (scope) { + const ref = scope.references.find((r) => r.identifier === node); + if (ref) { + const variable = ref.resolved; + if (!variable || isReassigned(variable)) return null; + const def = variable.defs[0]; + if (!def || def.type !== 'Variable' || !def.node.init) return null; + return def.node.init; + } + scope = scope.upper; + } + return null; +} + +/** + * Collects all return-statement arguments within a block body, recursing into + * `if`/`else` branches and `switch` cases. Does not recurse into nested + * functions — those have their own return scope. Used to validate block-body + * arrow functions. + */ +export function collectReturnValues( + node: TSESTree.Node +): TSESTree.Expression[] { + switch (node.type) { + case 'BlockStatement': + return flatMap(node.body, collectReturnValues); + case 'ReturnStatement': + return node.argument ? [node.argument] : []; + case 'IfStatement': + return [ + ...collectReturnValues(node.consequent), + ...(node.alternate ? collectReturnValues(node.alternate) : []), + ]; + case 'SwitchStatement': + return flatMap(node.cases, (c) => + flatMap(c.consequent, collectReturnValues) + ); + default: + return []; + } +} + +/** + * Resolves a JSX component name (e.g. `Buttons` in ``) to the + * nodes returned by its local arrow-function definition, if one exists. + * + * Handles both expression-body (`() => `) and block-body + * (`() => { return ; }`) arrow functions. Imports, class components, + * and reassigned variables return null — those stay opaque. + */ +export function resolveLocalComponent( + sourceCode: TSESLint.SourceCode, + name: string, + refNode: TSESTree.Node +): TSESTree.Node[] | null { + let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(refNode); + while (scope) { + const variable = scope.variables.find((v) => v.name === name); + if (variable) { + if (isReassigned(variable)) return null; + const def = variable.defs[0]; + if (!def || def.type !== 'Variable' || !def.node.init) return null; + const init = def.node.init; + if (init.type !== 'ArrowFunctionExpression') return null; + if (init.body.type !== 'BlockStatement') return [init.body]; + const returns = collectReturnValues(init.body); + return returns.length > 0 ? returns : null; + } + scope = scope.upper; + } + return null; +} + +/** + * Recursively collects concrete JSXElement nodes to validate, expanding: + * - `<>...`, ``, `` (transparent grouping) + * - `{expr}` (JSXExpressionContainer) + * - `{a && }` (LogicalExpression `&&` → right side only) + * - `{a || }`, `{a ?? }` (LogicalExpression `||`/`??` → both sides) + * - `{c ? : }` (ConditionalExpression → both branches) + * - `{[, ]}` (ArrayExpression → each element) + * - `{variable}` (Identifier → resolved via scope) + * - `` (local arrow-fn component, expression or block body) + * - `{(arg) => }` (ArrowFunctionExpression, expression or block body) + * - `{arr.map(fn)}` (CallExpression `.map()` with arrow-fn callback) + * + * Patterns that can't be resolved statically (arbitrary function calls, imported + * variables, non-arrow `.map()` callbacks) produce an empty list and are silently + * skipped. + */ +export function collectJsxChildren( + node: TSESTree.Node, + sourceCode: TSESLint.SourceCode +): TSESTree.JSXElement[] { + return collectChildren(node, sourceCode, new Set()); +} + +function collectChildren( + node: TSESTree.Node, + sourceCode: TSESLint.SourceCode, + visited: Set +): TSESTree.JSXElement[] { + const collect = (n: TSESTree.Node) => collectChildren(n, sourceCode, visited); + + switch (node.type) { + case 'JSXElement': { + if (isFragment(node.openingElement)) { + return flatMap(node.children, collect); + } + const { name } = node.openingElement; + if (name.type === 'JSXIdentifier') { + if (visited.has(name.name)) return []; + + const resolved = resolveLocalComponent( + sourceCode, + name.name, + node.openingElement + ); + if (resolved !== null) { + visited.add(name.name); + + const result = flatMap(resolved, collect); + + visited.delete(name.name); + return result; + } + } + return [node]; + } + case 'JSXFragment': + return flatMap(node.children, collect); + case 'JSXExpressionContainer': + if (node.expression.type === 'JSXEmptyExpression') return []; + return collect(node.expression); + case 'LogicalExpression': + if (node.operator === '&&') return collect(node.right); + return [...collect(node.left), ...collect(node.right)]; + case 'ConditionalExpression': + return [...collect(node.consequent), ...collect(node.alternate)]; + case 'ArrayExpression': + return flatMap(node.elements, (el) => + el && el.type !== 'SpreadElement' ? collect(el) : [] + ); + case 'Identifier': { + const resolved = resolveIdentifierValue(sourceCode, node); + return resolved ? collect(resolved) : []; + } + case 'ArrowFunctionExpression': + if (node.body.type !== 'BlockStatement') return collect(node.body); + return flatMap(collectReturnValues(node.body), collect); + case 'CallExpression': { + const { callee, arguments: args } = node; + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'map' && + args.length >= 1 && + args[0].type === 'ArrowFunctionExpression' + ) { + const fn = args[0]; + if (fn.body.type !== 'BlockStatement') return collect(fn.body); + return flatMap(collectReturnValues(fn.body), collect); + } + return []; + } + default: + return []; + } +} diff --git a/packages/eslint-plugin/src/utils/flat_map.ts b/packages/eslint-plugin/src/utils/flat_map.ts new file mode 100644 index 00000000000..6a1907dbf66 --- /dev/null +++ b/packages/eslint-plugin/src/utils/flat_map.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// `Array.prototype.flatMap` requires ES2019 lib; this shim keeps rules that +// need it within the package's es5 lib setting without widening it for others. +export function flatMap(arr: readonly T[], fn: (item: T) => U[]): U[] { + const out: U[] = []; + for (const item of arr) { + const items = fn(item); + for (const i of items) out.push(i); + } + return out; +} diff --git a/packages/eslint-plugin/src/utils/is_fragment.ts b/packages/eslint-plugin/src/utils/is_fragment.ts new file mode 100644 index 00000000000..db0e10ad7c2 --- /dev/null +++ b/packages/eslint-plugin/src/utils/is_fragment.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type TSESTree } from '@typescript-eslint/utils'; + +/** + * Returns true if the opening element is a React fragment: `` or + * ``. The `<>` shorthand is represented as a `JSXFragment` + * node (not a `JSXOpeningElement`) and is handled separately by callers. + */ +export function isFragment(opening: TSESTree.JSXOpeningElement): boolean { + const { name } = opening; + if (name.type === 'JSXIdentifier' && name.name === 'Fragment') return true; + return ( + name.type === 'JSXMemberExpression' && + name.object.type === 'JSXIdentifier' && + name.object.name === 'React' && + name.property.type === 'JSXIdentifier' && + name.property.name === 'Fragment' + ); +}