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
+1 -1
View File
@@ -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
+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}</>;
};