Merge remote-tracking branch 'origin/master' into feat/home-plugin-group-starred-entities-by-kind
This commit is contained in:
@@ -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],
|
||||
});
|
||||
@@ -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])]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user