feature(home-plugin): Create reference implementation (localStorage)

This is a reference implementation using local storage.

Signed-off-by: Renan Mendes Carvalho <aitherios@gmail.com>
This commit is contained in:
Renan Mendes Carvalho
2023-08-29 13:45:54 +02:00
committed by Camila Belo
parent a84cac67ca
commit 5d671e6220
8 changed files with 611 additions and 5 deletions
+41 -1
View File
@@ -161,6 +161,19 @@ export type LayoutConfiguration = {
resizable?: boolean;
};
// @public
export class LocalStorageVisitsApi extends VisitsApiFactory {
constructor({
localStorage,
randomUUID,
limit,
}?: {
localStorage?: Window['localStorage'];
randomUUID?: Window['crypto']['randomUUID'];
limit?: number;
});
}
// @public @deprecated (undocumented)
export type RendererProps = RendererProps_2;
@@ -232,7 +245,34 @@ export const VisitListener: ({
// @public
export interface VisitsApi {
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
}
// @public
export class VisitsApiFactory implements VisitsApi {
constructor({
randomUUID,
limit,
retrieveAll,
persistAll,
}: {
randomUUID: Window['crypto']['randomUUID'];
limit: number;
retrieveAll?: () => Promise<Array<Visit>>;
persistAll?: (visits: Array<Visit>) => Promise<void>;
});
// (undocumented)
protected readonly limit: number;
// (undocumented)
listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
// (undocumented)
protected persistAll: (visits: Array<Visit>) => Promise<void>;
// (undocumented)
protected readonly randomUUID: Window['crypto']['randomUUID'];
// (undocumented)
protected retrieveAll: () => Promise<Array<Visit>>;
// (undocumented)
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
}
@@ -0,0 +1,66 @@
/*
* 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 { LocalStorageVisitsApi } from './LocalStorageVisitsApi';
describe('new LocalStorageVisitsApi()', () => {
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}`;
beforeEach(() => {
window.crypto.randomUUID = mockRandomUUID;
});
afterEach(() => {
window.localStorage.clear();
});
it('instantiates with no configuration', () => {
const api = new LocalStorageVisitsApi();
expect(api).toBeTruthy();
});
it('saves a visit', async () => {
const api = new LocalStorageVisitsApi();
const visit = {
pathname: '/catalog/default/component/playback-order',
entityRef: 'component:default/playback-order',
name: 'Playback Order',
};
const returnedVisit = await api.saveVisit({ 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 = new LocalStorageVisitsApi();
const visit = {
pathname: '/catalog/default/component/playback-order',
entityRef: 'component:default/playback-order',
name: 'Playback Order',
};
const returnedVisit = await api.saveVisit({ visit });
const visits = await api.listVisits();
expect(visits).toHaveLength(1);
expect(visits).toEqual([expect.objectContaining(visit)]);
expect(visits).toEqual([returnedVisit]);
});
});
@@ -0,0 +1,54 @@
/*
* 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 { Visit } from './VisitsApi';
import { VisitsApiFactory } from './VisitsApiFactory';
/**
* @public
* This is a reference implementation of VisitsApi using window.localStorage.
*/
export class LocalStorageVisitsApi extends VisitsApiFactory {
private readonly localStorage: Window['localStorage'];
private readonly storageKey = '@backstage/plugin-home:visits';
constructor({
localStorage = window?.localStorage,
randomUUID = window?.crypto?.randomUUID,
limit = 100,
}: {
localStorage?: Window['localStorage'];
randomUUID?: Window['crypto']['randomUUID'];
limit?: number;
} = {}) {
super({ randomUUID, limit });
this.localStorage = localStorage;
this.retrieveAll = async (): Promise<Array<Visit>> => {
let visits: Array<Visit>;
try {
visits = JSON.parse(this.localStorage.getItem(this.storageKey) ?? '[]');
} catch {
visits = [];
}
return visits;
};
this.persistAll = async (visits: Array<Visit>) => {
this.localStorage.setItem(
this.storageKey,
JSON.stringify(visits.splice(0, this.limit)),
);
};
}
}
+2 -2
View File
@@ -108,10 +108,10 @@ export interface VisitsApi {
*/
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
/**
* Get the logged user visits.
* Get user visits.
* @param queryParams - optional search query params.
*/
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
}
/** @public */
@@ -0,0 +1,325 @@
/*
* 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 { Visit } from './VisitsApi';
import { VisitsApiFactory } from './VisitsApiFactory';
class MemoryVisitsApi extends VisitsApiFactory {
private visits: Array<Visit> = [];
constructor({
randomUUID = window?.crypto?.randomUUID,
limit = 100,
}: {
randomUUID?: Window['crypto']['randomUUID'];
limit?: number;
} = {}) {
super({ randomUUID, limit });
this.retrieveAll = async (): Promise<Array<Visit>> => {
let visits: Array<Visit>;
try {
visits = this.visits;
} catch {
visits = [];
}
return visits;
};
this.persistAll = async (visits: Array<Visit>) => {
this.visits = visits;
};
}
}
describe('new MemoryVisitsApi()', () => {
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}`;
beforeEach(() => {
jest.useFakeTimers();
window.crypto.randomUUID = mockRandomUUID;
});
afterEach(() => {
jest.resetAllMocks();
jest.useRealTimers();
window.localStorage.clear();
});
it('instantiates with no configuration', () => {
const api = new MemoryVisitsApi();
expect(api).toBeTruthy();
});
describe('.saveVisit()', () => {
it('saves a visit', async () => {
const api = new MemoryVisitsApi();
const visit = {
pathname: '/catalog/default/component/playback-order',
entityRef: 'component:default/playback-order',
name: 'Playback Order',
};
const returnedVisit = await api.saveVisit({ 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 = new MemoryVisitsApi({ 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.saveVisit({ 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.saveVisit({ 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.saveVisit({ visit: visit3 });
const visits = await api.listVisits();
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 = new MemoryVisitsApi();
const visit = {
pathname: '/catalog/default/component/playback-order',
entityRef: 'component:default/playback-order',
name: 'Playback Order',
};
const visit1 = await api.saveVisit({ visit });
const visit2 = await api.saveVisit({ visit });
const visits = await api.listVisits();
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('.listVisits()', () => {
let api: MemoryVisitsApi;
let visitsToSave: Array<Omit<Visit, 'id' | 'hits' | 'timestamp'>>;
let baseDate: number;
beforeEach(() => {
api = new MemoryVisitsApi();
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.saveVisit({ visit });
}),
Promise.resolve({}),
);
});
it('retrieves visits', async () => {
const visits = await api.listVisits();
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
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.listVisits({
filterBy: [
{ field: 'timestamp', operator: '==', value: baseDate + 360_000 },
],
});
expect(visits).toHaveLength(1);
expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]);
});
it('filters by entityRef with contains', async () => {
const visits = await api.listVisits({
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.listVisits({
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])]);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
/*
* 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 { Visit, VisitsApi, VisitsApiQueryParams, VisitsApiSaveParams } from './VisitsApi';
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
/**
* @public
* This helps the creation of VisitApi implementations. Important to note
* that it implements features like orderBy and filterBy on memory, therefore
* is intended to handle few visits. The default is 100.
* See LocalStorageVisitsApi for an usage example.
*/
export class VisitsApiFactory implements VisitsApi {
protected readonly randomUUID: Window['crypto']['randomUUID'];
protected readonly limit: number;
protected retrieveAll: () => Promise<Array<Visit>>;
protected persistAll: (visits: Array<Visit>) => Promise<void>;
constructor({
randomUUID = window?.crypto?.randomUUID,
limit = 100,
retrieveAll,
persistAll,
}: {
randomUUID: Window['crypto']['randomUUID'];
limit: number;
retrieveAll?: () => Promise<Array<Visit>>;
persistAll?: (visits: Array<Visit>) => Promise<void>;
}) {
this.randomUUID = randomUUID;
this.limit = Math.abs(limit);
this.retrieveAll = retrieveAll ?? (async () => []);
this.persistAll = persistAll ?? (async () => {});
}
async listVisits(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));
}
});
(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 === 'contains')
return `${field}`.includes(`${filter.value}`);
return false;
});
});
return visits;
}
async saveVisit(
saveParams: VisitsApiSaveParams,
): Promise<Visit> {
const visits = await this.retrieveAll();
const visit: Visit = {
...saveParams.visit,
id: this.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;
}
// 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]}`);
}
}
+2
View File
@@ -15,3 +15,5 @@
*/
export * from './VisitsApi';
export * from './LocalStorageVisitsApi';
export * from './VisitsApiFactory';
@@ -65,7 +65,7 @@ export const Content = ({
const { loading: reqLoading } = useAsync(async () => {
if (!visits && !loading && kind === 'recent') {
return await visitsApi
.listUserVisits({
.listVisits({
limit: numVisitsTotal ?? 8,
orderBy: [{ field: 'timestamp', direction: 'desc' }],
})
@@ -73,7 +73,7 @@ export const Content = ({
}
if (!visits && !loading && kind === 'top') {
return await visitsApi
.listUserVisits({
.listVisits({
limit: numVisitsTotal ?? 8,
orderBy: [{ field: 'hits', direction: 'desc' }],
})