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