diff --git a/.changeset/bui-analytics.md b/.changeset/bui-analytics.md new file mode 100644 index 0000000000..65ba31b1cf --- /dev/null +++ b/.changeset/bui-analytics.md @@ -0,0 +1,11 @@ +--- +'@backstage/ui': patch +--- + +Added analytics capabilities to the component library. Components with navigation behavior (Link, ButtonLink, Tab, MenuItem, Tag, Row) now fire analytics events on click when a `BUIProvider` is present. + +New exports: `BUIProvider`, `useAnalytics`, `getNodeText`, and associated types (`AnalyticsTracker`, `UseAnalyticsFn`, `BUIProviderProps`, `AnalyticsEventAttributes`). + +Components with analytics support now accept a `noTrack` prop to suppress event firing. + +**Affected components:** Link, ButtonLink, Tab, MenuItem, Tag, Row diff --git a/.changeset/core-app-api-analytics-provider.md b/.changeset/core-app-api-analytics-provider.md new file mode 100644 index 0000000000..f63bace0f3 --- /dev/null +++ b/.changeset/core-app-api-analytics-provider.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Added `BUIProvider` from `@backstage/ui` to the app shell provider tree, enabling BUI components to fire analytics events through the Backstage analytics system. diff --git a/.changeset/menu-item-onaction-chaining.md b/.changeset/menu-item-onaction-chaining.md new file mode 100644 index 0000000000..fe89854a07 --- /dev/null +++ b/.changeset/menu-item-onaction-chaining.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Fixed MenuItem `onAction` prop ordering so user-provided `onAction` handlers are chained rather than silently overwritten. diff --git a/.changeset/plugin-app-analytics-wrapper.md b/.changeset/plugin-app-analytics-wrapper.md new file mode 100644 index 0000000000..7e95de5f0f --- /dev/null +++ b/.changeset/plugin-app-analytics-wrapper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Added `BUIProvider` from `@backstage/ui` to the app root, enabling BUI components to fire analytics events through the Backstage analytics system. diff --git a/docs-ui/src/app/components/button-link/props-definition.tsx b/docs-ui/src/app/components/button-link/props-definition.tsx index deae5364df..e85a363b5a 100644 --- a/docs-ui/src/app/components/button-link/props-definition.tsx +++ b/docs-ui/src/app/components/button-link/props-definition.tsx @@ -62,6 +62,11 @@ export const buttonLinkPropDefs: Record = { ), }, + noTrack: { + type: 'boolean', + description: + 'Suppresses the automatic analytics click event, e.g. if you already have custom tracking.', + }, onSurface: { type: 'enum', values: ['0', '1', '2', '3', 'danger', 'warning', 'success', 'auto'], diff --git a/docs-ui/src/app/components/link/props-definition.tsx b/docs-ui/src/app/components/link/props-definition.tsx index be805d4540..951d67e8b4 100644 --- a/docs-ui/src/app/components/link/props-definition.tsx +++ b/docs-ui/src/app/components/link/props-definition.tsx @@ -68,6 +68,11 @@ export const linkPropDefs: Record = { 'Truncates text with ellipsis when it overflows its container.', default: 'false', }, + noTrack: { + type: 'boolean', + description: + 'Suppresses the automatic analytics click event, e.g. if you already have custom tracking.', + }, standalone: { type: 'boolean', description: 'Removes underline by default. Underline appears on hover.', diff --git a/docs-ui/src/app/components/menu/props-definition.tsx b/docs-ui/src/app/components/menu/props-definition.tsx index 5e11b22737..f6cb88b3e2 100644 --- a/docs-ui/src/app/components/menu/props-definition.tsx +++ b/docs-ui/src/app/components/menu/props-definition.tsx @@ -309,6 +309,11 @@ export const menuItemPropDefs: Record = { type: 'boolean', description: 'Whether the item is disabled.', }, + noTrack: { + type: 'boolean', + description: + 'Suppresses the automatic analytics click event, e.g. if you already have custom tracking.', + }, textValue: { type: 'string', description: 'Text used for typeahead and accessibility.', diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx index be24e86bf0..abfdd45549 100644 --- a/docs-ui/src/app/components/table/props-definition.tsx +++ b/docs-ui/src/app/components/table/props-definition.tsx @@ -456,6 +456,11 @@ export const rowPropDefs: Record = { description: 'Row content. Can be a render function receiving column config.', }, + noTrack: { + type: 'boolean', + description: + 'Suppresses the automatic analytics click event, e.g. if you already have custom tracking.', + }, ...classNamePropDefs, ...stylePropDefs, }; diff --git a/docs-ui/src/app/components/tabs/props-definition.ts b/docs-ui/src/app/components/tabs/props-definition.ts index e1c1f056cb..92d1c3d258 100644 --- a/docs-ui/src/app/components/tabs/props-definition.ts +++ b/docs-ui/src/app/components/tabs/props-definition.ts @@ -68,6 +68,11 @@ export const tabPropDefs: Record = { default: 'false', description: 'Disables this tab. Use for temporarily unavailable options.', }, + noTrack: { + type: 'boolean', + description: + 'Suppresses the automatic analytics click event, e.g. if you already have custom tracking.', + }, ...childrenPropDefs, ...classNamePropDefs, }; diff --git a/docs-ui/src/app/components/tag-group/props-definition.tsx b/docs-ui/src/app/components/tag-group/props-definition.tsx index 3f64b03e8c..dc10d5f7e7 100644 --- a/docs-ui/src/app/components/tag-group/props-definition.tsx +++ b/docs-ui/src/app/components/tag-group/props-definition.tsx @@ -84,6 +84,11 @@ export const tagPropDefs: Record = { type: 'boolean', description: 'Whether the tag is disabled.', }, + noTrack: { + type: 'boolean', + description: + 'Suppresses the automatic analytics click event, e.g. if you already have custom tracking.', + }, ...childrenPropDefs, ...classNamePropDefs, }; diff --git a/docs-ui/src/app/get-started/installation/page.mdx b/docs-ui/src/app/get-started/installation/page.mdx index cdb14d5924..c5421ae9d7 100644 --- a/docs-ui/src/app/get-started/installation/page.mdx +++ b/docs-ui/src/app/get-started/installation/page.mdx @@ -1,6 +1,10 @@ import { CodeBlock } from '@/components/CodeBlock'; import { Banner } from '@/components/Banner'; -import { snippet } from './snippets'; +import { + snippet, + analyticsSetupSnippet, + analyticsNoTrackSnippet, +} from './snippets'; # Installation @@ -44,3 +48,25 @@ your plugin and import the components you need. /> + +## Analytics + +BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — can fire analytics events when clicked. To enable this, you need to connect BUI's analytics layer to Backstage's analytics system. + +### Setup + +If you're using the **new frontend system**, analytics is wired automatically via `@backstage/plugin-app` — no setup required. + +For the **old frontend system**, the `BUIProvider` is included in the app shell from `@backstage/core-app-api` and works out of the box. + +If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` and pass in Backstage's `useAnalytics` hook: + + + +### How it works + +Once configured, BUI components fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree. + +To suppress tracking on an individual component, use the `noTrack` prop: + + diff --git a/docs-ui/src/app/get-started/installation/snippets.ts b/docs-ui/src/app/get-started/installation/snippets.ts index 7e63005172..12dfff4f44 100644 --- a/docs-ui/src/app/get-started/installation/snippets.ts +++ b/docs-ui/src/app/get-started/installation/snippets.ts @@ -4,3 +4,16 @@ export const snippet = `import { Flex, Button, Text } from '@backstage/ui'; Hello World ;`; + +export const analyticsSetupSnippet = `import { BUIProvider } from '@backstage/ui'; +import { useAnalytics } from '@backstage/core-plugin-api'; + +// Wrap your app content with the provider + + +`; + +export const analyticsNoTrackSnippet = `// Suppress analytics for a specific link + + Skip tracking +`; diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 231ee0f4c1..16ec1b1b70 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -49,6 +49,7 @@ "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/types": "workspace:^", + "@backstage/ui": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/prop-types": "^15.7.3", "history": "^5.0.0", diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index bfaeb2f2db..e1a83a79b7 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -45,7 +45,9 @@ import { fetchApiRef, discoveryApiRef, errorApiRef, + useAnalytics, } from '@backstage/core-plugin-api'; +import { BUIProvider } from '@backstage/ui'; import { AppLanguageApi, appLanguageApiRef, @@ -389,26 +391,28 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be return ( - - - - + + + - }>{children} - - - - + + }>{children} + + + + + ); }; diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index e6a54e32a4..420e54df52 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -21,6 +21,9 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'table.pagination.lastTooltip': 'Last Page'; readonly 'table.pagination.nextTooltip': 'Next Page'; readonly 'table.pagination.previousTooltip': 'Previous Page'; + readonly 'emptyState.missingAnnotation.title': 'Missing Annotation'; + readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:'; + readonly 'emptyState.missingAnnotation.readMore': 'Read more'; readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; @@ -44,9 +47,6 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'errorPage.goBack': 'Go back'; readonly 'errorPage.showMoreDetails': 'Show more details'; readonly 'errorPage.showLessDetails': 'Show less details'; - readonly 'emptyState.missingAnnotation.title': 'Missing Annotation'; - readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:'; - readonly 'emptyState.missingAnnotation.readMore': 'Read more'; readonly 'supportConfig.default.title': 'Support Not Configured'; readonly 'supportConfig.default.linkTitle': 'Add `app.support` config key'; readonly 'errorBoundary.title': 'Please contact {{slackChannel}} for help.'; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index a4777d408d..a82bf80aba 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -255,6 +255,23 @@ export interface AlertProps // @public (undocumented) export type AlignItems = 'stretch' | 'start' | 'center' | 'end'; +// @public +export type AnalyticsEventAttributes = { + [key: string]: string | boolean | number; +}; + +// @public +export type AnalyticsTracker = { + captureEvent: ( + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, + ) => void; +}; + // @public (undocumented) export const Avatar: ForwardRefExoticComponent< AvatarProps & RefAttributes @@ -413,6 +430,15 @@ export type BoxUtilityProps = { // @public (undocumented) export type Breakpoint = 'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +// @public +export function BUIProvider(props: BUIProviderProps): JSX_2.Element; + +// @public (undocumented) +export type BUIProviderProps = { + useAnalytics?: UseAnalyticsFn; + children: ReactNode; +}; + // @public export const Button: ForwardRefExoticComponent< ButtonProps & RefAttributes @@ -513,7 +539,9 @@ export const ButtonLinkDefinition: { readonly content: 'bui-ButtonLinkContent'; }; readonly bg: 'consumer'; + readonly analytics: true; readonly propDefs: { + readonly noTrack: {}; readonly size: { readonly dataAttribute: true; readonly default: 'small'; @@ -531,6 +559,7 @@ export const ButtonLinkDefinition: { // @public (undocumented) export type ButtonLinkOwnProps = { + noTrack?: boolean; size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; iconStart?: ReactElement; @@ -1260,6 +1289,11 @@ export interface FullPageProps extends Omit, 'className'>, FullPageOwnProps {} +// @public +export function getNodeText( + node: ReactNode | ((...args: any[]) => ReactNode), +): string | undefined; + // @public (undocumented) export const Grid: { Root: ForwardRefExoticComponent>; @@ -1448,7 +1482,9 @@ export const LinkDefinition: { readonly classNames: { readonly root: 'bui-Link'; }; + readonly analytics: true; readonly propDefs: { + readonly noTrack: {}; readonly variant: { readonly dataAttribute: true; readonly default: 'body-medium'; @@ -1475,6 +1511,7 @@ export const LinkDefinition: { // @public (undocumented) export type LinkOwnProps = { + noTrack?: boolean; variant?: TextVariants | Partial>; weight?: TextWeights | Partial>; color?: @@ -1578,6 +1615,7 @@ export type MenuItemOwnProps = { children: React.ReactNode; color?: 'primary' | 'danger'; href?: MenuItemProps_2['href']; + noTrack?: boolean; className?: string; }; @@ -1997,6 +2035,7 @@ export type RowOwnProps = { columns?: RowProps_2['columns']; children?: RowProps_2['children']; href?: string; + noTrack?: boolean; }; // @public (undocumented) @@ -2427,6 +2466,7 @@ export type TabOwnProps = { matchStrategy?: TabMatchStrategy; href?: TabProps_2['href']; id?: TabProps_2['id']; + noTrack?: boolean; }; // @public @@ -2522,6 +2562,7 @@ export type TagOwnProps = { href?: TagProps_2['href']; children?: TagProps_2['children']; className?: string; + noTrack?: boolean; }; // @public @@ -2779,6 +2820,12 @@ export const TooltipTrigger: ( props: TooltipTriggerComponentProps, ) => JSX_2.Element; +// @public +export function useAnalytics(): AnalyticsTracker; + +// @public +export type UseAnalyticsFn = () => AnalyticsTracker; + // @public export function useBgConsumer(): BgContextValue; diff --git a/packages/ui/src/analytics/BUIProvider.tsx b/packages/ui/src/analytics/BUIProvider.tsx new file mode 100644 index 0000000000..3fcbfc7698 --- /dev/null +++ b/packages/ui/src/analytics/BUIProvider.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo, type ReactNode } from 'react'; +import { createVersionedValueMap } from '@backstage/version-bridge'; +import { BUIContext } from './useAnalytics'; +import type { UseAnalyticsFn } from './types'; + +/** @public */ +export type BUIProviderProps = { + useAnalytics?: UseAnalyticsFn; + children: ReactNode; +}; + +/** + * Provides integration capabilities to all descendant BUI components. + * + * @example + * ```tsx + * import { BUIProvider } from '@backstage/ui'; + * import { useAnalytics as useBackstageAnalytics } from '@backstage/core-plugin-api'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + * + * @public + */ +export function BUIProvider(props: BUIProviderProps) { + const { useAnalytics, children } = props; + const value = useMemo( + () => + createVersionedValueMap({ + 1: { useAnalytics }, + }), + [useAnalytics], + ); + return {children}; +} diff --git a/packages/ui/src/analytics/getNodeText.ts b/packages/ui/src/analytics/getNodeText.ts new file mode 100644 index 0000000000..a1a58c34b7 --- /dev/null +++ b/packages/ui/src/analytics/getNodeText.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type ReactNode, isValidElement, Children } from 'react'; + +/** + * Recursively extracts text content from a React node tree. + * Returns undefined if no text content is found (e.g. icon-only children + * or render functions). + * + * @public + */ +export function getNodeText( + node: ReactNode | ((...args: any[]) => ReactNode), +): string | undefined { + if (typeof node === 'function') { + return undefined; + } + if (Array.isArray(node)) { + const text = Children.map(node, getNodeText)?.filter(Boolean).join(' '); + return text || undefined; + } + if (isValidElement(node)) { + return getNodeText(node.props.children); + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + return undefined; +} diff --git a/packages/ui/src/analytics/index.ts b/packages/ui/src/analytics/index.ts new file mode 100644 index 0000000000..a9b073afa7 --- /dev/null +++ b/packages/ui/src/analytics/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { useAnalytics } from './useAnalytics'; +export { BUIProvider } from './BUIProvider'; +export type { BUIProviderProps } from './BUIProvider'; +export { getNodeText } from './getNodeText'; +export type { + AnalyticsTracker, + AnalyticsEventAttributes, + UseAnalyticsFn, +} from './types'; diff --git a/packages/ui/src/analytics/types.ts b/packages/ui/src/analytics/types.ts new file mode 100644 index 0000000000..62b6eb6186 --- /dev/null +++ b/packages/ui/src/analytics/types.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Key-value metadata attached to analytics events. + * @public + */ +export type AnalyticsEventAttributes = { + [key: string]: string | boolean | number; +}; + +/** + * A generic interface for capturing analytics events. Consumers provide + * an implementation via `BUIProvider` — this allows `@backstage/ui` + * to fire analytics events without depending on any specific analytics + * system. The signature intentionally matches Backstage's own + * `AnalyticsTracker` so it can be wired through directly. + * @public + */ +export type AnalyticsTracker = { + captureEvent: ( + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, + ) => void; +}; + +/** + * A hook function that returns an AnalyticsTracker. + * Provided via context by the consumer (e.g. a Backstage app). + * @public + */ +export type UseAnalyticsFn = () => AnalyticsTracker; diff --git a/packages/ui/src/analytics/useAnalytics.ts b/packages/ui/src/analytics/useAnalytics.ts new file mode 100644 index 0000000000..127b3aa4b6 --- /dev/null +++ b/packages/ui/src/analytics/useAnalytics.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useRef } from 'react'; +import { + createVersionedContext, + useVersionedContext, +} from '@backstage/version-bridge'; +import type { AnalyticsTracker, UseAnalyticsFn } from './types'; + +/** @internal */ +export const noopTracker: AnalyticsTracker = { + captureEvent: () => {}, +}; + +const noopUseAnalytics: UseAnalyticsFn = () => noopTracker; + +/** @internal */ +export type BUIContextValue = { + useAnalytics?: UseAnalyticsFn; +}; + +/** @internal */ +export const BUIContext = createVersionedContext<{ + 1: BUIContextValue; +}>('bui'); + +/** + * Returns an AnalyticsTracker for capturing analytics events. + * + * By default returns a noop tracker. When a `BUIProvider` is present + * in the tree, returns the tracker provided by the consumer's hook. + * + * @public + */ +export function useAnalytics(): AnalyticsTracker { + const ctx = useVersionedContext<{ 1: BUIContextValue }>('bui')?.atVersion(1); + const impl = ctx?.useAnalytics ?? noopUseAnalytics; + + if (process.env.NODE_ENV !== 'production') { + const prevImpl = useRef(impl); + if ( + (prevImpl.current === noopUseAnalytics) !== + (impl === noopUseAnalytics) + ) { + throw new Error( + '@backstage/ui: The analytics hook changed between a noop and a real ' + + 'implementation. Ensure wraps all BUI components from first render.', + ); + } + prevImpl.current = impl; + } + + return impl(); +} diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx index ac691897c4..96f477a4e8 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx @@ -20,16 +20,28 @@ import type { ButtonLinkProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; import { ButtonLinkDefinition } from './definition'; import { InternalLinkProvider } from '../InternalLinkProvider'; +import { getNodeText } from '../../analytics/getNodeText'; /** @public */ export const ButtonLink = forwardRef( (props: ButtonLinkProps, ref: Ref) => { - const { ownProps, restProps, dataAttributes } = useDefinition( + const { ownProps, restProps, dataAttributes, analytics } = useDefinition( ButtonLinkDefinition, props, ); const { classes, iconStart, iconEnd, children } = ownProps; + const handlePress: typeof restProps.onPress = e => { + restProps.onPress?.(e); + const text = + restProps['aria-label'] ?? + getNodeText(children) ?? + String(restProps.href ?? ''); + analytics.captureEvent('click', text, { + attributes: { to: String(restProps.href ?? '') }, + }); + }; + return ( {iconStart} diff --git a/packages/ui/src/components/ButtonLink/definition.ts b/packages/ui/src/components/ButtonLink/definition.ts index ea271a966e..a5f6a28489 100644 --- a/packages/ui/src/components/ButtonLink/definition.ts +++ b/packages/ui/src/components/ButtonLink/definition.ts @@ -29,7 +29,9 @@ export const ButtonLinkDefinition = defineComponent()({ content: 'bui-ButtonLinkContent', }, bg: 'consumer', + analytics: true, propDefs: { + noTrack: {}, size: { dataAttribute: true, default: 'small' }, variant: { dataAttribute: true, default: 'primary' }, iconStart: {}, diff --git a/packages/ui/src/components/ButtonLink/types.ts b/packages/ui/src/components/ButtonLink/types.ts index f2753eafe5..a4c5dbe65e 100644 --- a/packages/ui/src/components/ButtonLink/types.ts +++ b/packages/ui/src/components/ButtonLink/types.ts @@ -20,6 +20,7 @@ import type { Responsive } from '../../types'; /** @public */ export type ButtonLinkOwnProps = { + noTrack?: boolean; size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; iconStart?: ReactElement; diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index 500bbc4f59..c0de9c3087 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -20,9 +20,10 @@ import type { LinkProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; import { LinkDefinition } from './definition'; import { InternalLinkProvider } from '../InternalLinkProvider'; +import { getNodeText } from '../../analytics/getNodeText'; const LinkInternal = forwardRef((props, ref) => { - const { ownProps, restProps, dataAttributes } = useDefinition( + const { ownProps, restProps, dataAttributes, analytics } = useDefinition( LinkDefinition, props, ); @@ -33,6 +34,17 @@ const LinkInternal = forwardRef((props, ref) => { const { linkProps } = useLink(restProps, linkRef); + const handleClick = (e: React.MouseEvent) => { + linkProps.onClick?.(e); + const text = + restProps['aria-label'] ?? + getNodeText(children) ?? + String(restProps.href ?? ''); + analytics.captureEvent('click', text, { + attributes: { to: String(restProps.href ?? '') }, + }); + }; + return ( ((props, ref) => { ref={linkRef} title={title} className={classes.root} + onClick={handleClick} > {children} diff --git a/packages/ui/src/components/Link/definition.ts b/packages/ui/src/components/Link/definition.ts index c0814c8a9a..15d0d35e12 100644 --- a/packages/ui/src/components/Link/definition.ts +++ b/packages/ui/src/components/Link/definition.ts @@ -27,7 +27,9 @@ export const LinkDefinition = defineComponent()({ classNames: { root: 'bui-Link', }, + analytics: true, propDefs: { + noTrack: {}, variant: { dataAttribute: true, default: 'body-medium' }, weight: { dataAttribute: true, default: 'regular' }, color: { dataAttribute: true, default: 'primary' }, diff --git a/packages/ui/src/components/Link/types.ts b/packages/ui/src/components/Link/types.ts index a2f8163e79..75b059a42b 100644 --- a/packages/ui/src/components/Link/types.ts +++ b/packages/ui/src/components/Link/types.ts @@ -26,6 +26,7 @@ import type { ReactNode } from 'react'; /** @public */ export type LinkOwnProps = { + noTrack?: boolean; variant?: TextVariants | Partial>; weight?: TextWeights | Partial>; color?: diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 354be33ce7..5ab55cf7d8 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -66,6 +66,7 @@ import { isInternalLink, createRoutingRegistration, } from '../InternalLinkProvider'; +import { getNodeText } from '../../analytics/getNodeText'; import { Box } from '../Box'; import { BgReset } from '../../hooks/useBg'; @@ -311,7 +312,7 @@ export const MenuAutocompleteListbox = ( /** @public */ export const MenuItem = (props: MenuItemProps) => { - const { ownProps, restProps, dataAttributes } = useDefinition( + const { ownProps, restProps, dataAttributes, analytics } = useDefinition( MenuItemDefinition, props, ); @@ -319,6 +320,16 @@ export const MenuItem = (props: MenuItemProps) => { useRoutingRegistrationEffect(href); + const handleAction = () => { + if (href) { + const text = + restProps['aria-label'] ?? getNodeText(children) ?? String(href); + analytics.captureEvent('click', text, { + attributes: { to: String(href) }, + }); + } + }; + // External links open in new tab via window.open instead of client-side routing if (href && !isInternalLink(href)) { return ( @@ -326,8 +337,12 @@ export const MenuItem = (props: MenuItemProps) => { className={classes.root} {...dataAttributes} textValue={typeof children === 'string' ? children : undefined} - onAction={() => window.open(href, '_blank', 'noopener,noreferrer')} {...restProps} + onAction={() => { + restProps.onAction?.(); + handleAction(); + window.open(href, '_blank', 'noopener,noreferrer'); + }} >
@@ -349,6 +364,10 @@ export const MenuItem = (props: MenuItemProps) => { href={href} textValue={typeof children === 'string' ? children : undefined} {...restProps} + onAction={() => { + restProps.onAction?.(); + handleAction(); + }} >
diff --git a/packages/ui/src/components/Menu/definition.ts b/packages/ui/src/components/Menu/definition.ts index ab65cf7dd1..076e9ce3ae 100644 --- a/packages/ui/src/components/Menu/definition.ts +++ b/packages/ui/src/components/Menu/definition.ts @@ -104,11 +104,13 @@ export const MenuItemDefinition = defineComponent()({ itemContent: 'bui-MenuItemContent', itemArrow: 'bui-MenuItemArrow', }, + analytics: true, propDefs: { iconStart: {}, children: {}, color: { dataAttribute: true, default: 'primary' }, href: {}, + noTrack: {}, className: {}, }, }); diff --git a/packages/ui/src/components/Menu/types.ts b/packages/ui/src/components/Menu/types.ts index 14c1baa5c3..8a40897ddd 100644 --- a/packages/ui/src/components/Menu/types.ts +++ b/packages/ui/src/components/Menu/types.ts @@ -91,6 +91,7 @@ export type MenuItemOwnProps = { children: React.ReactNode; color?: 'primary' | 'danger'; href?: RAMenuItemProps['href']; + noTrack?: boolean; className?: string; }; diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index c3b311a50c..54e2ea3a77 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -31,10 +31,21 @@ import { Flex } from '../../Flex'; /** @public */ export function Row(props: RowProps) { - const { ownProps, restProps } = useDefinition(RowDefinition, props); + const { ownProps, restProps, analytics } = useDefinition( + RowDefinition, + props, + ); const { classes, columns, children, href } = ownProps; const hasInternalHref = !!href && !isExternalLink(href); + const handlePress = () => { + if (href) { + analytics.captureEvent('click', href, { + attributes: { to: String(href) }, + }); + } + }; + let { selectionBehavior, selectionMode } = useTableOptions(); const content = ( @@ -59,6 +70,10 @@ export function Row(props: RowProps) { className={classes.root} data-react-aria-pressable={hasInternalHref ? 'true' : undefined} {...restProps} + onAction={() => { + restProps.onAction?.(); + handlePress(); + }} > {content} diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts index 496d4b6cfe..702e2aeb9a 100644 --- a/packages/ui/src/components/Table/definition.ts +++ b/packages/ui/src/components/Table/definition.ts @@ -75,6 +75,7 @@ export const TableBodyDefinition = defineComponent()({ */ export const RowDefinition = defineComponent()({ styles, + analytics: true, classNames: { root: 'bui-TableRow', cell: 'bui-TableCell', @@ -84,6 +85,7 @@ export const RowDefinition = defineComponent()({ columns: {}, children: {}, href: {}, + noTrack: {}, }, }); diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index d1cf5d1f13..099a855d33 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -73,6 +73,7 @@ export type RowOwnProps = { columns?: ReactAriaRowProps['columns']; children?: ReactAriaRowProps['children']; href?: string; + noTrack?: boolean; }; /** @public */ diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index d625d2abf0..95d2e54b82 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -54,6 +54,7 @@ import { isInternalLink, createRoutingRegistration, } from '../InternalLinkProvider'; +import { getNodeText } from '../../analytics/getNodeText'; const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration(); @@ -331,10 +332,25 @@ function RoutedTabEffects({ * @public */ export const Tab = (props: TabProps) => { - const { ownProps, restProps } = useDefinition(TabDefinition, props); + const { ownProps, restProps, analytics } = useDefinition( + TabDefinition, + props, + ); const { classes, matchStrategy, href, id } = ownProps; const { setTabRef } = useTabsContext(); + const handlePress = () => { + if (href) { + const text = + restProps['aria-label'] ?? + getNodeText(restProps.children) ?? + String(href); + analytics.captureEvent('click', text, { + attributes: { to: String(href) }, + }); + } + }; + return ( <> {isInternalLink(href) && ( @@ -350,6 +366,10 @@ export const Tab = (props: TabProps) => { ref={el => setTabRef(id as string, el as HTMLDivElement)} href={href} {...restProps} + onPress={e => { + restProps.onPress?.(e); + handlePress(); + }} /> ); diff --git a/packages/ui/src/components/Tabs/definition.ts b/packages/ui/src/components/Tabs/definition.ts index f938c8b3f8..4563d5e8c8 100644 --- a/packages/ui/src/components/Tabs/definition.ts +++ b/packages/ui/src/components/Tabs/definition.ts @@ -58,11 +58,13 @@ export const TabDefinition = defineComponent()({ classNames: { root: 'bui-Tab', }, + analytics: true, propDefs: { className: {}, matchStrategy: {}, href: {}, id: {}, + noTrack: {}, }, }); diff --git a/packages/ui/src/components/Tabs/types.ts b/packages/ui/src/components/Tabs/types.ts index 09f7e2e944..b722f82011 100644 --- a/packages/ui/src/components/Tabs/types.ts +++ b/packages/ui/src/components/Tabs/types.ts @@ -82,6 +82,7 @@ export type TabOwnProps = { matchStrategy?: TabMatchStrategy; href?: AriaTabProps['href']; id?: AriaTabProps['id']; + noTrack?: boolean; }; /** diff --git a/packages/ui/src/components/TagGroup/TagGroup.tsx b/packages/ui/src/components/TagGroup/TagGroup.tsx index 5208647bd8..8258ab8554 100644 --- a/packages/ui/src/components/TagGroup/TagGroup.tsx +++ b/packages/ui/src/components/TagGroup/TagGroup.tsx @@ -26,6 +26,7 @@ import { RiCloseCircleLine } from '@remixicon/react'; import { useDefinition } from '../../hooks/useDefinition'; import { TagGroupDefinition, TagDefinition } from './definition'; import { createRoutingRegistration } from '../InternalLinkProvider'; +import { getNodeText } from '../../analytics/getNodeText'; const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration(); @@ -60,7 +61,7 @@ export const TagGroup = (props: TagGroupProps) => { * @public */ export const Tag = forwardRef((props, ref) => { - const { ownProps, restProps, dataAttributes } = useDefinition( + const { ownProps, restProps, dataAttributes, analytics } = useDefinition( TagDefinition, props, ); @@ -69,6 +70,19 @@ export const Tag = forwardRef((props, ref) => { useRoutingRegistrationEffect(href); + const handlePress = () => { + if (href) { + const text = + (props as React.AriaAttributes)['aria-label'] ?? + textValue ?? + getNodeText(children) ?? + String(href); + analytics.captureEvent('click', text, { + attributes: { to: String(href) }, + }); + } + }; + return ( ((props, ref) => { href={href} {...dataAttributes} {...restProps} + onPress={e => { + restProps.onPress?.(e); + handlePress(); + }} > {({ allowsRemoving }) => ( <> diff --git a/packages/ui/src/components/TagGroup/definition.ts b/packages/ui/src/components/TagGroup/definition.ts index a4134645be..38f6bef0ba 100644 --- a/packages/ui/src/components/TagGroup/definition.ts +++ b/packages/ui/src/components/TagGroup/definition.ts @@ -44,7 +44,9 @@ export const TagDefinition = defineComponent()({ icon: 'bui-TagIcon', removeButton: 'bui-TagRemoveButton', }, + analytics: true, propDefs: { + noTrack: {}, icon: {}, size: { dataAttribute: true, default: 'small' }, href: {}, diff --git a/packages/ui/src/components/TagGroup/types.ts b/packages/ui/src/components/TagGroup/types.ts index daf146673d..3bcbdaafd1 100644 --- a/packages/ui/src/components/TagGroup/types.ts +++ b/packages/ui/src/components/TagGroup/types.ts @@ -58,6 +58,7 @@ export type TagOwnProps = { href?: ReactAriaTagProps['href']; children?: ReactAriaTagProps['children']; className?: string; + noTrack?: boolean; }; /** diff --git a/packages/ui/src/hooks/useDefinition/defineComponent.ts b/packages/ui/src/hooks/useDefinition/defineComponent.ts index 6dbfb5742b..fc58a6774e 100644 --- a/packages/ui/src/hooks/useDefinition/defineComponent.ts +++ b/packages/ui/src/hooks/useDefinition/defineComponent.ts @@ -14,13 +14,19 @@ * limitations under the License. */ -import type { ComponentConfig, BgPropsConstraint } from './types'; +import type { + ComponentConfig, + BgPropsConstraint, + AnalyticsPropsConstraint, +} from './types'; export function defineComponent

>() { return < const S extends Record, const C extends ComponentConfig, >( - config: C & BgPropsConstraint, + config: C & + BgPropsConstraint & + AnalyticsPropsConstraint, ): C => config; } diff --git a/packages/ui/src/hooks/useDefinition/types.ts b/packages/ui/src/hooks/useDefinition/types.ts index 2ddc0c88c5..a819cb5d35 100644 --- a/packages/ui/src/hooks/useDefinition/types.ts +++ b/packages/ui/src/hooks/useDefinition/types.ts @@ -16,6 +16,7 @@ import type { ReactNode } from 'react'; import type { Responsive } from '../../types'; +import type { AnalyticsTracker } from '../../analytics/types'; import type { utilityClassMap } from '../../utils/utilityClassMap'; export type UnwrapResponsive = T extends Responsive ? U : T; @@ -43,6 +44,13 @@ export interface ComponentConfig< * - `'consumer'` — calls `useBgConsumer`, sets `data-on-bg` */ bg?: 'provider' | 'consumer'; + /** + * Whether this component fires analytics events. + * When true, `useDefinition` will call `useAnalytics()` and return + * an `analytics` tracker. The component's own props type must include + * `noTrack?: boolean`. + */ + analytics?: boolean; } /** @@ -66,6 +74,18 @@ export type BgPropsConstraint = Bg extends 'provider' } : {}; +/** + * Type constraint that validates analytics props are present in the props type. + * Components with analytics: true must include 'noTrack' in their props. + */ +export type AnalyticsPropsConstraint = Analytics extends true + ? 'noTrack' extends keyof P + ? {} + : { + __error: 'Analytics components must include noTrack in own props type.'; + } + : {}; + export interface UseDefinitionOptions> { utilityTarget?: keyof D['classNames'] | null; classNameTarget?: keyof D['classNames'] | null; @@ -135,10 +155,10 @@ type ResolvedUtilityStyle> = UtilityStyle< UtilityKeys >; -export interface UseDefinitionResult< +export type UseDefinitionResult< D extends ComponentConfig, P extends Record, -> { +> = { ownProps: ResolveBgProps>; // Rest props excludes both propDefs keys AND utility prop keys @@ -149,4 +169,4 @@ export interface UseDefinitionResult< dataAttributes: DataAttributes; utilityStyle: ResolvedUtilityStyle; -} +} & (D['analytics'] extends true ? { analytics: AnalyticsTracker } : {}); diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx index 6bbd08e340..20ce94e123 100644 --- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx +++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx @@ -19,6 +19,8 @@ import clsx from 'clsx'; import { useBreakpoint } from '../useBreakpoint'; import { useBgProvider, useBgConsumer, BgProvider } from '../useBg'; import { resolveDefinitionProps, processUtilityProps } from './helpers'; +import { useAnalytics } from '../../analytics/useAnalytics'; +import { noopTracker } from '../../analytics/useAnalytics'; import type { ComponentConfig, UseDefinitionOptions, @@ -82,6 +84,14 @@ export function useDefinition< (definition.utilityProps ?? []) as readonly UtilityKeys[], ); + // Analytics: conditionally call useAnalytics based on definition flag + // Safe: definition is a module-level constant, condition never changes at runtime + let analytics = noopTracker; + if (definition.analytics) { + const tracker = useAnalytics(); + analytics = ownPropsResolved.noTrack ? noopTracker : tracker; + } + const utilityTarget = options?.utilityTarget ?? 'root'; const classNameTarget = options?.classNameTarget ?? 'root'; @@ -120,5 +130,6 @@ export function useDefinition< restProps, dataAttributes, utilityStyle, + ...(definition.analytics ? { analytics } : {}), } as unknown as UseDefinitionResult; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c70aa9d325..ca16a07886 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -67,3 +67,12 @@ export * from './types'; export { useBreakpoint } from './hooks/useBreakpoint'; export { useBgProvider, useBgConsumer, BgProvider } from './hooks/useBg'; export type { BgContextValue, BgProviderProps } from './hooks/useBg'; + +// Analytics +export { useAnalytics, BUIProvider, getNodeText } from './analytics'; +export type { + AnalyticsTracker, + AnalyticsEventAttributes, + UseAnalyticsFn, + BUIProviderProps, +} from './analytics'; diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 08eaa50bd9..e43e4b46e4 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -29,12 +29,14 @@ import { createExtension, createExtensionInput, routeResolutionApiRef, + useAnalytics, } from '@backstage/frontend-plugin-api'; import { AppRootWrapperBlueprint, RouterBlueprint, SignInPageBlueprint, } from '@backstage/plugin-app-react'; +import { BUIProvider } from '@backstage/ui'; import { DiscoveryApi, ErrorApi, @@ -115,19 +117,21 @@ export const AppRoot = createExtension({ return [ coreExtensionData.reactElement( - - el.get(coreExtensionData.reactElement), - )} - > - {content} - , + + + el.get(coreExtensionData.reactElement), + )} + > + {content} + + , ), ]; }, diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index e634153b87..7c96a74c8b 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -617,6 +617,8 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'renderSchema.tableCell.description': 'Description'; readonly 'templatingExtensions.content.values.title': 'Values'; readonly 'templatingExtensions.content.values.notAvailable': 'There are no global template values defined.'; + readonly 'templatingExtensions.content.emptyState.title': 'No information to display'; + readonly 'templatingExtensions.content.emptyState.description': 'There are no templating extensions available or there was an issue communicating with the backend.'; readonly 'templatingExtensions.content.filters.title': 'Filters'; readonly 'templatingExtensions.content.filters.schema.input': 'Input'; readonly 'templatingExtensions.content.filters.schema.output': 'Output'; @@ -624,8 +626,6 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templatingExtensions.content.filters.examples': 'Examples'; readonly 'templatingExtensions.content.filters.notAvailable': 'There are no template filters defined.'; readonly 'templatingExtensions.content.filters.metadataAbsent': 'Filter metadata unavailable'; - readonly 'templatingExtensions.content.emptyState.title': 'No information to display'; - readonly 'templatingExtensions.content.emptyState.description': 'There are no templating extensions available or there was an issue communicating with the backend.'; readonly 'templatingExtensions.content.functions.title': 'Functions'; readonly 'templatingExtensions.content.functions.schema.output': 'Output'; readonly 'templatingExtensions.content.functions.schema.arguments': 'Arguments'; diff --git a/yarn.lock b/yarn.lock index e8b582b26c..0f47ce5f69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3538,6 +3538,7 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" + "@backstage/ui": "workspace:^" "@backstage/version-bridge": "workspace:^" "@testing-library/dom": "npm:^10.0.0" "@testing-library/jest-dom": "npm:^6.0.0"