feat(ui): add analytics event tracking to BUI navigation components (#33150)

* feat(ui): add analytics types

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add useAnalytics hook with dev-mode swap guard

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add AnalyticsProvider, getNodeText, and analytics barrel exports

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): integrate analytics into defineComponent and useDefinition

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add analytics tracking to Link component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add analytics tracking to ButtonLink component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add analytics tracking to Tab component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add analytics tracking to MenuItem component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add analytics tracking to Tag component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add analytics tracking to Table Row component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): widen getNodeText to accept render functions

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): update API reports

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: add changeset for BUI analytics

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): chain MenuItem onAction with user-provided handler instead of overwriting

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): address review feedback — changeset wording and types docs

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(core-app-api): wire AnalyticsProvider from @backstage/ui into app shell

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(plugin-app): add AnalyticsProvider wrapper extension for BUI components

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: update API reports

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: update API reports for core-components and scaffolder

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): add analytics documentation and noTrack prop to component docs

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: update yarn.lock

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(plugin-app): move AnalyticsProvider into AppRoot directly

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: format installation docs

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): call user onPress before analytics in Tag component

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(ui): replace AnalyticsProvider with generic BUIProvider

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: replace remaining AnalyticsProvider references with BUIProvider

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(plugin-app): import useAnalytics from frontend-plugin-api

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): improve noTrack prop description

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-06 14:45:57 +01:00
committed by GitHub
parent 3e68fcae92
commit 12d8afe82d
46 changed files with 606 additions and 50 deletions
+11
View File
@@ -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
@@ -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.
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Fixed MenuItem `onAction` prop ordering so user-provided `onAction` handlers are chained rather than silently overwritten.
@@ -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.
@@ -62,6 +62,11 @@ export const buttonLinkPropDefs: Record<string, PropDef> = {
</>
),
},
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'],
@@ -68,6 +68,11 @@ export const linkPropDefs: Record<string, PropDef> = {
'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.',
@@ -309,6 +309,11 @@ export const menuItemPropDefs: Record<string, PropDef> = {
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.',
@@ -456,6 +456,11 @@ export const rowPropDefs: Record<string, PropDef> = {
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,
};
@@ -68,6 +68,11 @@ export const tabPropDefs: Record<string, PropDef> = {
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,
};
@@ -84,6 +84,11 @@ export const tagPropDefs: Record<string, PropDef> = {
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,
};
@@ -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.
/>
<CodeBlock lang="tsx" title="Let's get started 🚀" code={snippet} />
## 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:
<CodeBlock lang="tsx" code={analyticsSetupSnippet} />
### 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:
<CodeBlock lang="tsx" code={analyticsNoTrackSnippet} />
@@ -4,3 +4,16 @@ export const snippet = `import { Flex, Button, Text } from '@backstage/ui';
<Text>Hello World</Text>
<Button>Click me</Button>
</Flex>;`;
export const analyticsSetupSnippet = `import { BUIProvider } from '@backstage/ui';
import { useAnalytics } from '@backstage/core-plugin-api';
// Wrap your app content with the provider
<BUIProvider useAnalytics={useAnalytics}>
<AppContent />
</BUIProvider>`;
export const analyticsNoTrackSnippet = `// Suppress analytics for a specific link
<Link href="/internal" noTrack>
Skip tracking
</Link>`;
+1
View File
@@ -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",
+23 -19
View File
@@ -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 (
<ApiProvider apis={apis}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
<BUIProvider useAnalytics={useAnalytics}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<Suspense fallback={<Progress />}>{children}</Suspense>
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppContextProvider>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
>
<Suspense fallback={<Progress />}>{children}</Suspense>
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppContextProvider>
</BUIProvider>
</ApiProvider>
);
};
+3 -3
View File
@@ -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.';
+47
View File
@@ -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<HTMLDivElement>
@@ -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<HTMLButtonElement>
@@ -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<ComponentPropsWithoutRef<'main'>, 'className'>,
FullPageOwnProps {}
// @public
export function getNodeText(
node: ReactNode | ((...args: any[]) => ReactNode),
): string | undefined;
// @public (undocumented)
export const Grid: {
Root: ForwardRefExoticComponent<GridProps & RefAttributes<HTMLDivElement>>;
@@ -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<Record<Breakpoint, TextVariants>>;
weight?: TextWeights | Partial<Record<Breakpoint, TextWeights>>;
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<T = object> = {
columns?: RowProps_2<T>['columns'];
children?: RowProps_2<T>['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;
+57
View File
@@ -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 (
* <BUIProvider useAnalytics={useBackstageAnalytics}>
* <AppContent />
* </BUIProvider>
* );
* }
* ```
*
* @public
*/
export function BUIProvider(props: BUIProviderProps) {
const { useAnalytics, children } = props;
const value = useMemo(
() =>
createVersionedValueMap({
1: { useAnalytics },
}),
[useAnalytics],
);
return <BUIContext.Provider value={value}>{children}</BUIContext.Provider>;
}
+43
View File
@@ -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;
}
+25
View File
@@ -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';
+49
View File
@@ -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;
+68
View File
@@ -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 <BUIProvider> wraps all BUI components from first render.',
);
}
prevImpl.current = impl;
}
return impl();
}
@@ -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<HTMLAnchorElement>) => {
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 (
<InternalLinkProvider href={restProps.href}>
<RALink
@@ -37,6 +49,7 @@ export const ButtonLink = forwardRef(
ref={ref}
{...dataAttributes}
{...restProps}
onPress={handlePress}
>
<span className={classes.content}>
{iconStart}
@@ -29,7 +29,9 @@ export const ButtonLinkDefinition = defineComponent<ButtonLinkOwnProps>()({
content: 'bui-ButtonLinkContent',
},
bg: 'consumer',
analytics: true,
propDefs: {
noTrack: {},
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
iconStart: {},
@@ -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;
+14 -1
View File
@@ -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<HTMLAnchorElement, LinkProps>((props, ref) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
const { ownProps, restProps, dataAttributes, analytics } = useDefinition(
LinkDefinition,
props,
);
@@ -33,6 +34,17 @@ const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
const { linkProps } = useLink(restProps, linkRef);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
linkProps.onClick?.(e);
const text =
restProps['aria-label'] ??
getNodeText(children) ??
String(restProps.href ?? '');
analytics.captureEvent('click', text, {
attributes: { to: String(restProps.href ?? '') },
});
};
return (
<a
{...linkProps}
@@ -41,6 +53,7 @@ const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
ref={linkRef}
title={title}
className={classes.root}
onClick={handleClick}
>
{children}
</a>
@@ -27,7 +27,9 @@ export const LinkDefinition = defineComponent<LinkOwnProps>()({
classNames: {
root: 'bui-Link',
},
analytics: true,
propDefs: {
noTrack: {},
variant: { dataAttribute: true, default: 'body-medium' },
weight: { dataAttribute: true, default: 'regular' },
color: { dataAttribute: true, default: 'primary' },
+1
View File
@@ -26,6 +26,7 @@ import type { ReactNode } from 'react';
/** @public */
export type LinkOwnProps = {
noTrack?: boolean;
variant?: TextVariants | Partial<Record<Breakpoint, TextVariants>>;
weight?: TextWeights | Partial<Record<Breakpoint, TextWeights>>;
color?:
+21 -2
View File
@@ -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');
}}
>
<div className={classes.itemWrapper}>
<div className={classes.itemContent}>
@@ -349,6 +364,10 @@ export const MenuItem = (props: MenuItemProps) => {
href={href}
textValue={typeof children === 'string' ? children : undefined}
{...restProps}
onAction={() => {
restProps.onAction?.();
handleAction();
}}
>
<div className={classes.itemWrapper}>
<div className={classes.itemContent}>
@@ -104,11 +104,13 @@ export const MenuItemDefinition = defineComponent<MenuItemOwnProps>()({
itemContent: 'bui-MenuItemContent',
itemArrow: 'bui-MenuItemArrow',
},
analytics: true,
propDefs: {
iconStart: {},
children: {},
color: { dataAttribute: true, default: 'primary' },
href: {},
noTrack: {},
className: {},
},
});
+1
View File
@@ -91,6 +91,7 @@ export type MenuItemOwnProps = {
children: React.ReactNode;
color?: 'primary' | 'danger';
href?: RAMenuItemProps['href'];
noTrack?: boolean;
className?: string;
};
@@ -31,10 +31,21 @@ import { Flex } from '../../Flex';
/** @public */
export function Row<T extends object>(props: RowProps<T>) {
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<T extends object>(props: RowProps<T>) {
className={classes.root}
data-react-aria-pressable={hasInternalHref ? 'true' : undefined}
{...restProps}
onAction={() => {
restProps.onAction?.();
handlePress();
}}
>
{content}
</ReactAriaRow>
@@ -75,6 +75,7 @@ export const TableBodyDefinition = defineComponent<TableBodyOwnProps>()({
*/
export const RowDefinition = defineComponent<RowOwnProps>()({
styles,
analytics: true,
classNames: {
root: 'bui-TableRow',
cell: 'bui-TableCell',
@@ -84,6 +85,7 @@ export const RowDefinition = defineComponent<RowOwnProps>()({
columns: {},
children: {},
href: {},
noTrack: {},
},
});
@@ -73,6 +73,7 @@ export type RowOwnProps<T = object> = {
columns?: ReactAriaRowProps<T>['columns'];
children?: ReactAriaRowProps<T>['children'];
href?: string;
noTrack?: boolean;
};
/** @public */
+21 -1
View File
@@ -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();
}}
/>
</>
);
@@ -58,11 +58,13 @@ export const TabDefinition = defineComponent<TabOwnProps>()({
classNames: {
root: 'bui-Tab',
},
analytics: true,
propDefs: {
className: {},
matchStrategy: {},
href: {},
id: {},
noTrack: {},
},
});
+1
View File
@@ -82,6 +82,7 @@ export type TabOwnProps = {
matchStrategy?: TabMatchStrategy;
href?: AriaTabProps['href'];
id?: AriaTabProps['id'];
noTrack?: boolean;
};
/**
@@ -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 = <T extends object>(props: TagGroupProps<T>) => {
* @public
*/
export const Tag = forwardRef<HTMLDivElement, TagProps>((props, ref) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
const { ownProps, restProps, dataAttributes, analytics } = useDefinition(
TagDefinition,
props,
);
@@ -69,6 +70,19 @@ export const Tag = forwardRef<HTMLDivElement, TagProps>((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 (
<ReactAriaTag
ref={ref}
@@ -77,6 +91,10 @@ export const Tag = forwardRef<HTMLDivElement, TagProps>((props, ref) => {
href={href}
{...dataAttributes}
{...restProps}
onPress={e => {
restProps.onPress?.(e);
handlePress();
}}
>
{({ allowsRemoving }) => (
<>
@@ -44,7 +44,9 @@ export const TagDefinition = defineComponent<TagOwnProps>()({
icon: 'bui-TagIcon',
removeButton: 'bui-TagRemoveButton',
},
analytics: true,
propDefs: {
noTrack: {},
icon: {},
size: { dataAttribute: true, default: 'small' },
href: {},
@@ -58,6 +58,7 @@ export type TagOwnProps = {
href?: ReactAriaTagProps['href'];
children?: ReactAriaTagProps['children'];
className?: string;
noTrack?: boolean;
};
/**
@@ -14,13 +14,19 @@
* limitations under the License.
*/
import type { ComponentConfig, BgPropsConstraint } from './types';
import type {
ComponentConfig,
BgPropsConstraint,
AnalyticsPropsConstraint,
} from './types';
export function defineComponent<P extends Record<string, any>>() {
return <
const S extends Record<string, string>,
const C extends ComponentConfig<P, S>,
>(
config: C & BgPropsConstraint<P, C['bg']>,
config: C &
BgPropsConstraint<P, C['bg']> &
AnalyticsPropsConstraint<P, C['analytics']>,
): C => config;
}
+23 -3
View File
@@ -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> = T extends Responsive<infer U> ? 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<P, Bg> = 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<P, Analytics> = Analytics extends true
? 'noTrack' extends keyof P
? {}
: {
__error: 'Analytics components must include noTrack in own props type.';
}
: {};
export interface UseDefinitionOptions<D extends ComponentConfig<any, any>> {
utilityTarget?: keyof D['classNames'] | null;
classNameTarget?: keyof D['classNames'] | null;
@@ -135,10 +155,10 @@ type ResolvedUtilityStyle<D extends ComponentConfig<any, any>> = UtilityStyle<
UtilityKeys<D>
>;
export interface UseDefinitionResult<
export type UseDefinitionResult<
D extends ComponentConfig<any, any>,
P extends Record<string, any>,
> {
> = {
ownProps: ResolveBgProps<D, BaseOwnProps<D, P>>;
// Rest props excludes both propDefs keys AND utility prop keys
@@ -149,4 +169,4 @@ export interface UseDefinitionResult<
dataAttributes: DataAttributes<D['propDefs']>;
utilityStyle: ResolvedUtilityStyle<D>;
}
} & (D['analytics'] extends true ? { analytics: AnalyticsTracker } : {});
@@ -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<D>[],
);
// 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<D, P>;
}
+9
View File
@@ -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';
+17 -13
View File
@@ -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(
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>,
<BUIProvider useAnalytics={useAnalytics}>
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>
</BUIProvider>,
),
];
},
+2 -2
View File
@@ -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';
+1
View File
@@ -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"