feat: adds transform path and can save to VisitListener, fixes and adds tests

Signed-off-by: Rajib Quayum <rajibq@users.noreply.github.com>

chore: update API reports
This commit is contained in:
Rajib Quayum
2025-08-11 14:00:28 -04:00
committed by Stephanie Swaney
parent c324751332
commit c2e0020b97
3 changed files with 281 additions and 134 deletions
+18
View File
@@ -264,6 +264,13 @@ export type Visit = {
entityRef?: string;
};
// @public
export type VisitCanSaveFunction = ({
pathname,
}: {
pathname: string;
}) => boolean;
// @public
export interface VisitDisplayContextValue {
// (undocumented)
@@ -319,11 +326,15 @@ export const VisitListener: ({
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
@@ -382,6 +393,13 @@ export type VisitsWebStorageApiOptions = {
errorApi: ErrorApi;
};
// @public
export type VisitTransformPathnameFunction = ({
pathname,
}: {
pathname: string;
}) => string;
// @public
export const WelcomeTitle: ({
language,
+193 -129
View File
@@ -49,7 +49,33 @@ const mockVisitsApi = {
};
describe('<VisitListener/>', () => {
afterEach(jest.resetAllMocks);
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));
});
it('registers a visit', async () => {
const pathname = '/catalog/default/component/playback-order';
@@ -131,144 +157,182 @@ describe('<VisitListener/>', () => {
);
});
describe('requestId tests', () => {
beforeEach(() => {
// Mock requestAnimationFrame to execute immediately
global.requestAnimationFrame = jest.fn(callback => {
callback(0);
return 1;
});
global.cancelAnimationFrame = jest.fn();
});
it('saves base visit when no enrichment function is provided', async () => {
const pathname = '/catalog/default/component/test-component';
afterEach(() => {
jest.restoreAllMocks();
});
await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ routeEntries: [pathname] },
);
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(global.requestAnimationFrame).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1));
});
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({
await waitFor(() =>
expect(mockVisitsApi.save).toHaveBeenCalledWith({
visit: {
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('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',
});
});
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({
expect(mockVisitsApi.save).toHaveBeenCalledWith({
visit: {
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',
},
});
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());
});
});
+70 -5
View File
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
@@ -93,6 +93,48 @@ 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
@@ -103,25 +145,40 @@ export const VisitListener = ({
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 }),
pathname,
entityRef: toEntityRefImpl({ pathname }),
name: visitNameImpl({ pathname: visitPathname }),
pathname: visitPathname,
entityRef: toEntityRefImpl({ pathname: visitPathname }),
};
let visitToSave = baseVisit;
@@ -141,7 +198,15 @@ export const VisitListener = ({
});
});
return () => cancelAnimationFrame(requestId);
}, [visitsApi, pathname, toEntityRefImpl, visitNameImpl, enrichVisit]);
}, [
visitsApi,
pathname,
toEntityRefImpl,
visitNameImpl,
enrichVisit,
transformPathnameImpl,
canSaveImpl,
]);
return <>{children}</>;
};