From 00d33f93e42d5dc01068cf0c48568fd91c9f968e Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 9 Jun 2020 13:28:40 +0200 Subject: [PATCH] Enable Filtering of the Catalog Page (#1143) * feat(catalog/Filters): Add ability to render a React component as the count. Good for loading states and async data rendering for counts. * chore(Catalog/filters): Updating the table so that we use loading states of the table rather than the panel * chore(Catalog/filters): added some nice things for enabling the filtering with some nice count componeents * feat(Catalog/filters): Fetch the correct data and added in enum types to make some nice resolvers * chore(Catalog/filters): Use the new enum type here * chore(Catalog/filters): Removing the unused import to fix lintig * chore(Catalog/filters): Addressing some PR comments * feat(catalog/filters): Making WebStorage return the same instance for the same bucket for subscriptions * chore(core/Storage): fixing some issues with different instances of the storage and adding tests for it * chore(catalog/filters): fixing some tests and trying to remove some of the act warnings in the tests --- .../StorageApi/WebStorage.test.ts | 9 +++ .../implementations/StorageApi/WebStorage.ts | 8 +- .../DismissableBanner.stories.tsx | 2 +- .../DismissableBanner.test.tsx | 2 +- .../DismissableBanner/DismissableBanner.tsx | 2 +- plugins/catalog/package.json | 4 +- .../CatalogFilter/AllServicesCount.tsx | 32 ++++++++ .../CatalogFilter/CatalogFilter.test.tsx | 38 +++++++-- .../CatalogFilter/CatalogFilter.tsx | 13 +-- .../CatalogFilter/StarredCount.test.tsx | 33 ++++++++ .../components/CatalogFilter/StarredCount.tsx | 22 +++++ .../CatalogPage/CatalogPage.test.tsx | 53 +++++------- .../components/CatalogPage/CatalogPage.tsx | 14 +++- .../CatalogTable/CatalogTable.test.tsx | 10 --- .../components/CatalogTable/CatalogTable.tsx | 17 ++-- .../ComponentContextMenu.test.tsx | 2 +- .../ComponentPage/ComponentPage.test.tsx | 9 ++- plugins/catalog/src/data/filters.ts | 39 +++++++-- .../catalog/src/hooks/useStarredEntites.ts | 43 ++++++++++ .../src/hooks/useStarredEntities.test.tsx | 80 +++++++++++++++++++ plugins/catalog/src/types.ts | 6 ++ yarn.lock | 40 ++++++++++ 22 files changed, 400 insertions(+), 78 deletions(-) create mode 100644 plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx create mode 100644 plugins/catalog/src/components/CatalogFilter/StarredCount.test.tsx create mode 100644 plugins/catalog/src/components/CatalogFilter/StarredCount.tsx create mode 100644 plugins/catalog/src/hooks/useStarredEntites.ts create mode 100644 plugins/catalog/src/hooks/useStarredEntities.test.tsx diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index a96813347e..971b023629 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -15,6 +15,7 @@ */ import { WebStorage } from './WebStorage'; import { CreateStorageApiOptions, StorageApi } from '../../definitions'; + describe('WebStorage Storage API', () => { const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; const createWebStorage = ( @@ -161,4 +162,12 @@ describe('WebStorage Storage API', () => { }), ); }); + + it('should return a singleton for the same namespace and same bucket', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing/Thing ', + }); + + expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test')); + }); }); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index 8af8f760cf..b9b01cd20b 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -22,6 +22,8 @@ import { import { Observable } from '../../../types'; import ObservableImpl from 'zen-observable'; +const buckets = new Map(); + export class WebStorage implements StorageApi { constructor( private readonly namespace: string, @@ -46,7 +48,11 @@ export class WebStorage implements StorageApi { } forBucket(name: string): WebStorage { - return new WebStorage(`${this.namespace}/${name}`, this.errorApi); + const bucketPath = `${this.namespace}/${name}`; + if (!buckets.has(bucketPath)) { + buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi)); + } + return buckets.get(bucketPath)!; } async set(key: string, data: T): Promise { diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx index 37d982b0f4..78307bd5a3 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -25,7 +25,7 @@ import { storageApiRef, StorageApi, WebStorage, -} from '@backstage/core'; +} from '@backstage/core-api'; export default { title: 'DismissableBanner', diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.test.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.test.tsx index b31641eb8f..b3a33fc089 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.test.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.test.tsx @@ -25,7 +25,7 @@ import { CreateStorageApiOptions, StorageApi, WebStorage, -} from '@backstage/core'; +} from '@backstage/core-api'; describe('', () => { let apis: ApiRegistry; diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index 76c27827ec..e174ac8d55 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -15,7 +15,7 @@ */ import React, { FC, ReactNode, useState, useEffect } from 'react'; -import { useApi, storageApiRef } from '@backstage/core'; +import { useApi, storageApiRef } from '@backstage/core-api'; import { useObservable } from 'react-use'; import classNames from 'classnames'; import { makeStyles, Theme } from '@material-ui/core'; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 28bfcb0af0..e19869c946 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -42,11 +42,13 @@ "@backstage/test-utils": "^0.1.1-alpha.6", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", + "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^10.2.4", "@types/jest": "^25.2.2", "@types/node": "^12.0.0", "@types/testing-library__jest-dom": "^5.0.4", - "jest-fetch-mock": "^3.0.3" + "jest-fetch-mock": "^3.0.3", + "react-test-renderer": "^16.13.1" }, "files": [ "dist/**/*.{js,d.ts}" diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx new file mode 100644 index 0000000000..07879770b7 --- /dev/null +++ b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '../../api/types'; +import { useAsync } from 'react-use'; +import { CircularProgress, useTheme } from '@material-ui/core'; + +export const AllServicesCount: React.FC<{}> = () => { + const theme = useTheme(); + const catalogApi = useApi(catalogApiRef); + const { value, loading } = useAsync(() => catalogApi.getEntities()); + + if (loading) { + return ; + } + + return {value?.length ?? '-'}; +}; diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index ec44f53777..6246726fab 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; +import { FilterGroupItem } from '../../types'; describe('Catalog Filter', () => { it('should render the different groups', async () => { @@ -40,11 +41,11 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: 'first', + id: FilterGroupItem.ALL, label: 'First Label', }, { - id: 'second', + id: FilterGroupItem.STARRED, label: 'Second Label', }, ], @@ -67,12 +68,12 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: 'first', + id: FilterGroupItem.ALL, label: 'First Label', count: 100, }, { - id: 'second', + id: FilterGroupItem.STARRED, label: 'Second Label', count: 400, }, @@ -96,12 +97,12 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: 'first', + id: FilterGroupItem.ALL, label: 'First Label', count: 100, }, { - id: 'second', + id: FilterGroupItem.STARRED, label: 'Second Label', count: 400, }, @@ -128,4 +129,29 @@ describe('Catalog Filter', () => { expect(onSelectedChangeHandler).toHaveBeenCalledWith(item); }); + + it('should render a component when a function is passed to the count component', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: FilterGroupItem.ALL, + label: 'First Label', + count: () => BACKSTAGE!, + }, + { + id: FilterGroupItem.STARRED, + label: 'Second Label', + count: 400, + }, + ], + }, + ]; + const { findByText } = render( + wrapInTestApp(), + ); + + expect(await findByText('BACKSTAGE!')).toBeInTheDocument(); + }); }); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index eb45d8a80b..6541a4df8d 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -25,13 +25,12 @@ import { makeStyles, } from '@material-ui/core'; import type { IconComponent } from '@backstage/core'; - +import { FilterGroupItem } from '../../types'; export type CatalogFilterItem = { - id: string; + id: FilterGroupItem; label: string; icon?: IconComponent; - count?: number; - loading?: boolean; + count?: number | React.FC; }; export type CatalogFilterGroup = { @@ -105,7 +104,11 @@ export const CatalogFilter: React.FC = ({ {item.label} - {item.count} + {typeof item.count === 'function' ? ( + + ) : ( + item.count + )} ))} diff --git a/plugins/catalog/src/components/CatalogFilter/StarredCount.test.tsx b/plugins/catalog/src/components/CatalogFilter/StarredCount.test.tsx new file mode 100644 index 0000000000..57581e98f2 --- /dev/null +++ b/plugins/catalog/src/components/CatalogFilter/StarredCount.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { StarredCount } from './StarredCount'; +import * as Hooks from '../../hooks/useStarredEntites'; + +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']), + }); + + const { findByText } = render(wrapInTestApp()); + + expect(await findByText('4')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx b/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx new file mode 100644 index 0000000000..20e1be1a9a --- /dev/null +++ b/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; + +export const StarredCount: React.FC<{}> = () => { + const { starredEntities } = useStarredEntities(); + return {starredEntities.size}; +}; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 238a5a4043..c2130d5110 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -18,47 +18,34 @@ import React from 'react'; import { render } from '@testing-library/react'; import CatalogPage from './CatalogPage'; import { - ApiProvider, ApiRegistry, - CreateStorageApiOptions, + ApiProvider, errorApiRef, storageApiRef, - StorageApi, WebStorage, } from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, MockErrorApi } from '@backstage/test-utils'; import { catalogApiRef } from '../..'; import { CatalogApi } from '../../api/types'; import { Entity } from '@backstage/catalog-model'; -const errorApi = { post: () => {} }; -const catalogApi: Partial = { - getEntities: () => - Promise.resolve([ - { - metadata: { - name: 'Entity1', - }, - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - }, - ] as Entity[]), - getLocationByEntity: () => - Promise.resolve({ id: 'id', type: 'github', target: 'url' }), -}; - -const mockWebStorageErrorApi = { post: jest.fn(), error$: jest.fn() }; -const createWebStorage = ( - args?: Partial, -): StorageApi => { - return WebStorage.create({ - errorApi: mockWebStorageErrorApi, - ...args, - }); -}; -const storageApi = createWebStorage(); - describe('CatalogPage', () => { + const mockErrorApi = new MockErrorApi(); + const catalogApi: Partial = { + getEntities: () => + Promise.resolve([ + { + metadata: { + name: 'Entity1', + }, + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + }, + ] as Entity[]), + getLocationByEntity: () => + Promise.resolve({ id: 'id', type: 'github', target: 'url' }), + }; + // this test right now causes some red lines in the log output when running tests // related to some theme issues in mui-table // https://github.com/mbrn/material-table/issues/1293 @@ -67,9 +54,9 @@ describe('CatalogPage', () => { wrapInTestApp( diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 452254a3cf..859449c715 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -27,19 +27,22 @@ import { useApi, } from '@backstage/core'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { Button, Link, makeStyles, Typography } from '@material-ui/core'; +import { Button, makeStyles, Typography, Link } from '@material-ui/core'; import GitHub from '@material-ui/icons/GitHub'; 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 } from '../../data/filters'; +import { defaultFilter, filterGroups, dataResolvers } from '../../data/filters'; import { entityToComponent, findLocationForEntityMeta } from '../../data/utils'; import { CatalogFilter, CatalogFilterItem, } from '../CatalogFilter/CatalogFilter'; + +import { useStarredEntities } from '../../hooks/useStarredEntites'; + import CatalogTable from '../CatalogTable/CatalogTable'; const useStyles = makeStyles(theme => ({ @@ -57,15 +60,20 @@ const useStyles = makeStyles(theme => ({ const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); - const { value, error, loading } = useAsync(() => catalogApi.getEntities()); + const { starredEntities } = useStarredEntities(); const [selectedFilter, setSelectedFilter] = useState( defaultFilter, ); + const { value, error, loading } = useAsync( + () => dataResolvers[selectedFilter.id]({ catalogApi, starredEntities }), + [selectedFilter.id], + ); const onFilterSelected = useCallback( selected => setSelectedFilter(selected), [], ); + const styles = useStyles(); const actions = [ diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index ac7abe235a..41693188f1 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -41,16 +41,6 @@ const components: Component[] = [ ]; describe('CatalogTable component', () => { - it('should render loading when loading prop it set to true', async () => { - const rendered = render( - wrapInTestApp( - , - ), - ); - const progress = await rendered.findByTestId('progress'); - expect(progress).toBeInTheDocument(); - }); - it('should render error message when error is passed in props', async () => { const rendered = render( wrapInTestApp( diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index e918cc13aa..cd91838b06 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Progress, Table, TableColumn } from '@backstage/core'; +import { Table, TableColumn } from '@backstage/core'; import { Link } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { FC } from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, generatePath } from 'react-router-dom'; import { Component } from '../../data/component'; + import { entityRoute } from '../../routes'; const columns: TableColumn[] = [ @@ -60,9 +61,7 @@ const CatalogTable: FC = ({ titlePreamble, actions, }) => { - if (loading) { - return ; - } else if (error) { + if (error) { return (
@@ -74,8 +73,14 @@ const CatalogTable: FC = ({ return ( { it('should call onUnregisterComponent on button click', async () => { await act(async () => { const mockCallback = jest.fn(); - const menu = await render( + const menu = render( , ); const button = await menu.findByTestId('menu-button'); diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx index f580a84d8a..252f2d4e3f 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import ComponentPage from './ComponentPage'; -import { render } from '@testing-library/react'; +import { render, wait } from '@testing-library/react'; import * as React from 'react'; import { wrapInTestApp } from '@backstage/test-utils'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; @@ -38,7 +38,7 @@ const errorApi = { post: () => {} }; describe('ComponentPage', () => { it('should redirect to component table page when name is not provided', async () => { const props = getTestProps(''); - await render( + render( wrapInTestApp( { , ), ); - expect(props.history.push).toHaveBeenCalledWith('/catalog'); + + await wait(() => + expect(props.history.push).toHaveBeenCalledWith('/catalog'), + ); }); }); diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts index 1b0a2a407c..1fcdfa27a2 100644 --- a/plugins/catalog/src/data/filters.ts +++ b/plugins/catalog/src/data/filters.ts @@ -19,21 +19,26 @@ import { } from '../components/CatalogFilter/CatalogFilter'; import SettingsIcon from '@material-ui/icons/Settings'; import StarIcon from '@material-ui/icons/Star'; +import { StarredCount } from '../components/CatalogFilter/StarredCount'; +import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount'; +import { FilterGroupItem } from '../types'; +import { CatalogApi } from '../..'; +import { Entity } from '@backstage/catalog-model'; export const filterGroups: CatalogFilterGroup[] = [ { name: 'Personal', items: [ { - id: 'owned', + id: FilterGroupItem.OWNED, label: 'Owned', - count: 123, + count: 0, icon: SettingsIcon, }, { - id: 'starred', + id: FilterGroupItem.STARRED, label: 'Starred', - count: 10, + count: StarredCount, icon: StarIcon, }, ], @@ -43,12 +48,34 @@ export const filterGroups: CatalogFilterGroup[] = [ name: 'Company', items: [ { - id: 'all', + id: FilterGroupItem.ALL, label: 'All Services', - count: 123, + count: AllServicesCount, }, ], }, ]; +type ResolverFunction = ({ + catalogApi, + starredEntities, +}: { + catalogApi: CatalogApi; + starredEntities: Set; +}) => Promise; + +export const dataResolvers: Record = { + [FilterGroupItem.OWNED]: async () => [], + [FilterGroupItem.ALL]: async ({ catalogApi }) => { + return catalogApi.getEntities(); + }, + [FilterGroupItem.STARRED]: async ({ catalogApi, starredEntities }) => { + const allEntities = await catalogApi.getEntities(); + + return allEntities.filter(entity => + starredEntities.has(entity.metadata.name), + ); + }, +}; + export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/catalog/src/hooks/useStarredEntites.ts b/plugins/catalog/src/hooks/useStarredEntites.ts new file mode 100644 index 0000000000..631991e9b0 --- /dev/null +++ b/plugins/catalog/src/hooks/useStarredEntites.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useState, useEffect } from 'react'; +import { useApi, storageApiRef } from '@backstage/core'; +import { useObservable } from 'react-use'; + +export const useStarredEntities = () => { + const storageApi = useApi(storageApiRef); + const settingsStore = storageApi.forBucket('settings'); + const rawStarredItems = settingsStore.get('starredEntities') ?? []; + + const [starredEntities, setStarredEntities] = useState( + new Set(rawStarredItems), + ); + + const observedItems = useObservable( + settingsStore.observe$('starredEntities'), + ); + + useEffect(() => { + if (observedItems?.newValue) { + const currentValue = observedItems?.newValue ?? []; + setStarredEntities(new Set(currentValue)); + } + }, [observedItems?.newValue]); + + return { + starredEntities, + }; +}; diff --git a/plugins/catalog/src/hooks/useStarredEntities.test.tsx b/plugins/catalog/src/hooks/useStarredEntities.test.tsx new file mode 100644 index 0000000000..49890b7238 --- /dev/null +++ b/plugins/catalog/src/hooks/useStarredEntities.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useStarredEntities } from './useStarredEntites'; +import { + ApiProvider, + ApiRegistry, + storageApiRef, + WebStorage, + StorageApi, +} from '@backstage/core'; +import { MockErrorApi } from '@backstage/test-utils'; + +describe('useStarredEntities', () => { + let mockStorage: StorageApi | undefined; + + const wrapper: React.FC<{}> = ({ children }) => { + return ( + + {children} + + ); + }; + + beforeEach(() => { + mockStorage = new WebStorage('@backstage', new MockErrorApi()).forBucket( + Date.now().toString(), // TODO(blam): need something that changes every test run for now until the MockStorage is implemented + ); + }); + it('should return an empty set for when there is no items in storage', async () => { + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + expect(result.current.starredEntities.size).toBe(0); + }); + it('should return a set with the current items when there is items in storage', async () => { + const expectedIds = ['i', 'am', 'some', 'test', 'ids']; + const store = mockStorage?.forBucket('settings'); + await store?.set('starredEntities', expectedIds); + + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + for (const item of expectedIds) { + expect(result.current.starredEntities.has(item)).toBeTruthy(); + } + }); + 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(); + + // 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); + + await waitForNextUpdate(); + + expect(result.current.starredEntities.size).toBe(1); + expect(result.current.starredEntities.has('something')).toBeTruthy(); + }); +}); diff --git a/plugins/catalog/src/types.ts b/plugins/catalog/src/types.ts index 0f91e1ed8e..42f156ecb7 100644 --- a/plugins/catalog/src/types.ts +++ b/plugins/catalog/src/types.ts @@ -157,3 +157,9 @@ export type KindParser = { envelope: DescriptorEnvelope, ): Promise; }; + +export enum FilterGroupItem { + ALL = 'ALL', + STARRED = 'STARRED', + OWNED = 'OWNED', +} diff --git a/yarn.lock b/yarn.lock index 074e666e55..2b7246b230 100644 --- a/yarn.lock +++ b/yarn.lock @@ -891,6 +891,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.5.4": + version "7.10.2" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839" + integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -3279,6 +3286,14 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.3.0.tgz#dc217bfce8e7c34a99c811d73d23feef957b7c1d" + integrity sha512-rE9geI1+HJ6jqXkzzJ6abREbeud6bLF8OmF+Vyc7gBoPwZAEVBYjbC1up5nNoVfYBhO5HUwdD4u9mTehAUeiyw== + dependencies: + "@babel/runtime" "^7.5.4" + "@types/testing-library__react-hooks" "^3.0.0" + "@testing-library/react@^9.3.2": version "9.5.0" resolved "https://registry.npmjs.org/@testing-library/react/-/react-9.5.0.tgz#71531655a7890b61e77a1b39452fbedf0472ca5e" @@ -3910,6 +3925,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@*": + version "16.9.2" + resolved "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.2.tgz#e1c408831e8183e5ad748fdece02214a7c2ab6c5" + integrity sha512-4eJr1JFLIAlWhzDkBCkhrOIWOvOxcCAfQh+jiKg7l/nNZcCIL2MHl2dZhogIFKyHzedVWHaVP1Yydq/Ruu4agw== + dependencies: + "@types/react" "*" + "@types/react-textarea-autosize@^4.3.3": version "4.3.5" resolved "https://registry.npmjs.org/@types/react-textarea-autosize/-/react-textarea-autosize-4.3.5.tgz#6c4d2753fa1864c98c0b2b517f67bb1f6e4c46de" @@ -4067,6 +4089,14 @@ dependencies: "@types/jest" "*" +"@types/testing-library__react-hooks@^3.0.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.2.0.tgz#52f3a109bef06080e3b1e3ae7ea1c014ce859897" + integrity sha512-dE8iMTuR5lzB+MqnxlzORlXzXyCL0EKfzH0w/lau20OpkHD37EaWjZDz0iNG8b71iEtxT4XKGmSKAGVEqk46mw== + dependencies: + "@types/react" "*" + "@types/react-test-renderer" "*" + "@types/testing-library__react@^9.1.2": version "9.1.3" resolved "https://registry.npmjs.org/@types/testing-library__react/-/testing-library__react-9.1.3.tgz#35eca61cc6ea923543796f16034882a1603d7302" @@ -15617,6 +15647,16 @@ react-syntax-highlighter@^12.2.1: prismjs "^1.8.4" refractor "^2.4.1" +react-test-renderer@^16.13.1: + version "16.13.1" + resolved "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" + integrity sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ== + dependencies: + object-assign "^4.1.1" + prop-types "^15.6.2" + react-is "^16.8.6" + scheduler "^0.19.1" + react-textarea-autosize@^7.1.0: version "7.1.2" resolved "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-7.1.2.tgz#70fdb333ef86bcca72717e25e623e90c336e2cda"