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