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:
@@ -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>`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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?:
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user