diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 8c0177e251..acc073f685 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -14,13 +14,15 @@ import { CardSettings as CardSettings_2 } from '@backstage/plugin-home-react'; import { ComponentParts as ComponentParts_2 } from '@backstage/plugin-home-react'; import { ComponentRenderer as ComponentRenderer_2 } from '@backstage/plugin-home-react'; import { createCardExtension as createCardExtension_2 } from '@backstage/plugin-home-react'; -import { JsonValue } from '@backstage/types'; +import { Dispatch } from 'react'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { RendererProps as RendererProps_2 } from '@backstage/plugin-home-react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { SetStateAction } from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; // @public export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -99,6 +101,25 @@ export type CustomHomepageGridProps = { preventCollision?: boolean; }; +// @public +export const DoNotTrack: ({ + children, +}: { + children?: ReactNode; +}) => JSX.Element; + +// @public +export const getToEntityRef: ({ + rootPath, + stringifyEntityRefImpl, +}?: { + rootPath?: string | undefined; + stringifyEntityRefImpl?: typeof stringifyEntityRef | undefined; +}) => ({ pathname }: { pathname: string }) => string | undefined; + +// @public +export const getVisitName: (document: Document) => () => string; + // @public export const HeaderWorldClock: (props: { clockConfigs: ClockConfig[]; @@ -193,6 +214,9 @@ export type ToolkitContentProps = { tools: Tool[]; }; +// @public +export const useVisitListener: () => VisitListenerContextValue; + // @public export type Visit = { id: string; @@ -215,24 +239,46 @@ export type VisitedByTypeProps = { kind: VisitedByTypeKind; }; +// @public +export const VisitListener: ({ + children, + toEntityRef, + visitName, +}: { + children?: React_2.ReactNode; + toEntityRef?: + | (({ pathname }: { pathname: string }) => string | undefined) + | undefined; + visitName?: (({ pathname }: { pathname: string }) => string) | undefined; +}) => JSX.Element; + // @public (undocumented) -export type VisitFilter = { - field: string; - operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; - value: JsonValue; +export const VisitListenerContext: React_2.Context; + +// @public (undocumented) +export type VisitListenerContextValue = { + doNotTrack: boolean; + setDoNotTrack: Dispatch>; }; // @public export interface VisitsApi { listUserVisits(queryParams?: VisitsApiQueryParams): Promise; - saveVisit(saveParams: VisitsApiSaveParams): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; } // @public export type VisitsApiQueryParams = { limit?: number; - orderBy?: Record; - filterBy?: VisitFilter[]; + orderBy?: Array<{ + field: keyof Visit; + direction: 'asc' | 'desc'; + }>; + filterBy?: Array<{ + field: keyof Visit; + operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; + value: string | number; + }>; }; // @public (undocumented) diff --git a/plugins/home/package.json b/plugins/home/package.json index f69db9d4ee..6a9c5306c1 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -70,6 +70,7 @@ "@testing-library/dom": "^8.0.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.0.0", "@types/react-grid-layout": "^1.3.2", "msw": "^1.0.0" diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index c461a5159b..4ffb37e8b6 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -15,7 +15,6 @@ */ import { createApiRef } from '@backstage/core-plugin-api'; -import { JsonValue } from '@backstage/types'; /** * @public @@ -48,13 +47,6 @@ export type Visit = { entityRef?: string; }; -/** @public */ -export type VisitFilter = { - field: string; - operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; - value: JsonValue; -}; - /** * @public * This data structure represents the parameters associated with search queries for visits. @@ -65,24 +57,36 @@ export type VisitsApiQueryParams = { */ limit?: number; /** - * A record for which the key is a field name to sort on, and the value is the sort direction. - * For a multi-field sorting query, add multi entries to the record. + * Allows ordering visits on entity properties. * @example * Sort ascending by the timestamp field. * ``` - * { orderBy: { timestamp: 'asc' } } + * { orderBy: [{ field: 'timestamp', direction: 'asc' }] } * ``` */ - orderBy?: Record; + orderBy?: Array<{ + field: keyof Visit; + direction: 'asc' | 'desc'; + }>; /** - * Allows filtering visits on number of hits, timestamp and/or entityRef attributes. + * Allows filtering visits on entity properties. * @example * Most popular docs on the past 7 days * ``` - * { orderBy: { hits: 'desc' }, filterBy: [{ field: 'timestamp', operator: '>=', value: }, { field: 'entityRef', operator: 'contains', value: 'docs' }] } + * { + * orderBy: [{ field: 'hits', direction: 'desc' }], + * filterBy: [ + * { field: 'timestamp', operator: '>=', value: }, + * { field: 'entityRef', operator: 'contains', value: 'docs' } + * ] + * } * ``` */ - filterBy?: VisitFilter[]; + filterBy?: Array<{ + field: keyof Visit; + operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; + value: string | number; + }>; }; /** @@ -102,7 +106,7 @@ export interface VisitsApi { * Persist a new visit. * @param pageVisit - a new visit data */ - saveVisit(saveParams: VisitsApiSaveParams): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; /** * Get the logged user visits. * @param queryParams - optional search query params. diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx new file mode 100644 index 0000000000..81cff08a09 --- /dev/null +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -0,0 +1,203 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import { Visit, visitsApiRef } from '../api'; +import { DoNotTrack, VisitListener, useVisitListener } from './VisitListener'; +import { waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { MemoryRouter } from 'react-router-dom'; + +const visits: Array = [ + { + id: 'tech-radar', + name: 'Tech Radar', + pathname: '/tech-radar', + hits: 40, + timestamp: Date.now() - 360_000, + }, + { + id: 'explore', + name: 'Explore Backstage', + pathname: '/explore', + hits: 35, + timestamp: Date.now() - 86400_000 * 1, + }, + { + id: 'user-1', + name: 'Guest', + pathname: '/catalog/default/user/guest', + hits: 30, + timestamp: Date.now() - 86400_000 * 2, + entityRef: 'User:default/guest', + }, +]; + +const mockVisitsApi = { + saveVisit: jest.fn(async () => visits[0]), + listVisits: jest.fn(async () => visits), +}; + +describe('', () => { + afterEach(jest.resetAllMocks); + + it('registers a visit', async () => { + jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); + const pathname = '/catalog/default/component/playback-order'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.saveVisit).toHaveBeenCalledTimes(1), + ); + expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/playback-order', + name: 'MockedTitle', + }, + }); + }); + + it('renders its children', async () => { + const { getByTestId } = await renderInTestApp( + + +
child
+
+
, + ); + + expect(getByTestId('child')).toBeTruthy(); + }); + + it('is able to override how visit names are defined', async () => { + jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); + const pathname = '/catalog/default/component/playback-order'; + + const visitNameOverride = ({ pathname: path }: { pathname: string }) => + path; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/playback-order', + name: pathname, + }, + }), + ); + }); + + it('is able to override how entityRefs are defined', async () => { + jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); + const pathname = '/catalog/default/component/playback-order'; + + const toEntityRefOverride = ({ pathname: path }: { pathname: string }) => + path; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: pathname, + name: 'MockedTitle', + }, + }), + ); + }); +}); + +describe('', () => { + afterEach(jest.resetAllMocks); + + it("doesn't register a visit", async () => { + const requestAnimationFrameSpy = jest.spyOn( + window, + 'requestAnimationFrame', + ); + await renderInTestApp( + + + + + , + ); + await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled()); + expect(mockVisitsApi.saveVisit).not.toHaveBeenCalled(); + }); + + it('renders its children', async () => { + const requestAnimationFrameSpy = jest.spyOn( + window, + 'requestAnimationFrame', + ); + const { getByTestId } = await renderInTestApp( + + + +
child
+
+
+
, + ); + await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled()); + expect(getByTestId('child')).toBeTruthy(); + }); +}); + +describe('useVisitListener()', () => { + it('returns the default context', () => { + const { result } = renderHook(() => useVisitListener()); + expect(result.current.doNotTrack).toBeFalsy(); + expect(result.current.setDoNotTrack).toBeInstanceOf(Function); + }); + + it('changes the doNotTrack flag', () => { + const { result } = renderHook(() => useVisitListener(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + act(() => { + result.current.setDoNotTrack(true); + }); + expect(result.current.doNotTrack).toBeTruthy(); + }); +}); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx new file mode 100644 index 0000000000..cb92b47738 --- /dev/null +++ b/plugins/home/src/components/VisitListener.tsx @@ -0,0 +1,163 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { + createContext, + useState, + useEffect, + useContext, + Dispatch, + SetStateAction, + ReactNode, +} from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { visitsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; + +/** @public */ +export type VisitListenerContextValue = { + doNotTrack: boolean; + setDoNotTrack: Dispatch>; +}; + +const defaultVisitListenerContext: VisitListenerContextValue = { + doNotTrack: false, + setDoNotTrack: () => {}, +}; + +/** @public */ +export const VisitListenerContext = createContext( + defaultVisitListenerContext, +); + +/** + * @public + * This function returns an implementation of toEntityRef which is responsible + * for receiving a pathname and maybe returning an entityRef compatible with the + * catalog-model. + * By default this function uses the url root "/catalog" and the + * stringifyEntityRef implementation from catalog-model. + * Example: + * const toEntityRef = getToEntityRef(); + * toEntityRef(\{ pathname: "/catalog/default/component/playback-order" \}) + * // returns "component:default/playback-order" + */ +export const getToEntityRef = + ({ + rootPath = 'catalog', + stringifyEntityRefImpl = stringifyEntityRef, + } = {}) => + ({ pathname }: { pathname: string }): string | undefined => { + const regex = new RegExp( + `^\/${rootPath}\/(?[^\/]+)\/(?[^\/]+)\/(?[^\/]+)`, + ); + const result = regex.exec(pathname); + if (!result || !result?.groups) return undefined; + const entity = { + namespace: result.groups.namespace, + kind: result.groups.kind, + name: result.groups.name, + }; + return stringifyEntityRefImpl(entity); + }; + +/** + * @public + * This function returns an implementation of visitName which is responsible + * for receiving a pathname and returning a string (name). The default + * implementation ignores the pathname and uses the document.title . + */ +export const getVisitName = (document: Document) => () => document.title; + +/** + * @public + * Component responsible for listening to location changes and calling + * the visitsApi to save visits. + */ +export const VisitListener = ({ + children, + toEntityRef, + visitName, +}: { + children?: React.ReactNode; + toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; + visitName?: ({ pathname }: { pathname: string }) => string; +}): JSX.Element => { + const [doNotTrack, setDoNotTrack] = useState( + defaultVisitListenerContext.doNotTrack, + ); + const visitsApi = useApi(visitsApiRef); + const { pathname } = useLocation(); + const toEntityRefImpl = toEntityRef ?? getToEntityRef(); + const visitNameImpl = visitName ?? getVisitName(document); + useEffect(() => { + // Wait for the browser to finish with paint with the assumption react + // has finished with dom reconciliation and the doNotTrack state update. + const requestId = requestAnimationFrame(() => { + if (!doNotTrack) + visitsApi.saveVisit({ + visit: { + name: visitNameImpl({ pathname }), + pathname, + entityRef: toEntityRefImpl({ pathname }), + }, + }); + }); + return () => cancelAnimationFrame(requestId); + }, [doNotTrack, visitsApi, pathname, toEntityRefImpl, visitNameImpl]); + + return ( + + {children} + + ); +}; + +/** + * @public + * Hook used to access visit listener context. Is able to control if tracking + * should be disabled. + */ +export const useVisitListener = () => { + const value = useContext(VisitListenerContext); + + if (value === undefined) + throw new Error( + 'useVisitListener found an undefined context, could be missing', + ); + + return value; +}; + +/** + * @public + * Use this component to warn VisitListener to disable tracking. + */ +export const DoNotTrack = ({ + children, +}: { + children?: ReactNode; +}): JSX.Element => { + const { setDoNotTrack } = useVisitListener(); + useEffect(() => { + setDoNotTrack(true); + return () => setDoNotTrack(false); + }, [setDoNotTrack]); + + return <>{children}; +}; diff --git a/plugins/home/src/components/index.ts b/plugins/home/src/components/index.ts index e528e0795d..a6a4148e36 100644 --- a/plugins/home/src/components/index.ts +++ b/plugins/home/src/components/index.ts @@ -16,3 +16,4 @@ export { HomepageCompositionRoot } from './HomepageCompositionRoot'; export * from './CustomHomepage'; +export * from './VisitListener'; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx index 329bf4e845..d73a9d1a75 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -32,7 +32,7 @@ const visits = [ ]; const mockVisitsApi = { - saveVisit: async () => {}, + saveVisit: async () => visits[0], listUserVisits: async () => visits, }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx index 62b0de0d7d..4eef2a87ad 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -67,7 +67,7 @@ export const Content = ({ return await visitsApi .listUserVisits({ limit: numVisitsTotal ?? 8, - orderBy: { timestamp: 'desc' }, + orderBy: [{ field: 'timestamp', direction: 'desc' }], }) .then(setVisits); } @@ -75,7 +75,7 @@ export const Content = ({ return await visitsApi .listUserVisits({ limit: numVisitsTotal ?? 8, - orderBy: { hits: 'desc' }, + orderBy: [{ field: 'hits', direction: 'desc' }], }) .then(setVisits); } diff --git a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx index 64698da921..0dd9c9e873 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx @@ -87,7 +87,7 @@ const visits: Array = [ ]; const mockVisitsApi = { - saveVisit: async () => {}, + saveVisit: async () => visits[0], listUserVisits: async () => visits, }; diff --git a/yarn.lock b/yarn.lock index c9aa122ad9..2fdd9e041f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7406,6 +7406,7 @@ __metadata: "@testing-library/dom": ^8.0.0 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.1 "@testing-library/user-event": ^14.0.0 "@types/react": ^16.13.1 || ^17.0.0 "@types/react-grid-layout": ^1.3.2