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 <aitherios@gmail.com>
This commit is contained in:
Renan Mendes Carvalho
2023-09-20 14:03:20 +02:00
committed by Camila Belo
parent 72f3ea7042
commit baa8af646c
10 changed files with 429 additions and 577 deletions
+4 -3
View File
@@ -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 }),
}),
// ...
```
+8 -28
View File
@@ -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<Visit[]>;
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
}
// @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<Visit>;
}
// @public
export class VisitsApiFactory implements VisitsApi {
protected constructor(options: VisitsApiFactoryOptions);
// (undocumented)
protected readonly limit: number;
// (undocumented)
list(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
// (undocumented)
protected persistAll: (visits: Array<Visit>) => Promise<void>;
// (undocumented)
protected readonly randomUUID: () => `${string}-${string}-${string}-${string}-${string}`;
// (undocumented)
protected retrieveAll: () => Promise<Array<Visit>>;
// (undocumented)
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
}
// @public (undocumented)
export type VisitsApiFactoryOptions = {
limit: number;
retrieveAll?: () => Promise<Array<Visit>>;
persistAll?: (visits: Array<Visit>) => Promise<void>;
};
// @public
export type VisitsApiQueryParams = {
limit?: number;
+1 -1
View File
@@ -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",
+283 -29
View File
@@ -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<Omit<Visit, 'id' | 'hits' | 'timestamp'>>;
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]);
});
});
+108 -20
View File
@@ -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> = 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<Array<Visit>> => {
let visits: Array<Visit>;
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`;
}
try {
visits = this.storageApi.snapshot<Array<Visit>>(storageKey).value ?? [];
} catch {
visits = [];
/**
* 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));
}
return visits;
};
this.persistAll = async (visits: Array<Visit>) => {
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`;
});
return this.storageApi.set<Array<Visit>>(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<Visit> {
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<Visit>) {
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`;
return this.storageApi.set<Array<Visit>>(storageKey, visits);
}
private async retrieveAll(): Promise<Array<Visit>> {
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`;
let visits: Array<Visit>;
try {
visits = this.storageApi.snapshot<Array<Visit>>(storageKey).value ?? [];
} catch {
visits = [];
}
return visits;
}
// 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]}`);
}
}
@@ -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',
+11 -36
View File
@@ -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<Array<Visit>> => {
let visits: Array<Visit>;
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<Visit>) => {
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 }),
});
}
}
@@ -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<Visit> = [];
constructor({
limit = 100,
}: {
limit?: number;
} = {}) {
super({ 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('.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<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.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])]);
});
});
});
-120
View File
@@ -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> = A extends readonly (infer T)[] ? T : never;
/** @public */
export type VisitsApiFactoryOptions = {
limit: number;
retrieveAll?: () => Promise<Array<Visit>>;
persistAll?: (visits: Array<Visit>) => Promise<void>;
};
/**
* @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>;
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<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;
}
async save(saveParams: VisitsApiSaveParams): Promise<Visit> {
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<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]}`);
}
}
-1
View File
@@ -17,4 +17,3 @@
export * from './CoreStorageVisitsApi';
export * from './LocalStorageVisitsApi';
export * from './VisitsApi';
export * from './VisitsApiFactory';