feat: move new functions from VisitListener to VisitsStorageApi

Signed-off-by: Stephanie Swaney <stephanie.swaney.ddk6@statefarm.com>
This commit is contained in:
Stephanie Swaney
2025-09-17 20:55:21 -05:00
parent e26e444d53
commit 30f4b44c73
10 changed files with 403 additions and 372 deletions
+72 -21
View File
@@ -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;
};
<VisitListener transformPathname={transformPathname} />;
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')
);
};
<VisitListener canSave={canSave} />;
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 (
<>
<VisitListener enrichVisit={enrichVisit} />
{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
+1 -1
View File
@@ -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';
+13 -25
View File
@@ -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<string, any> | Promise<Record<string, any>>;
// @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<boolean>;
enrichVisit?(
visit: VisitInput,
): Promise<Record<string, any>> | Record<string, any>;
list(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
transformPathname?(pathname: string): string;
}
// @public
@@ -367,10 +354,13 @@ export type VisitsApiSaveParams = {
// @public
export class VisitsStorageApi implements VisitsApi {
canSave(visit: VisitInput): Promise<boolean>;
// (undocumented)
static create(options: VisitsStorageApiOptions): VisitsStorageApi;
enrichVisit(visit: VisitInput): Promise<Record<string, any>>;
list(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
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<boolean>;
enrichVisit?: (
visit: VisitInput,
) => Promise<Record<string, any>> | Record<string, any>;
};
// @public
@@ -393,13 +388,6 @@ export type VisitsWebStorageApiOptions = {
errorApi: ErrorApi;
};
// @public
export type VisitTransformPathnameFunction = ({
pathname,
}: {
pathname: string;
}) => string;
// @public
export const WelcomeTitle: ({
language,
+18
View File
@@ -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<Visit[]>;
/**
* 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<boolean>;
/**
* Add additional data to the visit before saving.
* @param visit - page visit data
*/
enrichVisit?(
visit: VisitInput,
): Promise<Record<string, any>> | Record<string, any>;
}
/** @public */
@@ -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);
});
});
});
+89 -8
View File
@@ -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<boolean>;
enrichVisit?: (
visit: VisitInput,
) => Promise<Record<string, any>> | Record<string, any>;
};
type ArrayElement<A> = 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<boolean>;
private readonly enrichVisitImpl?: (
visit: VisitInput,
) => Promise<Record<string, any>> | Record<string, any>;
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<boolean> {
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<Record<string, any>> {
if (!this.enrichVisitImpl) {
return {};
}
return Promise.resolve(this.enrichVisitImpl(visit));
}
/**
* Saves a visit through the visitsApi
*/
async save(saveParams: VisitsApiSaveParams): Promise<Visit> {
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<Visit>) {
+1
View File
@@ -17,3 +17,4 @@
export * from './VisitsStorageApi';
export * from './VisitsWebStorageApi';
export * from './VisitsApi';
export type { VisitInput } from './VisitsStorageApi';
@@ -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<Visit> = [
@@ -49,33 +49,7 @@ const mockVisitsApi = {
};
describe('<VisitListener/>', () => {
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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ 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('<VisitListener/>', () => {
}),
);
});
it('saves base visit when no enrichment function is provided', async () => {
const pathname = '/catalog/default/component/test-component';
await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener enrichVisit={enrichVisit} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener enrichVisit={enrichVisit} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener enrichVisit={enrichVisit} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener transformPathname={transformPathnameOverride} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener canSave={canSaveOverride} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener canSave={canSaveOverride} />
</TestApiProvider>,
{ routeEntries: [pathname] },
);
await waitFor(() => expect(mockVisitsApi.save).not.toHaveBeenCalled());
});
});
+8 -109
View File
@@ -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<string, any> | Promise<Record<string, any>>;
/**
* @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}</>;
};