Add the Analytics API to core-plugin-api

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-09-30 15:45:54 +02:00
parent 45ae9d9ccc
commit 829bc698f4
13 changed files with 689 additions and 5 deletions
@@ -0,0 +1,23 @@
---
'@backstage/core-plugin-api': minor
---
Introducing the Analytics API: a lightweight way for plugins to instrument key
events that could help inform a Backstage Integrator how their instance of
Backstage is being used. The API consists of the following:
- `useAnalytics()`, a hook to be used inside plugin components which retrieves
an Analytics Tracker.
- `tracker.captureEvent()`, a method on the tracker used to instrument key
events. The method expects a verb (the action performed), a noun (a unique
identifier of the object the action is being taken on), and optionally a
numeric metric/value.
- `<AnalyticsDomain />`, a way to declaratively attach additional information
to any/all events captured in the underlying React tree. There is also a
`withAnalyticsDomain()` HOC utility.
- The `tracker.captureEvent()` method also accepts a `context` object for
optionally providing additional run-time information about an event.
By default, captured events are not sent anywhere. In order to collect and
redirect events to an analytics system, the `analyticsApi` will need to be
implemented and instantiated by an App Integrator.
+118 -3
View File
@@ -34,6 +34,76 @@ export type AlertMessage = {
severity?: 'success' | 'info' | 'warning' | 'error';
};
// Warning: (ae-missing-release-tag) "AnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AnalyticsApi = {
getDecoratedTracker({
domain,
}: {
domain: AnalyticsDomainValue;
}): AnalyticsTracker;
};
// Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const analyticsApiRef: ApiRef<AnalyticsApi>;
// Warning: (ae-missing-release-tag) "AnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export const AnalyticsDomain: ({
attributes,
children,
}: {
attributes: AnalyticsDomainValue;
children: ReactNode;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "AnalyticsDomainValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AnalyticsDomainValue = Partial<
RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain
>;
// Warning: (ae-missing-release-tag) "AnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AnalyticsEvent = {
verb: string;
noun: string;
value?: number;
context?: AnalyticsEventContext;
};
// Warning: (ae-missing-release-tag) "AnalyticsEventContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AnalyticsEventContext = {
[attribute in string]: string | boolean | number;
};
// Warning: (ae-missing-release-tag) "AnalyticsTracker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AnalyticsTracker = {
captureEvent: (
verb: string,
noun: string,
value?: number,
context?: AnalyticsEventContext,
) => void;
};
// Warning: (ae-missing-release-tag) "AnyAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AnyAnalyticsDomain = {
[param in string]: string | boolean | number | undefined;
};
// Warning: (ae-missing-release-tag) "AnyApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -234,6 +304,14 @@ export type BootErrorPageProps = {
error: Error;
};
// Warning: (ae-missing-release-tag) "ComponentAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type ComponentAnalyticsDomain = {
pluginId: string;
componentName: string;
};
// Warning: (ae-missing-release-tag) "ConfigApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
@@ -273,7 +351,7 @@ export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
// @public (undocumented)
export function createComponentExtension<
T extends (props: any) => JSX.Element | null,
>(options: { component: ComponentLoader<T> }): Extension<T>;
>(options: { component: ComponentLoader<T>; name?: string }): Extension<T>;
// Warning: (ae-forgotten-export) The symbol "OptionalParams" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -309,6 +387,7 @@ export function createReactExtension<
>(options: {
component: ComponentLoader<T>;
data?: Record<string, unknown>;
name?: string;
}): Extension<T>;
// Warning: (ae-missing-release-tag) "createRoutableExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -316,7 +395,11 @@ export function createReactExtension<
// @public (undocumented)
export function createRoutableExtension<
T extends (props: any) => JSX.Element | null,
>(options: { component: () => Promise<T>; mountPoint: RouteRef }): Extension<T>;
>(options: {
component: () => Promise<T>;
mountPoint: RouteRef;
name?: string;
}): Extension<T>;
// Warning: (ae-missing-release-tag) "createRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -361,6 +444,13 @@ export type DiscoveryApi = {
// @public (undocumented)
export const discoveryApiRef: ApiRef<DiscoveryApi>;
// Warning: (ae-missing-release-tag) "DomainDecoratedAnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & {
domain: AnalyticsDomainValue;
};
// Warning: (ae-missing-release-tag) "ElementCollection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
@@ -704,6 +794,15 @@ export type ProfileInfoApi = {
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
};
// Warning: (ae-missing-release-tag) "RoutableAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type RoutableAnalyticsDomain = {
pluginId: string;
routeRef: string;
componentName: string;
};
// Warning: (ae-missing-release-tag) "RouteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -832,6 +931,11 @@ export type TypesToApiRefs<T> = {
[key in keyof T]: ApiRef<T[key]>;
};
// Warning: (ae-missing-release-tag) "useAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function useAnalytics(): AnalyticsTracker;
// Warning: (ae-missing-release-tag) "useApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -882,6 +986,17 @@ export function useRouteRefParams<Params extends AnyParams>(
_routeRef: RouteRef<Params> | SubRouteRef<Params>,
): Params;
// Warning: (ae-missing-release-tag) "withAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function withAnalyticsDomain<P>(
Component: React_2.ComponentType<P>,
domain: AnyAnalyticsDomain,
): {
(props: P): JSX.Element;
displayName: string;
};
// Warning: (ae-missing-release-tag) "withApis" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -900,7 +1015,7 @@ export function withApis<T>(apis: TypesToApiRefs<T>): <P extends T>(
// src/apis/definitions/auth.d.ts:96:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration
// src/apis/definitions/auth.d.ts:110:16 - (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration
// src/apis/definitions/auth.d.ts:113:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration
// src/extensions/extensions.d.ts:14:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts
// src/extensions/extensions.d.ts:15:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts
// src/routing/RouteRef.d.ts:35:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts
// src/routing/types.d.ts:30:5 - (ae-forgotten-export) The symbol "ParamKeys" needs to be exported by the entry point index.d.ts
```
@@ -0,0 +1,100 @@
/*
* Copyright 2021 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 React from 'react';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import {
AnalyticsDomain,
useAnalyticsDomain,
withAnalyticsDomain,
} from './AnalyticsDomain';
const DomainSpy = () => {
const domain = useAnalyticsDomain();
return (
<>
<div data-testid="route-ref">{domain.routeRef}</div>
<div data-testid="plugin-id">{domain.pluginId}</div>
<div data-testid="component-name">{domain.componentName}</div>
<div data-testid="custom">{domain.custom}</div>
</>
);
};
describe('AnalyticsDomain', () => {
describe('useAnalyticsDomain', () => {
it('returns default values', () => {
const { result } = renderHook(() => useAnalyticsDomain());
expect(result.current).toEqual({
componentName: 'App',
pluginId: 'root',
routeRef: 'unknown',
});
});
});
describe('AnalyticsDomain', () => {
it('uses default analytics domain', () => {
const result = render(
<AnalyticsDomain attributes={{}}>
<DomainSpy />
</AnalyticsDomain>,
);
expect(result.getByTestId('component-name')).toHaveTextContent('App');
expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
it('uses provided analytics domain', () => {
const result = render(
<AnalyticsDomain attributes={{ pluginId: 'custom' }}>
<DomainSpy />
</AnalyticsDomain>,
);
expect(result.getByTestId('component-name')).toHaveTextContent('App');
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
it('uses nested analytics domain', () => {
const result = render(
<AnalyticsDomain attributes={{ pluginId: 'custom' }}>
<AnalyticsDomain attributes={{ componentName: 'DomainSpy' }}>
<DomainSpy />
</AnalyticsDomain>
</AnalyticsDomain>,
);
expect(result.getByTestId('component-name')).toHaveTextContent(
'DomainSpy',
);
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
});
describe('withAnalyticsDomain', () => {
it('wraps component with analytics domain', () => {
const DomainSpyHOC = withAnalyticsDomain(DomainSpy, { custom: 'attr' });
const result = render(<DomainSpyHOC />);
expect(result.getByTestId('custom')).toHaveTextContent('attr');
expect(DomainSpyHOC.displayName).toBe('WithAnalyticsDomain(DomainSpy)');
});
});
});
@@ -0,0 +1,85 @@
/*
* Copyright 2021 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 React, { createContext, ReactNode, useContext } from 'react';
import { AnalyticsDomainValue, AnyAnalyticsDomain } from './types';
export const AnalyticsDomainContext = createContext<AnalyticsDomainValue>({
routeRef: 'unknown',
pluginId: 'root',
componentName: 'App',
});
/**
* A "private" (to this package) hook that enables context inheritance and a
* way to read Analytics Domain values at event capture-time.
* @private
*/
export const useAnalyticsDomain = () => {
return useContext(AnalyticsDomainContext);
};
/**
* Provides components in the child react tree an Analytics Domain, ensuring
* all analytics events captured within the domain have relevant contextual
* attributes.
*
* Analytics domains are additive, meaning the domain ultimately emitted with
* an event is the combination of all domains in the parent tree.
*/
export const AnalyticsDomain = ({
attributes,
children,
}: {
attributes: AnalyticsDomainValue;
children: ReactNode;
}) => {
const parentValues = useAnalyticsDomain();
const combinedValue = {
...parentValues,
...attributes,
};
return (
<AnalyticsDomainContext.Provider value={combinedValue}>
{children}
</AnalyticsDomainContext.Provider>
);
};
/**
* Returns an HOC wrapping the provided component in an Analytics Domain with
* the given values.
*
* @param Component - Component to be wrapped with analytics domain attributes.
* @param domain - Analytics domain key/value pairs.
*/
export function withAnalyticsDomain<P>(
Component: React.ComponentType<P>,
domain: AnyAnalyticsDomain,
) {
const ComponentWithAnalyticsDomain = (props: P) => {
return (
<AnalyticsDomain attributes={domain}>
<Component {...props} />
</AnalyticsDomain>
);
};
ComponentWithAnalyticsDomain.displayName = `WithAnalyticsDomain(${
Component.displayName || Component.name || 'Component'
})`;
return ComponentWithAnalyticsDomain;
}
@@ -0,0 +1,24 @@
/*
* Copyright 2021 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 { AnalyticsDomain, withAnalyticsDomain } from './AnalyticsDomain';
export type {
AnalyticsDomainValue,
AnyAnalyticsDomain,
ComponentAnalyticsDomain,
RoutableAnalyticsDomain,
} from './types';
export { useAnalytics } from './useAnalytics';
@@ -0,0 +1,64 @@
/*
* Copyright 2021 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.
*/
/**
* Analytics domain covering routable extensions.
*/
export type RoutableAnalyticsDomain = {
/**
* The plugin that exposed the route.
*/
pluginId: string;
/**
* The ID of the route ref associated with the route.
*/
routeRef: string;
/**
* The name of the component used to render the route.
*/
componentName: string;
};
/**
* Analytics domain covering component extensions.
*/
export type ComponentAnalyticsDomain = {
/**
* The plugin that exposed the component.
*/
pluginId: string;
/**
* The name of the component.
*/
componentName: string;
};
/**
* Allow arbitrary scalar values as domain attributes too.
*/
export type AnyAnalyticsDomain = {
[param in string]: string | boolean | number | undefined;
};
/**
* Common analytics domain attributes.
*/
export type AnalyticsDomainValue = Partial<
RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain
>;
@@ -0,0 +1,61 @@
/*
* Copyright 2021 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 { renderHook } from '@testing-library/react-hooks';
import { useAnalytics } from './useAnalytics';
import { useApi } from '../apis';
jest.mock('../apis');
const mocked = (f: Function) => f as jest.Mock;
describe('useAnalytics', () => {
it('returns tracker with no implementation defined', () => {
// Simulate useApi() throwing an error.
mocked(useApi).mockImplementation(() => {
throw new Error();
});
// Result should still have a captureEvent method.
const { result } = renderHook(() => useAnalytics());
expect(result.current.captureEvent).toBeDefined();
});
it('returns tracker from defined analytics api', () => {
const expectedFunction = 'capture function';
const getDecoratedTracker = jest.fn().mockReturnValue({
captureEvent: expectedFunction,
});
// Simulate useApi returning a valid tracker.
mocked(useApi).mockReturnValue({ getDecoratedTracker });
// The getDecoratedTracker method of the underlying implementation should
// have been called with the domain provided.
const { result } = renderHook(() => useAnalytics());
expect(getDecoratedTracker).toHaveBeenCalledWith({
domain: {
componentName: 'App',
pluginId: 'root',
routeRef: 'unknown',
},
});
// And the returned tracker's captureEvent should have come from the API
// implementation.
expect(result.current.captureEvent).toBe(expectedFunction);
});
});
@@ -0,0 +1,44 @@
/*
* Copyright 2021 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 { useAnalyticsDomain } from './AnalyticsDomain';
import {
analyticsApiRef,
AnalyticsTracker,
} from '../apis/definitions/AnalyticsApi';
import { useApi } from '../apis';
function useTracker(): AnalyticsTracker {
const analyticsApi = useApi(analyticsApiRef);
const domain = useAnalyticsDomain();
return analyticsApi.getDecoratedTracker({ domain });
}
/**
* Get a pre-configured analytics tracker.
*/
export function useAnalytics(): AnalyticsTracker {
// Return a no-op tracker if no implementation for the Analytics API is
// available. Having no default Analytics API implementation enables simple
// provider installation via plugin instantiation.
try {
return useTracker();
} catch {
return {
captureEvent: () => {},
};
}
}
@@ -0,0 +1,123 @@
/*
* Copyright 2021 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 { ApiRef, createApiRef } from '../system';
import { AnalyticsDomainValue } from '../../analytics/types';
/**
* Represents an event worth tracking in an analytics system that could inform
* how users of a Backstage instance are using its features.
*
* Note that attributes about the Backstage user or about the plugin tracking
* the event are inferred and captured separately on the Analytics Domain and
* do not need to be passed on the event itself.
*/
export type AnalyticsEvent = {
/**
* A string that identifies the event being tracked by the type of action the
* event represents. Examples include:
*
* - view
* - click
* - filter
* - search
* - hover
* - scroll
*/
verb: string;
/**
* A string that uniquely identifies the object that the verb or action is
* being taken on. Examples include:
*
* - The path of the page viewed
* - The url of the link clicked
* - The value that was filtered by
* - The text that was searched for
*/
noun: string;
/**
* An optional numeric value relevant to the event that could be aggregated
* by analytics tools. Examples include:
*
* - The index or position of the clicked element in an ordered list
* - The percentage of an element that has been scrolled through
* - The amount of time that has elapsed since a fixed point
*/
value?: number;
/**
* Optional context with any additional dimensions or metrics that could be
* forwarded on to analytics systems.
*/
context?: AnalyticsEventContext;
};
/**
* An analytics event combined with domain attributes.
*/
export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & {
/**
* Domain metadata relating to where the event was captured and by whom. This
* could include information about the route, plugin, or component in which
* an event was captured.
*/
domain: AnalyticsDomainValue;
};
/**
* A structure allowing other arbitrary metadata to be provided by analytics
* event emitters.
*/
export type AnalyticsEventContext = {
[attribute in string]: string | boolean | number;
};
/**
* Represents a tracker with methods that can be called to track events in a
* configured analytics service.
*/
export type AnalyticsTracker = {
captureEvent: (
verb: string,
noun: string,
value?: number,
context?: AnalyticsEventContext,
) => void;
};
/**
* The Analytics API is used to track user behavior in a Backstage instance.
*
* To instrument your App or Plugin, retrieve an analytics tracker using the
* useAnalytics() hook. This will return a pre-configured AnalyticsTracker
* with relevant methods for instrumentation.
*/
export type AnalyticsApi = {
/**
* Retrieves a tracker decorated with a given analytics domain.
*/
getDecoratedTracker({
domain,
}: {
domain: AnalyticsDomainValue;
}): AnalyticsTracker;
};
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
id: 'core.analytics',
});
@@ -23,6 +23,7 @@
export * from './auth';
export * from './AlertApi';
export * from './AnalyticsApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
@@ -17,6 +17,7 @@
import { withLogCollector } from '@backstage/test-utils-core';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { useAnalyticsDomain } from '../analytics/AnalyticsDomain';
import { useApp, ErrorBoundaryFallbackProps } from '../app';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
@@ -107,4 +108,32 @@ describe('extensions', () => {
screen.getByText('Error in my-plugin');
expect(errors[0]).toMatch('Test error');
});
it('should wrap extended component with analytics domain', async () => {
const DomainSpyExtension = plugin.provide(
createReactExtension({
name: 'DomainSpy',
component: {
sync: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const domain = useAnalyticsDomain();
return (
<>
<div data-testid="plugin-id">{domain.pluginId}</div>
<div data-testid="route-ref">{domain.routeRef}</div>
<div data-testid="component-name">{domain.componentName}</div>
</>
);
},
},
data: { 'core.mountpoint': { id: 'some-ref' } },
}),
);
const result = render(<DomainSpyExtension />);
expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin');
expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref');
expect(result.getByTestId('component-name')).toHaveTextContent('DomainSpy');
});
});
@@ -15,6 +15,7 @@
*/
import React, { lazy, Suspense } from 'react';
import { AnalyticsDomain } from '../analytics/AnalyticsDomain';
import { useApp } from '../app';
import { RouteRef, useRouteRef } from '../routing';
import { attachComponentData } from './componentData';
@@ -94,7 +95,7 @@ export function createRoutableExtension<
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
// making it impossible to make the usage of children type safe.
export function createComponentExtension<
T extends (props: any) => JSX.Element | null
T extends (props: any) => JSX.Element | null,
>(options: { component: ComponentLoader<T>; name?: string }): Extension<T> {
const { component, name } = options;
return createReactExtension({ component, name });
@@ -136,7 +137,20 @@ export function createReactExtension<
return (
<Suspense fallback={<Progress />}>
<PluginErrorBoundary app={app} plugin={plugin}>
<Component {...props} />
<AnalyticsDomain
attributes={{
pluginId: plugin.getId(),
componentName,
...(data['core.mountpoint']
? {
routeRef: (data['core.mountpoint'] as { id?: string })
.id,
}
: {}),
}}
>
<Component {...props} />
</AnalyticsDomain>
</PluginErrorBoundary>
</Suspense>
);
+1
View File
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './analytics';
export * from './apis';
export * from './app';
export * from './extensions';