Merge remote-tracking branch 'origin/master' into feat/home-plugin-group-starred-entities-by-kind

This commit is contained in:
Antonio Bergas
2023-11-11 09:07:56 +01:00
2504 changed files with 121091 additions and 25877 deletions
+67
View File
@@ -0,0 +1,67 @@
/*
* 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 {
coreExtensionData,
createExtensionDataRef,
createExtensionInput,
createPageExtension,
createPlugin,
createRouteRef,
} from '@backstage/frontend-plugin-api';
const rootRouteRef = createRouteRef();
/**
* @alpha
*/
export const titleExtensionDataRef = createExtensionDataRef<string>('title');
const HomepageCompositionRootExtension = createPageExtension({
id: 'home',
defaultPath: '/home',
routeRef: rootRouteRef,
inputs: {
props: createExtensionInput(
{
children: coreExtensionData.reactElement.optional(),
title: titleExtensionDataRef.optional(),
},
{
singleton: true,
optional: true,
},
),
},
loader: ({ inputs }) =>
import('./components/').then(m => (
<m.HomepageCompositionRoot
children={inputs.props?.children}
title={inputs.props?.title}
/>
)),
});
/**
* @alpha
*/
export default createPlugin({
id: 'home',
extensions: [HomepageCompositionRootExtension],
});
+120
View File
@@ -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: <date> },
* { 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<Visit, 'id' | 'hits' | 'timestamp'>;
};
/**
* @public
* Visits API public contract.
*/
export interface VisitsApi {
/**
* Persist a new visit.
* @param pageVisit - a new visit data
*/
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
/**
* Get user visits.
* @param queryParams - optional search query params.
*/
list(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
}
/** @public */
export const visitsApiRef = createApiRef<VisitsApi>({
id: 'homepage.visits',
});
@@ -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<Omit<Visit, 'id' | 'hits' | 'timestamp'>>;
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])]);
});
});
});
+166
View File
@@ -0,0 +1,166 @@
/*
* 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> = 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<Visit[]> {
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<Visit> {
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<Visit>) {
const storageKey = await this.getStorageKey();
return this.storageApi.set<Array<Visit>>(storageKey, visits);
}
private async retrieveAll(): Promise<Array<Visit>> {
const storageKey = await this.getStorageKey();
// Handles for case when snapshot is and is not referenced per storaged type used
const snapshot = this.storageApi.snapshot<Array<Visit>>(storageKey);
if (snapshot?.presence !== 'unknown') {
return snapshot?.value ?? [];
}
return new Promise((resolve, reject) => {
const subsription = this.storageApi
.observe$<Visit[]>(storageKey)
.subscribe({
next: next => {
const visits = next.value ?? [];
subsription.unsubscribe();
resolve(visits);
},
error: err => {
subsription.unsubscribe();
reject(err);
},
});
});
}
private async getStorageKey(): Promise<string> {
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`;
return storageKey;
}
// This assumes Visit fields are either numbers or strings
private compare(
order: ArrayElement<VisitsApiQueryParams['orderBy']>,
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]}`);
}
}
@@ -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]);
});
});
@@ -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 }),
});
}
}
+19
View File
@@ -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';
@@ -27,10 +27,11 @@ import SettingsIcon from '@material-ui/icons/Settings';
import DeleteIcon from '@material-ui/icons/Delete';
import React from 'react';
import { Widget } from './types';
import { withTheme } from '@rjsf/core-v5';
import { withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import validator from '@rjsf/validator-ajv8';
const Form = withTheme(require('@rjsf/material-ui-v5').Theme);
const Form = withTheme(MuiTheme);
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -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 (
<Chip
size="small"
className={classes.chip}
label={(entity?.kind ?? 'Other').toLocaleLowerCase('en-US')}
style={{ background: getChipColor(entity) }}
/>
);
};
@@ -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 }) => (
<Typography component="span" variant="caption" color="textSecondary">
{visit.hits} time{visit.hits > 1 ? 's' : ''}
</Typography>
);
const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => {
const visitDate = DateTime.fromMillis(visit.timestamp);
return (
<Typography
component="time"
variant="caption"
color="textSecondary"
dateTime={visitDate.toISO() ?? undefined}
>
{visitDate >= DateTime.now().startOf('day')
? visitDate.toFormat('HH:mm')
: visitDate.toRelative()}
</Typography>
);
};
export type ItemDetailType = 'time-ago' | 'hits';
export const ItemDetail = ({
visit,
type,
}: {
visit: Visit;
type: ItemDetailType;
}) =>
type === 'time-ago' ? (
<ItemDetailTimeAgo visit={visit} />
) : (
<ItemDetailHits visit={visit} />
);
@@ -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 (
<Typography
component={Link}
to={visit.pathname}
noWrap
className={classes.name}
>
{visit.name}
</Typography>
);
};
@@ -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('<VisitList/>', () => {
it('renders with mandatory parameters', async () => {
const { getByText } = await render(
<VisitList title="My title" detailType="time-ago" />,
);
expect(getByText('My title')).toBeInTheDocument();
});
it('renders skeleton when loading is true', async () => {
const { container } = await render(
<VisitList title="My title" detailType="time-ago" loading />,
);
expect(container.querySelectorAll('li')).toHaveLength(8);
expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(16);
});
it('renders specified amount of items', async () => {
const { container } = await render(
<VisitList
title="My title"
detailType="time-ago"
loading
numVisitsOpen={1}
numVisitsTotal={2}
/>,
);
expect(container.querySelectorAll('li')).toHaveLength(2);
});
it('renders some items hidden', async () => {
const { container } = await render(
<VisitList
title="My title"
detailType="time-ago"
loading
numVisitsOpen={1}
numVisitsTotal={2}
/>,
);
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(
<VisitList
title="My title"
detailType="time-ago"
loading
collapsed={false}
numVisitsOpen={1}
numVisitsTotal={2}
/>,
);
expect(container.querySelectorAll('li')[0]).toBeVisible();
expect(container.querySelectorAll('li')[1]).toBeVisible();
});
it('renders visit with time-ago', async () => {
const { container, getByText } = await render(
<BrowserRouter>
<VisitList
title="My title"
detailType="time-ago"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
]}
/>
,
</BrowserRouter>,
);
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(
<BrowserRouter>
<VisitList
title="My title"
detailType="hits"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
]}
/>
,
</BrowserRouter>,
);
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(
<BrowserRouter>
<VisitList
title="My title"
detailType="hits"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
]}
/>
,
</BrowserRouter>,
);
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(
<BrowserRouter>
<VisitList title="My title" detailType="hits" visits={[]} />,
</BrowserRouter>,
);
expect(getByText('There are no visits to show yet.')).toBeInTheDocument();
});
});
@@ -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 = (
<VisitListSkeleton
numVisitsOpen={numVisitsOpen}
numVisitsTotal={numVisitsTotal}
collapsed={collapsed}
/>
);
} else if (visits.length === 0) {
listBody = <VisitListEmpty />;
} else if (visits.length < numVisitsOpen) {
listBody = (
<>
{visits.map((visit, index) => (
<VisitListItem visit={visit} key={index} detailType={detailType} />
))}
<VisitListFew />
</>
);
} else {
listBody = (
<>
{visits.slice(0, numVisitsOpen).map((visit, index) => (
<VisitListItem visit={visit} key={index} detailType={detailType} />
))}
{visits.length > numVisitsOpen && (
<Collapse in={!collapsed}>
{visits.slice(numVisitsOpen, numVisitsTotal).map((visit, index) => (
<VisitListItem
visit={visit}
key={index}
detailType={detailType}
/>
))}
</Collapse>
)}
</>
);
}
return (
<>
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
<List dense disablePadding>
{listBody}
</List>
</>
);
};
@@ -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 = () => (
<>
<Typography variant="body2" color="textSecondary">
There are no visits to show yet.
</Typography>
<Typography variant="body2" color="textSecondary">
Once you start using Backstage, your visits will appear here as a quick
link to carry on where you left off.
</Typography>
</>
);
@@ -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 = () => (
<>
<Typography variant="body2" color="textSecondary">
The more pages you visit, the more pages will appear here.
</Typography>
</>
);
@@ -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 (
<ListItem disableGutters>
<ListItemAvatar className={classes.avatar}>
<ItemCategory visit={visit} />
</ListItemAvatar>
<ListItemText
primary={<ItemName visit={visit} />}
secondary={<ItemDetail visit={visit} type={detailType} />}
disableTypography
/>
</ListItem>
);
};
@@ -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 (
<ListItem disableGutters>
<ListItemAvatar>
<Skeleton
className={classes.skeleton}
variant="rect"
width={50}
height={24}
/>
</ListItemAvatar>
<ListItemText
primary={<Skeleton variant="text" width="100%" height={28} />}
disableTypography
/>
</ListItem>
);
};
export const VisitListSkeleton = ({
numVisitsOpen,
numVisitsTotal,
collapsed,
}: {
numVisitsOpen: number;
numVisitsTotal: number;
collapsed: boolean;
}) => (
<>
{Array(numVisitsOpen)
.fill(null)
.map((_e, index) => (
<VisitListItemSkeleton key={index} />
))}
{numVisitsTotal > numVisitsOpen && (
<Collapse in={!collapsed}>
{Array(numVisitsTotal - numVisitsOpen)
.fill(null)
.map((_e, index) => (
<VisitListItemSkeleton key={index} />
))}
</Collapse>
)}
</>
);
@@ -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';
@@ -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<Visit> = [
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000 * 1,
},
{
id: 'user-1',
name: 'Guest',
pathname: '/catalog/default/user/guest',
hits: 30,
timestamp: Date.now() - 86400_000 * 2,
entityRef: 'User:default/guest',
},
];
const mockVisitsApi = {
save: jest.fn(async () => visits[0]),
list: jest.fn(async () => visits),
};
describe('<VisitListener/>', () => {
afterEach(jest.resetAllMocks);
it('registers a visit', async () => {
const pathname = '/catalog/default/component/playback-order';
await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener>
<div data-testid="child">child</div>
</VisitListener>
</TestApiProvider>,
);
expect(getByTestId('child')).toBeTruthy();
});
it('is able to override how visit names are defined', async () => {
const pathname = '/catalog/default/component/playback-order';
const visitNameOverride = ({ pathname: path }: { pathname: string }) =>
path;
await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener visitName={visitNameOverride} />
</TestApiProvider>,
{ routeEntries: [pathname] },
);
await waitFor(() =>
expect(mockVisitsApi.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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener toEntityRef={toEntityRefOverride} />
</TestApiProvider>,
{ routeEntries: [pathname] },
);
await waitFor(() =>
expect(mockVisitsApi.save).toHaveBeenCalledWith({
visit: {
pathname,
entityRef: pathname,
name: 'playback-order',
},
}),
);
});
});
@@ -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}\/(?<namespace>[^\/]+)\/(?<kind>[^\/]+)\/(?<name>[^\/]+)`,
);
const result = regex.exec(pathname);
if (!result || !result?.groups) return undefined;
const entity = {
namespace: result.groups.namespace,
kind: result.groups.kind,
name: result.groups.name,
};
return stringifyEntityRefImpl(entity);
};
/**
* @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}\/(?<namespace>[^\/]+)\/(?<kind>[^\/]+)\/(?<name>[^\/]+)`,
);
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 = /^\/(?<name>[^\/]+)$/.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}</>;
};
+1
View File
@@ -16,3 +16,4 @@
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
export * from './CustomHomepage';
export * from './VisitListener';
@@ -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 (
<Button variant="text" onClick={onClick}>
{label}
</Button>
);
};
@@ -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('<Content kind="recent"/>', () => {
it('renders', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content kind="recent" />
</ContextProvider>
</TestApiProvider>,
);
expect(getByText('Recently Visited')).toBeInTheDocument();
await waitFor(() =>
expect(getByText('Explore Backstage')).toBeInTheDocument(),
);
});
it('allows visits to be overridden', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content
kind="recent"
visits={[
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
]}
/>
</ContextProvider>
</TestApiProvider>,
);
expect(getByText('Recently Visited')).toBeInTheDocument();
await waitFor(() => expect(getByText('Tech Radar')).toBeInTheDocument());
});
it('allows loading to be overridden', async () => {
const { container } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content kind="recent" loading />
</ContextProvider>
</TestApiProvider>,
);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
});
it('allows number of items to be specified', async () => {
const { container } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content
kind="recent"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
]}
numVisitsOpen={1}
numVisitsTotal={2}
/>
</ContextProvider>
</TestApiProvider>,
);
expect(container.querySelectorAll('li')).toHaveLength(2);
expect(container.querySelectorAll('li')[0]).toBeVisible();
expect(container.querySelectorAll('li')[1]).not.toBeVisible();
});
});
describe('<Content kind="top"/>', () => {
it('renders', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content kind="top" />
</ContextProvider>
</TestApiProvider>,
);
expect(getByText('Top Visited')).toBeInTheDocument();
await waitFor(() =>
expect(getByText('Explore Backstage')).toBeInTheDocument(),
);
});
});
@@ -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<Visit>;
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<ContextValueOnly> = {};
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 <VisitedByType />;
};
@@ -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<Visit>;
loading: boolean;
kind: VisitedByTypeKind;
};
export type ContextValue = ContextValueOnly & {
setCollapsed: Dispatch<SetStateAction<boolean>>;
setNumVisitsOpen: Dispatch<SetStateAction<number>>;
setNumVisitsTotal: Dispatch<SetStateAction<number>>;
setVisits: Dispatch<SetStateAction<Array<Visit>>>;
setLoading: Dispatch<SetStateAction<boolean>>;
setKind: Dispatch<SetStateAction<VisitedByTypeKind>>;
setContext: Dispatch<SetStateAction<ContextValueOnly>>;
};
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<ContextValue>(defaultContextValue);
const getFilteredSet =
<T,>(
setContext: Dispatch<SetStateAction<ContextValueOnly>>,
contextKey: keyof ContextValueOnly,
) =>
(e: SetStateAction<T>) =>
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<ContextValueOnly>(
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 <Context.Provider value={value}>{children}</Context.Provider>;
};
export const useContext = () => {
const value = React.useContext(Context);
if (value === undefined)
throw new Error(
'VisitedByType useContext found undefined ContextValue, <ContextProvider/> could be missing',
);
return value;
};
export default Context;
@@ -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<Visit> = [
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000 * 1,
},
{
id: 'user-1',
name: 'Guest',
pathname: '/catalog/default/user/guest',
hits: 30,
timestamp: Date.now() - 86400_000 * 2,
entityRef: 'User:default/guest',
},
{
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<VisitedByTypeProps>({
name: 'HomePageTopVisited',
components: () => import('./'),
}),
);
const mockVisitsApi = {
save: async () => visits[0],
list: async () => visits,
};
export default {
title: 'Plugins/Home/Components/VisitedByType',
decorators: [
(Story: ComponentType<PropsWithChildren<{}>>) =>
wrapInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<Story />
</TestApiProvider>,
),
],
};
export const RecentlyDefault = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="recent" />
</Grid>
);
};
export const RecentlyEmpty = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="recent" visits={[]} />
</Grid>
);
};
export const RecentlyFewItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="recent" visits={visits.slice(0, 1)} />
</Grid>
);
};
export const RecentlyMoreItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType
kind="recent"
numVisitsOpen={5}
numVisitsTotal={6}
/>
</Grid>
);
};
export const RecentlyLoading = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType
kind="recent"
numVisitsOpen={5}
numVisitsTotal={6}
loading
/>
</Grid>
);
};
export const TopDefault = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" />
</Grid>
);
};
export const TopEmpty = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" visits={[]} />
</Grid>
);
};
export const TopFewItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" visits={visits.slice(0, 1)} />
</Grid>
);
};
export const TopMoreItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" numVisitsOpen={5} numVisitsTotal={6} />
</Grid>
);
};
export const TopLoading = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType
kind="top"
numVisitsOpen={5}
numVisitsTotal={6}
loading
/>
</Grid>
);
};
@@ -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<VisitedByTypeProps>) => (
<Content {...props} kind="recent" />
);
export { RecentlyVisitedContent as Content };
@@ -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<VisitedByTypeProps>) => (
<Content {...props} kind="top" />
);
export { TopVisitedContent as Content };
@@ -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('<VisitedByType/> kind="top"', () => {
it('should render', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider value={{ ...defaultContextValue, kind: 'top' }}>
<VisitedByType />
</Context.Provider>,
);
expect(getByText('Top Visited')).toBeInTheDocument();
});
it('should display hits', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider
value={{
...defaultContextValue,
kind: 'top',
loading: false,
visits: [
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
],
}}
>
<VisitedByType />
</Context.Provider>,
);
await waitFor(() => expect(getByText('40 times')).toBeInTheDocument());
});
});
describe('<VisitedByType/> kind="recent"', () => {
it('should render', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider value={{ ...defaultContextValue, kind: 'recent' }}>
<VisitedByType />
</Context.Provider>,
);
expect(getByText('Recently Visited')).toBeInTheDocument();
});
it('should display how long ago a visit happened', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider
value={{
...defaultContextValue,
kind: 'recent',
loading: false,
visits: [
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 86400_000,
},
],
}}
>
<VisitedByType />
</Context.Provider>,
);
await waitFor(() => expect(getByText('1 day ago')).toBeInTheDocument());
});
});
@@ -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 (
<VisitList
visits={visits}
title={kind === 'top' ? 'Top Visited' : 'Recently Visited'}
detailType={kind === 'top' ? 'hits' : 'time-ago'}
collapsed={collapsed}
numVisitsOpen={numVisitsOpen}
numVisitsTotal={numVisitsTotal}
loading={loading}
/>
);
};
@@ -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';
@@ -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';
+3
View File
@@ -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';
+9
View File
@@ -13,10 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import packageJson from '../package.json';
import { homePlugin } from './plugin';
describe('home', () => {
it('should export plugin', () => {
expect(homePlugin).toBeDefined();
});
// Temporarily ensure we are installing a working version of the react-grid-layout library
// For more details, see: https://github.com/react-grid-layout/react-grid-layout/issues/1959
// TODO(@backstage/discoverability-maintainers): Delete this once the sub-dependency issue has been resolved.
it('should pin react-grid-layout version to 1.3.4', async () => {
expect(packageJson.dependencies['react-grid-layout']).toBe('1.3.4');
});
});
+39 -1
View File
@@ -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<Partial<VisitedByTypeProps>>({
name: 'HomePageTopVisited',
components: () => import('./homePageComponents/VisitedByType/TopVisited'),
}),
);
/**
* Display recently visited pages for the homepage
* @public
*/
export const HomePageRecentlyVisited = homePlugin.provide(
createCardExtension<Partial<VisitedByTypeProps>>({
name: 'HomePageRecentlyVisited',
components: () =>
import('./homePageComponents/VisitedByType/RecentlyVisited'),
}),
);