From c0700220c97500b12ab1d65bde2f2abead4281ba Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Thu, 24 Aug 2023 17:56:59 +0200 Subject: [PATCH] feature(home-plugin): Creates the component This patch creates the component to be used on the app to register user visits enabling the homepage Recently Visited and Top Visited experience () to be functional. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 62 +++++- plugins/home/package.json | 1 + plugins/home/src/api/VisitsApi.ts | 36 ++-- .../src/components/VisitListener.test.tsx | 203 ++++++++++++++++++ plugins/home/src/components/VisitListener.tsx | 163 ++++++++++++++ plugins/home/src/components/index.ts | 1 + .../VisitedByType/Content.test.tsx | 2 +- .../VisitedByType/Content.tsx | 4 +- .../HomePageVisitedByType.stories.tsx | 2 +- yarn.lock | 1 + 10 files changed, 447 insertions(+), 28 deletions(-) create mode 100644 plugins/home/src/components/VisitListener.test.tsx create mode 100644 plugins/home/src/components/VisitListener.tsx 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