diff --git a/.changeset/eighty-mails-leave.md b/.changeset/eighty-mails-leave.md
index 4924b73056..3c6ec5ab8f 100644
--- a/.changeset/eighty-mails-leave.md
+++ b/.changeset/eighty-mails-leave.md
@@ -2,4 +2,4 @@
'@backstage/plugin-home': patch
---
-Allow customization of VisitList with optional enrichVisit, transformPathname, canSave functions along with VisitDisplayProvider for colors, labels
+Allow customization of VisitList by adding optional enrichVisit, transformPathname, canSave functions to VisitsStorageApi, along with VisitDisplayProvider for colors, labels
diff --git a/plugins/home/README.md b/plugins/home/README.md
index 92ad82424f..b08e785403 100644
--- a/plugins/home/README.md
+++ b/plugins/home/README.md
@@ -366,11 +366,14 @@ Provide a `transformPathname` function to transform the pathname before it's pro
```tsx
import {
- VisitListener,
- VisitTransformPathnameFunction,
-} from '@backstage/plugin-home';
+ AnyApiFactory,
+ createApiFactory,
+ identityApiRef,
+ storageApiRef,
+} from '@backstage/core-plugin-api';
+import { VisitsStorageApi } from '@backstage/plugin-home';
-const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => {
+const transformPathname = (pathname: string) => {
const pathnameParts = pathname.split('/').filter(part => part !== '');
const rootPathFromPathname = pathnameParts[0] ?? '';
if (rootPathFromPathname === 'catalog' && pathnameParts.length >= 4) {
@@ -379,7 +382,21 @@ const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => {
return pathname;
};
-;
+export const apis: AnyApiFactory[] = [
+ createApiFactory({
+ api: visitsApiRef,
+ deps: {
+ storageApi: storageApiRef,
+ identityApi: identityApiRef,
+ },
+ factory: ({ storageApi, identityApi }) =>
+ VisitsStorageApi.create({
+ storageApi,
+ identityApi,
+ transformPathname,
+ }),
+ }),
+];
```
#### Can Save Function
@@ -387,14 +404,37 @@ const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => {
Provide a `canSave` function to determine which visits should be tracked and saved. This allows you to conditionally save visits to the list:
```tsx
-import { VisitListener, VisitCanSaveFunction } from '@backstage/plugin-home';
+import {
+ AnyApiFactory,
+ createApiFactory,
+ identityApiRef,
+ storageApiRef,
+} from '@backstage/core-plugin-api';
+import { VisitInput, VisitsStorageApi } from '@backstage/plugin-home';
-const canSave: VisitCanSaveFunction = ({ pathname }) => {
+const canSave = (visit: VisitInput) => {
// Don't save visits to admin or settings pages
- return !pathname.startsWith('/admin') && !pathname.startsWith('/settings');
+ return (
+ !visit.pathname.startsWith('/admin') &&
+ !visit.pathname.startsWith('/settings')
+ );
};
-;
+export const apis: AnyApiFactory[] = [
+ createApiFactory({
+ api: visitsApiRef,
+ deps: {
+ storageApi: storageApiRef,
+ identityApi: identityApiRef,
+ },
+ factory: ({ storageApi, identityApi }) =>
+ VisitsStorageApi.create({
+ storageApi,
+ identityApi,
+ canSave,
+ }),
+ }),
+];
```
#### Enrich Visit Function
@@ -402,8 +442,14 @@ const canSave: VisitCanSaveFunction = ({ pathname }) => {
You can also add the `enrichVisit` function to put additional values on each `Visit`. The values could later be used to customize the chips in the `VisitList`. For example, you could add the entity `type` on the `Visit` so that `type` is used for labels instead of `kind`.
```tsx
-import { VisitListener, VisitInput } from '@backstage/plugin-home';
+import {
+ AnyApiFactory,
+ createApiFactory,
+ identityApiRef,
+ storageApiRef,
+} from '@backstage/core-plugin-api';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
+import { VisitsStorageApi } from '@backstage/plugin-home';
type EnrichedVisit = VisitInput & {
type?: string;
@@ -423,18 +469,23 @@ const createEnrichVisit =
return visit;
}
};
-// This example requires its own component in order to use hook to look up entity in catalog
-const AppVisitListener = ({ children }: { children: React.ReactNode }) => {
- const catalogApi = useApi(catalogApiRef);
- const enrichVisit = createEnrichVisit(catalogApi);
- return (
- <>
-
- {children}
- >
- );
-};
+export const apis: AnyApiFactory[] = [
+ createApiFactory({
+ api: visitsApiRef,
+ deps: {
+ storageApi: storageApiRef,
+ identityApi: identityApiRef,
+ catalogApi: catalogApiRef,
+ },
+ factory: ({ storageApi, identityApi, catalogApi }) =>
+ VisitsStorageApi.create({
+ storageApi,
+ identityApi,
+ enrichVisit: createEnrichVisit(catalogApi),
+ }),
+ }),
+];
```
#### Custom Chip Colors and Labels
diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md
index d2e5c22ef8..83568c0e45 100644
--- a/plugins/home/report-alpha.api.md
+++ b/plugins/home/report-alpha.api.md
@@ -105,7 +105,6 @@ export default _default;
export const homeTranslationRef: TranslationRef<
'home',
{
- readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!';
readonly 'addWidgetDialog.title': 'Add new widget to dashboard';
readonly 'customHomepageButtons.clearAll': 'Clear all';
readonly 'customHomepageButtons.edit': 'Edit';
@@ -124,6 +123,7 @@ export const homeTranslationRef: TranslationRef<
readonly 'quickStart.title': 'Onboarding';
readonly 'quickStart.description': 'Get started with Backstage';
readonly 'quickStart.learnMoreLinkTitle': 'Learn more';
+ readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!';
readonly 'visitedByType.action.viewMore': 'View more';
readonly 'visitedByType.action.viewLess': 'View less';
readonly 'featuredDocsCard.empty.title': 'No documents to show';
diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md
index 3e6ff5de04..7036e9d6d2 100644
--- a/plugins/home/report.api.md
+++ b/plugins/home/report.api.md
@@ -264,13 +264,6 @@ export type Visit = {
entityRef?: string;
};
-// @public
-export type VisitCanSaveFunction = ({
- pathname,
-}: {
- pathname: string;
-}) => boolean;
-
// @public
export interface VisitDisplayContextValue {
// (undocumented)
@@ -308,11 +301,6 @@ export type VisitedByTypeProps = {
kind: VisitedByTypeKind;
};
-// @public
-export type VisitEnrichmentFunction = (
- visit: VisitInput,
-) => Record | Promise>;
-
// @public
export type VisitInput = {
name: string;
@@ -325,22 +313,21 @@ export const VisitListener: ({
children,
toEntityRef,
visitName,
- enrichVisit,
- transformPathname,
- canSave,
}: {
children?: ReactNode;
toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined;
visitName?: ({ pathname }: { pathname: string }) => string;
- enrichVisit?: VisitEnrichmentFunction;
- transformPathname?: VisitTransformPathnameFunction;
- canSave?: VisitCanSaveFunction;
}) => JSX.Element;
// @public
export interface VisitsApi {
+ canSave?(visit: VisitInput): boolean | Promise;
+ enrichVisit?(
+ visit: VisitInput,
+ ): Promise> | Record;
list(queryParams?: VisitsApiQueryParams): Promise;
save(saveParams: VisitsApiSaveParams): Promise;
+ transformPathname?(pathname: string): string;
}
// @public
@@ -367,10 +354,13 @@ export type VisitsApiSaveParams = {
// @public
export class VisitsStorageApi implements VisitsApi {
+ canSave(visit: VisitInput): Promise;
// (undocumented)
static create(options: VisitsStorageApiOptions): VisitsStorageApi;
+ enrichVisit(visit: VisitInput): Promise>;
list(queryParams?: VisitsApiQueryParams): Promise;
save(saveParams: VisitsApiSaveParams): Promise;
+ transformPathname(pathname: string): string;
}
// @public (undocumented)
@@ -378,6 +368,11 @@ export type VisitsStorageApiOptions = {
limit?: number;
storageApi: StorageApi;
identityApi: IdentityApi;
+ transformPathname?: (pathname: string) => string;
+ canSave?: (visit: VisitInput) => boolean | Promise;
+ enrichVisit?: (
+ visit: VisitInput,
+ ) => Promise> | Record;
};
// @public
@@ -393,13 +388,6 @@ export type VisitsWebStorageApiOptions = {
errorApi: ErrorApi;
};
-// @public
-export type VisitTransformPathnameFunction = ({
- pathname,
-}: {
- pathname: string;
-}) => string;
-
// @public
export const WelcomeTitle: ({
language,
diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts
index 610f4be483..5cbd65fab3 100644
--- a/plugins/home/src/api/VisitsApi.ts
+++ b/plugins/home/src/api/VisitsApi.ts
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '@backstage/core-plugin-api';
+import { VisitInput } from './VisitsStorageApi';
/**
* @public
@@ -126,6 +127,23 @@ export interface VisitsApi {
* @param queryParams - optional search query params.
*/
list(queryParams?: VisitsApiQueryParams): Promise;
+ /**
+ * Transform the pathname before it is considered for any other processing.
+ * @param pathname - the original pathname
+ */
+ transformPathname?(pathname: string): string;
+ /**
+ * Determine whether a visit should be saved.
+ * @param visit - page visit data
+ */
+ canSave?(visit: VisitInput): boolean | Promise;
+ /**
+ * Add additional data to the visit before saving.
+ * @param visit - page visit data
+ */
+ enrichVisit?(
+ visit: VisitInput,
+ ): Promise> | Record;
}
/** @public */
diff --git a/plugins/home/src/api/VisitsStorageApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts
index 55cf623149..c6629a39a0 100644
--- a/plugins/home/src/api/VisitsStorageApi.test.ts
+++ b/plugins/home/src/api/VisitsStorageApi.test.ts
@@ -359,4 +359,202 @@ describe('VisitsStorageApi.create', () => {
expect(visits.length).toEqual(8);
});
});
+ describe('.save() with transformPathname', () => {
+ it('transforms pathname before saving', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ transformPathname: (pathname: string) =>
+ pathname.replace(/\/admin$/, ''),
+ });
+
+ const visit = {
+ pathname: '/catalog/default/component/test/admin',
+ entityRef: 'component:default/test',
+ name: 'Test Component',
+ };
+
+ const savedVisit = await api.save({ visit });
+ expect(savedVisit.pathname).toBe('/catalog/default/component/test');
+ });
+ });
+
+ describe('.save() with canSave', () => {
+ it('skips saving when canSave returns false', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ canSave: visitInput => !visitInput.pathname.includes('/private'),
+ });
+
+ const privateVisit = {
+ pathname: '/private/admin',
+ entityRef: 'component:default/admin',
+ name: 'Admin Component',
+ };
+
+ const result = await api.save({ visit: privateVisit });
+ expect(result.id).toBe('');
+ expect(result.hits).toBe(0);
+
+ const visits = await api.list();
+ expect(visits).toHaveLength(0);
+ });
+
+ it('saves when canSave returns true', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ canSave: visitInput => !visitInput.pathname.includes('/private'),
+ });
+
+ const publicVisit = {
+ pathname: '/catalog/default/component/public',
+ entityRef: 'component:default/public',
+ name: 'Public Component',
+ };
+
+ const result = await api.save({ visit: publicVisit });
+ expect(result.id).toBeTruthy();
+ expect(result.hits).toBe(1);
+
+ const visits = await api.list();
+ expect(visits).toHaveLength(1);
+ });
+
+ it('handles async canSave function', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ canSave: async visitInput =>
+ Promise.resolve(!visitInput.pathname.includes('/restricted')),
+ });
+
+ const restrictedVisit = {
+ pathname: '/restricted/area',
+ entityRef: 'component:default/restricted',
+ name: 'Restricted Component',
+ };
+
+ const result = await api.save({ visit: restrictedVisit });
+ expect(result.id).toBe('');
+
+ const visits = await api.list();
+ expect(visits).toHaveLength(0);
+ });
+ });
+
+ describe('.save() with enrichVisit', () => {
+ it('enriches visit data before saving', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ enrichVisit: visitInput => ({
+ category: visitInput.entityRef?.split(':')[0] || 'unknown',
+ source: 'test',
+ }),
+ });
+
+ const visit = {
+ pathname: '/catalog/default/component/test',
+ entityRef: 'component:default/test',
+ name: 'Test Component',
+ };
+
+ const savedVisit = await api.save({ visit });
+ expect(savedVisit).toEqual(
+ expect.objectContaining({
+ ...visit,
+ category: 'component',
+ source: 'test',
+ }),
+ );
+
+ const visits = await api.list();
+ expect(visits[0]).toEqual(
+ expect.objectContaining({
+ category: 'component',
+ source: 'test',
+ }),
+ );
+ });
+
+ it('handles async enrichVisit function', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ enrichVisit: async visitInput =>
+ Promise.resolve({
+ enrichedAt: Date.now(),
+ type: visitInput.entityRef?.split(':')[0],
+ }),
+ });
+
+ const visit = {
+ pathname: '/catalog/default/api/test-api',
+ entityRef: 'api:default/test-api',
+ name: 'Test API',
+ };
+
+ const savedVisit = await api.save({ visit });
+ expect(savedVisit).toEqual(
+ expect.objectContaining({
+ type: 'api',
+ enrichedAt: expect.any(Number),
+ }),
+ );
+ });
+ });
+
+ describe('.save() with combined options', () => {
+ it('applies transformPathname, canSave, and enrichVisit in sequence', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ transformPathname: pathname => pathname.toLowerCase(),
+ canSave: visitInput => !visitInput.pathname.includes('forbidden'),
+ enrichVisit: visitInput => ({
+ processed: true,
+ originalPath: visitInput.pathname,
+ }),
+ });
+
+ const visit = {
+ pathname: '/CATALOG/Default/Component/Test',
+ entityRef: 'component:default/test',
+ name: 'Test Component',
+ };
+
+ const savedVisit = await api.save({ visit });
+ expect(savedVisit).toEqual(
+ expect.objectContaining({
+ pathname: '/catalog/default/component/test',
+ processed: true,
+ originalPath: '/catalog/default/component/test',
+ }),
+ );
+ });
+
+ it('prevents saving when canSave returns false after pathname transformation', async () => {
+ const api = VisitsStorageApi.create({
+ storageApi: mockApis.storage(),
+ identityApi: mockIdentityApi,
+ transformPathname: pathname =>
+ pathname.replace('/test/', '/forbidden/'),
+ canSave: visitInput => !visitInput.pathname.includes('forbidden'),
+ });
+
+ const visit = {
+ pathname: '/catalog/test/component/sample',
+ entityRef: 'component:default/sample',
+ name: 'Sample Component',
+ };
+
+ const result = await api.save({ visit });
+ expect(result.id).toBe('');
+
+ const visits = await api.list();
+ expect(visits).toHaveLength(0);
+ });
+ });
});
diff --git a/plugins/home/src/api/VisitsStorageApi.ts b/plugins/home/src/api/VisitsStorageApi.ts
index da3adc285e..3a55a6b16f 100644
--- a/plugins/home/src/api/VisitsStorageApi.ts
+++ b/plugins/home/src/api/VisitsStorageApi.ts
@@ -21,11 +21,26 @@ import {
VisitsApiSaveParams,
} from './VisitsApi';
+/**
+ * @public
+ * Type definition for visit data before it's saved (without auto-generated fields)
+ */
+export type VisitInput = {
+ name: string;
+ pathname: string;
+ entityRef?: string;
+};
+
/** @public */
export type VisitsStorageApiOptions = {
limit?: number;
storageApi: StorageApi;
identityApi: IdentityApi;
+ transformPathname?: (pathname: string) => string;
+ canSave?: (visit: VisitInput) => boolean | Promise;
+ enrichVisit?: (
+ visit: VisitInput,
+ ) => Promise> | Record;
};
type ArrayElement = A extends readonly (infer T)[] ? T : never;
@@ -43,6 +58,13 @@ export class VisitsStorageApi implements VisitsApi {
private readonly storageApi: StorageApi;
private readonly storageKeyPrefix = '@backstage/plugin-home:visits';
private readonly identityApi: IdentityApi;
+ private readonly transformPathnameImpl?: (pathname: string) => string;
+ private readonly canSaveImpl?: (
+ visit: VisitInput,
+ ) => boolean | Promise;
+ private readonly enrichVisitImpl?: (
+ visit: VisitInput,
+ ) => Promise> | Record;
static create(options: VisitsStorageApiOptions) {
return new VisitsStorageApi(options);
@@ -52,6 +74,9 @@ export class VisitsStorageApi implements VisitsApi {
this.limit = Math.abs(options.limit ?? 100);
this.storageApi = options.storageApi;
this.identityApi = options.identityApi;
+ this.transformPathnameImpl = options.transformPathname;
+ this.canSaveImpl = options.canSave;
+ this.enrichVisitImpl = options.enrichVisit;
}
/**
@@ -88,34 +113,90 @@ export class VisitsStorageApi implements VisitsApi {
return visits.slice(0, queryParams?.limit ?? DEFAULT_LIST_LIMIT);
}
+ /**
+ * Transform the pathname before it is considered for any other processing.
+ * @param pathname - the original pathname
+ * @returns the transformed pathname
+ */
+ transformPathname(pathname: string): string {
+ return this.transformPathnameImpl?.(pathname) ?? pathname;
+ }
+
+ /**
+ * Determine whether a visit should be saved.
+ * @param visit - page visit data
+ */
+ async canSave(visit: VisitInput): Promise {
+ if (!this.canSaveImpl) {
+ return true;
+ }
+ return Promise.resolve(this.canSaveImpl(visit));
+ }
+
+ /**
+ * Add additional data to the visit before saving.
+ * @param visit - page visit data
+ */
+ async enrichVisit(visit: VisitInput): Promise> {
+ if (!this.enrichVisitImpl) {
+ return {};
+ }
+ return Promise.resolve(this.enrichVisitImpl(visit));
+ }
+
/**
* Saves a visit through the visitsApi
*/
async save(saveParams: VisitsApiSaveParams): Promise {
+ let visit = saveParams.visit;
+
+ // Transform pathname if needed
+ visit = {
+ ...visit,
+ pathname: this.transformPathname(visit.pathname),
+ };
+
+ // Check if visit should be saved
+ if (!(await this.canSave(visit))) {
+ // Return a minimal visit object without saving
+ return {
+ ...visit,
+ id: '',
+ hits: 0,
+ timestamp: Date.now(),
+ };
+ }
+
+ // Enrich the visit
+ const enrichedData = await this.enrichVisit(visit);
+ const enrichedVisit = { ...visit, ...enrichedData };
+
const visits: Visit[] = [...(await this.retrieveAll())];
- const visit: Visit = {
- ...saveParams.visit,
+ const visitToSave: Visit = {
+ ...enrichedVisit,
id: window.crypto.randomUUID(),
hits: 1,
timestamp: Date.now(),
};
// Updates entry if pathname is already registered
- const visitIndex = visits.findIndex(e => e.pathname === visit.pathname);
+ const visitIndex = visits.findIndex(
+ e => e.pathname === visitToSave.pathname,
+ );
if (visitIndex >= 0) {
- visit.id = visits[visitIndex].id;
- visit.hits = visits[visitIndex].hits + 1;
- visits[visitIndex] = visit;
+ visitToSave.id = visits[visitIndex].id;
+ visitToSave.hits = visits[visitIndex].hits + 1;
+ visits[visitIndex] = visitToSave;
} else {
- visits.push(visit);
+ visits.push(visitToSave);
}
// Sort by time, most recent first
visits.sort((a, b) => b.timestamp - a.timestamp);
// Keep the most recent items up to limit
await this.persistAll(visits.splice(0, this.limit));
- return visit;
+ return visitToSave;
}
private async persistAll(visits: Array) {
diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts
index 944fa65330..3ac9d8fce8 100644
--- a/plugins/home/src/api/index.ts
+++ b/plugins/home/src/api/index.ts
@@ -17,3 +17,4 @@
export * from './VisitsStorageApi';
export * from './VisitsWebStorageApi';
export * from './VisitsApi';
+export type { VisitInput } from './VisitsStorageApi';
diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx
index 1661dc202c..7f7db95a9e 100644
--- a/plugins/home/src/components/VisitListener.test.tsx
+++ b/plugins/home/src/components/VisitListener.test.tsx
@@ -15,7 +15,7 @@
*/
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { Visit, visitsApiRef } from '../api';
-import { VisitListener, VisitEnrichmentFunction } from './VisitListener';
+import { VisitListener } from './VisitListener';
import { waitFor } from '@testing-library/react';
const visits: Array = [
@@ -49,33 +49,7 @@ const mockVisitsApi = {
};
describe('', () => {
- beforeEach(() => {
- jest
- .spyOn(window, 'requestAnimationFrame')
- .mockImplementation((cb: FrameRequestCallback): number => {
- cb(0);
- return 0;
- });
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- jest.resetAllMocks();
- });
-
- it('uses requestAnimationFrame to defer visit saving', async () => {
- const pathname = '/catalog/default/component/test-component';
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
- await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1));
- });
+ afterEach(jest.resetAllMocks);
it('registers a visit', async () => {
const pathname = '/catalog/default/component/playback-order';
@@ -156,183 +130,4 @@ describe('', () => {
}),
);
});
-
- it('saves base visit when no enrichment function is provided', async () => {
- const pathname = '/catalog/default/component/test-component';
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() =>
- expect(mockVisitsApi.save).toHaveBeenCalledWith({
- visit: {
- pathname,
- entityRef: 'component:default/test-component',
- name: 'test-component',
- },
- }),
- );
- });
-
- it('enriches visit with additional data when enrichVisit function is provided', async () => {
- const pathname = '/catalog/default/component/test-component';
- const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({
- customProperty: 'custom-value',
- category: 'test-category',
- priority: 1,
- }));
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() => {
- expect(enrichVisit).toHaveBeenCalledWith({
- pathname,
- entityRef: 'component:default/test-component',
- name: 'test-component',
- });
- expect(mockVisitsApi.save).toHaveBeenCalledWith({
- visit: {
- pathname,
- entityRef: 'component:default/test-component',
- name: 'test-component',
- customProperty: 'custom-value',
- category: 'test-category',
- priority: 1,
- },
- });
- });
- });
-
- it('handles synchronous enrichment function', async () => {
- const pathname = '/catalog/default/component/test-component';
- const enrichVisit: VisitEnrichmentFunction = jest.fn(_visit => ({
- syncProperty: 'sync-value',
- }));
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() => {
- expect(enrichVisit).toHaveBeenCalledWith({
- pathname,
- entityRef: 'component:default/test-component',
- name: 'test-component',
- });
- expect(mockVisitsApi.save).toHaveBeenCalledWith({
- visit: {
- pathname,
- entityRef: 'component:default/test-component',
- name: 'test-component',
- syncProperty: 'sync-value',
- },
- });
- });
- });
-
- it('enrichment function can override base visit properties', async () => {
- const pathname = '/catalog/default/component/test-component';
- const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({
- name: 'Overridden Name',
- entityRef: 'overridden:ref/value',
- customField: 'additional-data',
- }));
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() => {
- expect(mockVisitsApi.save).toHaveBeenCalledWith({
- visit: {
- pathname,
- name: 'Overridden Name',
- entityRef: 'overridden:ref/value',
- customField: 'additional-data',
- },
- });
- });
- });
-
- it('is able to override transformPathname and change the pathname', async () => {
- const pathname = '/catalog/default/component/playback-order-2/sub-path';
-
- const transformPathnameOverride = ({
- pathname: mypathname,
- }: {
- pathname: string;
- }) => mypathname.replace('/sub-path', '');
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() =>
- expect(mockVisitsApi.save).toHaveBeenCalledWith({
- visit: {
- pathname: '/catalog/default/component/playback-order-2',
- entityRef: 'component:default/playback-order-2',
- name: 'playback-order-2',
- },
- }),
- );
- });
-
- it('is able to override canSave and save under set conditions', async () => {
- const pathname = '/catalog';
-
- const canSaveOverride = ({ pathname: path }: { pathname: string }) =>
- path === '/catalog';
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() =>
- expect(mockVisitsApi.save).toHaveBeenCalledWith({
- visit: {
- pathname,
- entityRef: undefined,
- name: 'catalog',
- },
- }),
- );
- });
-
- it('is able to override canSave and not save under set conditions', async () => {
- const pathname = '/catalog';
-
- const canSaveOverride = ({ pathname: path }: { pathname: string }) =>
- path !== '/catalog';
-
- await renderInTestApp(
-
-
- ,
- { routeEntries: [pathname] },
- );
-
- await waitFor(() => expect(mockVisitsApi.save).not.toHaveBeenCalled());
- });
});
diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx
index 534e6805b2..ee4f4558c1 100644
--- a/plugins/home/src/components/VisitListener.tsx
+++ b/plugins/home/src/components/VisitListener.tsx
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { ReactNode, useEffect, useRef } from 'react';
+import { ReactNode, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
@@ -74,67 +74,6 @@ const getVisitName =
return document.title;
};
-/**
- * @public
- * Type definition for visit data before it's saved (without auto-generated fields)
- */
-export type VisitInput = {
- name: string;
- pathname: string;
- entityRef?: string;
-};
-
-/**
- * @public
- * Type definition for the visit enrichment function
- * This allows adding custom properties to visits at save time
- */
-export type VisitEnrichmentFunction = (
- visit: VisitInput,
-) => Record | Promise>;
-
-/**
- * @public
- * Type definition for the transform pathname function
- * This allows transforming the pathname before it is considered for any other processing
- */
-export type VisitTransformPathnameFunction = ({
- pathname,
-}: {
- pathname: string;
-}) => string;
-
-/**
- * @internal
- * Default implementation of visit pathname transform function
- */
-const getTransformPathname =
- (): VisitTransformPathnameFunction =>
- ({ pathname }: { pathname: string }): string => {
- return pathname;
- };
-
-/**
- * @public
- * Type definition for the can save function
- * This allows checking whether a visit can be saved
- */
-export type VisitCanSaveFunction = ({
- pathname,
-}: {
- pathname: string;
-}) => boolean;
-
-/**
- * @internal
- * Default implementation of visit can save function
- */
-const getCanSave =
- (): VisitCanSaveFunction =>
- (_: { pathname: string }): boolean => {
- return true;
- };
-
/**
* @public
* Component responsible for listening to location changes and calling
@@ -144,69 +83,29 @@ export const VisitListener = ({
children,
toEntityRef,
visitName,
- enrichVisit,
- transformPathname,
- canSave,
}: {
children?: ReactNode;
toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined;
visitName?: ({ pathname }: { pathname: string }) => string;
- enrichVisit?: VisitEnrichmentFunction;
- transformPathname?: VisitTransformPathnameFunction;
- canSave?: VisitCanSaveFunction;
}): JSX.Element => {
- const previousVisitPathname = useRef('');
const visitsApi = useApi(visitsApiRef);
const { pathname } = useLocation();
const toEntityRefImpl = toEntityRef ?? getToEntityRef();
const visitNameImpl = visitName ?? getVisitName();
- const transformPathnameImpl = transformPathname ?? getTransformPathname();
- const canSaveImpl = canSave ?? getCanSave();
-
useEffect(() => {
- const visitPathname = transformPathnameImpl({ pathname });
- if (previousVisitPathname.current === visitPathname) {
- return () => {};
- }
- previousVisitPathname.current = visitPathname;
- if (!canSaveImpl({ pathname: visitPathname })) {
- return () => {};
- }
// Wait for the browser to finish with paint with the assumption react
// has finished with dom reconciliation.
- const requestId = requestAnimationFrame(async () => {
- const baseVisit = {
- name: visitNameImpl({ pathname: visitPathname }),
- pathname: visitPathname,
- entityRef: toEntityRefImpl({ pathname: visitPathname }),
- };
-
- let visitToSave = baseVisit;
-
- if (enrichVisit) {
- try {
- const enrichedData = await enrichVisit(baseVisit);
- visitToSave = { ...baseVisit, ...enrichedData };
- } catch (error) {
- // If enrichment fails, save the base visit without enrichment
- visitToSave = baseVisit;
- }
- }
-
+ const requestId = requestAnimationFrame(() => {
visitsApi.save({
- visit: visitToSave,
+ visit: {
+ name: visitNameImpl({ pathname }),
+ pathname,
+ entityRef: toEntityRefImpl({ pathname }),
+ },
});
});
return () => cancelAnimationFrame(requestId);
- }, [
- visitsApi,
- pathname,
- toEntityRefImpl,
- visitNameImpl,
- enrichVisit,
- transformPathnameImpl,
- canSaveImpl,
- ]);
+ }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl]);
return <>{children}>;
};