Merge pull request #1214 from spotify/feat/star-components

Ability to star items in the catalog table
This commit is contained in:
Ben Lambert
2020-06-10 14:10:56 +02:00
committed by GitHub
8 changed files with 202 additions and 26 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",
+17 -1
View File
@@ -21,8 +21,13 @@ import {
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
export class CatalogClient implements CatalogApi {
// TODO(blam): This cache is just temporary until we have GraphQL.
// And client side caching using things like React Apollo or Relay.
// There's a lot of loading states that cause flickering around the app which aren't needed.
private cache: Cache;
private apiOrigin: string;
private basePath: string;
constructor({
@@ -34,6 +39,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 +54,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 +76,13 @@ export class CatalogClient implements CatalogApi {
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
const value = await response.json();
return await response.json();
if (value?.length) {
this.cache.set(`get:${JSON.stringify(filter)}`, value);
}
return value;
}
async getEntity({
@@ -24,6 +24,8 @@ describe('Starred Count', () => {
it('should render the count returned from the hook', async () => {
jest.spyOn(Hooks, 'useStarredEntities').mockReturnValue({
starredEntities: new Set(['id1', 'id2', 'id3', 'id4']),
isStarredEntity: () => false,
toggleStarredEntity: () => undefined,
});
const { findByText } = render(wrapInTestApp(<StarredCount />));
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { LocationSpec } from '@backstage/catalog-model';
import { LocationSpec, Entity } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
@@ -27,16 +27,20 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import GitHub from '@material-ui/icons/GitHub';
import StarOutline from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import Edit from '@material-ui/icons/Edit';
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 { dataResolvers, defaultFilter, filterGroups } from '../../data/filters';
import { defaultFilter, filterGroups, dataResolvers } from '../../data/filters';
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
@@ -60,13 +64,18 @@ const useStyles = makeStyles(theme => ({
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const { starredEntities } = useStarredEntities();
const {
starredEntities,
toggleStarredEntity,
isStarredEntity,
} = useStarredEntities();
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const { value, error, loading } = useAsync(
() => dataResolvers[selectedFilter.id]({ catalogApi, starredEntities }),
[selectedFilter.id],
() => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }),
[selectedFilter.id, starredEntities.size],
);
const onFilterSelected = useCallback(
@@ -77,7 +86,7 @@ export const CatalogPage: FC<{}> = () => {
const styles = useStyles();
const actions = [
(rowData: Component) => {
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: GitHub,
@@ -89,7 +98,7 @@ export const CatalogPage: FC<{}> = () => {
hidden: location ? location?.type !== 'github' : true,
};
},
(rowData: Component) => {
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
@@ -112,6 +121,14 @@ export const CatalogPage: FC<{}> = () => {
hidden: location ? location?.type !== 'github' : true,
};
},
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: isStarred ? Star : StarOutline,
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
},
];
// TODO: replace me with the proper tabs implemntation
+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));
},
};
+34 -3
View File
@@ -13,17 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useApi, storageApiRef } from '@backstage/core';
import { useObservable } from 'react-use';
import { Entity } from '@backstage/catalog-model';
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
component.metadata.name
}`;
export const useStarredEntities = () => {
const storageApi = useApi(storageApiRef);
const settingsStore = storageApi.forBucket('settings');
const rawStarredItems = settingsStore.get<string[]>('starredEntities') ?? [];
const rawStarredEntityKeys =
settingsStore.get<string[]>('starredEntities') ?? [];
const [starredEntities, setStarredEntities] = useState(
new Set(rawStarredItems),
new Set(rawStarredEntityKeys),
);
const observedItems = useObservable(
@@ -37,7 +44,31 @@ export const useStarredEntities = () => {
}
}, [observedItems?.newValue]);
const toggleStarredEntity = useCallback(
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
if (starredEntities.has(entityKey)) {
starredEntities.delete(entityKey);
} else {
starredEntities.add(entityKey);
}
settingsStore.set('starredEntities', Array.from(starredEntities));
},
[starredEntities, settingsStore],
);
const isStarredEntity = useCallback(
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
return starredEntities.has(entityKey);
},
[starredEntities],
);
return {
starredEntities,
toggleStarredEntity,
isStarredEntity,
};
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { renderHook, act } from '@testing-library/react-hooks';
import { useStarredEntities } from './useStarredEntites';
import {
ApiProvider,
@@ -24,10 +24,28 @@ import {
StorageApi,
} from '@backstage/core';
import { MockErrorApi } from '@backstage/test-utils';
import { Entity } from '@backstage/catalog-model';
describe('useStarredEntities', () => {
let mockStorage: StorageApi | undefined;
const mockEntity: Entity = {
apiVersion: '1',
kind: 'Component',
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)}>
@@ -58,23 +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 { result } = renderHook(() => useStarredEntities(), { wrapper });
act(() => {
result.current.toggleStarredEntity(mockEntity);
});
expect(result.current.isStarredEntity(mockEntity)).toBeTruthy();
expect(result.current.isStarredEntity(secondMockEntity)).toBeFalsy();
});
it('should remove an existing entity when toggling entries', async () => {
const { result } = renderHook(() => useStarredEntities(), { wrapper });
act(() => {
result.current.toggleStarredEntity(mockEntity);
result.current.toggleStarredEntity(secondMockEntity);
result.current.toggleStarredEntity(mockEntity);
});
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy();
});
});
+71
View File
@@ -2406,6 +2406,11 @@
dependencies:
"@types/node" ">= 8"
"@open-draft/until@^1.0.0":
version "1.0.3"
resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca"
integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==
"@reach/router@^1.2.1":
version "1.3.3"
resolved "https://registry.npmjs.org/@reach/router/-/router-1.3.3.tgz#58162860dce6c9449d49be86b0561b5ef46d80db"
@@ -3476,6 +3481,11 @@
dependencies:
"@types/express" "*"
"@types/cookie@^0.3.3":
version "0.3.3"
resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==
"@types/cookiejar@*":
version "2.1.1"
resolved "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80"
@@ -6221,6 +6231,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"
@@ -6642,6 +6657,11 @@ cookie@0.4.0:
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
cookie@^0.4.1:
version "0.4.1"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
cookiejar@^2.1.0:
version "2.1.2"
resolved "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c"
@@ -9751,6 +9771,11 @@ graphql@15.0.0:
resolved "https://registry.npmjs.org/graphql/-/graphql-15.0.0.tgz#042a5eb5e2506a2e2111ce41eb446a8e570b8be9"
integrity sha512-ZyVO1xIF9F+4cxfkdhOJINM+51B06Friuv4M66W7HzUOeFd+vNzUn4vtswYINPi6sysjf1M2Ri/rwZALqgwbaQ==
graphql@^15.0.0:
version "15.1.0"
resolved "https://registry.npmjs.org/graphql/-/graphql-15.1.0.tgz#b93e28de805294ec08e1630d901db550cb8960a1"
integrity sha512-0TVyfOlCGhv/DBczQkJmwXOK6fjWkjzY3Pt7wY8i0gcYXq8aogG3weCsg48m72lywKSeOqedEHvVPOvZvSD51Q==
growly@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
@@ -9915,6 +9940,11 @@ he@^1.2.0:
resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
headers-utils@^1.1.3, headers-utils@^1.1.9:
version "1.2.0"
resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.0.tgz#5e10d1bc9d2bccf789547afca5b991a3167241e8"
integrity sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A==
helmet-crossdomain@0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz#5f1fe5a836d0325f1da0a78eaa5fd8429078894e"
@@ -13139,6 +13169,22 @@ ms@^2.0.0, ms@^2.1.1:
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
msw@^0.19.0:
version "0.19.0"
resolved "https://registry.npmjs.org/msw/-/msw-0.19.0.tgz#fd37015787d40db82d243a2853be66c466675e72"
integrity sha512-1TpmJzJ+afBWTRNJYoeW8KwLQbCVlvvhw2u/eRuIYfel+bPqcut5NaSgo+Bi4C0Q/7M5wza00w1GEuOXQu6FCA==
dependencies:
"@open-draft/until" "^1.0.0"
"@types/cookie" "^0.3.3"
chalk "^4.0.0"
cookie "^0.4.1"
graphql "^15.0.0"
headers-utils "^1.1.9"
node-match-path "^0.4.2"
node-request-interceptor "^0.2.4"
statuses "^2.0.0"
yargs "^15.3.1"
multicast-dns-service-types@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901"
@@ -13259,6 +13305,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"
@@ -13346,6 +13399,11 @@ node-libs-browser@^2.2.1:
util "^0.11.0"
vm-browserify "^1.0.1"
node-match-path@^0.4.2:
version "0.4.2"
resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.2.tgz#30cc39510fa493bff03c3d0d2fff711c868ec457"
integrity sha512-wfde4FOC5A8RTSUVZ7pTpBV+dJsr2vVxT6374VrNam6wnnhx6EvwAwL/E/r3AW/YU6XkeZggF5xfBlu4a/ULBg==
node-modules-regexp@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40"
@@ -13386,6 +13444,14 @@ node-releases@^1.1.29, node-releases@^1.1.52:
dependencies:
semver "^6.3.0"
node-request-interceptor@^0.2.4:
version "0.2.4"
resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.2.4.tgz#f03a1b874823d0bea311a14280227707be946298"
integrity sha512-/htjDLmygBczT5qYPaSxfAEtMkc0LGuH6jqAP1o+TKfQh6yQfFyTtac25cpY8+pb4EawHljCLUN7dCeed9SdPA==
dependencies:
debug "^4.1.1"
headers-utils "^1.1.3"
nodemon@^2.0.2:
version "2.0.4"
resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz#55b09319eb488d6394aa9818148c0c2d1c04c416"
@@ -17248,6 +17314,11 @@ static-extend@^0.1.1:
resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
statuses@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.0.tgz#aa7b107e018eb33e08e8aee2e7337e762dda1028"
integrity sha512-w9jNUUQdpuVoYqXxnyOakhckBbOxRaoYqJscyIBYCS5ixyCnO7nQn7zBZvP9zf5QOPZcz2DLUpE3KsNPbJBOFA==
stealthy-require@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"