feature(home-plugin): Creates the <VisitListener/> component

This patch creates the <VisitListener/> component to be used on the
app to register user visits enabling the homepage Recently Visited and
Top Visited experience (<VisitedByType/>) to be functional.

Signed-off-by: Renan Mendes Carvalho <aitherios@gmail.com>
This commit is contained in:
Renan Mendes Carvalho
2023-08-24 17:56:59 +02:00
committed by Camila Belo
parent cac684376e
commit c0700220c9
10 changed files with 447 additions and 28 deletions
+54 -8
View File
@@ -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<VisitListenerContextValue>;
// @public (undocumented)
export type VisitListenerContextValue = {
doNotTrack: boolean;
setDoNotTrack: Dispatch<SetStateAction<boolean>>;
};
// @public
export interface VisitsApi {
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
saveVisit(saveParams: VisitsApiSaveParams): Promise<void>;
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
}
// @public
export type VisitsApiQueryParams = {
limit?: number;
orderBy?: Record<string, 'asc' | 'desc'>;
filterBy?: VisitFilter[];
orderBy?: Array<{
field: keyof Visit;
direction: 'asc' | 'desc';
}>;
filterBy?: Array<{
field: keyof Visit;
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
value: string | number;
}>;
};
// @public (undocumented)
+1
View File
@@ -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"
+20 -16
View File
@@ -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<string, 'asc' | 'desc'>;
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: <date> }, { field: 'entityRef', operator: 'contains', value: 'docs' }] }
* {
* orderBy: [{ field: 'hits', direction: 'desc' }],
* filterBy: [
* { field: 'timestamp', operator: '>=', value: <date> },
* { 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<void>;
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
/**
* Get the logged user visits.
* @param queryParams - optional search query params.
@@ -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<Visit> = [
{
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('<VisitListener/>', () => {
afterEach(jest.resetAllMocks);
it('registers a visit', async () => {
jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle');
const pathname = '/catalog/default/component/playback-order';
await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener>
<div data-testid="child">child</div>
</VisitListener>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener visitName={visitNameOverride} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener toEntityRef={toEntityRefOverride} />
</TestApiProvider>,
{ routeEntries: [pathname] },
);
await waitFor(() =>
expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({
visit: {
pathname,
entityRef: pathname,
name: 'MockedTitle',
},
}),
);
});
});
describe('<DoNotTrack/>', () => {
afterEach(jest.resetAllMocks);
it("doesn't register a visit", async () => {
const requestAnimationFrameSpy = jest.spyOn(
window,
'requestAnimationFrame',
);
await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener>
<DoNotTrack />
</VisitListener>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener>
<DoNotTrack>
<div data-testid="child">child</div>
</DoNotTrack>
</VisitListener>
</TestApiProvider>,
);
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 }) => (
<MemoryRouter>
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener>{children}</VisitListener>
</TestApiProvider>
</MemoryRouter>
),
});
act(() => {
result.current.setDoNotTrack(true);
});
expect(result.current.doNotTrack).toBeTruthy();
});
});
@@ -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<SetStateAction<boolean>>;
};
const defaultVisitListenerContext: VisitListenerContextValue = {
doNotTrack: false,
setDoNotTrack: () => {},
};
/** @public */
export const VisitListenerContext = createContext<VisitListenerContextValue>(
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}\/(?<namespace>[^\/]+)\/(?<kind>[^\/]+)\/(?<name>[^\/]+)`,
);
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<boolean>(
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 (
<VisitListenerContext.Provider value={{ doNotTrack, setDoNotTrack }}>
{children}
</VisitListenerContext.Provider>
);
};
/**
* @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, <VisitListener/> 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}</>;
};
+1
View File
@@ -16,3 +16,4 @@
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
export * from './CustomHomepage';
export * from './VisitListener';
@@ -32,7 +32,7 @@ const visits = [
];
const mockVisitsApi = {
saveVisit: async () => {},
saveVisit: async () => visits[0],
listUserVisits: async () => visits,
};
@@ -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);
}
@@ -87,7 +87,7 @@ const visits: Array<Visit> = [
];
const mockVisitsApi = {
saveVisit: async () => {},
saveVisit: async () => visits[0],
listUserVisits: async () => visits,
};
+1
View File
@@ -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