feat: move new functions from VisitListener to VisitsStorageApi
Signed-off-by: Stephanie Swaney <stephanie.swaney.ddk6@statefarm.com>
This commit is contained in:
+72
-21
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user