From baa8af646ca83c42d87e2e5fa34821735a5b15cc Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 20 Sep 2023 14:03:20 +0200 Subject: [PATCH] refactor(plugins/home): Implementations depend only on StorageApi This patch removes the VisitsApiFactory, promotes CoreStorageVisitsApi to the main implementation and LocalStorageVisitsApi starts to rely on WebStorage. Signed-off-by: Renan Mendes Carvalho --- plugins/home/README.md | 7 +- plugins/home/api-report.md | 36 +- plugins/home/package.json | 2 +- .../home/src/api/CoreStorageVisitsApi.test.ts | 312 ++++++++++++++-- plugins/home/src/api/CoreStorageVisitsApi.ts | 128 +++++-- .../src/api/LocalStorageVisitsApi.test.ts | 17 +- plugins/home/src/api/LocalStorageVisitsApi.ts | 47 +-- plugins/home/src/api/VisitsApiFactory.test.ts | 336 ------------------ plugins/home/src/api/VisitsApiFactory.ts | 120 ------- plugins/home/src/api/index.ts | 1 - 10 files changed, 429 insertions(+), 577 deletions(-) delete mode 100644 plugins/home/src/api/VisitsApiFactory.test.ts delete mode 100644 plugins/home/src/api/VisitsApiFactory.ts diff --git a/plugins/home/README.md b/plugins/home/README.md index f6a223701b..af3b3a12f6 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -280,7 +280,7 @@ import { } from '@backstage/plugin-home'; // ... export const apis: AnyApiFactory[] = [ - // Implementation that relies on the integration with storageApi + // Implementation that relies on a provided storageApi createApiFactory({ api: visitsApiRef, deps: { @@ -291,13 +291,14 @@ export const apis: AnyApiFactory[] = [ CoreStorageVisitsApi.create({ storageApi, identityApi }), }), - // Or a local data implementation, relies on the browser's window.localStorage + // Or a local data implementation, relies on WebStorage implementation of storageApi createApiFactory({ api: visitsApiRef, deps: { identityApi: identityApiRef, + errorApi: errorApiRef }, - factory: ({ identityApi }) => LocalStoreVisitsApi.create({ identityApi }), + factory: ({ identityApi, errorApi }) => LocalStoreVisitsApi.create({ identityApi, errorApi }), }), // ... ``` diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index f0a38506ca..5da8f912a5 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -14,6 +14,7 @@ import { CardSettings as CardSettings_2 } from '@backstage/plugin-home-react'; import { ComponentParts as ComponentParts_2 } from '@backstage/plugin-home-react'; import { ComponentRenderer as ComponentRenderer_2 } from '@backstage/plugin-home-react'; import { createCardExtension as createCardExtension_2 } from '@backstage/plugin-home-react'; +import { ErrorApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; @@ -77,15 +78,17 @@ export const ComponentTabs: (props: { }) => JSX_2.Element; // @public -export class CoreStorageVisitsApi extends VisitsApiFactory { +export class CoreStorageVisitsApi implements VisitsApi { // (undocumented) static create(options: CoreStorageVisitsApiOptions): CoreStorageVisitsApi; + list(queryParams?: VisitsApiQueryParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; } // @public (undocumented) export type CoreStorageVisitsApiOptions = { - storageApi: StorageApi; limit?: number; + storageApi: StorageApi; identityApi: IdentityApi; }; @@ -177,15 +180,16 @@ export type LayoutConfiguration = { }; // @public -export class LocalStorageVisitsApi extends VisitsApiFactory { +export class LocalStorageVisitsApi { // (undocumented) - static create(options: LocalStorageVisitsApiOptions): LocalStorageVisitsApi; + static create(options: LocalStorageVisitsApiOptions): CoreStorageVisitsApi; } // @public (undocumented) export type LocalStorageVisitsApiOptions = { limit?: number; identityApi: IdentityApi; + errorApi: ErrorApi; }; // @public @deprecated (undocumented) @@ -263,30 +267,6 @@ export interface VisitsApi { save(saveParams: VisitsApiSaveParams): Promise; } -// @public -export class VisitsApiFactory implements VisitsApi { - protected constructor(options: VisitsApiFactoryOptions); - // (undocumented) - protected readonly limit: number; - // (undocumented) - list(queryParams?: VisitsApiQueryParams): Promise; - // (undocumented) - protected persistAll: (visits: Array) => Promise; - // (undocumented) - protected readonly randomUUID: () => `${string}-${string}-${string}-${string}-${string}`; - // (undocumented) - protected retrieveAll: () => Promise>; - // (undocumented) - save(saveParams: VisitsApiSaveParams): Promise; -} - -// @public (undocumented) -export type VisitsApiFactoryOptions = { - limit: number; - retrieveAll?: () => Promise>; - persistAll?: (visits: Array) => Promise; -}; - // @public export type VisitsApiQueryParams = { limit?: number; diff --git a/plugins/home/package.json b/plugins/home/package.json index 6a9c5306c1..80fb8496b1 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -36,6 +36,7 @@ "dependencies": { "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", @@ -64,7 +65,6 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^8.0.0", diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/CoreStorageVisitsApi.test.ts index e1f39d8464..9118b2546b 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.test.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.test.ts @@ -17,8 +17,9 @@ import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; import { MockStorageApi } from '@backstage/test-utils'; +import { Visit, VisitsApi } from './VisitsApi'; -describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', () => { +describe('CoreStorageVisitsApi.create', () => { const mockRandomUUID = () => '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( /x/g, @@ -35,9 +36,12 @@ describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', beforeEach(() => { window.crypto.randomUUID = mockRandomUUID; + jest.useFakeTimers(); }); afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); window.localStorage.clear(); }); @@ -49,37 +53,287 @@ describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', expect(api).toBeTruthy(); }); - it('saves a visit', async () => { - const api = CoreStorageVisitsApi.create({ - storageApi: MockStorageApi.create(), - identityApi: mockIdentityApi, + describe('.save()', () => { + it('saves a visit', async () => { + const api = CoreStorageVisitsApi.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 = CoreStorageVisitsApi.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 = CoreStorageVisitsApi.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 }), + ); }); - 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 = CoreStorageVisitsApi.create({ - storageApi: MockStorageApi.create(), - identityApi: mockIdentityApi, + describe('.list()', () => { + let api: VisitsApi; + let visitsToSave: Array>; + let baseDate: number; + + beforeEach(() => { + api = CoreStorageVisitsApi.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])]); }); - const visit = { - pathname: '/catalog/default/component/playback-order', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - const returnedVisit = await api.save({ visit }); - const visits = await api.list(); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visit)]); - expect(visits).toEqual([returnedVisit]); }); }); diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts index 18cdaca90f..2707a50b87 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -14,21 +14,31 @@ * limitations under the License. */ import { IdentityApi, StorageApi } from '@backstage/core-plugin-api'; -import { Visit } from './VisitsApi'; -import { VisitsApiFactory } from './VisitsApiFactory'; +import { + Visit, + VisitsApi, + VisitsApiQueryParams, + VisitsApiSaveParams, +} from './VisitsApi'; /** @public */ export type CoreStorageVisitsApiOptions = { - storageApi: StorageApi; limit?: number; + storageApi: StorageApi; identityApi: IdentityApi; }; +type ArrayElement = A extends readonly (infer T)[] ? T : never; + /** * @public - * This is an implementation of VisitsApi that relies on a StorageApi + * 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 CoreStorageVisitsApi extends VisitsApiFactory { +export class CoreStorageVisitsApi implements VisitsApi { + private readonly randomUUID = window.crypto.randomUUID; + private readonly limit: number; private readonly storageApi: StorageApi; private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; @@ -38,26 +48,104 @@ export class CoreStorageVisitsApi extends VisitsApiFactory { } private constructor(options: CoreStorageVisitsApiOptions) { - super({ limit: options.limit ?? 100 }); + this.limit = Math.abs(options.limit ?? 100); this.storageApi = options.storageApi; this.identityApi = options.identityApi; - this.retrieveAll = async (): Promise> => { - let visits: Array; - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + } - try { - visits = this.storageApi.snapshot>(storageKey).value ?? []; - } catch { - visits = []; + /** + * Returns a list of visits through the visitsApi + */ + async list(queryParams?: VisitsApiQueryParams): Promise { + let visits = [...(await this.retrieveAll())]; + + // reversing order to guarantee orderBy priority + (queryParams?.orderBy ?? []).reverse().forEach(order => { + if (order.direction === 'asc') { + visits.sort((a, b) => this.compare(order, a, b)); + } else { + visits.sort((a, b) => this.compare(order, b, a)); } - return visits; - }; - this.persistAll = async (visits: Array) => { - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + }); - return this.storageApi.set>(storageKey, visits); + // reversing order to guarantee filterBy priority + (queryParams?.filterBy ?? []).reverse().forEach(filter => { + visits = visits.filter(visit => { + const field = visit[filter.field] as number | string; + if (filter.operator === '>') return field > filter.value; + if (filter.operator === '>=') return field >= filter.value; + if (filter.operator === '<') return field < filter.value; + if (filter.operator === '<=') return field <= filter.value; + if (filter.operator === '==') return field === filter.value; + if (filter.operator === '!=') return field !== filter.value; + if (filter.operator === 'contains') + return `${field}`.includes(`${filter.value}`); + return false; + }); + }); + + return visits; + } + + /** + * Saves a visit through the visitsApi + */ + async save(saveParams: VisitsApiSaveParams): Promise { + const visits: Visit[] = [...(await this.retrieveAll())]; + + const visit: Visit = { + ...saveParams.visit, + id: 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; + } + + private async persistAll(visits: Array) { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + + return this.storageApi.set>(storageKey, visits); + } + + private async retrieveAll(): Promise> { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + let visits: Array; + + try { + visits = this.storageApi.snapshot>(storageKey).value ?? []; + } catch { + visits = []; + } + return visits; + } + + // This assumes Visit fields are either numbers or strings + private compare( + order: ArrayElement, + a: Visit, + b: Visit, + ): number { + const isNumber = typeof a[order.field] === 'number'; + return isNumber + ? (a[order.field] as number) - (b[order.field] as number) + : `${a[order.field]}`.localeCompare(`${b[order.field]}`); } } diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts index eb19859055..b646af209e 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.test.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -32,6 +32,8 @@ describe('LocalStorageVisitsApi.create()', () => { getCredentials: jest.fn(), }; + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + beforeEach(() => { window.crypto.randomUUID = mockRandomUUID; }); @@ -42,12 +44,18 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('instantiates with only identitiyApi', () => { - const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); expect(api).toBeTruthy(); }); it('saves a visit', async () => { - const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', @@ -61,7 +69,10 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('retrieves visits', async () => { - const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts index 44932b93f5..01d57b93d9 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -13,52 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { IdentityApi } from '@backstage/core-plugin-api'; -import { Visit } from './VisitsApi'; -import { VisitsApiFactory } from './VisitsApiFactory'; +import { ErrorApi, IdentityApi } from '@backstage/core-plugin-api'; +import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; +import { WebStorage } from '@backstage/core-app-api'; /** @public */ export type LocalStorageVisitsApiOptions = { limit?: number; identityApi: IdentityApi; + errorApi: ErrorApi; }; /** * @public - * This is a reference implementation of VisitsApi using window.localStorage. + * This is a reference implementation of VisitsApi using WebStorage. */ -export class LocalStorageVisitsApi extends VisitsApiFactory { - private readonly localStorage = window.localStorage; - private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; - private readonly identityApi: IdentityApi; - +export class LocalStorageVisitsApi { static create(options: LocalStorageVisitsApiOptions) { - return new LocalStorageVisitsApi(options); - } - - private constructor(options: LocalStorageVisitsApiOptions) { - super({ limit: options.limit ?? 100 }); - this.identityApi = options.identityApi; - this.retrieveAll = async (): Promise> => { - let visits: Array; - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; - - try { - visits = JSON.parse(this.localStorage.getItem(storageKey) ?? '[]'); - } catch { - visits = []; - } - return visits; - }; - this.persistAll = async (visits: Array) => { - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; - - return this.localStorage.setItem( - storageKey, - JSON.stringify(visits.splice(0, this.limit)), - ); - }; + return CoreStorageVisitsApi.create({ + limit: options.limit, + identityApi: options.identityApi, + storageApi: WebStorage.create({ errorApi: options.errorApi }), + }); } } diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts deleted file mode 100644 index 416481be53..0000000000 --- a/plugins/home/src/api/VisitsApiFactory.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/* - * 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({ - limit = 100, - }: { - limit?: number; - } = {}) { - super({ 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('.save()', () => { - 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.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 = 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.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 = new MemoryVisitsApi(); - 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: 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.save({ visit }); - }), - Promise.resolve({}), - ); - }); - - it('retrieves visits', async () => { - const visits = await api.list(); - expect(visits).toHaveLength(3); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('orders by timestamp asc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'timestamp', direction: 'asc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[0]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[2]), - ]); - }); - - it('orders by timestamp desc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'timestamp', direction: 'desc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('orders by entityRef asc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'entityRef', direction: 'asc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[0]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[2]), - ]); - }); - - it('orders by entityRef desc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'entityRef', direction: 'desc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('orders by name asc then by entityRef asc', async () => { - const visits = await api.list({ - orderBy: [ - { field: 'name', direction: 'asc' }, - { field: 'entityRef', direction: 'asc' }, - ], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 - expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 - expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 - ]); - }); - - it('orders by name desc then by entityRef asc', async () => { - const visits = await api.list({ - orderBy: [ - { field: 'name', direction: 'desc' }, - { field: 'entityRef', direction: 'asc' }, - ], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 - expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 - expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 - ]); - }); - - it('filters by timestamp with >', async () => { - const visits = await api.list({ - filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], - }); - expect(visits).toHaveLength(2); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - ]); - }); - - it('filters by timestamp with >=', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[2])]); - }); - - it('filters by timestamp with <', async () => { - const visits = await api.list({ - filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); - }); - - it('filters by timestamp with <=', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, - ], - }); - expect(visits).toHaveLength(2); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('filters by timestamp with ==', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '==', value: baseDate + 360_000 }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); - }); - - it('filters by timestamp with !=', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '!=', value: baseDate + 360_000 }, - ], - }); - expect(visits).toHaveLength(2); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('filters by entityRef with contains', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'entityRef', operator: 'contains', value: 'order-2' }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); - }); - - it('filters by timestamp with <= then by name with contains', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, - { field: 'name', operator: 'contains', value: 'Odd' }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); - }); - }); -}); diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts deleted file mode 100644 index 9ba6a2fe79..0000000000 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * 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 */ -export type VisitsApiFactoryOptions = { - limit: number; - retrieveAll?: () => Promise>; - persistAll?: (visits: Array) => Promise; -}; - -/** - * @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; - - protected constructor(options: VisitsApiFactoryOptions) { - this.limit = Math.abs(options.limit ?? 100); - this.retrieveAll = options.retrieveAll ?? (async () => []); - this.persistAll = options.persistAll ?? (async () => {}); - } - - async list(queryParams?: VisitsApiQueryParams): Promise { - let visits = await this.retrieveAll(); - - // reversing order to guarantee orderBy priority - (queryParams?.orderBy ?? []).reverse().forEach(order => { - if (order.direction === 'asc') { - visits.sort((a, b) => this.compare(order, a, b)); - } else { - visits.sort((a, b) => this.compare(order, b, a)); - } - }); - - // reversing order to guarantee filterBy priority - (queryParams?.filterBy ?? []).reverse().forEach(filter => { - visits = visits.filter(visit => { - const field = visit[filter.field] as number | string; - if (filter.operator === '>') return field > filter.value; - if (filter.operator === '>=') return field >= filter.value; - if (filter.operator === '<') return field < filter.value; - if (filter.operator === '<=') return field <= filter.value; - if (filter.operator === '==') return field === filter.value; - if (filter.operator === '!=') return field !== filter.value; - if (filter.operator === 'contains') - return `${field}`.includes(`${filter.value}`); - return false; - }); - }); - - return visits; - } - - async save(saveParams: VisitsApiSaveParams): Promise { - const visits = 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; - } - - // 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 cb283aa2cf..50b48e3204 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -17,4 +17,3 @@ export * from './CoreStorageVisitsApi'; export * from './LocalStorageVisitsApi'; export * from './VisitsApi'; -export * from './VisitsApiFactory';