diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 7b8c1ccc70..106f6c713a 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 9ea59aab48..32530f0b83 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -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 { const response = await fetch( @@ -48,6 +51,11 @@ export class CatalogClient implements CatalogApi { async getEntities( filter?: Record, ): Promise { + const cachedValue = this.cache.get( + `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 { const response = await fetch( diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 426b3f8470..37d83fcd29 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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, diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts index 1fcdfa27a2..a344a1cfb2 100644 --- a/plugins/catalog/src/data/filters.ts +++ b/plugins/catalog/src/data/filters.ts @@ -58,10 +58,10 @@ export const filterGroups: CatalogFilterGroup[] = [ type ResolverFunction = ({ catalogApi, - starredEntities, + isStarredEntity, }: { catalogApi: CatalogApi; - starredEntities: Set; + isStarredEntity: (entity: Entity) => boolean; }) => Promise; export const dataResolvers: Record = { @@ -69,12 +69,10 @@ export const dataResolvers: Record = { [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)); }, }; diff --git a/plugins/catalog/src/hooks/useStarredEntites.ts b/plugins/catalog/src/hooks/useStarredEntites.ts index 4c8751d142..20f13f8e21 100644 --- a/plugins/catalog/src/hooks/useStarredEntites.ts +++ b/plugins/catalog/src/hooks/useStarredEntites.ts @@ -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); }, diff --git a/plugins/catalog/src/hooks/useStarredEntities.test.tsx b/plugins/catalog/src/hooks/useStarredEntities.test.tsx index 607318b45c..1ac9ac799d 100644 --- a/plugins/catalog/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog/src/hooks/useStarredEntities.test.tsx @@ -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 ( @@ -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(); }); }); diff --git a/yarn.lock b/yarn.lock index 2b7246b230..73d9ccfad1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"