chore(catalog/star): adding a simple cache to stop flicker as a stopgap

This commit is contained in:
blam
2020-06-10 02:04:41 +02:00
parent 7d0f8a2aac
commit 74b087daf8
7 changed files with 61 additions and 41 deletions
+1
View File
@@ -30,6 +30,7 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"node-cache": "^5.1.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^5.2.0",
+11 -2
View File
@@ -21,8 +21,10 @@ import {
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
export class CatalogClient implements CatalogApi {
private cache: Cache;
private apiOrigin: string;
private basePath: string;
constructor({
@@ -34,6 +36,7 @@ export class CatalogClient implements CatalogApi {
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.cache = new Cache({ stdTTL: 10 });
}
async getLocationById(id: String): Promise<Location | undefined> {
const response = await fetch(
@@ -48,6 +51,11 @@ export class CatalogClient implements CatalogApi {
async getEntities(
filter?: Record<string, string>,
): Promise<DescriptorEnvelope[]> {
const cachedValue = this.cache.get<DescriptorEnvelope[]>(
`get:${JSON.stringify(filter)}`,
);
if (cachedValue) return cachedValue;
let url = `${this.apiOrigin}${this.basePath}/entities`;
if (filter) {
url += '?';
@@ -65,8 +73,9 @@ export class CatalogClient implements CatalogApi {
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
return await response.json();
const value = await response.json();
this.cache.set(`get:${JSON.stringify(filter)}`, value);
return value;
}
async getEntityByName(name: string): Promise<DescriptorEnvelope> {
const response = await fetch(
@@ -26,7 +26,7 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
import { LocationSpec } from '@backstage/catalog-model';
import { LocationSpec, Entity } from '@backstage/catalog-model';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import GitHub from '@material-ui/icons/GitHub';
@@ -37,7 +37,6 @@ import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { Component } from '../../data/component';
import { defaultFilter, filterGroups, dataResolvers } from '../../data/filters';
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
import {
@@ -74,7 +73,7 @@ const CatalogPage: FC<{}> = () => {
);
const { value, error, loading } = useAsync(
() => dataResolvers[selectedFilter.id]({ catalogApi, starredEntities }),
() => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }),
[selectedFilter.id, starredEntities.size],
);
@@ -86,7 +85,7 @@ const CatalogPage: FC<{}> = () => {
const styles = useStyles();
const actions = [
(rowData: Component) => {
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: GitHub,
@@ -98,7 +97,7 @@ const CatalogPage: FC<{}> = () => {
hidden: location ? location?.type !== 'github' : true,
};
},
(rowData: Component) => {
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
@@ -121,7 +120,7 @@ const CatalogPage: FC<{}> = () => {
hidden: location ? location?.type !== 'github' : true,
};
},
(rowData: Component) => {
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: isStarred ? Star : StarOutline,
+4 -6
View File
@@ -58,10 +58,10 @@ export const filterGroups: CatalogFilterGroup[] = [
type ResolverFunction = ({
catalogApi,
starredEntities,
isStarredEntity,
}: {
catalogApi: CatalogApi;
starredEntities: Set<string>;
isStarredEntity: (entity: Entity) => boolean;
}) => Promise<Entity[]>;
export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
@@ -69,12 +69,10 @@ export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
[FilterGroupItem.ALL]: async ({ catalogApi }) => {
return catalogApi.getEntities();
},
[FilterGroupItem.STARRED]: async ({ catalogApi, starredEntities }) => {
[FilterGroupItem.STARRED]: async ({ catalogApi, isStarredEntity }) => {
const allEntities = await catalogApi.getEntities();
return allEntities.filter(entity =>
starredEntities.has(entity.metadata.name),
);
return allEntities.filter(entity => isStarredEntity(entity));
},
};
@@ -16,9 +16,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi, storageApiRef } from '@backstage/core';
import { useObservable } from 'react-use';
import { Component } from '../data/component';
import { Entity } from '@backstage/catalog-model';
const buildEntityKey = (component: Component) =>
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
component.metadata.name
}`;
@@ -45,7 +45,7 @@ export const useStarredEntities = () => {
}, [observedItems?.newValue]);
const toggleStarredEntity = useCallback(
(entity: Component) => {
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
if (starredEntities.has(entityKey)) {
starredEntities.delete(entityKey);
@@ -59,7 +59,7 @@ export const useStarredEntities = () => {
);
const isStarredEntity = useCallback(
(entity: Component) => {
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
return starredEntities.has(entityKey);
},
@@ -24,19 +24,28 @@ import {
StorageApi,
} from '@backstage/core';
import { MockErrorApi } from '@backstage/test-utils';
import { Component } from '../data/component';
import { Entity } from '@backstage/catalog-model';
describe('useStarredEntities', () => {
let mockStorage: StorageApi | undefined;
const mockEntity: Component = {
description: 'some mock description',
const mockEntity: Entity = {
apiVersion: '1',
kind: 'Component',
name: 'mock',
metadata: {
name: 'mock',
},
};
const secondMockEntity: Entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'test',
name: 'mock2',
},
};
const wrapper: React.FC<{}> = ({ children }) => {
return (
<ApiProvider apis={ApiRegistry.with(storageApiRef, mockStorage)}>
@@ -67,53 +76,45 @@ describe('useStarredEntities', () => {
}
});
it('should listen to changes when the storage is set elsewhere', async () => {
const store = mockStorage?.forBucket('settings');
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntities(),
{ wrapper },
);
expect(result.current.starredEntities.size).toBe(0);
expect(result.current.starredEntities.has('something')).toBeFalsy();
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
// Make this happen after awaiting for the next update so we can
// catch when the hook re-renders with the latest data
setTimeout(() => store?.set('starredEntities', ['something']), 1);
setTimeout(() => result.current.toggleStarredEntity(mockEntity), 1);
await waitForNextUpdate();
expect(result.current.starredEntities.size).toBe(1);
expect(result.current.starredEntities.has('something')).toBeTruthy();
expect(result.current.isStarredEntity(mockEntity)).toBeTruthy();
});
it('should write new entries to the local store when adding a togglging entity', async () => {
const store = mockStorage?.forBucket('settings');
await store?.set('starredEntities', ['something1']);
const { result } = renderHook(() => useStarredEntities(), { wrapper });
act(() => {
result.current.toggleStarredEntity('something2');
result.current.toggleStarredEntity(mockEntity);
});
expect(result.current.starredEntities.has('something2')).toBeTruthy();
expect(result.current.starredEntities.has('something1')).toBeTruthy();
expect(result.current.isStarredEntity(mockEntity)).toBeTruthy();
expect(result.current.isStarredEntity(secondMockEntity)).toBeFalsy();
});
it('should remove an existing entity when toggling entries', async () => {
const store = mockStorage?.forBucket('settings');
await store?.set('starredEntities', ['something1', 'something2']);
const { result } = renderHook(() => useStarredEntities(), { wrapper });
act(() => {
result.current.toggleStarredEntity('something2');
result.current.toggleStarredEntity(mockEntity);
result.current.toggleStarredEntity(secondMockEntity);
result.current.toggleStarredEntity(mockEntity);
});
expect(result.current.starredEntities.has('something2')).toBeFalsy();
expect(result.current.starredEntities.has('something1')).toBeTruthy();
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy();
});
});
+12
View File
@@ -6221,6 +6221,11 @@ clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
clone@2.x:
version "2.1.2"
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
clone@^1.0.2:
version "1.0.4"
resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
@@ -13259,6 +13264,13 @@ nocache@2.1.0:
resolved "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f"
integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==
node-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.1.tgz#5fcc887176b23bdcd19cd1461b9544d2d501e786"
integrity sha512-bJ9nH25Z51HG2QIu66K4dMVyMs6o8bNQpviDnXzG+O/gfNxPU9IpIig0j4pzlO707GcGZ6QA4rWhlRxjJsjnZw==
dependencies:
clone "2.x"
node-cleanup@^2.1.2:
version "2.1.2"
resolved "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c"