feature(home-plugin): Create reference implementation (localStorage)
This is a reference implementation using local storage. Signed-off-by: Renan Mendes Carvalho <aitherios@gmail.com>
This commit is contained in:
committed by
Camila Belo
parent
a84cac67ca
commit
5d671e6220
@@ -161,6 +161,19 @@ export type LayoutConfiguration = {
|
||||
resizable?: boolean;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class LocalStorageVisitsApi extends VisitsApiFactory {
|
||||
constructor({
|
||||
localStorage,
|
||||
randomUUID,
|
||||
limit,
|
||||
}?: {
|
||||
localStorage?: Window['localStorage'];
|
||||
randomUUID?: Window['crypto']['randomUUID'];
|
||||
limit?: number;
|
||||
});
|
||||
}
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type RendererProps = RendererProps_2;
|
||||
|
||||
@@ -232,7 +245,34 @@ export const VisitListener: ({
|
||||
|
||||
// @public
|
||||
export interface VisitsApi {
|
||||
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
|
||||
listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class VisitsApiFactory implements VisitsApi {
|
||||
constructor({
|
||||
randomUUID,
|
||||
limit,
|
||||
retrieveAll,
|
||||
persistAll,
|
||||
}: {
|
||||
randomUUID: Window['crypto']['randomUUID'];
|
||||
limit: number;
|
||||
retrieveAll?: () => Promise<Array<Visit>>;
|
||||
persistAll?: (visits: Array<Visit>) => Promise<void>;
|
||||
});
|
||||
// (undocumented)
|
||||
protected readonly limit: number;
|
||||
// (undocumented)
|
||||
listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
|
||||
// (undocumented)
|
||||
protected persistAll: (visits: Array<Visit>) => Promise<void>;
|
||||
// (undocumented)
|
||||
protected readonly randomUUID: Window['crypto']['randomUUID'];
|
||||
// (undocumented)
|
||||
protected retrieveAll: () => Promise<Array<Visit>>;
|
||||
// (undocumented)
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LocalStorageVisitsApi } from './LocalStorageVisitsApi';
|
||||
|
||||
describe('new LocalStorageVisitsApi()', () => {
|
||||
const mockRandomUUID = () =>
|
||||
'068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace(
|
||||
/x/g,
|
||||
() => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf
|
||||
) as `${string}-${string}-${string}-${string}-${string}`;
|
||||
|
||||
beforeEach(() => {
|
||||
window.crypto.randomUUID = mockRandomUUID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('instantiates with no configuration', () => {
|
||||
const api = new LocalStorageVisitsApi();
|
||||
expect(api).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saves a visit', async () => {
|
||||
const api = new LocalStorageVisitsApi();
|
||||
const visit = {
|
||||
pathname: '/catalog/default/component/playback-order',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
const returnedVisit = await api.saveVisit({ visit });
|
||||
expect(returnedVisit).toEqual(expect.objectContaining(visit));
|
||||
expect(returnedVisit.id).toBeTruthy();
|
||||
expect(returnedVisit.timestamp).toBeTruthy();
|
||||
expect(returnedVisit.hits).toBeTruthy();
|
||||
});
|
||||
|
||||
it('retrieves visits', async () => {
|
||||
const api = new LocalStorageVisitsApi();
|
||||
const visit = {
|
||||
pathname: '/catalog/default/component/playback-order',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
const returnedVisit = await api.saveVisit({ visit });
|
||||
const visits = await api.listVisits();
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toEqual([expect.objectContaining(visit)]);
|
||||
expect(visits).toEqual([returnedVisit]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Visit } from './VisitsApi';
|
||||
import { VisitsApiFactory } from './VisitsApiFactory';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This is a reference implementation of VisitsApi using window.localStorage.
|
||||
*/
|
||||
export class LocalStorageVisitsApi extends VisitsApiFactory {
|
||||
private readonly localStorage: Window['localStorage'];
|
||||
private readonly storageKey = '@backstage/plugin-home:visits';
|
||||
|
||||
constructor({
|
||||
localStorage = window?.localStorage,
|
||||
randomUUID = window?.crypto?.randomUUID,
|
||||
limit = 100,
|
||||
}: {
|
||||
localStorage?: Window['localStorage'];
|
||||
randomUUID?: Window['crypto']['randomUUID'];
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
super({ randomUUID, limit });
|
||||
this.localStorage = localStorage;
|
||||
this.retrieveAll = async (): Promise<Array<Visit>> => {
|
||||
let visits: Array<Visit>;
|
||||
try {
|
||||
visits = JSON.parse(this.localStorage.getItem(this.storageKey) ?? '[]');
|
||||
} catch {
|
||||
visits = [];
|
||||
}
|
||||
return visits;
|
||||
};
|
||||
this.persistAll = async (visits: Array<Visit>) => {
|
||||
this.localStorage.setItem(
|
||||
this.storageKey,
|
||||
JSON.stringify(visits.splice(0, this.limit)),
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -108,10 +108,10 @@ export interface VisitsApi {
|
||||
*/
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
|
||||
/**
|
||||
* Get the logged user visits.
|
||||
* Get user visits.
|
||||
* @param queryParams - optional search query params.
|
||||
*/
|
||||
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
|
||||
listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Visit } from './VisitsApi';
|
||||
import { VisitsApiFactory } from './VisitsApiFactory';
|
||||
|
||||
class MemoryVisitsApi extends VisitsApiFactory {
|
||||
private visits: Array<Visit> = [];
|
||||
|
||||
constructor({
|
||||
randomUUID = window?.crypto?.randomUUID,
|
||||
limit = 100,
|
||||
}: {
|
||||
randomUUID?: Window['crypto']['randomUUID'];
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
super({ randomUUID, limit });
|
||||
this.retrieveAll = async (): Promise<Array<Visit>> => {
|
||||
let visits: Array<Visit>;
|
||||
try {
|
||||
visits = this.visits;
|
||||
} catch {
|
||||
visits = [];
|
||||
}
|
||||
return visits;
|
||||
};
|
||||
this.persistAll = async (visits: Array<Visit>) => {
|
||||
this.visits = visits;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('new MemoryVisitsApi()', () => {
|
||||
const mockRandomUUID = () =>
|
||||
'068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace(
|
||||
/x/g,
|
||||
() => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf
|
||||
) as `${string}-${string}-${string}-${string}-${string}`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
window.crypto.randomUUID = mockRandomUUID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.useRealTimers();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('instantiates with no configuration', () => {
|
||||
const api = new MemoryVisitsApi();
|
||||
expect(api).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('.saveVisit()', () => {
|
||||
it('saves a visit', async () => {
|
||||
const api = new MemoryVisitsApi();
|
||||
const visit = {
|
||||
pathname: '/catalog/default/component/playback-order',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
const returnedVisit = await api.saveVisit({ visit });
|
||||
expect(returnedVisit).toEqual(expect.objectContaining(visit));
|
||||
expect(returnedVisit.id).toBeTruthy();
|
||||
expect(returnedVisit.timestamp).toBeTruthy();
|
||||
expect(returnedVisit.hits).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can control the number of stored entities', async () => {
|
||||
const api = new MemoryVisitsApi({ limit: 2 });
|
||||
const baseDate = Date.now();
|
||||
const visit1 = {
|
||||
pathname: '/catalog/default/component/playback-order-1',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
jest.setSystemTime(baseDate);
|
||||
await api.saveVisit({ visit: visit1 });
|
||||
const visit2 = {
|
||||
pathname: '/catalog/default/component/playback-order-2',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
jest.setSystemTime(baseDate + 360_000);
|
||||
await api.saveVisit({ visit: visit2 });
|
||||
const visit3 = {
|
||||
pathname: '/catalog/default/component/playback-order-3',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
jest.setSystemTime(baseDate + 360_000 * 2);
|
||||
await api.saveVisit({ visit: visit3 });
|
||||
const visits = await api.listVisits();
|
||||
expect(visits).toHaveLength(2);
|
||||
expect(visits).toContainEqual(expect.objectContaining(visit2));
|
||||
expect(visits).toContainEqual(expect.objectContaining(visit3));
|
||||
});
|
||||
|
||||
it('correctly bumps the hits from a previous visit', async () => {
|
||||
const api = new MemoryVisitsApi();
|
||||
const visit = {
|
||||
pathname: '/catalog/default/component/playback-order',
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'Playback Order',
|
||||
};
|
||||
const visit1 = await api.saveVisit({ visit });
|
||||
const visit2 = await api.saveVisit({ visit });
|
||||
const visits = await api.listVisits();
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toContainEqual(expect.objectContaining(visit));
|
||||
// keeps the original id created on the first visit
|
||||
expect(visits).toContainEqual(expect.objectContaining({ id: visit1.id }));
|
||||
// updates timestamp and hits
|
||||
expect(visits).toContainEqual(
|
||||
expect.objectContaining({ timestamp: visit2.timestamp, hits: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.listVisits()', () => {
|
||||
let api: MemoryVisitsApi;
|
||||
let visitsToSave: Array<Omit<Visit, 'id' | 'hits' | 'timestamp'>>;
|
||||
let baseDate: number;
|
||||
beforeEach(() => {
|
||||
api = new MemoryVisitsApi();
|
||||
visitsToSave = [
|
||||
{
|
||||
pathname: '/catalog/default/component/playback-order-1',
|
||||
entityRef: 'component:default/playback-order-1',
|
||||
name: 'Playback Order Odd',
|
||||
},
|
||||
{
|
||||
pathname: '/catalog/default/component/playback-order-2',
|
||||
entityRef: 'component:default/playback-order-2',
|
||||
name: 'Playback Order Even',
|
||||
},
|
||||
{
|
||||
pathname: '/catalog/default/component/playback-order-3',
|
||||
entityRef: 'component:default/playback-order-3',
|
||||
name: 'Playback Order Odd',
|
||||
},
|
||||
];
|
||||
baseDate = Date.now();
|
||||
// Chaining items to ensure the right setSystemTime
|
||||
return visitsToSave.reduce(
|
||||
(acc, visit, index) =>
|
||||
acc.then(() => {
|
||||
jest.setSystemTime(baseDate + 360_000 * index);
|
||||
return api.saveVisit({ visit });
|
||||
}),
|
||||
Promise.resolve({}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retrieves visits', async () => {
|
||||
const visits = await api.listVisits();
|
||||
expect(visits).toHaveLength(3);
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[2]),
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
expect.objectContaining(visitsToSave[0]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders by timestamp asc', async () => {
|
||||
const visits = await api.listVisits({
|
||||
orderBy: [{ field: 'timestamp', direction: 'asc' }],
|
||||
});
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[0]),
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
expect.objectContaining(visitsToSave[2]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders by timestamp desc', async () => {
|
||||
const visits = await api.listVisits({
|
||||
orderBy: [{ field: 'timestamp', direction: 'desc' }],
|
||||
});
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[2]),
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
expect.objectContaining(visitsToSave[0]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders by entityRef asc', async () => {
|
||||
const visits = await api.listVisits({
|
||||
orderBy: [{ field: 'entityRef', direction: 'asc' }],
|
||||
});
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[0]),
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
expect.objectContaining(visitsToSave[2]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders by entityRef desc', async () => {
|
||||
const visits = await api.listVisits({
|
||||
orderBy: [{ field: 'entityRef', direction: 'desc' }],
|
||||
});
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[2]),
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
expect.objectContaining(visitsToSave[0]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders by name asc then by entityRef asc', async () => {
|
||||
const visits = await api.listVisits({
|
||||
orderBy: [
|
||||
{ field: 'name', direction: 'asc' },
|
||||
{ field: 'entityRef', direction: 'asc' },
|
||||
],
|
||||
});
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2
|
||||
expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1
|
||||
expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders by name desc then by entityRef asc', async () => {
|
||||
const visits = await api.listVisits({
|
||||
orderBy: [
|
||||
{ field: 'name', direction: 'desc' },
|
||||
{ field: 'entityRef', direction: 'asc' },
|
||||
],
|
||||
});
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1
|
||||
expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3
|
||||
expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with >', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }],
|
||||
});
|
||||
expect(visits).toHaveLength(2);
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[2]),
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with >=', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [
|
||||
{ field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 },
|
||||
],
|
||||
});
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toEqual([expect.objectContaining(visitsToSave[2])]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with <', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }],
|
||||
});
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with <=', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [
|
||||
{ field: 'timestamp', operator: '<=', value: baseDate + 360_000 },
|
||||
],
|
||||
});
|
||||
expect(visits).toHaveLength(2);
|
||||
expect(visits).toEqual([
|
||||
expect.objectContaining(visitsToSave[1]),
|
||||
expect.objectContaining(visitsToSave[0]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with ==', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [
|
||||
{ field: 'timestamp', operator: '==', value: baseDate + 360_000 },
|
||||
],
|
||||
});
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]);
|
||||
});
|
||||
|
||||
it('filters by entityRef with contains', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [
|
||||
{ field: 'entityRef', operator: 'contains', value: 'order-2' },
|
||||
],
|
||||
});
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with <= then by name with contains', async () => {
|
||||
const visits = await api.listVisits({
|
||||
filterBy: [
|
||||
{ field: 'timestamp', operator: '<=', value: baseDate + 360_000 },
|
||||
{ field: 'name', operator: 'contains', value: 'Odd' },
|
||||
],
|
||||
});
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Visit, VisitsApi, VisitsApiQueryParams, VisitsApiSaveParams } from './VisitsApi';
|
||||
|
||||
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This helps the creation of VisitApi implementations. Important to note
|
||||
* that it implements features like orderBy and filterBy on memory, therefore
|
||||
* is intended to handle few visits. The default is 100.
|
||||
* See LocalStorageVisitsApi for an usage example.
|
||||
*/
|
||||
export class VisitsApiFactory implements VisitsApi {
|
||||
protected readonly randomUUID: Window['crypto']['randomUUID'];
|
||||
protected readonly limit: number;
|
||||
protected retrieveAll: () => Promise<Array<Visit>>;
|
||||
protected persistAll: (visits: Array<Visit>) => Promise<void>;
|
||||
|
||||
constructor({
|
||||
randomUUID = window?.crypto?.randomUUID,
|
||||
limit = 100,
|
||||
retrieveAll,
|
||||
persistAll,
|
||||
}: {
|
||||
randomUUID: Window['crypto']['randomUUID'];
|
||||
limit: number;
|
||||
retrieveAll?: () => Promise<Array<Visit>>;
|
||||
persistAll?: (visits: Array<Visit>) => Promise<void>;
|
||||
}) {
|
||||
this.randomUUID = randomUUID;
|
||||
this.limit = Math.abs(limit);
|
||||
this.retrieveAll = retrieveAll ?? (async () => []);
|
||||
this.persistAll = persistAll ?? (async () => {});
|
||||
}
|
||||
|
||||
async listVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]> {
|
||||
let visits = await this.retrieveAll();
|
||||
|
||||
// reversing order to guarantee orderBy priority
|
||||
(queryParams?.orderBy ?? []).reverse().forEach(order => {
|
||||
if (order.direction === 'asc') {
|
||||
visits.sort((a, b) => this.compare(order, a, b));
|
||||
} else {
|
||||
visits.sort((a, b) => this.compare(order, b, a));
|
||||
}
|
||||
});
|
||||
|
||||
(queryParams?.filterBy ?? []).reverse().forEach(filter => {
|
||||
visits = visits.filter(visit => {
|
||||
const field = visit[filter.field] as number | string;
|
||||
if (filter.operator === '>') return field > filter.value;
|
||||
if (filter.operator === '>=') return field >= filter.value;
|
||||
if (filter.operator === '<') return field < filter.value;
|
||||
if (filter.operator === '<=') return field <= filter.value;
|
||||
if (filter.operator === '==') return field === filter.value;
|
||||
if (filter.operator === 'contains')
|
||||
return `${field}`.includes(`${filter.value}`);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
return visits;
|
||||
}
|
||||
|
||||
async saveVisit(
|
||||
saveParams: VisitsApiSaveParams,
|
||||
): Promise<Visit> {
|
||||
const visits = await this.retrieveAll();
|
||||
|
||||
const visit: Visit = {
|
||||
...saveParams.visit,
|
||||
id: this.randomUUID(),
|
||||
hits: 1,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Updates entry if pathname is already registered
|
||||
const visitIndex = visits.findIndex(e => e.pathname === visit.pathname);
|
||||
if (visitIndex >= 0) {
|
||||
visit.id = visits[visitIndex].id;
|
||||
visit.hits = visits[visitIndex].hits + 1;
|
||||
visits[visitIndex] = visit;
|
||||
} else {
|
||||
visits.push(visit);
|
||||
}
|
||||
|
||||
// Sort by time, most recent first
|
||||
visits.sort((a, b) => b.timestamp - a.timestamp);
|
||||
// Keep the most recent items up to limit
|
||||
await this.persistAll(visits.splice(0, this.limit));
|
||||
return visit;
|
||||
}
|
||||
|
||||
// This assumes Visit fields are either numbers or strings
|
||||
private compare(
|
||||
order: ArrayElement<VisitsApiQueryParams['orderBy']>,
|
||||
a: Visit,
|
||||
b: Visit,
|
||||
): number {
|
||||
const isNumber = typeof a[order.field] === 'number';
|
||||
return isNumber
|
||||
? (a[order.field] as number) - (b[order.field] as number)
|
||||
: `${a[order.field]}`.localeCompare(`${b[order.field]}`);
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,5 @@
|
||||
*/
|
||||
|
||||
export * from './VisitsApi';
|
||||
export * from './LocalStorageVisitsApi';
|
||||
export * from './VisitsApiFactory';
|
||||
|
||||
@@ -65,7 +65,7 @@ export const Content = ({
|
||||
const { loading: reqLoading } = useAsync(async () => {
|
||||
if (!visits && !loading && kind === 'recent') {
|
||||
return await visitsApi
|
||||
.listUserVisits({
|
||||
.listVisits({
|
||||
limit: numVisitsTotal ?? 8,
|
||||
orderBy: [{ field: 'timestamp', direction: 'desc' }],
|
||||
})
|
||||
@@ -73,7 +73,7 @@ export const Content = ({
|
||||
}
|
||||
if (!visits && !loading && kind === 'top') {
|
||||
return await visitsApi
|
||||
.listUserVisits({
|
||||
.listVisits({
|
||||
limit: numVisitsTotal ?? 8,
|
||||
orderBy: [{ field: 'hits', direction: 'desc' }],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user