From ccc095bc248dd0920bb7099dfd5f60176f6add16 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 22 May 2026 15:49:40 +0200 Subject: [PATCH 01/17] prefer-info-text --- eslint.config.ts | 1 + .../app/components/core/modal/index.spec.tsx | 6 +- .../eslintPluginScraps/src/rules/index.ts | 2 + .../src/rules/prefer-info-text.spec.ts | 195 ++++++++++++++++++ .../src/rules/prefer-info-text.ts | 124 +++++++++++ 5 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts create mode 100644 static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts diff --git a/eslint.config.ts b/eslint.config.ts index 323d3a70330f78..395562591d0655 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -466,6 +466,7 @@ export default typescript.config([ rules: { '@sentry/scraps/no-core-import': 'error', '@sentry/scraps/no-token-import': 'error', + '@sentry/scraps/prefer-info-text': 'error', '@sentry/scraps/use-semantic-token': [ 'error', {enabledCategories: ['background', 'border', 'content']}, diff --git a/static/app/components/core/modal/index.spec.tsx b/static/app/components/core/modal/index.spec.tsx index b2bd156355a53d..9b3bd14d0fcf8e 100644 --- a/static/app/components/core/modal/index.spec.tsx +++ b/static/app/components/core/modal/index.spec.tsx @@ -9,8 +9,8 @@ import { } from 'sentry-test/reactTestingLibrary'; import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {InfoText} from '@sentry/scraps/info'; import {useModal} from '@sentry/scraps/modal'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {closeModal, openModal} from 'sentry/actionCreators/modal'; @@ -286,9 +286,7 @@ describe('GlobalModal', () => { act(() => openModal(({Body}) => ( - Click me} isHoverable> - Hi - + Click me}>Hi )) ); diff --git a/static/eslint/eslintPluginScraps/src/rules/index.ts b/static/eslint/eslintPluginScraps/src/rules/index.ts index 0cef2b34f85bb4..dda03efafd03a7 100644 --- a/static/eslint/eslintPluginScraps/src/rules/index.ts +++ b/static/eslint/eslintPluginScraps/src/rules/index.ts @@ -1,11 +1,13 @@ import {noCoreImport} from './no-core-import'; import {noTokenImport} from './no-token-import'; +import {preferInfoText} from './prefer-info-text'; import {restrictJsxSlotChildren} from './restrict-jsx-slot-children'; import {useSemanticToken} from './use-semantic-token'; export const rules = { 'no-core-import': noCoreImport, 'no-token-import': noTokenImport, + 'prefer-info-text': preferInfoText, 'restrict-jsx-slot-children': restrictJsxSlotChildren, 'use-semantic-token': useSemanticToken, }; diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts new file mode 100644 index 00000000000000..bd9a0db74e7433 --- /dev/null +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts @@ -0,0 +1,195 @@ +import {RuleTester} from '@typescript-eslint/rule-tester'; + +import {preferInfoText} from './prefer-info-text'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: {jsx: true}, + }, + }, +}); + +ruleTester.run('prefer-info-text', preferInfoText, { + valid: [ + { + name: 'Tooltip wrapping a non-text component', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = ; + `, + }, + { + name: 'Tooltip wrapping mixed text and non-text children', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = text; + `, + }, + { + name: 'Self-closing Tooltip', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = ; + `, + }, + { + name: 'Tooltip from a different package', + code: ` + import {Tooltip} from 'other-package'; + const x = text; + `, + }, + { + name: 'Tooltip wrapping a styled component (not detectable)', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = text; + `, + }, + { + name: 'Tooltip wrapping a div', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x =
text
; + `, + }, + { + name: 'Tooltip wrapping a variable reference', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {someVariable}; + `, + }, + { + name: 'Tooltip wrapping a complex component child', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = ; + `, + }, + { + name: 'No Tooltip import at all', + code: ` + const Tooltip = (props: any) => null; + const x = text; + `, + }, + ], + + invalid: [ + { + name: 'Raw text child', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 't() i18n call', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {t('Click to expand')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'tct() i18n call', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {tct('Hello [name]', {name})}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'String literal in expression container', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {'some string'}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Template literal', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {\`hello \${name}\`}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'span wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = label text; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'span wrapping t() call', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {t('label')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Text component wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {Text} from '@sentry/scraps/text'; + const x = label; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Tooltip with extra props still flagged', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {t('label')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Multiple text-like children', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Hello {t('world')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Aliased Tooltip import', + code: ` + import {Tooltip as Tip} from '@sentry/scraps/tooltip'; + const x = {t('label')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Fragment wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = <>{t('label')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Conditional expression with text branches', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {condition ? t('a') : t('b')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Logical AND with text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {condition && t('label')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + ], +}); diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts new file mode 100644 index 00000000000000..723a3599e8fc45 --- /dev/null +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts @@ -0,0 +1,124 @@ +import {AST_NODE_TYPES, ESLintUtils, type TSESTree} from '@typescript-eslint/utils'; + +import {createImportTracker} from '../ast/tracker/imports'; + +const TOOLTIP_SOURCE = '@sentry/scraps/tooltip'; +const TEXT_SOURCE = '@sentry/scraps/text'; +const I18N_FUNCTIONS = new Set(['t', 'tct']); + +function getElementName(nameNode: TSESTree.JSXTagNameExpression): string { + switch (nameNode.type) { + case AST_NODE_TYPES.JSXIdentifier: + return nameNode.name; + case AST_NODE_TYPES.JSXMemberExpression: + return `${getElementName(nameNode.object)}.${nameNode.property.name}`; + case AST_NODE_TYPES.JSXNamespacedName: + return `${nameNode.namespace.name}:${nameNode.name.name}`; + } +} + +function isI18nCall(node: TSESTree.Expression): boolean { + return ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.Identifier && + I18N_FUNCTIONS.has(node.callee.name) + ); +} + +export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'suggestion', + docs: { + description: 'Prefer over wrapping text content.', + }, + schema: [], + messages: { + preferInfoText: + "Prefer over wrapping text content. Import InfoText from '@sentry/scraps/info'.", + }, + }, + + create(context) { + const importTracker = createImportTracker(); + let resolved = false; + let tooltipNames: string[] = []; + let textNames: string[] = []; + + function resolveNames() { + if (resolved) return; + resolved = true; + tooltipNames = importTracker.findLocalNames(TOOLTIP_SOURCE, 'Tooltip'); + textNames = importTracker.findLocalNames(TEXT_SOURCE, 'Text'); + } + + function isTextLikeExpression(expr: TSESTree.Expression): boolean { + switch (expr.type) { + case AST_NODE_TYPES.Literal: + return typeof expr.value === 'string'; + case AST_NODE_TYPES.TemplateLiteral: + return true; + case AST_NODE_TYPES.CallExpression: + return isI18nCall(expr); + case AST_NODE_TYPES.ConditionalExpression: + return ( + isTextLikeExpression(expr.consequent) && isTextLikeExpression(expr.alternate) + ); + case AST_NODE_TYPES.LogicalExpression: + if (expr.operator === '&&') { + return isTextLikeExpression(expr.right); + } + return isTextLikeExpression(expr.left) && isTextLikeExpression(expr.right); + default: + return false; + } + } + + function isTextLikeChild(child: TSESTree.JSXChild): boolean { + switch (child.type) { + case AST_NODE_TYPES.JSXText: + return child.value.trim().length > 0; + case AST_NODE_TYPES.JSXExpressionContainer: + if (child.expression.type === AST_NODE_TYPES.JSXEmptyExpression) { + return false; + } + return isTextLikeExpression(child.expression); + case AST_NODE_TYPES.JSXElement: { + const name = getElementName(child.openingElement.name); + if (name === 'span' || textNames.includes(name)) { + return allChildrenAreTextLike(child.children); + } + return false; + } + case AST_NODE_TYPES.JSXFragment: + return allChildrenAreTextLike(child.children); + default: + return false; + } + } + + function allChildrenAreTextLike(children: TSESTree.JSXChild[]): boolean { + const meaningful = children.filter( + c => !(c.type === AST_NODE_TYPES.JSXText && c.value.trim() === '') + ); + return meaningful.length > 0 && meaningful.every(isTextLikeChild); + } + + return { + ...importTracker.visitors, + + JSXElement(node) { + resolveNames(); + const name = getElementName(node.openingElement.name); + if (!tooltipNames.includes(name)) { + return; + } + if (allChildrenAreTextLike(node.children)) { + context.report({ + node, + messageId: 'preferInfoText', + }); + } + }, + }; + }, +}); From 509501db9a406dec1c75f7b5183bb513cd1bc63b Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 22 May 2026 16:11:28 +0200 Subject: [PATCH 02/17] fix towards variant="inherit" --- .../src/rules/prefer-info-text.spec.ts | 210 ++++++++++++++++-- .../src/rules/prefer-info-text.ts | 165 +++++++++++++- 2 files changed, 356 insertions(+), 19 deletions(-) diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts index bd9a0db74e7433..ecc257b9f8fb92 100644 --- a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts @@ -10,6 +10,18 @@ const ruleTester = new RuleTester({ }, }); +function errorWithSuggestion(output: string) { + return { + messageId: 'preferInfoText', + suggestions: [ + { + messageId: 'replaceWithInfoText', + output, + }, + ], + } as const; +} + ruleTester.run('prefer-info-text', preferInfoText, { valid: [ { @@ -75,6 +87,14 @@ ruleTester.run('prefer-info-text', preferInfoText, { const x = text; `, }, + { + name: 't() call from another binding', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const t = (value: string) => value; + const x = {t('label')}; + `, + }, ], invalid: [ @@ -84,23 +104,48 @@ ruleTester.run('prefer-info-text', preferInfoText, { import {Tooltip} from '@sentry/scraps/tooltip'; const x = Some text here; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = Some text here; + `), + ], }, { name: 't() i18n call', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = {t('Click to expand')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {t('Click to expand')}; + `), + ], }, { name: 'tct() i18n call', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {tct} from 'sentry/locale'; const x = {tct('Hello [name]', {name})}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {tct} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {tct('Hello [name]', {name})}; + `), + ], }, { name: 'String literal in expression container', @@ -108,7 +153,14 @@ ruleTester.run('prefer-info-text', preferInfoText, { import {Tooltip} from '@sentry/scraps/tooltip'; const x = {'some string'}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {'some string'}; + `), + ], }, { name: 'Template literal', @@ -116,7 +168,14 @@ ruleTester.run('prefer-info-text', preferInfoText, { import {Tooltip} from '@sentry/scraps/tooltip'; const x = {\`hello \${name}\`}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {\`hello \${name}\`}; + `), + ], }, { name: 'span wrapping text', @@ -124,29 +183,84 @@ ruleTester.run('prefer-info-text', preferInfoText, { import {Tooltip} from '@sentry/scraps/tooltip'; const x = label text; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = label text; + `), + ], }, { name: 'span wrapping t() call', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = {t('label')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {t('label')}; + `), + ], + }, + { + name: 'inline semantic text wrapper', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = label text; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = label text; + `), + ], + }, + { + name: 'paragraph wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x =

label text

; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x =

label text

; + `), + ], }, { name: 'Text component wrapping text', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; import {Text} from '@sentry/scraps/text'; - const x = label; + const x = label; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {Text} from '@sentry/scraps/text'; +import {InfoText} from '@sentry/scraps/info'; + + const x = label; + `), + ], }, { name: 'Tooltip with extra props still flagged', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = {t('label')}; `, errors: [{messageId: 'preferInfoText'}], @@ -155,41 +269,109 @@ ruleTester.run('prefer-info-text', preferInfoText, { name: 'Multiple text-like children', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = Hello {t('world')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = Hello {t('world')}; + `), + ], }, { name: 'Aliased Tooltip import', code: ` import {Tooltip as Tip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = {t('label')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip as Tip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {t('label')}; + `), + ], }, { name: 'Fragment wrapping text', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = <>{t('label')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = <>{t('label')}; + `), + ], }, { name: 'Conditional expression with text branches', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = {condition ? t('a') : t('b')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {condition ? t('a') : t('b')}; + `), + ], }, { name: 'Logical AND with text', code: ` import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; const x = {condition && t('label')}; `, - errors: [{messageId: 'preferInfoText'}], + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {condition && t('label')}; + `), + ], + }, + { + name: 'Uses existing InfoText import in suggestion', + code: ` + import {InfoText as TextWithInfo} from '@sentry/scraps/info'; + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + errors: [ + { + messageId: 'preferInfoText', + suggestions: [ + { + messageId: 'replaceWithInfoText', + output: ` + import {InfoText as TextWithInfo} from '@sentry/scraps/info'; + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + }, + ], + }, + ], }, ], }); diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts index 723a3599e8fc45..c2ffecc1889d38 100644 --- a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts @@ -1,10 +1,39 @@ -import {AST_NODE_TYPES, ESLintUtils, type TSESTree} from '@typescript-eslint/utils'; +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils'; import {createImportTracker} from '../ast/tracker/imports'; const TOOLTIP_SOURCE = '@sentry/scraps/tooltip'; const TEXT_SOURCE = '@sentry/scraps/text'; +const INFO_SOURCE = '@sentry/scraps/info'; +const LOCALE_SOURCE = 'sentry/locale'; const I18N_FUNCTIONS = new Set(['t', 'tct']); +const TEXT_LIKE_INTRINSICS = new Set([ + 'a', + 'abbr', + 'b', + 'code', + 'del', + 'em', + 'i', + 'kbd', + 'label', + 'mark', + 'p', + 's', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', +]); +const TOOLTIP_PROPS_SUPPORTED_BY_INFO_TEXT = new Set(['title', 'showUnderline']); function getElementName(nameNode: TSESTree.JSXTagNameExpression): string { switch (nameNode.type) { @@ -17,11 +46,11 @@ function getElementName(nameNode: TSESTree.JSXTagNameExpression): string { } } -function isI18nCall(node: TSESTree.Expression): boolean { +function isI18nCall(node: TSESTree.Expression, i18nNames: string[]): boolean { return ( node.type === AST_NODE_TYPES.CallExpression && node.callee.type === AST_NODE_TYPES.Identifier && - I18N_FUNCTIONS.has(node.callee.name) + i18nNames.includes(node.callee.name) ); } @@ -31,10 +60,12 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ docs: { description: 'Prefer over wrapping text content.', }, + hasSuggestions: true, schema: [], messages: { preferInfoText: "Prefer over wrapping text content. Import InfoText from '@sentry/scraps/info'.", + replaceWithInfoText: 'Replace with .', }, }, @@ -43,12 +74,16 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ let resolved = false; let tooltipNames: string[] = []; let textNames: string[] = []; + let i18nNames: string[] = []; function resolveNames() { if (resolved) return; resolved = true; tooltipNames = importTracker.findLocalNames(TOOLTIP_SOURCE, 'Tooltip'); textNames = importTracker.findLocalNames(TEXT_SOURCE, 'Text'); + i18nNames = Array.from(I18N_FUNCTIONS).flatMap(name => + importTracker.findLocalNames(LOCALE_SOURCE, name) + ); } function isTextLikeExpression(expr: TSESTree.Expression): boolean { @@ -58,7 +93,7 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ case AST_NODE_TYPES.TemplateLiteral: return true; case AST_NODE_TYPES.CallExpression: - return isI18nCall(expr); + return isI18nCall(expr, i18nNames); case AST_NODE_TYPES.ConditionalExpression: return ( isTextLikeExpression(expr.consequent) && isTextLikeExpression(expr.alternate) @@ -84,7 +119,7 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ return isTextLikeExpression(child.expression); case AST_NODE_TYPES.JSXElement: { const name = getElementName(child.openingElement.name); - if (name === 'span' || textNames.includes(name)) { + if (TEXT_LIKE_INTRINSICS.has(name) || textNames.includes(name)) { return allChildrenAreTextLike(child.children); } return false; @@ -103,6 +138,81 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ return meaningful.length > 0 && meaningful.every(isTextLikeChild); } + function getMeaningfulChildren(children: TSESTree.JSXChild[]) { + return children.filter( + child => !(child.type === AST_NODE_TYPES.JSXText && child.value.trim() === '') + ); + } + + function getSingleTextElementChild(node: TSESTree.JSXElement) { + const meaningfulChildren = getMeaningfulChildren(node.children); + if (meaningfulChildren.length !== 1) { + return null; + } + + const child = meaningfulChildren[0]!; + if (child.type !== AST_NODE_TYPES.JSXElement || child.closingElement === null) { + return null; + } + + const name = getElementName(child.openingElement.name); + if (!textNames.includes(name)) { + return null; + } + + return child; + } + + function canSuggestInfoText(node: TSESTree.JSXElement): boolean { + if (node.openingElement.selfClosing || node.closingElement === null) { + return false; + } + + if (!allChildrenAreTextLike(node.children)) { + return false; + } + + return node.openingElement.attributes.every(attr => { + if (attr.type !== AST_NODE_TYPES.JSXAttribute) { + return false; + } + return ( + attr.name.type === AST_NODE_TYPES.JSXIdentifier && + TOOLTIP_PROPS_SUPPORTED_BY_INFO_TEXT.has(attr.name.name) + ); + }); + } + + function getInfoTextName() { + return importTracker.findLocalNames(INFO_SOURCE, 'InfoText')[0] ?? 'InfoText'; + } + + function getInfoTextImportFix(fixer: TSESLint.RuleFixer) { + if (importTracker.findLocalNames(INFO_SOURCE, 'InfoText').length > 0) { + return null; + } + + const imports = context.sourceCode.ast.body.filter( + node => node.type === AST_NODE_TYPES.ImportDeclaration + ); + const infoImport = `import {InfoText} from '${INFO_SOURCE}';\n`; + + if (imports.length === 0) { + return fixer.insertTextBeforeRange([0, 0], infoImport); + } + + return fixer.insertTextAfter(imports[imports.length - 1]!, `\n${infoImport}`); + } + + function getAttributeText(attributes: TSESTree.JSXOpeningElement['attributes']) { + return attributes.map(attr => context.sourceCode.getText(attr)).join(' '); + } + + function buildOpeningTag(name: string, attributes: string[]) { + const attributeText = attributes.filter(Boolean).join(' '); + return attributeText ? `<${name} ${attributeText}>` : `<${name}>`; + } + return { ...importTracker.visitors, @@ -116,6 +226,51 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ context.report({ node, messageId: 'preferInfoText', + suggest: canSuggestInfoText(node) + ? [ + { + messageId: 'replaceWithInfoText', + fix(fixer) { + const infoTextName = getInfoTextName(); + const textChild = getSingleTextElementChild(node); + if (textChild !== null) { + const attributes = [ + getAttributeText(node.openingElement.attributes), + getAttributeText(textChild.openingElement.attributes), + ]; + const childrenText = context.sourceCode.text.slice( + textChild.openingElement.range[1], + textChild.closingElement!.range[0] + ); + const replacement = `${buildOpeningTag( + infoTextName, + attributes + )}${childrenText}`; + const fixes = [fixer.replaceText(node, replacement)]; + const importFix = getInfoTextImportFix(fixer); + if (importFix !== null) { + fixes.push(importFix); + } + return fixes; + } + + const fixes = [ + fixer.replaceText(node.openingElement.name, infoTextName), + fixer.replaceText(node.closingElement!.name, infoTextName), + fixer.insertTextAfter( + node.openingElement.name, + ' variant="inherit"' + ), + ]; + const importFix = getInfoTextImportFix(fixer); + if (importFix !== null) { + fixes.push(importFix); + } + return fixes; + }, + }, + ] + : undefined, }); } }, From 33a6330ee4b3812efd45e3f2ff4a15cc2d31265a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 14:32:00 +0200 Subject: [PATCH 03/17] fix: remove tooltip that never shows up --- .../views/detectors/components/details/common/assignee.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/static/app/views/detectors/components/details/common/assignee.tsx b/static/app/views/detectors/components/details/common/assignee.tsx index 813694a5015625..a3be0a734fd825 100644 --- a/static/app/views/detectors/components/details/common/assignee.tsx +++ b/static/app/views/detectors/components/details/common/assignee.tsx @@ -1,6 +1,5 @@ import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {Placeholder} from 'sentry/components/placeholder'; import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; @@ -48,11 +47,7 @@ function AssignToUser({userId}: {userId: string}) { } const title = user?.name ?? user?.email ?? t('Unknown user'); - return ( - - {t('Assign to %s', title)} - - ); + return t('Assign to %s', title); } function DetectorOwner({owner}: {owner: Detector['owner']}) { From a09835d3c3fd85957f83d4e40a89ef44529ba319 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 14:32:37 +0200 Subject: [PATCH 04/17] feat: allow position param to InfoText --- static/app/components/core/info/infoText.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/static/app/components/core/info/infoText.tsx b/static/app/components/core/info/infoText.tsx index c4658995295129..0203e37724d355 100644 --- a/static/app/components/core/info/infoText.tsx +++ b/static/app/components/core/info/infoText.tsx @@ -2,25 +2,26 @@ import styled from '@emotion/styled'; import type {DistributedOmit} from 'type-fest'; import {Text, type TextProps} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; +import {Tooltip, type TooltipProps} from '@sentry/scraps/tooltip'; type InfoTextProps = DistributedOmit< TextProps, 'title' > & { title: React.ReactNode; -}; +} & Pick; export function InfoText({ title, children, + position, ...textProps }: InfoTextProps) { if (!title) { return {children}; } return ( - + {children} From a06c3cb0950598461ca8cef9c08cf74149922eec Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 14:32:55 +0200 Subject: [PATCH 05/17] use info text --- .../feedback/feedbackItem/messageTitle.tsx | 8 +++--- .../replays/table/replayTableColumns.tsx | 28 ++++++++++--------- static/app/utils/discover/fieldRenderers.tsx | 5 ++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/static/app/components/feedback/feedbackItem/messageTitle.tsx b/static/app/components/feedback/feedbackItem/messageTitle.tsx index f8ecbc8cd451c7..7ce8583c808db0 100644 --- a/static/app/components/feedback/feedbackItem/messageTitle.tsx +++ b/static/app/components/feedback/feedbackItem/messageTitle.tsx @@ -1,9 +1,9 @@ import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; +import {InfoText} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {FeedbackItemUsername} from 'sentry/components/feedback/feedbackItem/feedbackItemUsername'; import {FeedbackTimestampsTooltip} from 'sentry/components/feedback/feedbackItem/feedbackTimestampsTooltip'; @@ -26,8 +26,8 @@ export function MessageTitle({feedbackItem, eventData}: Props) { {isSpam ? ( - {t('spam')} - + ) : null} ( - {t('Activity')} - + ), interactive: false, sortKey: 'activity', @@ -179,8 +181,8 @@ export const ReplayBrowserColumn: ReplayTableColumn = { export const ReplayCountDeadClicksColumn: ReplayTableColumn = { Header: () => ( - = [minSDK]. [link:Learn more.]', { @@ -190,7 +192,7 @@ export const ReplayCountDeadClicksColumn: ReplayTableColumn = { )} > {t('Dead clicks')} - + ), interactive: false, sortKey: 'count_dead_clicks', @@ -223,8 +225,8 @@ export const ReplayCountDeadClicksColumn: ReplayTableColumn = { export const ReplayCountErrorsColumn: ReplayTableColumn = { Header: () => ( - {t('Errors')} - +
), interactive: false, sortKey: 'count_errors', @@ -274,8 +276,8 @@ export const ReplayCountErrorsColumn: ReplayTableColumn = { export const ReplayCountRageClicksColumn: ReplayTableColumn = { Header: () => ( - = [minSDK]. [link:Learn more.]', { @@ -285,7 +287,7 @@ export const ReplayCountRageClicksColumn: ReplayTableColumn = { )} > {t('Rage clicks')} - +
), interactive: false, sortKey: 'count_rage_clicks', @@ -497,9 +499,9 @@ export const ReplaySelectColumn: ReplayTableColumn = { export const ReplaySessionColumn: ReplayTableColumn = { Header: () => ( - + {t('Replay')} - + ), interactive: true, sortKey: 'started_at', diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 01f1516794da10..21fe1726aac2d7 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -6,6 +6,7 @@ import partial from 'lodash/partial'; import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -332,9 +333,9 @@ export const FIELD_FORMATTERS: FieldFormatters = { if (data[field] > 0 && data[field] < NUMBER_MIN_VALUE) { return ( - + {`<${NUMBER_MIN_VALUE}`} - + ); } From 58300c2528b09bb0573e44787f1453e13d0d254e Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 14:41:51 +0200 Subject: [PATCH 06/17] fix: lint-ignore generated files --- eslint.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eslint.config.ts b/eslint.config.ts index 395562591d0655..9c1023f8da9762 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -276,6 +276,8 @@ export default typescript.config([ 'src/sentry/templates/sentry/**/*', 'stylelint.config.js', '.artifacts/**/*', + // auto-generated by figma code connect + '**/*.figma.tsx', ]), /** * Rules are grouped by plugin. If you want to override a specific rule inside From 468ea1176caaf4a0ee0b588cd87e33567483c899 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 17:45:00 +0200 Subject: [PATCH 07/17] ref: use button in tests --- .../app/components/core/tooltip/tooltip.spec.tsx | 16 ++++++++-------- static/app/components/hovercard.spec.tsx | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/static/app/components/core/tooltip/tooltip.spec.tsx b/static/app/components/core/tooltip/tooltip.spec.tsx index be762f0d3b0480..206738d8e21c8a 100644 --- a/static/app/components/core/tooltip/tooltip.spec.tsx +++ b/static/app/components/core/tooltip/tooltip.spec.tsx @@ -28,7 +28,7 @@ describe('Tooltip', () => { it('renders', async () => { render( - My Button + ); @@ -47,14 +47,14 @@ describe('Tooltip', () => { it('updates title', async () => { const {rerender} = render( - My Button + ); // Change title rerender( - My Button + ); @@ -70,7 +70,7 @@ describe('Tooltip', () => { it('disables and does not render', async () => { render( - My Button + ); @@ -84,7 +84,7 @@ describe('Tooltip', () => { it('resets visibility when becoming disabled', async () => { const {rerender} = render( - My Button + ); @@ -93,7 +93,7 @@ describe('Tooltip', () => { rerender( - My Button + ); expect(screen.queryByText('test')).not.toBeInTheDocument(); @@ -101,7 +101,7 @@ describe('Tooltip', () => { // Becomes enabled again rerender( - My Button + ); expect(screen.queryByText('test')).not.toBeInTheDocument(); @@ -110,7 +110,7 @@ describe('Tooltip', () => { it('does not render an empty tooltip', async () => { render( - My Button + ); await userEvent.hover(screen.getByText('My Button')); diff --git a/static/app/components/hovercard.spec.tsx b/static/app/components/hovercard.spec.tsx index 110184f5ac82dd..2a17b480a36b80 100644 --- a/static/app/components/hovercard.spec.tsx +++ b/static/app/components/hovercard.spec.tsx @@ -103,7 +103,7 @@ describe('Hovercard', () => { position="top" body={ - Inner trigger + } header="Hovercard Header" From bdc0e25c7d5f9f9c424d94284a8b50653e598fd1 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 18:30:04 +0200 Subject: [PATCH 08/17] fix: make InfoText show the correct underline color depending on text variant --- static/app/components/core/info/infoText.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/static/app/components/core/info/infoText.tsx b/static/app/components/core/info/infoText.tsx index 0203e37724d355..3a31d84b62e038 100644 --- a/static/app/components/core/info/infoText.tsx +++ b/static/app/components/core/info/infoText.tsx @@ -6,9 +6,10 @@ import {Tooltip, type TooltipProps} from '@sentry/scraps/tooltip'; type InfoTextProps = DistributedOmit< TextProps, - 'title' + 'title' | 'variant' > & { title: React.ReactNode; + variant?: TooltipProps['underlineColor'] | 'inherit'; } & Pick; export function InfoText({ @@ -21,7 +22,14 @@ export function InfoText({ return {children}; } return ( - + {children} From 19084d5e3329fc018863c67808a1cc2a40ea29e0 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 18:30:18 +0200 Subject: [PATCH 09/17] rootAllocationCard to InfoText --- .../spendAllocations/rootAllocationCard.tsx | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/static/gsApp/views/spendAllocations/rootAllocationCard.tsx b/static/gsApp/views/spendAllocations/rootAllocationCard.tsx index a49132dd7b572e..d1deae34e43a7b 100644 --- a/static/gsApp/views/spendAllocations/rootAllocationCard.tsx +++ b/static/gsApp/views/spendAllocations/rootAllocationCard.tsx @@ -3,10 +3,10 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {IconAdd} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; @@ -35,7 +35,6 @@ export function RootAllocationCard({ selectedMetric, subscription, }: Props) { - const theme = useTheme(); const availableEvents = useMemo(() => { return rootAllocation ? Math.max(rootAllocation.reservedQuantity - rootAllocation.consumedQuantity, 0) @@ -124,9 +123,9 @@ export function RootAllocationCard({ {t('Available')} {rootAllocation.costPerItem === 0 ? ( - - -- - + + N/A + ) : ( displayPrice({ cents: rootAllocation.costPerItem * availableEvents, @@ -134,9 +133,9 @@ export function RootAllocationCard({ )} - + {bigNumFormatter(availableEvents, 2, metricUnit)} - + @@ -144,9 +143,9 @@ export function RootAllocationCard({ {/* TODO: include OD costs if enabled */} {rootAllocation.costPerItem === 0 ? ( - - -- - + + N/A + ) : ( displayPrice({ cents: @@ -159,27 +158,25 @@ export function RootAllocationCard({ )} - - {bigNumFormatter( - Math.min( - rootAllocation.reservedQuantity, - rootAllocation.consumedQuantity - ), - 2, - metricUnit - )} - - {rootAllocation.consumedQuantity > - rootAllocation.reservedQuantity && ( - -   - + + {bigNumFormatter( + Math.min( + rootAllocation.reservedQuantity, + rootAllocation.consumedQuantity + ), + 2, + metricUnit + )} + + {rootAllocation.consumedQuantity > + rootAllocation.reservedQuantity && ( + {tct('([overCount] over)', { overCount: bigNumFormatter( @@ -189,9 +186,9 @@ export function RootAllocationCard({ metricUnit ), })} - - - )} + + )} + From 6e03f7bf0af1116274c6935c100178fb2f8238a3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 18:42:34 +0200 Subject: [PATCH 10/17] spendAllocation to InfoText --- .../components/allocationRow.tsx | 46 +++++++++++-------- .../projectAllocationsTable.tsx | 14 ++++-- .../spendAllocations/rootAllocationCard.tsx | 17 +++++-- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/static/gsApp/views/spendAllocations/components/allocationRow.tsx b/static/gsApp/views/spendAllocations/components/allocationRow.tsx index b2d3dcdcd2fd16..421ec10ddeaf89 100644 --- a/static/gsApp/views/spendAllocations/components/allocationRow.tsx +++ b/static/gsApp/views/spendAllocations/components/allocationRow.tsx @@ -2,7 +2,7 @@ import {useState} from 'react'; import {useTheme} from '@emotion/react'; import {Button} from '@sentry/scraps/button'; -import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; import {IconDelete, IconEdit} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -39,9 +39,12 @@ export function AllocationRow({ {allocation.costPerItem === 0 && ( - - -- - + + N/A + )} {allocation.costPerItem > 0 && ( @@ -55,9 +58,12 @@ export function AllocationRow({ - + {bigNumFormatter(allocation.reservedQuantity, undefined, metricUnit)} - + @@ -66,9 +72,12 @@ export function AllocationRow({ {allocation.costPerItem === 0 && ( - - -- - + + N/A + )} {allocation.costPerItem > 0 && ( @@ -82,7 +91,12 @@ export function AllocationRow({ - allocation.reservedQuantity + ? 'danger' + : 'inherit' + } title={(allocation.consumedQuantity > allocation.reservedQuantity ? `${allocation.consumedQuantity} (${ allocation.consumedQuantity - allocation.reservedQuantity @@ -90,16 +104,8 @@ export function AllocationRow({ : allocation.consumedQuantity ).toLocaleString()} > - allocation.reservedQuantity - ? {color: theme.colors.red500} - : {} - } - > - {bigNumFormatter(allocation.consumedQuantity, 2, metricUnit)} - - + {bigNumFormatter(allocation.consumedQuantity, 2, metricUnit)} + diff --git a/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx b/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx index ac4575b3bdb2b3..6a121a594fd563 100644 --- a/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx +++ b/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx @@ -1,8 +1,8 @@ import {useMemo} from 'react'; import styled from '@emotion/styled'; +import {InfoText} from '@sentry/scraps/info'; import {Container} from '@sentry/scraps/layout'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {t} from 'sentry/locale'; import type {DataCategory} from 'sentry/types/core'; @@ -60,13 +60,14 @@ export function ProjectAllocationsTable({ {t('Project')} - {t('Allocated')} - + @@ -78,9 +79,12 @@ export function ProjectAllocationsTable({ - + {t('Consumed')} - + diff --git a/static/gsApp/views/spendAllocations/rootAllocationCard.tsx b/static/gsApp/views/spendAllocations/rootAllocationCard.tsx index d1deae34e43a7b..975ee34e6b83fe 100644 --- a/static/gsApp/views/spendAllocations/rootAllocationCard.tsx +++ b/static/gsApp/views/spendAllocations/rootAllocationCard.tsx @@ -123,7 +123,10 @@ export function RootAllocationCard({ {t('Available')} {rootAllocation.costPerItem === 0 ? ( - + N/A ) : ( @@ -133,7 +136,7 @@ export function RootAllocationCard({ )} - + {bigNumFormatter(availableEvents, 2, metricUnit)} @@ -143,7 +146,10 @@ export function RootAllocationCard({ {/* TODO: include OD costs if enabled */} {rootAllocation.costPerItem === 0 ? ( - + N/A ) : ( @@ -159,7 +165,10 @@ export function RootAllocationCard({ - + {bigNumFormatter( Math.min( rootAllocation.reservedQuantity, From deab40f9c8b4329b70eb999a6696869b0c7570b6 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 18:52:38 +0200 Subject: [PATCH 11/17] gsAdmin and superuser things to InfoText --- static/gsAdmin/components/customerStatus.tsx | 6 ++--- .../components/customers/customerOverview.tsx | 23 +++++++++++++------ .../components/superuser/superuserWarning.tsx | 6 ++--- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/static/gsAdmin/components/customerStatus.tsx b/static/gsAdmin/components/customerStatus.tsx index 6a55ba89476850..eae30317a9ce3c 100644 --- a/static/gsAdmin/components/customerStatus.tsx +++ b/static/gsAdmin/components/customerStatus.tsx @@ -1,7 +1,7 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; import type {Subscription} from 'getsentry/types'; import {formatCurrency} from 'getsentry/utils/formatCurrency'; @@ -69,9 +69,9 @@ export function CustomerStatus({customer}: Props) { {typeof label !== 'object' && label}
- + {`${customer.planDetails?.name} Plan (${customer.planTier})`} - +
); } diff --git a/static/gsAdmin/components/customers/customerOverview.tsx b/static/gsAdmin/components/customers/customerOverview.tsx index 5a4d4f44030678..69763cc7464bb3 100644 --- a/static/gsAdmin/components/customers/customerOverview.tsx +++ b/static/gsAdmin/components/customers/customerOverview.tsx @@ -5,9 +5,9 @@ import moment from 'moment-timezone'; import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {ConfigStore} from 'sentry/stores/configStore'; import {DataCategory} from 'sentry/types/core'; @@ -757,9 +757,12 @@ export function CustomerOverview({customer, onAction, organization}: Props) { + Partner -
+ } > {customer.partner ? ( @@ -874,14 +877,20 @@ export function CustomerOverview({customer, onAction, organization}: Props) { Standard Default - + Downsampled - + - + Downsample Default - + diff --git a/static/gsApp/components/superuser/superuserWarning.tsx b/static/gsApp/components/superuser/superuserWarning.tsx index 9e1fa09cbb2565..c5242709c9eff3 100644 --- a/static/gsApp/components/superuser/superuserWarning.tsx +++ b/static/gsApp/components/superuser/superuserWarning.tsx @@ -6,6 +6,7 @@ import {useResizeObserver} from '@react-aria/utils'; import {Badge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {Flex, Container} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -167,8 +168,7 @@ export function SuperuserWarning({organization, className}: Props) { return ( - {WARNING_MESSAGE} @@ -177,7 +177,7 @@ export function SuperuserWarning({organization, className}: Props) { } > Superuser - + ); } From 7e0c1e705fa5901514e7edc4f5a3be9064c52ebd Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 19:50:22 +0200 Subject: [PATCH 12/17] more infoTip --- .../components/conversationsTable.tsx | 7 ++++--- .../replays/detail/layout/focusTabs.tsx | 8 ++++---- .../insights/uptime/components/percent.tsx | 19 +++++++++---------- .../projectionPeriodControl.tsx | 8 ++++---- .../organizationMembers/inviteRequestRow.tsx | 7 ++++--- .../organizationTeams/otherTeamsTable.tsx | 14 ++++++-------- 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/static/app/views/explore/conversations/components/conversationsTable.tsx b/static/app/views/explore/conversations/components/conversationsTable.tsx index 38665c437c5ed7..cdc44deb262f89 100644 --- a/static/app/views/explore/conversations/components/conversationsTable.tsx +++ b/static/app/views/explore/conversations/components/conversationsTable.tsx @@ -1,6 +1,7 @@ import {Fragment, memo, useCallback, type ComponentPropsWithRef} from 'react'; import styled from '@emotion/styled'; +import {InfoText} from '@sentry/scraps/info'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Pagination} from '@sentry/scraps/pagination'; @@ -265,9 +266,9 @@ const BodyCell = memo(function BodyCell({ case 'user': { if (!dataRow.user) { return ( - } isHoverable> - - + }> + — + ); } const displayName = getUserDisplayName(dataRow.user); diff --git a/static/app/views/explore/replays/detail/layout/focusTabs.tsx b/static/app/views/explore/replays/detail/layout/focusTabs.tsx index 2db2f0e61b51e7..2ecdabe47a29a3 100644 --- a/static/app/views/explore/replays/detail/layout/focusTabs.tsx +++ b/static/app/views/explore/replays/detail/layout/focusTabs.tsx @@ -1,10 +1,10 @@ import {useEffect, type ReactNode} from 'react'; import {FeatureBadge} from '@sentry/scraps/badge'; +import {InfoText} from '@sentry/scraps/info'; import {Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {TabList, Tabs} from '@sentry/scraps/tabs'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {useOrganizationSeerSetup} from 'sentry/components/events/autofix/useOrganizationSeerSetup'; import {t, tct} from 'sentry/locale'; @@ -40,8 +40,8 @@ function getReplayTabs({ [TabKey.AI]: hasAiSummary && (!isVideoReplay || hasMobileSummary) ? ( - {t('AI Summary')} - + ) : null, diff --git a/static/app/views/insights/uptime/components/percent.tsx b/static/app/views/insights/uptime/components/percent.tsx index ff1a80b4962e0d..8895764692b46d 100644 --- a/static/app/views/insights/uptime/components/percent.tsx +++ b/static/app/views/insights/uptime/components/percent.tsx @@ -1,7 +1,7 @@ +import {InfoText} from '@sentry/scraps/info'; import {Flex, Grid} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import type {TextProps} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {t} from 'sentry/locale'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; @@ -51,14 +51,13 @@ export function UptimePercent({summary, note, size}: UptimePercentProps) { ); return ( - - 99 ? 'success' : percent > 95 ? 'warning' : 'danger'} - > - {`${percent}%`} - - + 99 ? 'success' : percent > 95 ? 'warning' : 'danger'} + title={tooltip} + > + {`${percent}%`} + ); } diff --git a/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx b/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx index 27cc60b0e192c8..7a4d5940003666 100644 --- a/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx +++ b/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; +import {InfoText} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {RadioGroup} from 'sentry/components/forms/controls/radioGroup'; import {t} from 'sentry/locale'; @@ -15,12 +15,12 @@ interface Props { export function ProjectionPeriodControl({period, onChange}: Props) { return ( - {t('Project the next')} - + - + ) ) : ( diff --git a/static/app/views/settings/organizationTeams/otherTeamsTable.tsx b/static/app/views/settings/organizationTeams/otherTeamsTable.tsx index 36d00895beb755..ed7fade16fd9e2 100644 --- a/static/app/views/settings/organizationTeams/otherTeamsTable.tsx +++ b/static/app/views/settings/organizationTeams/otherTeamsTable.tsx @@ -3,11 +3,10 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; -import {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {openCreateTeamModal} from 'sentry/actionCreators/modal'; import {IdBadge} from 'sentry/components/idBadge'; @@ -193,16 +192,15 @@ function TeamAction({ if (isPending) { return ( - - - {t('Request Pending')} - - + {t('Request Pending')} + ); } From 7691d74f6516b5cb125e064ddbf920c032a61c98 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 19:56:43 +0200 Subject: [PATCH 13/17] fix curly --- .../eslint/eslintPluginScraps/src/rules/prefer-info-text.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts index c2ffecc1889d38..5b4871f08a9725 100644 --- a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts @@ -77,7 +77,9 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ let i18nNames: string[] = []; function resolveNames() { - if (resolved) return; + if (resolved) { + return; + } resolved = true; tooltipNames = importTracker.findLocalNames(TOOLTIP_SOURCE, 'Tooltip'); textNames = importTracker.findLocalNames(TEXT_SOURCE, 'Text'); From abf07de171f35cd078d31c1a988981612f30d6b9 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 26 May 2026 19:57:07 +0200 Subject: [PATCH 14/17] fix another infoText --- .../views/settings/dynamicSampling/samplingModeSwitch.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx b/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx index 5eaae30143c5cc..6ec43922f26dc8 100644 --- a/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx +++ b/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx @@ -1,3 +1,4 @@ +import {InfoText} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {Switch} from '@sentry/scraps/switch'; @@ -28,7 +29,8 @@ export function SamplingModeSwitch({initialTargetRate}: Props) { return ( - {t('Advanced Mode')} - + Date: Tue, 26 May 2026 20:30:31 +0200 Subject: [PATCH 15/17] ref: fix bang operator with defensive propgramming --- .../src/rules/prefer-info-text.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts index 5b4871f08a9725..7a9289346416ab 100644 --- a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts @@ -148,11 +148,11 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ function getSingleTextElementChild(node: TSESTree.JSXElement) { const meaningfulChildren = getMeaningfulChildren(node.children); - if (meaningfulChildren.length !== 1) { + const child = meaningfulChildren[0]; + if (meaningfulChildren.length !== 1 || !child) { return null; } - const child = meaningfulChildren[0]!; if (child.type !== AST_NODE_TYPES.JSXElement || child.closingElement === null) { return null; } @@ -198,12 +198,12 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ node => node.type === AST_NODE_TYPES.ImportDeclaration ); const infoImport = `import {InfoText} from '${INFO_SOURCE}';\n`; + const lastImport = imports.at(-1); - if (imports.length === 0) { - return fixer.insertTextBeforeRange([0, 0], infoImport); + if (lastImport) { + return fixer.insertTextAfter(lastImport, `\n${infoImport}`); } - - return fixer.insertTextAfter(imports[imports.length - 1]!, `\n${infoImport}`); + return fixer.insertTextBeforeRange([0, 0], infoImport); } function getAttributeText(attributes: TSESTree.JSXOpeningElement['attributes']) { @@ -233,16 +233,19 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ { messageId: 'replaceWithInfoText', fix(fixer) { + if (!node.closingElement) { + return null; + } const infoTextName = getInfoTextName(); const textChild = getSingleTextElementChild(node); - if (textChild !== null) { + if (textChild && textChild.closingElement !== null) { const attributes = [ getAttributeText(node.openingElement.attributes), getAttributeText(textChild.openingElement.attributes), ]; const childrenText = context.sourceCode.text.slice( textChild.openingElement.range[1], - textChild.closingElement!.range[0] + textChild.closingElement.range[0] ); const replacement = `${buildOpeningTag( infoTextName, @@ -258,7 +261,7 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ const fixes = [ fixer.replaceText(node.openingElement.name, infoTextName), - fixer.replaceText(node.closingElement!.name, infoTextName), + fixer.replaceText(node.closingElement.name, infoTextName), fixer.insertTextAfter( node.openingElement.name, ' variant="inherit"' From 29b0afc1f51f744dca054db587951aa54a2ec6b8 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 27 May 2026 12:04:16 +0200 Subject: [PATCH 16/17] fix: missing inherit variant --- static/gsApp/components/superuser/superuserWarning.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/gsApp/components/superuser/superuserWarning.tsx b/static/gsApp/components/superuser/superuserWarning.tsx index c5242709c9eff3..a28f5d1f1a80e2 100644 --- a/static/gsApp/components/superuser/superuserWarning.tsx +++ b/static/gsApp/components/superuser/superuserWarning.tsx @@ -169,6 +169,7 @@ export function SuperuserWarning({organization, className}: Props) { return ( {WARNING_MESSAGE} From 36af936a66ba8ee44000c4194ff17f5fdc8fef6f Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 27 May 2026 12:10:13 +0200 Subject: [PATCH 17/17] fix: showUnderline is stripped in suggestion --- .../src/rules/prefer-info-text.spec.ts | 16 ++++++++ .../src/rules/prefer-info-text.ts | 41 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts index ecc257b9f8fb92..22252b90b9ee68 100644 --- a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts @@ -256,6 +256,22 @@ import {InfoText} from '@sentry/scraps/info'; `), ], }, + + { + name: 'showUnderline is stripped in suggestion', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = Some text here; + `), + ], + }, { name: 'Tooltip with extra props still flagged', code: ` diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts index 7a9289346416ab..ff301e99bc967c 100644 --- a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts @@ -34,6 +34,7 @@ const TEXT_LIKE_INTRINSICS = new Set([ 'u', ]); const TOOLTIP_PROPS_SUPPORTED_BY_INFO_TEXT = new Set(['title', 'showUnderline']); +const TOOLTIP_PROPS_TO_STRIP = new Set(['showUnderline']); function getElementName(nameNode: TSESTree.JSXTagNameExpression): string { switch (nameNode.type) { @@ -206,8 +207,23 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ return fixer.insertTextBeforeRange([0, 0], infoImport); } - function getAttributeText(attributes: TSESTree.JSXOpeningElement['attributes']) { - return attributes.map(attr => context.sourceCode.getText(attr)).join(' '); + function getAttributeText( + attributes: TSESTree.JSXOpeningElement['attributes'], + stripNames?: Set + ) { + return attributes + .filter(attr => { + if (!stripNames) { + return true; + } + return !( + attr.type === AST_NODE_TYPES.JSXAttribute && + attr.name.type === AST_NODE_TYPES.JSXIdentifier && + stripNames.has(attr.name.name) + ); + }) + .map(attr => context.sourceCode.getText(attr)) + .join(' '); } function buildOpeningTag(name: string, attributes: string[]) { @@ -240,7 +256,10 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ const textChild = getSingleTextElementChild(node); if (textChild && textChild.closingElement !== null) { const attributes = [ - getAttributeText(node.openingElement.attributes), + getAttributeText( + node.openingElement.attributes, + TOOLTIP_PROPS_TO_STRIP + ), getAttributeText(textChild.openingElement.attributes), ]; const childrenText = context.sourceCode.text.slice( @@ -259,7 +278,7 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ return fixes; } - const fixes = [ + const fixes: TSESLint.RuleFix[] = [ fixer.replaceText(node.openingElement.name, infoTextName), fixer.replaceText(node.closingElement.name, infoTextName), fixer.insertTextAfter( @@ -267,6 +286,20 @@ export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ ' variant="inherit"' ), ]; + for (const attr of node.openingElement.attributes) { + if ( + attr.type === AST_NODE_TYPES.JSXAttribute && + attr.name.type === AST_NODE_TYPES.JSXIdentifier && + TOOLTIP_PROPS_TO_STRIP.has(attr.name.name) + ) { + const src = context.sourceCode.getText(); + let start = attr.range[0]; + while (start > 0 && src[start - 1] === ' ') { + start--; + } + fixes.push(fixer.removeRange([start, attr.range[1]])); + } + } const importFix = getInfoTextImportFix(fixer); if (importFix !== null) { fixes.push(importFix);