From 5d671e622021a2484d93e6bd6754dc7088dce0ab Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 29 Aug 2023 13:45:54 +0200 Subject: [PATCH] feature(home-plugin): Create reference implementation (localStorage) This is a reference implementation using local storage. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 42 ++- .../src/api/LocalStorageVisitsApi.test.ts | 66 ++++ plugins/home/src/api/LocalStorageVisitsApi.ts | 54 +++ plugins/home/src/api/VisitsApi.ts | 4 +- plugins/home/src/api/VisitsApiFactory.test.ts | 325 ++++++++++++++++++ plugins/home/src/api/VisitsApiFactory.ts | 119 +++++++ plugins/home/src/api/index.ts | 2 + .../VisitedByType/Content.tsx | 4 +- 8 files changed, 611 insertions(+), 5 deletions(-) create mode 100644 plugins/home/src/api/LocalStorageVisitsApi.test.ts create mode 100644 plugins/home/src/api/LocalStorageVisitsApi.ts create mode 100644 plugins/home/src/api/VisitsApiFactory.test.ts create mode 100644 plugins/home/src/api/VisitsApiFactory.ts diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index a3c56dd7dd..56fa3ef281 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -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; + listVisits(queryParams?: VisitsApiQueryParams): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; +} + +// @public +export class VisitsApiFactory implements VisitsApi { + constructor({ + randomUUID, + limit, + retrieveAll, + persistAll, + }: { + randomUUID: Window['crypto']['randomUUID']; + limit: number; + retrieveAll?: () => Promise>; + persistAll?: (visits: Array) => Promise; + }); + // (undocumented) + protected readonly limit: number; + // (undocumented) + listVisits(queryParams?: VisitsApiQueryParams): Promise; + // (undocumented) + protected persistAll: (visits: Array) => Promise; + // (undocumented) + protected readonly randomUUID: Window['crypto']['randomUUID']; + // (undocumented) + protected retrieveAll: () => Promise>; + // (undocumented) saveVisit(saveParams: VisitsApiSaveParams): Promise; } diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts new file mode 100644 index 0000000000..8cf94e6c25 --- /dev/null +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -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]); + }); +}); diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts new file mode 100644 index 0000000000..5999930e3a --- /dev/null +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -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> => { + let visits: Array; + try { + visits = JSON.parse(this.localStorage.getItem(this.storageKey) ?? '[]'); + } catch { + visits = []; + } + return visits; + }; + this.persistAll = async (visits: Array) => { + this.localStorage.setItem( + this.storageKey, + JSON.stringify(visits.splice(0, this.limit)), + ); + }; + } +} diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index 4ffb37e8b6..ab53ea7f10 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -108,10 +108,10 @@ export interface VisitsApi { */ saveVisit(saveParams: VisitsApiSaveParams): Promise; /** - * Get the logged user visits. + * Get user visits. * @param queryParams - optional search query params. */ - listUserVisits(queryParams?: VisitsApiQueryParams): Promise; + listVisits(queryParams?: VisitsApiQueryParams): Promise; } /** @public */ diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts new file mode 100644 index 0000000000..3281c320b7 --- /dev/null +++ b/plugins/home/src/api/VisitsApiFactory.test.ts @@ -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 = []; + + constructor({ + randomUUID = window?.crypto?.randomUUID, + limit = 100, + }: { + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + } = {}) { + super({ randomUUID, limit }); + this.retrieveAll = async (): Promise> => { + let visits: Array; + try { + visits = this.visits; + } catch { + visits = []; + } + return visits; + }; + this.persistAll = async (visits: Array) => { + 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>; + 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])]); + }); + }); +}); diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts new file mode 100644 index 0000000000..ba3fe1fe3e --- /dev/null +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -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 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>; + protected persistAll: (visits: Array) => Promise; + + constructor({ + randomUUID = window?.crypto?.randomUUID, + limit = 100, + retrieveAll, + persistAll, + }: { + randomUUID: Window['crypto']['randomUUID']; + limit: number; + retrieveAll?: () => Promise>; + persistAll?: (visits: Array) => Promise; + }) { + this.randomUUID = randomUUID; + this.limit = Math.abs(limit); + this.retrieveAll = retrieveAll ?? (async () => []); + this.persistAll = persistAll ?? (async () => {}); + } + + async listVisits(queryParams?: VisitsApiQueryParams): Promise { + let visits = await this.retrieveAll(); + + // reversing order to guarantee orderBy priority + (queryParams?.orderBy ?? []).reverse().forEach(order => { + if (order.direction === 'asc') { + visits.sort((a, b) => this.compare(order, a, b)); + } else { + visits.sort((a, b) => this.compare(order, b, a)); + } + }); + + (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 { + 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, + a: Visit, + b: Visit, + ): number { + const isNumber = typeof a[order.field] === 'number'; + return isNumber + ? (a[order.field] as number) - (b[order.field] as number) + : `${a[order.field]}`.localeCompare(`${b[order.field]}`); + } +} diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts index 29a8fb5468..b90a14299c 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -15,3 +15,5 @@ */ export * from './VisitsApi'; +export * from './LocalStorageVisitsApi'; +export * from './VisitsApiFactory'; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx index 4eef2a87ad..8f492a2099 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -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' }], })