diff --git a/.changeset/cool-deers-cough.md b/.changeset/cool-deers-cough.md new file mode 100644 index 0000000000..6d3e923d7d --- /dev/null +++ b/.changeset/cool-deers-cough.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': patch +--- + +Introduce a new `StarredEntitiesApi` that is used in the `useStarredEntities` hook. +The `@backstage/plugin-catalog` installs a default implementation that is backed by the `StorageApi`, but one can also override the `starredEntitiesApiRef`. diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 955e477f3c..089cb9c7cf 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -14,13 +14,17 @@ * limitations under the License. */ -import React from 'react'; -import { EntityLayout } from '@backstage/plugin-catalog'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { cicdContent } from './EntityPage'; -import { githubActionsApiRef } from '@backstage/plugin-github-actions'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { EntityLayout } from '@backstage/plugin-catalog'; +import { + DefaultStarredEntitiesApi, + EntityProvider, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { githubActionsApiRef } from '@backstage/plugin-github-actions'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { cicdContent } from './EntityPage'; describe('EntityPage Test', () => { const entity = { @@ -48,7 +52,10 @@ describe('EntityPage Test', () => { downloadJobLogsForWorkflowRun: jest.fn(), } as jest.Mocked; - const apis = ApiRegistry.with(githubActionsApiRef, mockedApi); + const apis = ApiRegistry.with(githubActionsApiRef, mockedApi).with( + starredEntitiesApiRef, + new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), + ); describe('cicdContent', () => { it('Should render GitHub Actions View', async () => { diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index d0b7523ead..292d03c838 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -15,30 +15,31 @@ */ import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model'; -import { - CatalogApi, - catalogApiRef, - entityRouteRef, -} from '@backstage/plugin-catalog-react'; -import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; -import React from 'react'; -import { apiDocsConfigRef } from '../../config'; -import { ApiExplorerPage } from './ApiExplorerPage'; - import { ApiProvider, ApiRegistry, ConfigReader, } from '@backstage/core-app-api'; +import { TableColumn, TableProps } from '@backstage/core-components'; import { - storageApiRef, ConfigApi, configApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; -import { TableColumn, TableProps } from '@backstage/core-components'; -import DashboardIcon from '@material-ui/icons/Dashboard'; import { CatalogTableRow } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + DefaultStarredEntitiesApi, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { starredEntitiesApiRef } from '@backstage/plugin-catalog-react/src/apis'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { apiDocsConfigRef } from '../../config'; +import { ApiExplorerPage } from './ApiExplorerPage'; describe('ApiCatalogPage', () => { const catalogApi: Partial = { @@ -82,6 +83,8 @@ describe('ApiCatalogPage', () => { getApiDefinitionWidget: () => undefined, }; + const storageApi = MockStorageApi.create(); + const renderWrapped = (children: React.ReactNode) => render( wrapInTestApp( @@ -89,7 +92,11 @@ describe('ApiCatalogPage', () => { apis={ApiRegistry.from([ [catalogApiRef, catalogApi], [configApiRef, configApi], - [storageApiRef, MockStorageApi.create()], + [storageApiRef, storageApi], + [ + starredEntitiesApiRef, + new DefaultStarredEntitiesApi({ storageApi }), + ], [apiDocsConfigRef, apiDocsConfig], ])} > diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index ce7282276e..e176d56129 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -16,11 +16,13 @@ import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; import { IconButton } from '@material-ui/core'; import { LinkProps } from '@backstage/core-components'; +import { Observable } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import { StorageApi } from '@backstage/core-plugin-api'; import { SystemEntity } from '@backstage/catalog-model'; import { TableColumn } from '@backstage/core-components'; import { UserEntity } from '@backstage/catalog-model'; @@ -131,6 +133,23 @@ export type DefaultEntityFilters = { text?: EntityTextFilter; }; +// Warning: (ae-missing-release-tag) "DefaultStarredEntitiesApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class DefaultStarredEntitiesApi implements StarredEntitiesApi { + constructor(opts: { storageApi: StorageApi }); + // (undocumented) + isStarred(entity: Entity): boolean; + // (undocumented) + star(entity: Entity): Promise; + // (undocumented) + starredEntities$(): Observable; + // (undocumented) + toggleStarred(entity: Entity): Promise; + // (undocumented) + unstar(entity: Entity): Promise; +} + // Warning: (ae-forgotten-export) The symbol "EntityLoadingStatus" needs to be exported by the entry point index.d.ts // // @public @deprecated (undocumented) @@ -742,6 +761,27 @@ export function reduceEntityFilters( // @public (undocumented) export const rootRoute: RouteRef; +// @public +export interface StarredEntitiesApi { + star(entity: Entity): Promise; + starredEntities$(): Observable; + toggleStarred(entity: Entity): Promise; + unstar(entity: Entity): Promise; +} + +// Warning: (ae-missing-release-tag) "StarredEntitiesApiObservable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type StarredEntitiesApiObservable = { + starredEntities: Set; + isStarred: (entity: Entity) => boolean; +}; + +// Warning: (ae-missing-release-tag) "starredEntitiesApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const starredEntitiesApiRef: ApiRef; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "UnregisterEntityDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index f6b36a7191..9ddee679c3 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -45,7 +45,8 @@ "qs": "^6.9.4", "react": "^16.13.1", "react-router": "6.0.0-beta.0", - "react-use": "^17.2.4" + "react-use": "^17.2.4", + "zen-observable": "^0.8.15" }, "devDependencies": { "@backstage/cli": "^0.7.15", @@ -56,6 +57,7 @@ "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/jwt-decode": "^3.1.0", + "@types/zen-observable": "^0.8.0", "cross-fetch": "^3.0.6", "react-test-renderer": "^16.13.1" }, diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts new file mode 100644 index 0000000000..6a4f90200b --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from '@backstage/test-utils'; +import { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi'; + +describe('DefaultStarredEntitiesApi', () => { + let mockStorage: StorageApi; + let starredEntitiesApi: DefaultStarredEntitiesApi; + + const mockEntity: Entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock', + }, + }; + + beforeEach(() => { + mockStorage = MockStorageApi.create(); + starredEntitiesApi = new DefaultStarredEntitiesApi({ + storageApi: mockStorage, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('toggleStarred', () => { + it('should star unstarred entity', async () => { + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false); + + await starredEntitiesApi.toggleStarred(mockEntity); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true); + }); + + it('should unstar starred entity', async () => { + const bucket = mockStorage.forBucket('settings'); + await bucket.set('starredEntities', ['entity:Component:default:mock']); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true); + + await starredEntitiesApi.toggleStarred(mockEntity); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false); + }); + }); + + describe('star', () => { + it('should star unstarred entity', async () => { + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false); + + await starredEntitiesApi.star(mockEntity); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true); + }); + + it('should keep starred entity', async () => { + const bucket = mockStorage.forBucket('settings'); + await bucket.set('starredEntities', ['entity:Component:default:mock']); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true); + + await starredEntitiesApi.star(mockEntity); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true); + }); + }); + + describe('unstar', () => { + it('should unstar starred entity', async () => { + const bucket = mockStorage.forBucket('settings'); + await bucket.set('starredEntities', ['entity:Component:default:mock']); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true); + + await starredEntitiesApi.unstar(mockEntity); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false); + }); + + it('should keep unstarred entity', async () => { + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false); + + await starredEntitiesApi.unstar(mockEntity); + + expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false); + }); + }); + + describe('starredEntities$', () => { + const handler = jest.fn(); + + beforeEach(async () => { + await new Promise(resolve => { + starredEntitiesApi.starredEntities$().subscribe({ + next: (...args) => { + handler(...args); + + if (handler.mock.calls.length >= 2) { + resolve(); + } + }, + }); + + const bucket = mockStorage.forBucket('settings'); + bucket.set('starredEntities', ['entity:Component:default:mock']).then(); + }); + }); + + it('should receive updates', async () => { + expect(handler).toBeCalledTimes(2); + expect(handler).toBeCalledWith({ + starredEntities: new Set(), + isStarred: expect.any(Function), + }); + expect(handler).toBeCalledWith({ + starredEntities: new Set(['entity:Component:default:mock']), + isStarred: expect.any(Function), + }); + }); + + it('should receive isStarred function that operates on the latest state', async () => { + expect(handler.mock.calls[0][0].isStarred(mockEntity)).toBe(true); + expect(handler.mock.calls[1][0].isStarred(mockEntity)).toBe(true); + }); + }); +}); diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts new file mode 100644 index 0000000000..fce6541dca --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { Observable, StorageApi } from '@backstage/core-plugin-api'; +import ObservableImpl from 'zen-observable'; +import { + StarredEntitiesApi, + StarredEntitiesApiObservable, +} from './StarredEntitiesApi'; + +const buildEntityKey = (component: Entity) => + `entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${ + component.metadata.name + }`; + +export class DefaultStarredEntitiesApi implements StarredEntitiesApi { + private readonly settingsStore: StorageApi; + private starredEntities: Set; + + constructor(opts: { storageApi: StorageApi }) { + this.settingsStore = opts.storageApi.forBucket('settings'); + + this.starredEntities = new Set( + this.settingsStore.get('starredEntities') ?? [], + ); + + this.settingsStore.observe$('starredEntities').subscribe({ + next: next => { + this.starredEntities = new Set(next.newValue ?? []); + this.notifyChanges(); + }, + }); + } + + async toggleStarred(entity: Entity): Promise { + const entityKey = buildEntityKey(entity); + + if (this.starredEntities.has(entityKey)) { + await this.unstar(entity); + } else { + await this.star(entity); + } + } + + async star(entity: Entity): Promise { + const entityKey = buildEntityKey(entity); + + this.starredEntities.add(entityKey); + + await this.settingsStore.set( + 'starredEntities', + Array.from(this.starredEntities), + ); + } + + async unstar(entity: Entity): Promise { + const entityKey = buildEntityKey(entity); + + this.starredEntities.delete(entityKey); + + await this.settingsStore.set( + 'starredEntities', + Array.from(this.starredEntities), + ); + } + + starredEntities$(): Observable { + return this.observable; + } + + isStarred(entity: Entity): boolean { + const entityKey = buildEntityKey(entity); + return this.starredEntities.has(entityKey); + } + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = + new ObservableImpl(subscriber => { + // forward the the latest value + subscriber.next({ + starredEntities: this.starredEntities, + isStarred: e => this.isStarred(e), + }); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private notifyChanges() { + for (const subscription of this.subscribers) { + subscription.next({ + starredEntities: this.starredEntities, + isStarred: e => this.isStarred(e), + }); + } + } +} diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/StarredEntitiesApi.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/StarredEntitiesApi.ts new file mode 100644 index 0000000000..bf29a425ed --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/StarredEntitiesApi.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { ApiRef, createApiRef, Observable } from '@backstage/core-plugin-api'; + +export const starredEntitiesApiRef: ApiRef = createApiRef({ + id: 'catalog-react.starred-entities', +}); + +export type StarredEntitiesApiObservable = { + /** + * A set of starred entities + */ + starredEntities: Set; + + /** + * A function to check if an entity is starred. + * + * @param entity - the entity to check + * @returns true, if the entity is starred. + */ + isStarred: (entity: Entity) => boolean; +}; + +/** + * An API to store and retrieve starred entities + * + * @public + */ +export interface StarredEntitiesApi { + /** + * Toggle the star state of the entity + * + * @param entity - the entity to be toggled + */ + toggleStarred(entity: Entity): Promise; + + /** + * Star the entity + * + * @param entity - the entity to be starred + */ + star(entity: Entity): Promise; + + /** + * Unstar the entity + * + * @param entity - the entity to be unstarred + */ + unstar(entity: Entity): Promise; + + /** + * Observe the state of starred entities and receive a handler + * to check the star state of an entity. + */ + starredEntities$(): Observable; +} diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/index.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/index.ts new file mode 100644 index 0000000000..c71a795b9e --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 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. + */ + +export { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi'; +export { starredEntitiesApiRef } from './StarredEntitiesApi'; +export type { + StarredEntitiesApi, + StarredEntitiesApiObservable, +} from './StarredEntitiesApi'; diff --git a/plugins/catalog-react/src/apis/index.ts b/plugins/catalog-react/src/apis/index.ts new file mode 100644 index 0000000000..5c7e980890 --- /dev/null +++ b/plugins/catalog-react/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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. + */ + +export * from './StarredEntitiesApi'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 5d711016a4..e448ee73f1 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -14,21 +14,8 @@ * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import qs from 'qs'; -import { act, renderHook } from '@testing-library/react-hooks'; -import { MockStorageApi } from '@backstage/test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; -import { - EntityListProvider, - useEntityListProvider, -} from './useEntityListProvider'; -import { catalogApiRef } from '../api'; -import { UserListFilterKind } from '../types'; -import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters'; -import { EntityKindPicker, UserListPicker } from '../components'; - import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { ConfigApi, @@ -37,6 +24,19 @@ import { identityApiRef, storageApiRef, } from '@backstage/core-plugin-api'; +import { MockStorageApi } from '@backstage/test-utils'; +import { act, renderHook } from '@testing-library/react-hooks'; +import qs from 'qs'; +import React, { PropsWithChildren } from 'react'; +import { catalogApiRef } from '../api'; +import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis'; +import { EntityKindPicker, UserListPicker } from '../components'; +import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters'; +import { UserListFilterKind } from '../types'; +import { + EntityListProvider, + useEntityListProvider, +} from './useEntityListProvider'; const entities: Entity[] = [ { @@ -81,6 +81,10 @@ const apis = ApiRegistry.from([ [catalogApiRef, mockCatalogApi], [identityApiRef, mockIdentityApi], [storageApiRef, MockStorageApi.create()], + [ + starredEntitiesApiRef, + new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), + ], ]); const wrapper = ({ diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx index 50662312da..0379e49351 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx @@ -14,16 +14,18 @@ * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import { useStarredEntities } from './useStarredEntities'; -import { storageApiRef, StorageApi } from '@backstage/core-plugin-api'; -import { MockErrorApi } from '@backstage/test-utils'; import { Entity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry, WebStorage } from '@backstage/core-app-api'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from '@backstage/test-utils'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis'; +import { useStarredEntities } from './useStarredEntities'; describe('useStarredEntities', () => { - let mockStorage: StorageApi | undefined; + let mockStorage: StorageApi; + let wrapper: React.ComponentType; const mockEntity: Entity = { apiVersion: '1', @@ -42,41 +44,56 @@ describe('useStarredEntities', () => { }, }; - const wrapper = ({ children }: PropsWithChildren<{}>) => { - return ( - + beforeEach(() => { + mockStorage = MockStorageApi.create(); + wrapper = ({ children }: PropsWithChildren<{}>) => ( + {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 }); + const { result, waitForNextUpdate } = renderHook( + () => useStarredEntities(), + { wrapper }, + ); + + await waitForNextUpdate(); expect(result.current.starredEntities.size).toBe(0); }); + it('should return a set with the current items when there are 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 }); + const { result, waitForNextUpdate } = renderHook( + () => useStarredEntities(), + { wrapper }, + ); + + await waitForNextUpdate(); 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 { result, waitForNextUpdate } = renderHook( () => useStarredEntities(), { wrapper }, ); + await waitForNextUpdate(); + expect(result.current.starredEntities.size).toBe(0); expect(result.current.isStarredEntity(mockEntity)).toBeFalsy(); @@ -91,18 +108,26 @@ describe('useStarredEntities', () => { }); it('should write new entries to the local store when adding a toggling entity', async () => { - const { result } = renderHook(() => useStarredEntities(), { wrapper }); + const { result, waitForNextUpdate } = renderHook( + () => useStarredEntities(), + { wrapper }, + ); act(() => { result.current.toggleStarredEntity(mockEntity); }); + await waitForNextUpdate(); + 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 }); + const { result, waitForNextUpdate } = renderHook( + () => useStarredEntities(), + { wrapper }, + ); act(() => { result.current.toggleStarredEntity(mockEntity); @@ -110,6 +135,8 @@ describe('useStarredEntities', () => { result.current.toggleStarredEntity(mockEntity); }); + await waitForNextUpdate(); + expect(result.current.isStarredEntity(mockEntity)).toBeFalsy(); expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy(); }); diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.ts b/plugins/catalog-react/src/hooks/useStarredEntities.ts index 6f7b895fc8..56a0ee6c3c 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.ts +++ b/plugins/catalog-react/src/hooks/useStarredEntities.ts @@ -15,56 +15,24 @@ */ import { Entity } from '@backstage/catalog-model'; -import { storageApiRef, useApi } from '@backstage/core-plugin-api'; -import { useCallback, useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { useCallback } from 'react'; import { useObservable } from 'react-use'; - -const buildEntityKey = (component: Entity) => - `entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${ - component.metadata.name - }`; +import { starredEntitiesApiRef } from '../apis'; export const useStarredEntities = () => { - const storageApi = useApi(storageApiRef); - const settingsStore = storageApi.forBucket('settings'); - const rawStarredEntityKeys = - settingsStore.get('starredEntities') ?? []; + const starredEntitiesApi = useApi(starredEntitiesApiRef); - const [starredEntities, setStarredEntities] = useState( - new Set(rawStarredEntityKeys), + const { starredEntities, isStarred: isStarredEntity } = useObservable( + starredEntitiesApi.starredEntities$(), + { starredEntities: new Set(), isStarred: _ => false }, ); - const observedItems = useObservable( - settingsStore.observe$('starredEntities'), - ); - - useEffect(() => { - if (observedItems?.newValue) { - const currentValue = observedItems?.newValue ?? []; - setStarredEntities(new Set(currentValue)); - } - }, [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)); + starredEntitiesApi.toggleStarred(entity).then(); }, - [starredEntities, settingsStore], - ); - - const isStarredEntity = useCallback( - (entity: Entity) => { - const entityKey = buildEntityKey(entity); - return starredEntities.has(entityKey); - }, - [starredEntities], + [starredEntitiesApi], ); return { diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts index 1ac67d514b..cf7a679f7a 100644 --- a/plugins/catalog-react/src/index.ts +++ b/plugins/catalog-react/src/index.ts @@ -23,6 +23,7 @@ export type { CatalogApi } from '@backstage/catalog-client'; export { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; export { catalogApiRef } from './api'; +export * from './apis'; export * from './components'; export * from './hooks'; export * from './filters'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 48ec1ef176..6b33b33b08 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -20,28 +20,32 @@ import { RELATION_MEMBER_OF, RELATION_OWNED_BY, } from '@backstage/catalog-model'; -import { TableColumn, TableProps } from '@backstage/core-components'; -import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; -import { - MockStorageApi, - renderWithEffects, - wrapInTestApp, - mockBreakpoint, -} from '@backstage/test-utils'; -import { fireEvent, screen } from '@testing-library/react'; -import React from 'react'; -import { createComponentRouteRef } from '../../routes'; -import { EntityRow } from '../CatalogTable/types'; -import { CatalogPage } from './CatalogPage'; -import DashboardIcon from '@material-ui/icons/Dashboard'; - import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { TableColumn, TableProps } from '@backstage/core-components'; import { IdentityApi, identityApiRef, ProfileInfo, storageApiRef, } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + DefaultStarredEntitiesApi, + entityRouteRef, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { + mockBreakpoint, + MockStorageApi, + renderWithEffects, + wrapInTestApp, +} from '@backstage/test-utils'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; +import { createComponentRouteRef } from '../../routes'; +import { EntityRow } from '../CatalogTable'; +import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { const origReplaceState = window.history.replaceState; @@ -120,6 +124,7 @@ describe('CatalogPage', () => { getIdToken: async () => undefined, getProfile: () => testProfile, }; + const storageApi = MockStorageApi.create(); const renderWrapped = (children: React.ReactNode) => renderWithEffects( @@ -128,7 +133,11 @@ describe('CatalogPage', () => { apis={ApiRegistry.from([ [catalogApiRef, catalogApi], [identityApiRef, identityApi], - [storageApiRef, MockStorageApi.create()], + [storageApiRef, storageApi], + [ + starredEntitiesApiRef, + new DefaultStarredEntitiesApi({ storageApi }), + ], ])} > {children} diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 6c80e64fa9..822913a385 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -15,19 +15,22 @@ */ import { + EDIT_URL_ANNOTATION, Entity, VIEW_URL_ANNOTATION, - EDIT_URL_ANNOTATION, } from '@backstage/catalog-model'; -import { act, fireEvent } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; -import * as React from 'react'; -import { CatalogTable } from './CatalogTable'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { entityRouteRef, + DefaultStarredEntitiesApi, MockEntityListContextProvider, + starredEntitiesApiRef, UserListFilter, } from '@backstage/plugin-catalog-react'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { act, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { CatalogTable } from './CatalogTable'; const entities: Entity[] = [ { @@ -48,6 +51,11 @@ const entities: Entity[] = [ ]; describe('CatalogTable component', () => { + const mockApis = ApiRegistry.with( + starredEntitiesApiRef, + new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), + ); + beforeEach(() => { window.open = jest.fn(); }); @@ -58,9 +66,11 @@ describe('CatalogTable component', () => { it('should render error message', async () => { const rendered = await renderInTestApp( - - - , + + + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -75,20 +85,22 @@ describe('CatalogTable component', () => { it('should display entity names when loading has finished and no error occurred', async () => { const rendered = await renderInTestApp( - false, - () => false, - ), - }, - }} - > - - , + + false, + () => false, + ), + }, + }} + > + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -112,9 +124,11 @@ describe('CatalogTable component', () => { }; const { getByTitle } = await renderInTestApp( - - - , + + + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -142,9 +156,11 @@ describe('CatalogTable component', () => { }; const { getByTitle } = await renderInTestApp( - - - , + + + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 7722f6ba17..2216070984 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -13,24 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; import { - catalogApiRef, - EntityProvider, AsyncEntityProvider, + catalogApiRef, + DefaultStarredEntitiesApi, + EntityProvider, entityRouteRef, + starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { Route, Routes } from 'react-router'; import { EntityLayout } from './EntityLayout'; -import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; - const mockEntity = { kind: 'MyKind', metadata: { @@ -38,10 +40,12 @@ const mockEntity = { }, } as Entity; -const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi).with( - alertApiRef, - {} as AlertApi, -); +const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi) + .with(alertApiRef, {} as AlertApi) + .with( + starredEntitiesApiRef, + new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), + ); describe('EntityLayout', () => { it('renders simplest case', async () => { diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 9b90a44aff..20f937b2e7 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -18,7 +18,9 @@ import { CatalogClient } from '@backstage/catalog-client'; import { catalogApiRef, catalogRouteRef, + DefaultStarredEntitiesApi, entityRouteRef, + starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; import { CatalogClientWrapper } from './CatalogClientWrapper'; import { createComponentRouteRef, viewTechDocRouteRef } from './routes'; @@ -29,6 +31,7 @@ import { createRoutableExtension, discoveryApiRef, identityApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; export const catalogPlugin = createPlugin({ @@ -43,6 +46,13 @@ export const catalogPlugin = createPlugin({ identityApi, }), }), + + createApiFactory({ + api: starredEntitiesApiRef, + deps: { storageApi: storageApiRef }, + factory: ({ storageApi }) => + new DefaultStarredEntitiesApi({ storageApi }), + }), ], routes: { catalogIndex: catalogRouteRef, diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 48ea67585b..0ba1f510cd 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -14,12 +14,6 @@ * limitations under the License. */ -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { DefaultTechDocsHome } from './DefaultTechDocsHome'; - import { ApiProvider, ApiRegistry, @@ -30,7 +24,17 @@ import { configApiRef, storageApiRef, } from '@backstage/core-plugin-api'; +import { + CatalogApi, + catalogApiRef, + DefaultStarredEntitiesApi, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; import { rootDocsRouteRef } from '../../routes'; +import { DefaultTechDocsHome } from './DefaultTechDocsHome'; jest.mock('@backstage/plugin-catalog-react', () => { const actual = jest.requireActual('@backstage/plugin-catalog-react'); @@ -63,10 +67,13 @@ describe('TechDocs Home', () => { }, }); + const storageApi = MockStorageApi.create(); + const apiRegistry = ApiRegistry.from([ [catalogApiRef, mockCatalogApi], [configApiRef, configApi], [storageApiRef, MockStorageApi.create()], + [starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi })], ]); it('should render a TechDocs home page', async () => {