diff --git a/.changeset/happy-books-smoke.md b/.changeset/happy-books-smoke.md new file mode 100644 index 0000000000..68ea19ea5e --- /dev/null +++ b/.changeset/happy-books-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Adds Top/Recently Visited components to homepage diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a0a6419328..f2b7d66b21 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -61,7 +61,7 @@ import { import { orgPlugin } from '@backstage/plugin-org'; import { ExplorePage } from '@backstage/plugin-explore'; import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; -import { HomepageCompositionRoot } from '@backstage/plugin-home'; +import { HomepageCompositionRoot, VisitListener } from '@backstage/plugin-home'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; import { NextScaffolderPage } from '@backstage/plugin-scaffolder/alpha'; @@ -330,6 +330,7 @@ export default app.createRoot( + {routes} , diff --git a/packages/app/src/components/home/HomePage.tsx b/packages/app/src/components/home/HomePage.tsx index a8ddc0e0e2..5940b0ac6c 100644 --- a/packages/app/src/components/home/HomePage.tsx +++ b/packages/app/src/components/home/HomePage.tsx @@ -22,6 +22,8 @@ import { HomePageRandomJoke, HomePageStarredEntities, HomePageToolkit, + HomePageTopVisited, + HomePageRecentlyVisited, WelcomeTitle, } from '@backstage/plugin-home'; import { Content, Header, Page } from '@backstage/core-components'; @@ -111,6 +113,8 @@ export const homePage = ( }, ]} /> + + diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index a1e952a07c..36f7a86acc 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -18,3 +18,4 @@ // ideally we have an API for the context menu that permits that. export { badgesPlugin } from '@backstage/plugin-badges'; export { shortcutsPlugin } from '@backstage/plugin-shortcuts'; +export { homePlugin } from '@backstage/plugin-home'; diff --git a/plugins/home/README.md b/plugins/home/README.md index e147cdfbdf..d41ae57162 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -242,6 +242,89 @@ const defaultConfig = [ ``` +## Page visit homepage component (HomePageTopVisited / HomePageRecentlyVisited) + +This component shows the homepage user a view for "Recently visited" or "Top visited". +Being provided by the `` and `` component, see it in use on a homepage example below: + +```tsx +// packages/app/src/components/home/HomePage.tsx +import React from 'react'; +import Grid from '@material-ui/core/Grid'; +import { + HomePageTopVisited, + HomePageRecentlyVisited, +} from '@backstage/plugin-home'; + +export const homePage = ( + + + + + + + + +); +``` + +There are some requirements to provide its functionality, so please ensure the following: + +These components need an API to handle visit data, please refer to the [utility-apis](../../docs/api/utility-apis.md) +documentation for more information. Bellow you can see an example for two options: + +```ts +// packages/app/src/apis.ts +// ... +import { + VisitsStorageApi, + VisitsWebStorageApi, + visitsApiRef, +} from '@backstage/plugin-home'; +// ... +export const apis: AnyApiFactory[] = [ + // Implementation that relies on a provided storageApi + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + }, + factory: ({ storageApi, identityApi }) => + VisitsStorageApi.create({ storageApi, identityApi }), + }), + + // Or a localStorage data implementation, relies on WebStorage implementation of storageApi + createApiFactory({ + api: visitsApiRef, + deps: { + identityApi: identityApiRef, + errorApi: errorApiRef + }, + factory: ({ identityApi, errorApi }) => VisitsWebStorageApi.create({ identityApi, errorApi }), + }), + // ... +``` + +To monitor page visit activity and save it on behalf of the user a component is provided, please add it to your app. +See the example usage: + +```ts +// packages/app/src/App.tsx +import { VisitListener } from '@backstage/plugin-home'; +// ... +export default app.createRoot( + <> + + + + + {routes} + + , +); +``` + ## Contributing ### Homepage Components diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 497b11224d..14f685cfc1 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { CardConfig as CardConfig_2 } from '@backstage/plugin-home-react'; import { CardExtensionProps as CardExtensionProps_2 } from '@backstage/plugin-home-react'; @@ -13,12 +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 { ErrorApi } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; 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 { StorageApi } from '@backstage/core-plugin-api'; // @public export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -122,6 +126,11 @@ export const HomePageRandomJoke: ( }>, ) => JSX_2.Element; +// @public +export const HomePageRecentlyVisited: ( + props: CardExtensionProps_2>, +) => JSX_2.Element; + // @public export const HomePageStarredEntities: ( props: CardExtensionProps_2, @@ -132,6 +141,11 @@ export const HomePageToolkit: ( props: CardExtensionProps_2, ) => JSX_2.Element; +// @public +export const HomePageTopVisited: ( + props: CardExtensionProps_2>, +) => JSX_2.Element; + // @public (undocumented) export const homePlugin: BackstagePlugin< { @@ -186,6 +200,97 @@ export type ToolkitContentProps = { tools: Tool[]; }; +// @public +export type Visit = { + id: string; + name: string; + pathname: string; + hits: number; + timestamp: number; + entityRef?: string; +}; + +// @public (undocumented) +export type VisitedByTypeKind = 'recent' | 'top'; + +// @public (undocumented) +export type VisitedByTypeProps = { + visits?: Array; + numVisitsOpen?: number; + numVisitsTotal?: number; + loading?: boolean; + 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 +export interface VisitsApi { + list(queryParams?: VisitsApiQueryParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; +} + +// @public +export type VisitsApiQueryParams = { + limit?: number; + orderBy?: Array<{ + field: keyof Visit; + direction: 'asc' | 'desc'; + }>; + filterBy?: Array<{ + field: keyof Visit; + operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; + value: string | number; + }>; +}; + +// @public (undocumented) +export const visitsApiRef: ApiRef; + +// @public +export type VisitsApiSaveParams = { + visit: Omit; +}; + +// @public +export class VisitsStorageApi implements VisitsApi { + // (undocumented) + static create(options: VisitsStorageApiOptions): VisitsStorageApi; + list(queryParams?: VisitsApiQueryParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; +} + +// @public (undocumented) +export type VisitsStorageApiOptions = { + limit?: number; + storageApi: StorageApi; + identityApi: IdentityApi; +}; + +// @public +export class VisitsWebStorageApi { + // (undocumented) + static create(options: VisitsWebStorageApiOptions): VisitsStorageApi; +} + +// @public (undocumented) +export type VisitsWebStorageApiOptions = { + limit?: number; + identityApi: IdentityApi; + errorApi: ErrorApi; +}; + // @public export const WelcomeTitle: ({ language, diff --git a/plugins/home/package.json b/plugins/home/package.json index bba62e9706..80fb8496b1 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -36,11 +36,13 @@ "dependencies": { "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-home-react": "workspace:^", "@backstage/theme": "workspace:^", + "@backstage/types": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", @@ -50,6 +52,7 @@ "@rjsf/validator-ajv8": "5.13.0", "@types/react": "^16.13.1 || ^17.0.0", "lodash": "^4.17.21", + "luxon": "^3.4.3", "react-grid-layout": "^1.3.4", "react-resizable": "^3.0.4", "react-use": "^17.2.4", @@ -62,12 +65,12 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", "@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 new file mode 100644 index 0000000000..ad750d81f2 --- /dev/null +++ b/plugins/home/src/api/VisitsApi.ts @@ -0,0 +1,120 @@ +/* + * 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 { createApiRef } from '@backstage/core-plugin-api'; + +/** + * @public + * Model for a visit entity. + */ +export type Visit = { + /** + * The auto-generated visit identification. + */ + id: string; + /** + * The visited entity, usually an entity id. + */ + name: string; + /** + * The visited url pathname, usually the entity route. + */ + pathname: string; + /** + * An individual view count. + */ + hits: number; + /** + * Last date and time of visit. Format: unix epoch in ms. + */ + timestamp: number; + /** + * Optional entity reference. See stringifyEntityRef from catalog-model. + */ + entityRef?: string; +}; + +/** + * @public + * This data structure represents the parameters associated with search queries for visits. + */ +export type VisitsApiQueryParams = { + /** + * Limits the number of results returned. The default is 8. + */ + limit?: number; + /** + * Allows ordering visits on entity properties. + * @example + * Sort ascending by the timestamp field. + * ``` + * { orderBy: [{ field: 'timestamp', direction: 'asc' }] } + * ``` + */ + orderBy?: Array<{ + field: keyof Visit; + direction: 'asc' | 'desc'; + }>; + /** + * Allows filtering visits on entity properties. + * @example + * Most popular docs on the past 7 days + * ``` + * { + * orderBy: [{ field: 'hits', direction: 'desc' }], + * filterBy: [ + * { field: 'timestamp', operator: '>=', value: }, + * { field: 'entityRef', operator: 'contains', value: 'docs' } + * ] + * } + * ``` + */ + filterBy?: Array<{ + field: keyof Visit; + operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; + value: string | number; + }>; +}; + +/** + * @public + * This data structure represents the parameters associated with saving visits. + */ +export type VisitsApiSaveParams = { + visit: Omit; +}; + +/** + * @public + * Visits API public contract. + */ +export interface VisitsApi { + /** + * Persist a new visit. + * @param pageVisit - a new visit data + */ + save(saveParams: VisitsApiSaveParams): Promise; + /** + * Get user visits. + * @param queryParams - optional search query params. + */ + list(queryParams?: VisitsApiQueryParams): Promise; +} + +/** @public */ +export const visitsApiRef = createApiRef({ + id: 'homepage.visits', +}); diff --git a/plugins/home/src/api/VisitsStorageApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts new file mode 100644 index 0000000000..6faeb62350 --- /dev/null +++ b/plugins/home/src/api/VisitsStorageApi.test.ts @@ -0,0 +1,339 @@ +/* + * 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 { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; +import { VisitsStorageApi } from './VisitsStorageApi'; +import { MockStorageApi } from '@backstage/test-utils'; +import { Visit, VisitsApi } from './VisitsApi'; + +describe('VisitsStorageApi.create', () => { + const mockRandomUUID = () => + '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( + /x/g, + () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf + ) as `${string}-${string}-${string}-${string}-${string}`; + + const mockIdentityApi: IdentityApi = { + signOut: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: async () => + ({ userEntityRef: 'user:default/guest' } as BackstageUserIdentity), + getCredentials: jest.fn(), + }; + + beforeEach(() => { + window.crypto.randomUUID = mockRandomUUID; + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + window.localStorage.clear(); + }); + + it('instantiates', () => { + const api = VisitsStorageApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + expect(api).toBeTruthy(); + }); + + describe('.save()', () => { + it('saves a visit', async () => { + const api = VisitsStorageApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.save({ visit }); + expect(returnedVisit).toEqual(expect.objectContaining(visit)); + expect(returnedVisit.id).toBeTruthy(); + expect(returnedVisit.timestamp).toBeTruthy(); + expect(returnedVisit.hits).toBeTruthy(); + }); + + it('can control the number of stored entities', async () => { + const api = VisitsStorageApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + limit: 2, + }); + const baseDate = Date.now(); + const visit1 = { + pathname: '/catalog/default/component/playback-order-1', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate); + await api.save({ visit: visit1 }); + const visit2 = { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate + 360_000); + await api.save({ visit: visit2 }); + const visit3 = { + pathname: '/catalog/default/component/playback-order-3', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate + 360_000 * 2); + await api.save({ visit: visit3 }); + const visits = await api.list(); + expect(visits).toHaveLength(2); + expect(visits).toContainEqual(expect.objectContaining(visit2)); + expect(visits).toContainEqual(expect.objectContaining(visit3)); + }); + + it('correctly bumps the hits from a previous visit', async () => { + const api = VisitsStorageApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const visit1 = await api.save({ visit }); + const visit2 = await api.save({ visit }); + const visits = await api.list(); + expect(visits).toHaveLength(1); + expect(visits).toContainEqual(expect.objectContaining(visit)); + // keeps the original id created on the first visit + expect(visits).toContainEqual(expect.objectContaining({ id: visit1.id })); + // updates timestamp and hits + expect(visits).toContainEqual( + expect.objectContaining({ timestamp: visit2.timestamp, hits: 2 }), + ); + }); + }); + + describe('.list()', () => { + let api: VisitsApi; + let visitsToSave: Array>; + let baseDate: number; + + beforeEach(() => { + api = VisitsStorageApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + visitsToSave = [ + { + pathname: '/catalog/default/component/playback-order-1', + entityRef: 'component:default/playback-order-1', + name: 'Playback Order Odd', + }, + { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order-2', + name: 'Playback Order Even', + }, + { + pathname: '/catalog/default/component/playback-order-3', + entityRef: 'component:default/playback-order-3', + name: 'Playback Order Odd', + }, + ]; + baseDate = Date.now(); + // Chaining items to ensure the right setSystemTime + return visitsToSave.reduce( + (acc, visit, index) => + acc.then(() => { + jest.setSystemTime(baseDate + 360_000 * index); + return api.save({ visit }); + }), + Promise.resolve({}), + ); + }); + + it('retrieves visits', async () => { + const visits = await api.list(); + expect(visits).toHaveLength(3); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by timestamp asc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'timestamp', direction: 'asc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[2]), + ]); + }); + + it('orders by timestamp desc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'timestamp', direction: 'desc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by entityRef asc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'entityRef', direction: 'asc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[2]), + ]); + }); + + it('orders by entityRef desc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'entityRef', direction: 'desc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by name asc then by entityRef asc', async () => { + const visits = await api.list({ + orderBy: [ + { field: 'name', direction: 'asc' }, + { field: 'entityRef', direction: 'asc' }, + ], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 + expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 + expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 + ]); + }); + + it('orders by name desc then by entityRef asc', async () => { + const visits = await api.list({ + orderBy: [ + { field: 'name', direction: 'desc' }, + { field: 'entityRef', direction: 'asc' }, + ], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 + expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 + expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 + ]); + }); + + it('filters by timestamp with >', async () => { + const visits = await api.list({ + filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + ]); + }); + + it('filters by timestamp with >=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[2])]); + }); + + it('filters by timestamp with <', async () => { + const visits = await api.list({ + filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); + }); + + it('filters by timestamp with <=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('filters by timestamp with ==', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '==', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); + }); + + it('filters by timestamp with !=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '!=', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('filters by entityRef with contains', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'entityRef', operator: 'contains', value: 'order-2' }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); + }); + + it('filters by timestamp with <= then by name with contains', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, + { field: 'name', operator: 'contains', value: 'Odd' }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); + }); + }); +}); diff --git a/plugins/home/src/api/VisitsStorageApi.ts b/plugins/home/src/api/VisitsStorageApi.ts new file mode 100644 index 0000000000..daf24d716b --- /dev/null +++ b/plugins/home/src/api/VisitsStorageApi.ts @@ -0,0 +1,150 @@ +/* + * 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 { IdentityApi, StorageApi } from '@backstage/core-plugin-api'; +import { + Visit, + VisitsApi, + VisitsApiQueryParams, + VisitsApiSaveParams, +} from './VisitsApi'; + +/** @public */ +export type VisitsStorageApiOptions = { + limit?: number; + storageApi: StorageApi; + identityApi: IdentityApi; +}; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +/** + * @public + * This is an implementation of VisitsApi that relies on a StorageApi. + * Beware that filtering and ordering are done in memory therefore it is + * prudent to keep limit to a reasonable size. + */ +export class VisitsStorageApi implements VisitsApi { + private readonly limit: number; + private readonly storageApi: StorageApi; + private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; + private readonly identityApi: IdentityApi; + + static create(options: VisitsStorageApiOptions) { + return new VisitsStorageApi(options); + } + + private constructor(options: VisitsStorageApiOptions) { + this.limit = Math.abs(options.limit ?? 100); + this.storageApi = options.storageApi; + this.identityApi = options.identityApi; + } + + /** + * Returns a list of visits through the visitsApi + */ + async list(queryParams?: VisitsApiQueryParams): Promise { + let visits = [...(await this.retrieveAll())]; + + // reversing order to guarantee orderBy priority + (queryParams?.orderBy ?? []).reverse().forEach(order => { + if (order.direction === 'asc') { + visits.sort((a, b) => this.compare(order, a, b)); + } else { + visits.sort((a, b) => this.compare(order, b, a)); + } + }); + + // reversing order to guarantee filterBy priority + (queryParams?.filterBy ?? []).reverse().forEach(filter => { + visits = visits.filter(visit => { + const field = visit[filter.field] as number | string; + if (filter.operator === '>') return field > filter.value; + if (filter.operator === '>=') return field >= filter.value; + if (filter.operator === '<') return field < filter.value; + if (filter.operator === '<=') return field <= filter.value; + if (filter.operator === '==') return field === filter.value; + if (filter.operator === '!=') return field !== filter.value; + if (filter.operator === 'contains') + return `${field}`.includes(`${filter.value}`); + return false; + }); + }); + + return visits; + } + + /** + * Saves a visit through the visitsApi + */ + async save(saveParams: VisitsApiSaveParams): Promise { + const visits: Visit[] = [...(await this.retrieveAll())]; + + const visit: Visit = { + ...saveParams.visit, + 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); + if (visitIndex >= 0) { + visit.id = visits[visitIndex].id; + visit.hits = visits[visitIndex].hits + 1; + visits[visitIndex] = visit; + } else { + visits.push(visit); + } + + // 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; + } + + private async persistAll(visits: Array) { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + + return this.storageApi.set>(storageKey, visits); + } + + private async retrieveAll(): Promise> { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + let visits: Array; + + try { + visits = this.storageApi.snapshot>(storageKey).value ?? []; + } catch { + visits = []; + } + return visits; + } + + // This assumes Visit fields are either numbers or strings + private compare( + order: ArrayElement, + a: Visit, + b: Visit, + ): number { + const isNumber = typeof a[order.field] === 'number'; + return isNumber + ? (a[order.field] as number) - (b[order.field] as number) + : `${a[order.field]}`.localeCompare(`${b[order.field]}`); + } +} diff --git a/plugins/home/src/api/VisitsWebStorageApi.test.ts b/plugins/home/src/api/VisitsWebStorageApi.test.ts new file mode 100644 index 0000000000..1cf1fa3129 --- /dev/null +++ b/plugins/home/src/api/VisitsWebStorageApi.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; +import { VisitsWebStorageApi } from './VisitsWebStorageApi'; + +describe('VisitsWebStorageApi.create()', () => { + const mockRandomUUID = () => + '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( + /x/g, + () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf + ) as `${string}-${string}-${string}-${string}-${string}`; + + const mockIdentityApi: IdentityApi = { + signOut: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: async () => + ({ userEntityRef: 'user:default/guest' } as BackstageUserIdentity), + getCredentials: jest.fn(), + }; + + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + + beforeEach(() => { + window.crypto.randomUUID = mockRandomUUID; + }); + + afterEach(() => { + window.localStorage.clear(); + jest.resetAllMocks(); + }); + + it('instantiates with only identitiyApi', () => { + const api = VisitsWebStorageApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); + expect(api).toBeTruthy(); + }); + + it('saves a visit', async () => { + const api = VisitsWebStorageApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.save({ visit }); + expect(returnedVisit).toEqual(expect.objectContaining(visit)); + expect(returnedVisit.id).toBeTruthy(); + expect(returnedVisit.timestamp).toBeTruthy(); + expect(returnedVisit.hits).toBeTruthy(); + }); + + it('retrieves visits', async () => { + const api = VisitsWebStorageApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.save({ visit }); + const visits = await api.list(); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visit)]); + expect(visits).toEqual([returnedVisit]); + }); +}); diff --git a/plugins/home/src/api/VisitsWebStorageApi.ts b/plugins/home/src/api/VisitsWebStorageApi.ts new file mode 100644 index 0000000000..56b3ae53c5 --- /dev/null +++ b/plugins/home/src/api/VisitsWebStorageApi.ts @@ -0,0 +1,39 @@ +/* + * 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 { ErrorApi, IdentityApi } from '@backstage/core-plugin-api'; +import { VisitsStorageApi } from './VisitsStorageApi'; +import { WebStorage } from '@backstage/core-app-api'; + +/** @public */ +export type VisitsWebStorageApiOptions = { + limit?: number; + identityApi: IdentityApi; + errorApi: ErrorApi; +}; + +/** + * @public + * This is a reference implementation of VisitsApi using WebStorage. + */ +export class VisitsWebStorageApi { + static create(options: VisitsWebStorageApiOptions) { + return VisitsStorageApi.create({ + limit: options.limit, + identityApi: options.identityApi, + storageApi: WebStorage.create({ errorApi: options.errorApi }), + }); + } +} diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts new file mode 100644 index 0000000000..944fa65330 --- /dev/null +++ b/plugins/home/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export * from './VisitsStorageApi'; +export * from './VisitsWebStorageApi'; +export * from './VisitsApi'; diff --git a/plugins/home/src/components/VisitList/ItemCategory.tsx b/plugins/home/src/components/VisitList/ItemCategory.tsx new file mode 100644 index 0000000000..0f9636ce31 --- /dev/null +++ b/plugins/home/src/components/VisitList/ItemCategory.tsx @@ -0,0 +1,79 @@ +/* + * 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 { Chip, makeStyles } from '@material-ui/core'; +import { colorVariants } from '@backstage/theme'; +import { Visit } from '../../api/VisitsApi'; +import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; + +const useStyles = makeStyles(theme => ({ + chip: { + color: theme.palette.common.white, + fontWeight: 'bold', + margin: 0, + }, +})); +const maybeEntity = (visit: Visit): CompoundEntityRef | undefined => { + try { + return parseEntityRef(visit?.entityRef ?? ''); + } catch (e) { + return undefined; + } +}; +const getColorByIndex = (index: number) => { + const variants = Object.keys(colorVariants); + const variantIndex = index % variants.length; + return colorVariants[variants[variantIndex]][0]; +}; +const getChipColor = (entity: CompoundEntityRef | undefined): string => { + const defaultColor = getColorByIndex(0); + if (!entity) return defaultColor; + + // IDEA: Use or replicate useAllKinds hook thus supporting all software catalog + // registered kinds. See: + // plugins/catalog-react/src/components/EntityKindPicker/kindFilterUtils.ts + // Provide extension point to register your own color code. + const entityKinds = [ + 'component', + 'template', + 'api', + 'group', + 'user', + 'resource', + 'system', + 'domain', + 'location', + ]; + const foundIndex = entityKinds.indexOf( + entity.kind.toLocaleLowerCase('en-US'), + ); + return foundIndex === -1 ? defaultColor : getColorByIndex(foundIndex + 1); +}; + +export const ItemCategory = ({ visit }: { visit: Visit }) => { + const classes = useStyles(); + const entity = maybeEntity(visit); + + return ( + + ); +}; diff --git a/plugins/home/src/components/VisitList/ItemDetail.tsx b/plugins/home/src/components/VisitList/ItemDetail.tsx new file mode 100644 index 0000000000..3f55557f73 --- /dev/null +++ b/plugins/home/src/components/VisitList/ItemDetail.tsx @@ -0,0 +1,58 @@ +/* + * 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 { Typography } from '@material-ui/core'; +import { Visit } from '../../api/VisitsApi'; +import { DateTime } from 'luxon'; + +const ItemDetailHits = ({ visit }: { visit: Visit }) => ( + + {visit.hits} time{visit.hits > 1 ? 's' : ''} + +); + +const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => { + const visitDate = DateTime.fromMillis(visit.timestamp); + + return ( + + {visitDate >= DateTime.now().startOf('day') + ? visitDate.toFormat('HH:mm') + : visitDate.toRelative()} + + ); +}; + +export type ItemDetailType = 'time-ago' | 'hits'; + +export const ItemDetail = ({ + visit, + type, +}: { + visit: Visit; + type: ItemDetailType; +}) => + type === 'time-ago' ? ( + + ) : ( + + ); diff --git a/plugins/home/src/components/VisitList/ItemName.tsx b/plugins/home/src/components/VisitList/ItemName.tsx new file mode 100644 index 0000000000..965ad80d5e --- /dev/null +++ b/plugins/home/src/components/VisitList/ItemName.tsx @@ -0,0 +1,41 @@ +/* + * 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 { Typography, makeStyles } from '@material-ui/core'; +import { Visit } from '../../api/VisitsApi'; +import { Link } from '@backstage/core-components'; + +const useStyles = makeStyles(_theme => ({ + name: { + marginLeft: '0.8rem', + marginRight: '0.8rem', + }, +})); +export const ItemName = ({ visit }: { visit: Visit }) => { + const classes = useStyles(); + + return ( + + {visit.name} + + ); +}; diff --git a/plugins/home/src/components/VisitList/VisitList.test.tsx b/plugins/home/src/components/VisitList/VisitList.test.tsx new file mode 100644 index 0000000000..077c428706 --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitList.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 { VisitList } from './VisitList'; +import { render } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; + +describe('', () => { + it('renders with mandatory parameters', async () => { + const { getByText } = await render( + , + ); + expect(getByText('My title')).toBeInTheDocument(); + }); + + it('renders skeleton when loading is true', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')).toHaveLength(8); + expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(16); + }); + + it('renders specified amount of items', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')).toHaveLength(2); + }); + + it('renders some items hidden', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')[0]).toBeVisible(); + expect(container.querySelectorAll('li')[1]).not.toBeVisible(); + }); + + it('renders all items when not collapsed', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')[0]).toBeVisible(); + expect(container.querySelectorAll('li')[1]).toBeVisible(); + }); + + it('renders visit with time-ago', async () => { + const { container, getByText } = await render( + + + , + , + ); + expect(container.querySelectorAll('li')).toHaveLength(1); + expect(getByText('Explore Backstage')).toBeInTheDocument(); + expect(getByText('1 day ago')).toBeInTheDocument(); + }); + + it('renders visit with hits', async () => { + const { container, getByText } = await render( + + + , + , + ); + expect(container.querySelectorAll('li')).toHaveLength(1); + expect(getByText('Explore Backstage')).toBeInTheDocument(); + expect(getByText('35 times')).toBeInTheDocument(); + }); + + it('renders text warning about few items', async () => { + const { getByText } = await render( + + + , + , + ); + expect( + getByText('The more pages you visit, the more pages will appear here.'), + ).toBeInTheDocument(); + }); + + it('renders text warning about no items', async () => { + const { getByText } = await render( + + , + , + ); + expect(getByText('There are no visits to show yet.')).toBeInTheDocument(); + }); +}); diff --git a/plugins/home/src/components/VisitList/VisitList.tsx b/plugins/home/src/components/VisitList/VisitList.tsx new file mode 100644 index 0000000000..f1dc855d9d --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitList.tsx @@ -0,0 +1,102 @@ +/* + * 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 { Collapse, List, Typography, makeStyles } from '@material-ui/core'; +import { Visit } from '../../api/VisitsApi'; +import { VisitListItem } from './VisitListItem'; +import { ItemDetailType } from './ItemDetail'; +import { VisitListEmpty } from './VisitListEmpty'; +import { VisitListFew } from './VisitListFew'; +import { VisitListSkeleton } from './VisitListSkeleton'; + +const useStyles = makeStyles(_theme => ({ + title: { + marginBottom: '2rem', + }, +})); + +export const VisitList = ({ + title, + detailType, + visits = [], + numVisitsOpen = 3, + numVisitsTotal = 8, + collapsed = true, + loading = false, +}: { + title: string; + detailType: ItemDetailType; + visits?: Visit[]; + numVisitsOpen?: number; + numVisitsTotal?: number; + collapsed?: boolean; + loading?: boolean; +}) => { + const classes = useStyles(); + + let listBody: React.ReactElement = <>; + if (loading) { + listBody = ( + + ); + } else if (visits.length === 0) { + listBody = ; + } else if (visits.length < numVisitsOpen) { + listBody = ( + <> + {visits.map((visit, index) => ( + + ))} + + + ); + } else { + listBody = ( + <> + {visits.slice(0, numVisitsOpen).map((visit, index) => ( + + ))} + {visits.length > numVisitsOpen && ( + + {visits.slice(numVisitsOpen, numVisitsTotal).map((visit, index) => ( + + ))} + + )} + + ); + } + + return ( + <> + + {title} + + + {listBody} + + + ); +}; diff --git a/plugins/home/src/components/VisitList/VisitListEmpty.tsx b/plugins/home/src/components/VisitList/VisitListEmpty.tsx new file mode 100644 index 0000000000..3996f0f863 --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitListEmpty.tsx @@ -0,0 +1,30 @@ +/* + * 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 { Typography } from '@material-ui/core'; + +export const VisitListEmpty = () => ( + <> + + There are no visits to show yet. + + + Once you start using Backstage, your visits will appear here as a quick + link to carry on where you left off. + + +); diff --git a/plugins/home/src/components/VisitList/VisitListFew.tsx b/plugins/home/src/components/VisitList/VisitListFew.tsx new file mode 100644 index 0000000000..c28f1af931 --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitListFew.tsx @@ -0,0 +1,26 @@ +/* + * 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 { Typography } from '@material-ui/core'; + +export const VisitListFew = () => ( + <> + + The more pages you visit, the more pages will appear here. + + +); diff --git a/plugins/home/src/components/VisitList/VisitListItem.tsx b/plugins/home/src/components/VisitList/VisitListItem.tsx new file mode 100644 index 0000000000..20087c884a --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitListItem.tsx @@ -0,0 +1,55 @@ +/* + * 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 { + ListItem, + ListItemAvatar, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { Visit } from '../../api/VisitsApi'; +import { ItemName } from './ItemName'; +import { ItemDetail, ItemDetailType } from './ItemDetail'; +import { ItemCategory } from './ItemCategory'; + +const useStyles = makeStyles(_theme => ({ + avatar: { + minWidth: 0, + }, +})); +export const VisitListItem = ({ + visit, + detailType, +}: { + visit: Visit; + detailType: ItemDetailType; +}) => { + const classes = useStyles(); + + return ( + + + + + } + secondary={} + disableTypography + /> + + ); +}; diff --git a/plugins/home/src/components/VisitList/VisitListSkeleton.tsx b/plugins/home/src/components/VisitList/VisitListSkeleton.tsx new file mode 100644 index 0000000000..94ba840d9d --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitListSkeleton.tsx @@ -0,0 +1,79 @@ +/* + * 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 { + Collapse, + ListItem, + ListItemAvatar, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; + +const useStyles = makeStyles(_theme => ({ + skeleton: { + borderRadius: 30, + }, +})); + +const VisitListItemSkeleton = () => { + const classes = useStyles(); + + return ( + + + + + } + disableTypography + /> + + ); +}; + +export const VisitListSkeleton = ({ + numVisitsOpen, + numVisitsTotal, + collapsed, +}: { + numVisitsOpen: number; + numVisitsTotal: number; + collapsed: boolean; +}) => ( + <> + {Array(numVisitsOpen) + .fill(null) + .map((_e, index) => ( + + ))} + {numVisitsTotal > numVisitsOpen && ( + + {Array(numVisitsTotal - numVisitsOpen) + .fill(null) + .map((_e, index) => ( + + ))} + + )} + +); diff --git a/plugins/home/src/components/VisitList/index.ts b/plugins/home/src/components/VisitList/index.ts new file mode 100644 index 0000000000..2d9513893b --- /dev/null +++ b/plugins/home/src/components/VisitList/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { VisitList } from './VisitList'; diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx new file mode 100644 index 0000000000..72c530071a --- /dev/null +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { VisitListener } from './VisitListener'; +import { waitFor } from '@testing-library/react'; + +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 = { + save: jest.fn(async () => visits[0]), + list: jest.fn(async () => visits), +}; + +describe('', () => { + afterEach(jest.resetAllMocks); + + it('registers a visit', async () => { + const pathname = '/catalog/default/component/playback-order'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1)); + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/playback-order', + name: 'playback-order', + }, + }); + }); + + 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 () => { + const pathname = '/catalog/default/component/playback-order'; + + const visitNameOverride = ({ pathname: path }: { pathname: string }) => + path; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/playback-order', + name: pathname, + }, + }), + ); + }); + + it('is able to override how entityRefs are defined', async () => { + const pathname = '/catalog/default/component/playback-order'; + + const toEntityRefOverride = ({ pathname: path }: { pathname: string }) => + path; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: pathname, + name: 'playback-order', + }, + }), + ); + }); +}); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx new file mode 100644 index 0000000000..866fd69885 --- /dev/null +++ b/plugins/home/src/components/VisitListener.tsx @@ -0,0 +1,111 @@ +/* + * 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, { useEffect } 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'; + +/** + * 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" + */ +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); + }; + +/** + * @internal + * This function returns an implementation of visitName which is responsible + * for receiving a pathname and returning a string (name). + */ +const getVisitName = + ({ rootPath = 'catalog', document = global.document } = {}) => + ({ pathname }: { pathname: string }) => { + // If it is a catalog entity, get the name from the path + const regex = new RegExp( + `^\/${rootPath}\/(?[^\/]+)\/(?[^\/]+)\/(?[^\/]+)`, + ); + let result = regex.exec(pathname); + if (result && result?.groups) return result.groups.name; + + // If it is a root pathname, get the name from there + result = /^\/(?[^\/]+)$/.exec(pathname); + if (result && result?.groups) return result.groups.name; + + // Fallback to document title + return 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 visitsApi = useApi(visitsApiRef); + const { pathname } = useLocation(); + const toEntityRefImpl = toEntityRef ?? getToEntityRef(); + const visitNameImpl = visitName ?? getVisitName(); + useEffect(() => { + // Wait for the browser to finish with paint with the assumption react + // has finished with dom reconciliation. + const requestId = requestAnimationFrame(() => { + visitsApi.save({ + visit: { + name: visitNameImpl({ pathname }), + pathname, + entityRef: toEntityRefImpl({ pathname }), + }, + }); + }); + return () => cancelAnimationFrame(requestId); + }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl]); + + 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/Actions.tsx b/plugins/home/src/homePageComponents/VisitedByType/Actions.tsx new file mode 100644 index 0000000000..45e67802c9 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/Actions.tsx @@ -0,0 +1,37 @@ +/* + * 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, { useCallback } from 'react'; +import { Button } from '@material-ui/core'; +import { useContext } from './Context'; + +export const Actions = () => { + const { collapsed, setCollapsed, visits, numVisitsOpen, loading } = + useContext(); + const onClick = useCallback( + () => setCollapsed(prevCollapsed => !prevCollapsed), + [setCollapsed], + ); + const label = collapsed ? 'View More' : 'View Less'; + + if (!loading && visits.length <= numVisitsOpen) return <>; + + return ( + + ); +}; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx new file mode 100644 index 0000000000..4442c319e6 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { Content } from './Content'; +import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import { visitsApiRef } from '../../api'; +import { ContextProvider } from './Context'; +import { waitFor } from '@testing-library/react'; + +const visits = [ + { + id: 'explore', + name: 'Explore Backstage', + pathname: '/explore', + hits: 35, + timestamp: Date.now() - 86400_000, + }, +]; + +const mockVisitsApi = { + save: async () => visits[0], + list: async () => visits, +}; + +describe('', () => { + it('renders', async () => { + const { getByText } = await renderInTestApp( + + + + + , + ); + expect(getByText('Recently Visited')).toBeInTheDocument(); + await waitFor(() => + expect(getByText('Explore Backstage')).toBeInTheDocument(), + ); + }); + + it('allows visits to be overridden', async () => { + const { getByText } = await renderInTestApp( + + + + + , + ); + expect(getByText('Recently Visited')).toBeInTheDocument(); + await waitFor(() => expect(getByText('Tech Radar')).toBeInTheDocument()); + }); + + it('allows loading to be overridden', async () => { + const { container } = await renderInTestApp( + + + + + , + ); + expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); + + it('allows number of items to be specified', async () => { + const { container } = await renderInTestApp( + + + + + , + ); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.querySelectorAll('li')[0]).toBeVisible(); + expect(container.querySelectorAll('li')[1]).not.toBeVisible(); + }); +}); + +describe('', () => { + it('renders', async () => { + const { getByText } = await renderInTestApp( + + + + + , + ); + expect(getByText('Top Visited')).toBeInTheDocument(); + await waitFor(() => + expect(getByText('Explore Backstage')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx new file mode 100644 index 0000000000..4e847b717f --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -0,0 +1,91 @@ +/* + * 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, { useEffect } from 'react'; +import { VisitedByType } from './VisitedByType'; +import { Visit, visitsApiRef } from '../../api/VisitsApi'; +import { ContextValueOnly, useContext } from './Context'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; + +/** @public */ +export type VisitedByTypeKind = 'recent' | 'top'; + +/** @public */ +export type VisitedByTypeProps = { + visits?: Array; + numVisitsOpen?: number; + numVisitsTotal?: number; + loading?: boolean; + kind: VisitedByTypeKind; +}; + +/** + * Display recently visited pages for the homepage + * @public + */ +export const Content = ({ + visits, + numVisitsOpen, + numVisitsTotal, + loading, + kind, +}: VisitedByTypeProps) => { + const { setContext, setVisits, setLoading } = useContext(); + // Allows behavior override from properties + useEffect(() => { + const context: Partial = {}; + context.kind = kind; + if (visits) { + context.visits = visits; + context.loading = false; + } else if (loading) { + context.loading = loading; + } + if (numVisitsOpen) context.numVisitsOpen = numVisitsOpen; + if (numVisitsTotal) context.numVisitsTotal = numVisitsTotal; + setContext(state => ({ ...state, ...context })); + }, [setContext, kind, visits, loading, numVisitsOpen, numVisitsTotal]); + + // Fetches data from visitsApi in case visits and loading are not provided + const visitsApi = useApi(visitsApiRef); + const { loading: reqLoading } = useAsync(async () => { + if (!visits && !loading && kind === 'recent') { + return await visitsApi + .list({ + limit: numVisitsTotal ?? 8, + orderBy: [{ field: 'timestamp', direction: 'desc' }], + }) + .then(setVisits); + } + if (!visits && !loading && kind === 'top') { + return await visitsApi + .list({ + limit: numVisitsTotal ?? 8, + orderBy: [{ field: 'hits', direction: 'desc' }], + }) + .then(setVisits); + } + return undefined; + }, [visitsApi, visits, loading, setVisits]); + useEffect(() => { + if (!loading) { + setLoading(reqLoading); + } + }, [loading, setLoading, reqLoading]); + + return ; +}; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Context.tsx b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx new file mode 100644 index 0000000000..cd316d8b75 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx @@ -0,0 +1,122 @@ +/* + * 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, { Dispatch, SetStateAction, createContext, useMemo } from 'react'; +import { Visit } from '../../api/VisitsApi'; +import { VisitedByTypeKind } from './Content'; + +export type ContextValueOnly = { + collapsed: boolean; + numVisitsOpen: number; + numVisitsTotal: number; + visits: Array; + loading: boolean; + kind: VisitedByTypeKind; +}; + +export type ContextValue = ContextValueOnly & { + setCollapsed: Dispatch>; + setNumVisitsOpen: Dispatch>; + setNumVisitsTotal: Dispatch>; + setVisits: Dispatch>>; + setLoading: Dispatch>; + setKind: Dispatch>; + setContext: Dispatch>; +}; + +const defaultContextValueOnly: ContextValueOnly = { + collapsed: true, + numVisitsOpen: 3, + numVisitsTotal: 8, + visits: [], + loading: true, + kind: 'recent', +}; + +export const defaultContextValue: ContextValue = { + ...defaultContextValueOnly, + setCollapsed: () => {}, + setNumVisitsOpen: () => {}, + setNumVisitsTotal: () => {}, + setVisits: () => {}, + setLoading: () => {}, + setKind: () => {}, + setContext: () => {}, +}; + +export const Context = createContext(defaultContextValue); + +const getFilteredSet = + ( + setContext: Dispatch>, + contextKey: keyof ContextValueOnly, + ) => + (e: SetStateAction) => + setContext(state => ({ + ...state, + [contextKey]: + typeof e === 'function' ? (e as Function)(state[contextKey]) : e, + })); + +export const ContextProvider = ({ children }: { children: JSX.Element }) => { + const [context, setContext] = React.useState( + defaultContextValueOnly, + ); + const { + setCollapsed, + setNumVisitsOpen, + setNumVisitsTotal, + setVisits, + setLoading, + setKind, + } = useMemo( + () => ({ + setCollapsed: getFilteredSet(setContext, 'collapsed'), + setNumVisitsOpen: getFilteredSet(setContext, 'numVisitsOpen'), + setNumVisitsTotal: getFilteredSet(setContext, 'numVisitsTotal'), + setVisits: getFilteredSet(setContext, 'visits'), + setLoading: getFilteredSet(setContext, 'loading'), + setKind: getFilteredSet(setContext, 'kind'), + }), + [setContext], + ); + + const value: ContextValue = { + ...context, + setContext, + setCollapsed, + setNumVisitsOpen, + setNumVisitsTotal, + setVisits, + setLoading, + setKind, + }; + + return {children}; +}; + +export const useContext = () => { + const value = React.useContext(Context); + + if (value === undefined) + throw new Error( + 'VisitedByType useContext found undefined ContextValue, could be missing', + ); + + return value; +}; + +export default Context; diff --git a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx new file mode 100644 index 0000000000..d20d5344ae --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx @@ -0,0 +1,207 @@ +/* + * 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, wrapInTestApp } from '@backstage/test-utils'; +import { ComponentType, PropsWithChildren } from 'react'; +import { Grid } from '@material-ui/core'; +import { homePlugin } from '../../plugin'; +import { Visit, visitsApiRef } from '../../api/VisitsApi'; +import { createCardExtension } from '@backstage/plugin-home-react'; +import { VisitedByTypeProps } from './Content'; + +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', + }, + { + id: 'audio-playback', + name: 'Audio Playback', + pathname: '/catalog/default/system/audio-playback', + hits: 25, + timestamp: Date.now() - 86400_000 * 3, + entityRef: 'System:default/audio-playback', + }, + { + id: 'team-a', + name: 'Team A', + pathname: '/catalog/default/group/team-a', + hits: 20, + timestamp: Date.now() - 86400_000 * 4, + entityRef: 'Group:default/team-a', + }, + { + id: 'playback-order', + name: 'Playback Order', + pathname: '/catalog/default/component/playback-order', + hits: 15, + timestamp: Date.now() - 86400_000 * 5, + entityRef: 'Component:default/playback-order', + }, + { + id: 'playback', + name: 'Playback', + pathname: '/catalog/default/domain/playback', + hits: 10, + timestamp: Date.now() - 86400_000 * 6, + entityRef: 'Domain:default/playback', + }, + { + id: 'hello-world', + name: 'Hello World gRPC', + pathname: '/catalog/default/api/hello-world', + hits: 1, + timestamp: Date.now() - 86400_000 * 7, + entityRef: 'API:default/hello-world', + }, +]; + +const HomePageVisitedByType = homePlugin.provide( + createCardExtension({ + name: 'HomePageTopVisited', + components: () => import('./'), + }), +); + +const mockVisitsApi = { + save: async () => visits[0], + list: async () => visits, +}; + +export default { + title: 'Plugins/Home/Components/VisitedByType', + decorators: [ + (Story: ComponentType>) => + wrapInTestApp( + + + , + ), + ], +}; + +export const RecentlyDefault = () => { + return ( + + + + ); +}; + +export const RecentlyEmpty = () => { + return ( + + + + ); +}; + +export const RecentlyFewItems = () => { + return ( + + + + ); +}; + +export const RecentlyMoreItems = () => { + return ( + + + + ); +}; + +export const RecentlyLoading = () => { + return ( + + + + ); +}; + +export const TopDefault = () => { + return ( + + + + ); +}; + +export const TopEmpty = () => { + return ( + + + + ); +}; + +export const TopFewItems = () => { + return ( + + + + ); +}; + +export const TopMoreItems = () => { + return ( + + + + ); +}; + +export const TopLoading = () => { + return ( + + + + ); +}; diff --git a/plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx b/plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx new file mode 100644 index 0000000000..a6896f4e91 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export { Actions } from './Actions'; +export { ContextProvider } from './Context'; +export type { VisitedByTypeProps, VisitedByTypeKind } from './Content'; +import React from 'react'; +import { Content, VisitedByTypeProps } from './Content'; + +const RecentlyVisitedContent = (props: Partial) => ( + +); + +export { RecentlyVisitedContent as Content }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx b/plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx new file mode 100644 index 0000000000..5a326abf51 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export { Actions } from './Actions'; +export { ContextProvider } from './Context'; +export type { VisitedByTypeProps, VisitedByTypeKind } from './Content'; +import React from 'react'; +import { Content, VisitedByTypeProps } from './Content'; + +const TopVisitedContent = (props: Partial) => ( + +); + +export { TopVisitedContent as Content }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx new file mode 100644 index 0000000000..9e363f55aa --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 { VisitedByType } from './VisitedByType'; +import { Context, defaultContextValue } from './Context'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; + +describe(' kind="top"', () => { + it('should render', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText('Top Visited')).toBeInTheDocument(); + }); + it('should display hits', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + await waitFor(() => expect(getByText('40 times')).toBeInTheDocument()); + }); +}); + +describe(' kind="recent"', () => { + it('should render', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText('Recently Visited')).toBeInTheDocument(); + }); + it('should display how long ago a visit happened', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + await waitFor(() => expect(getByText('1 day ago')).toBeInTheDocument()); + }); +}); diff --git a/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx new file mode 100644 index 0000000000..23d7f3fffd --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx @@ -0,0 +1,36 @@ +/* + * 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 { VisitList } from '../../components/VisitList'; +import { useContext } from './Context'; + +export const VisitedByType = () => { + const { collapsed, numVisitsOpen, numVisitsTotal, visits, loading, kind } = + useContext(); + + return ( + + ); +}; diff --git a/plugins/home/src/homePageComponents/VisitedByType/index.ts b/plugins/home/src/homePageComponents/VisitedByType/index.ts new file mode 100644 index 0000000000..f085007522 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { Content } from './Content'; +export { Actions } from './Actions'; +export { ContextProvider } from './Context'; +export type { VisitedByTypeProps, VisitedByTypeKind } from './Content'; diff --git a/plugins/home/src/homePageComponents/index.ts b/plugins/home/src/homePageComponents/index.ts index 36d2335afe..9149afa295 100644 --- a/plugins/home/src/homePageComponents/index.ts +++ b/plugins/home/src/homePageComponents/index.ts @@ -17,3 +17,4 @@ export type { ToolkitContentProps, Tool } from './Toolkit'; export type { ClockConfig } from './HeaderWorldClock'; export type { WelcomeTitleLanguageProps } from './WelcomeTitle'; +export type { VisitedByTypeProps, VisitedByTypeKind } from './VisitedByType'; diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index 63407c8531..a80f8bd93d 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -32,8 +32,11 @@ export { ComponentTab, WelcomeTitle, HeaderWorldClock, + HomePageTopVisited, + HomePageRecentlyVisited, } from './plugin'; export * from './components'; export * from './assets'; export * from './homePageComponents'; export * from './deprecated'; +export * from './api'; diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index 74a1c11390..ab8c46baa1 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -15,17 +15,32 @@ */ import { + createApiFactory, createComponentExtension, createPlugin, createRoutableExtension, + identityApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; import { createCardExtension } from '@backstage/plugin-home-react'; -import { ToolkitContentProps } from './homePageComponents'; +import { ToolkitContentProps, VisitedByTypeProps } from './homePageComponents'; import { rootRouteRef } from './routes'; +import { VisitsStorageApi, visitsApiRef } from './api'; /** @public */ export const homePlugin = createPlugin({ id: 'home', + apis: [ + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + }, + factory: ({ storageApi, identityApi }) => + VisitsStorageApi.create({ storageApi, identityApi }), + }), + ], routes: { root: rootRouteRef, }, @@ -172,3 +187,26 @@ export const HeaderWorldClock = homePlugin.provide( }, }), ); + +/** + * Display top visited pages for the homepage + * @public + */ +export const HomePageTopVisited = homePlugin.provide( + createCardExtension>({ + name: 'HomePageTopVisited', + components: () => import('./homePageComponents/VisitedByType/TopVisited'), + }), +); + +/** + * Display recently visited pages for the homepage + * @public + */ +export const HomePageRecentlyVisited = homePlugin.provide( + createCardExtension>({ + name: 'HomePageRecentlyVisited', + components: () => + import('./homePageComponents/VisitedByType/RecentlyVisited'), + }), +); diff --git a/yarn.lock b/yarn.lock index 9419064a2e..9a356d0351 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7395,6 +7395,7 @@ __metadata: "@backstage/plugin-home-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" + "@backstage/types": "workspace:^" "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 @@ -7405,10 +7406,12 @@ __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 lodash: ^4.17.21 + luxon: ^3.4.3 msw: ^1.0.0 react-grid-layout: ^1.3.4 react-resizable: ^3.0.4 @@ -31783,7 +31786,7 @@ __metadata: languageName: node linkType: hard -"luxon@npm:^3.0.0, luxon@npm:^3.3.0": +"luxon@npm:^3.0.0, luxon@npm:^3.3.0, luxon@npm:^3.4.3": version: 3.4.3 resolution: "luxon@npm:3.4.3" checksum: 3eade81506224d038ed24035a0cd0dd4887848d7eba9361dce9ad8ef81380596a68153240be3988721f9690c624fb449fcf8fd8c3fc0681a6a8496faf48e92a3