diff --git a/.changeset/cool-deers-cough.md b/.changeset/cool-deers-cough.md new file mode 100644 index 0000000000..4f4921db8d --- /dev/null +++ b/.changeset/cool-deers-cough.md @@ -0,0 +1,10 @@ +--- +'@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`. + +This change also updates the storage format from a custom string to an entity reference and moves the location in the local storage. +A migration will convert the previously starred entities to the location on the first load of Backstage. diff --git a/.changeset/gorgeous-laws-report.md b/.changeset/gorgeous-laws-report.md new file mode 100644 index 0000000000..47e5aa301f --- /dev/null +++ b/.changeset/gorgeous-laws-report.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Introduce a `useStarredEntity` hook to check if a single entity is starred. +It provides a more efficient implementation compared to the `useStarredEntities` hook, because the rendering is only triggered if the selected entity is starred, not if _any_ entity is starred. diff --git a/.changeset/stupid-elephants-invent.md b/.changeset/stupid-elephants-invent.md new file mode 100644 index 0000000000..11b87a667d --- /dev/null +++ b/.changeset/stupid-elephants-invent.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Store the namespaced bucket storage for each instance that was created with `MockStorage.create()` instead of global variable. 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/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts index fabfa989e2..a3028f8b57 100644 --- a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { MockStorageApi } from './MockStorageApi'; import { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from './MockStorageApi'; describe('WebStorage Storage API', () => { const createMockStorage = (): StorageApi => { @@ -124,7 +124,7 @@ describe('WebStorage Storage API', () => { expect(secondStorage.get(keyName)).toBe('deerp'); }); - it('should not clash with other namesapces when creating buckets', async () => { + it('should not clash with other namespaces when creating buckets', async () => { const rootStorage = createMockStorage(); // when getting key test2 it will translate to /profile/something/deep/test2 @@ -139,4 +139,17 @@ describe('WebStorage Storage API', () => { expect(secondStorage.get('deep/test2')).toBe(undefined); }); + + it('should not reuse storage instances between different rootStorages', async () => { + const rootStorage1 = createMockStorage(); + const rootStorage2 = createMockStorage(); + + const firstStorage = rootStorage1.forBucket('something'); + const secondStorage = rootStorage2.forBucket('something'); + + await firstStorage.set('test2', true); + + expect(firstStorage.get('test2')).toBe(true); + expect(secondStorage.get('test2')).toBe(undefined); + }); }); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts index 9f21b4e4c8..ab5d26afc5 100644 --- a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -23,29 +23,37 @@ import ObservableImpl from 'zen-observable'; export type MockStorageBucket = { [key: string]: any }; -const bucketStorageApis = new Map(); - export class MockStorageApi implements StorageApi { private readonly namespace: string; private readonly data: MockStorageBucket; + private readonly bucketStorageApis: Map; - private constructor(namespace: string, data?: MockStorageBucket) { + private constructor( + namespace: string, + bucketStorageApis: Map, + data?: MockStorageBucket, + ) { this.namespace = namespace; + this.bucketStorageApis = bucketStorageApis; this.data = { ...data }; } static create(data?: MockStorageBucket) { - return new MockStorageApi('', data); + return new MockStorageApi('', new Map(), data); } forBucket(name: string): StorageApi { - if (!bucketStorageApis.has(name)) { - bucketStorageApis.set( + if (!this.bucketStorageApis.has(name)) { + this.bucketStorageApis.set( name, - new MockStorageApi(`${this.namespace}/${name}`, this.data), + new MockStorageApi( + `${this.namespace}/${name}`, + this.bucketStorageApis, + this.data, + ), ); } - return bucketStorageApis.get(name)!; + return this.bucketStorageApis.get(name)!; } get(key: string): T | undefined { diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index d0b7523ead..cf40e5160c 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, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +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..e28b8b0d32 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,17 @@ export type DefaultEntityFilters = { text?: EntityTextFilter; }; +// @public +export class DefaultStarredEntitiesApi implements StarredEntitiesApi { + constructor(opts: { storageApi: StorageApi }); + // (undocumented) + isStarred(entityRef: string): boolean; + // (undocumented) + starredEntitie$(): Observable>; + // (undocumented) + toggleStarred(entityRef: string): Promise; +} + // Warning: (ae-forgotten-export) The symbol "EntityLoadingStatus" needs to be exported by the entry point index.d.ts // // @public @deprecated (undocumented) @@ -742,6 +755,15 @@ export function reduceEntityFilters( // @public (undocumented) export const rootRoute: RouteRef; +// @public +export interface StarredEntitiesApi { + starredEntitie$(): Observable>; + toggleStarred(entityRef: string): Promise; +} + +// @public +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) // @@ -866,10 +888,18 @@ export const UserListPicker: ({ // Warning: (ae-missing-release-tag) "useStarredEntities" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const useStarredEntities: () => { +export function useStarredEntities(): { starredEntities: Set; - toggleStarredEntity: (entity: Entity) => void; - isStarredEntity: (entity: Entity) => boolean; + toggleStarredEntity: (entityOrRef: Entity | EntityName | string) => void; + isStarredEntity: (entityOrRef: Entity | EntityName | string) => boolean; +}; + +// Warning: (ae-missing-release-tag) "useStarredEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function useStarredEntity(entityOrRef: Entity | EntityName | string): { + toggleStarredEntity: () => void; + isStarredEntity: boolean; }; // Warnings were encountered during analysis: 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..55b992fadf --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from '@backstage/test-utils'; +import { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi'; +import { performMigrationToTheNewBucket } from './migration'; + +jest.mock('./migration'); + +describe('DefaultStarredEntitiesApi', () => { + let mockStorage: StorageApi; + let starredEntitiesApi: DefaultStarredEntitiesApi; + + const mockEntityRef = stringifyEntityRef({ + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock', + }, + }); + + beforeEach(() => { + (performMigrationToTheNewBucket as jest.Mock).mockResolvedValue(undefined); + + mockStorage = MockStorageApi.create(); + starredEntitiesApi = new DefaultStarredEntitiesApi({ + storageApi: mockStorage, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + it('should call migration', () => { + expect(performMigrationToTheNewBucket).toBeCalledTimes(1); + }); + }); + + describe('toggleStarred', () => { + it('should star unstarred entity', async () => { + expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(false); + + await starredEntitiesApi.toggleStarred(mockEntityRef); + + expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(true); + }); + + it('should unstar starred entity', async () => { + const bucket = mockStorage.forBucket('starredEntities'); + await bucket.set('entityRefs', ['component:default/mock']); + + expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(true); + + await starredEntitiesApi.toggleStarred(mockEntityRef); + + expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(false); + }); + }); + + describe('starredEntities$', () => { + const handler = jest.fn(); + + beforeEach(async () => { + await new Promise(resolve => { + starredEntitiesApi.starredEntitie$().subscribe({ + next: (...args) => { + handler(...args); + + if (handler.mock.calls.length >= 2) { + resolve(); + } + }, + }); + + const bucket = mockStorage.forBucket('starredEntities'); + bucket.set('entityRefs', ['component:default/mock']).then(); + }); + }); + + it('should receive updates', async () => { + expect(handler).toBeCalledTimes(2); + expect(handler).toBeCalledWith(new Set()); + expect(handler).toBeCalledWith(new Set(['component:default/mock'])); + }); + }); +}); 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..87d99c8904 --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts @@ -0,0 +1,89 @@ +/* + * 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 { Observable, StorageApi } from '@backstage/core-plugin-api'; +import ObservableImpl from 'zen-observable'; +import { performMigrationToTheNewBucket } from './migration'; +import { StarredEntitiesApi } from './StarredEntitiesApi'; + +/** + * Default implementation of the StarredEntitiesApi that is backed by the StorageApi. + * + * @public + */ +export class DefaultStarredEntitiesApi implements StarredEntitiesApi { + private readonly settingsStore: StorageApi; + private starredEntities: Set; + + constructor(opts: { storageApi: StorageApi }) { + // no need to await. The updated content will be caught by the observe$ + performMigrationToTheNewBucket(opts).then(); + + this.settingsStore = opts.storageApi.forBucket('starredEntities'); + + this.starredEntities = new Set( + this.settingsStore.get('entityRefs') ?? [], + ); + + this.settingsStore.observe$('entityRefs').subscribe({ + next: next => { + this.starredEntities = new Set(next.newValue ?? []); + this.notifyChanges(); + }, + }); + } + + async toggleStarred(entityRef: string): Promise { + if (this.starredEntities.has(entityRef)) { + this.starredEntities.delete(entityRef); + } else { + this.starredEntities.add(entityRef); + } + + await this.settingsStore.set( + 'entityRefs', + Array.from(this.starredEntities), + ); + } + + starredEntitie$(): Observable> { + return this.observable; + } + + isStarred(entityRef: string): boolean { + return this.starredEntities.has(entityRef); + } + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver> + >(); + + private readonly observable = new ObservableImpl>(subscriber => { + // forward the the latest value + subscriber.next(this.starredEntities); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private notifyChanges() { + for (const subscription of this.subscribers) { + subscription.next(this.starredEntities); + } + } +} 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..4c4c68d66a --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/StarredEntitiesApi.ts @@ -0,0 +1,45 @@ +/* + * 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 { ApiRef, createApiRef, Observable } from '@backstage/core-plugin-api'; + +/** + * An API to store starred entities + * + * @public + */ +export const starredEntitiesApiRef: ApiRef = createApiRef({ + id: 'catalog-react.starred-entities', +}); + +/** + * An API to store and retrieve starred entities + * + * @public + */ +export interface StarredEntitiesApi { + /** + * Toggle the star state of the entity + * + * @param entityRef - an entity reference to toggle + */ + toggleStarred(entityRef: string): Promise; + + /** + * Observe the set of starred entity references. + */ + starredEntitie$(): 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..e9f9c8923a --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/index.ts @@ -0,0 +1,19 @@ +/* + * 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 } from './StarredEntitiesApi'; diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts new file mode 100644 index 0000000000..1ff3be811f --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from '@backstage/test-utils'; +import { performMigrationToTheNewBucket } from './migration'; + +describe('performMigrationToTheNewBucket', () => { + let mockStorage: StorageApi; + + beforeEach(() => { + mockStorage = MockStorageApi.create(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should migrate', async () => { + const oldBucket = mockStorage.forBucket('settings'); + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill NEW bucket + await newBucket.set('entityRefs', ['component:default/c']); + + // fill OLD bucket + await oldBucket.set('starredEntities', [ + 'entity:Component:default:a', + 'entity:template:custom:b', + ]); + expect(oldBucket.get('starredEntities')).not.toBeUndefined(); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(await newBucket.get('entityRefs')).toEqual([ + 'component:default/c', + 'component:default/a', + 'template:custom/b', + ]); + + // OLD bucket should be removed + expect(oldBucket.get('starredEntities')).toBeUndefined(); + }); + + it('should ignore invalid entries', async () => { + const oldBucket = mockStorage.forBucket('settings'); + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill OLD bucket + await oldBucket.set('starredEntities', [ + 'entity:Component:default:a', + 1, + 'entity:Component:a', + 'invalid', + ]); + expect(oldBucket.get('starredEntities')).not.toBeUndefined(); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(await newBucket.get('entityRefs')).toEqual(['component:default/a']); + + // OLD bucket should be removed + expect(oldBucket.get('starredEntities')).toBeUndefined(); + }); + + it('should skip migration without old starred entities', async () => { + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill NEW bucket + const expectedEntries = ['component:default/a']; + await newBucket.set('entityRefs', expectedEntries); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(newBucket.get('entityRefs')).toBe(expectedEntries); + }); + + it('should skip migration with non-array old starred entities', async () => { + const oldBucket = mockStorage.forBucket('settings'); + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill OLD bucket with invalid content + await oldBucket.set('starredEntities', 'invalid'); + + // fill NEW bucket + const expectedEntries = ['component:default/a']; + await newBucket.set('entityRefs', expectedEntries); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(newBucket.get('entityRefs')).toBe(expectedEntries); + + // OLD bucket should be unchanged + expect(oldBucket.get('starredEntities')).toBe('invalid'); + }); +}); diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts new file mode 100644 index 0000000000..7147d449ab --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts @@ -0,0 +1,62 @@ +/* + * 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { isArray, isString } from 'lodash'; + +/** + * Migrate the starred entities from the old format (entity:::) from the + * old storage location (/settings/starredEntities) to entity references in the new location + * (/starredEntities/entityRefs). + * + * This will only be executed once since the old location is cleared. + * + * @param storageApi - the StorageApi to migrate + */ +export async function performMigrationToTheNewBucket({ + storageApi, +}: { + storageApi: StorageApi; +}) { + const source = storageApi.forBucket('settings'); + const target = storageApi.forBucket('starredEntities'); + + const oldStarredEntities = source.get('starredEntities'); + + if (!isArray(oldStarredEntities)) { + // nothing to do + return; + } + + const targetEntities = new Set(target.get('entityRefs') ?? []); + + oldStarredEntities + .filter(isString) + // extract the old format 'entity:::' + .map(old => old.split(':')) + // check if the format is valid + .filter(split => split.length === 4 && split[0] === 'entity') + // convert to entity references + .map(([_, kind, namespace, name]) => + stringifyEntityRef({ kind, namespace, name }), + ) + .forEach(e => targetEntities.add(e)); + + await target.set('entityRefs', Array.from(targetEntities)); + + await source.remove('starredEntities'); +} 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/components/FavoriteEntity/FavoriteEntity.tsx b/plugins/catalog-react/src/components/FavoriteEntity/FavoriteEntity.tsx index 95e617ab7f..db60c34b66 100644 --- a/plugins/catalog-react/src/components/FavoriteEntity/FavoriteEntity.tsx +++ b/plugins/catalog-react/src/components/FavoriteEntity/FavoriteEntity.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React, { ComponentProps } from 'react'; -import { useStarredEntities } from '../../hooks/useStarredEntities'; -import { IconButton, Tooltip, withStyles } from '@material-ui/core'; -import StarBorder from '@material-ui/icons/StarBorder'; -import Star from '@material-ui/icons/Star'; import { Entity } from '@backstage/catalog-model'; +import { IconButton, Tooltip, withStyles } from '@material-ui/core'; +import Star from '@material-ui/icons/Star'; +import StarBorder from '@material-ui/icons/StarBorder'; +import React, { ComponentProps } from 'react'; +import { useStarredEntity } from '../../hooks/useStarredEntity'; type Props = ComponentProps & { entity: Entity }; @@ -40,16 +40,17 @@ export const favoriteEntityIcon = (isStarred: boolean) => * @param props MaterialUI IconButton props extended by required `entity` prop */ export const FavoriteEntity = (props: Props) => { - const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const isStarred = isStarredEntity(props.entity); + const { toggleStarredEntity, isStarredEntity } = useStarredEntity( + props.entity, + ); return ( toggleStarredEntity(props.entity)} + onClick={() => toggleStarredEntity()} > - - {favoriteEntityIcon(isStarred)} + + {favoriteEntityIcon(isStarredEntity)} ); diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 47bca8892a..38fc58222d 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -36,4 +36,5 @@ export { useEntityKinds } from './useEntityKinds'; export { useOwnUser } from './useOwnUser'; export { useRelatedEntities } from './useRelatedEntities'; export { useStarredEntities } from './useStarredEntities'; +export { useStarredEntity } from './useStarredEntity'; export { useEntityOwnership } from './useEntityOwnership'; 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..4f41ef090f 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 store = mockStorage?.forBucket('starredEntities'); + await store?.set('entityRefs', 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..8f6b09614f 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.ts +++ b/plugins/catalog-react/src/hooks/useStarredEntities.ts @@ -14,62 +14,49 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { storageApiRef, useApi } from '@backstage/core-plugin-api'; -import { useCallback, useEffect, useState } from 'react'; +import { + Entity, + EntityName, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { useCallback } from 'react'; import { useObservable } from 'react-use'; +import { starredEntitiesApiRef } from '../apis'; -const buildEntityKey = (component: Entity) => - `entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${ - component.metadata.name - }`; +function getEntityRef(entityOrRef: Entity | EntityName | string): string { + return typeof entityOrRef === 'string' + ? entityOrRef + : stringifyEntityRef(entityOrRef); +} -export const useStarredEntities = () => { - const storageApi = useApi(storageApiRef); - const settingsStore = storageApi.forBucket('settings'); - const rawStarredEntityKeys = - settingsStore.get('starredEntities') ?? []; +export function useStarredEntities(): { + starredEntities: Set; + toggleStarredEntity: (entityOrRef: Entity | EntityName | string) => void; + isStarredEntity: (entityOrRef: Entity | EntityName | string) => boolean; +} { + const starredEntitiesApi = useApi(starredEntitiesApiRef); - const [starredEntities, setStarredEntities] = useState( - new Set(rawStarredEntityKeys), - ); - - 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)); - }, - [starredEntities, settingsStore], + const starredEntities = useObservable( + starredEntitiesApi.starredEntitie$(), + new Set(), ); const isStarredEntity = useCallback( - (entity: Entity) => { - const entityKey = buildEntityKey(entity); - return starredEntities.has(entityKey); - }, + (entityOrRef: Entity | EntityName | string) => + starredEntities.has(getEntityRef(entityOrRef)), [starredEntities], ); + const toggleStarredEntity = useCallback( + (entityOrRef: Entity | EntityName | string) => + starredEntitiesApi.toggleStarred(getEntityRef(entityOrRef)).then(), + [starredEntitiesApi], + ); + return { starredEntities, toggleStarredEntity, isStarredEntity, }; -}; +} diff --git a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx new file mode 100644 index 0000000000..b8577aec0c --- /dev/null +++ b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx @@ -0,0 +1,103 @@ +/* + * 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, EntityName } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import Observable from 'zen-observable'; +import { StarredEntitiesApi, starredEntitiesApiRef } from '../apis'; +import { useStarredEntity } from './useStarredEntity'; + +describe('useStarredEntity', () => { + const mockStarredEntitiesApi: jest.Mocked = { + toggleStarred: jest.fn(), + starredEntitie$: jest.fn(), + }; + let wrapper: React.ComponentType; + + beforeEach(() => { + wrapper = ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe.each` + title | entityOrRef + ${'entity reference'} | ${'component:default/mock'} + ${'entity name'} | ${{ kind: 'component', namespace: 'default', name: 'mock' } as EntityName} + ${'entity'} | ${{ apiVersion: '1', kind: 'Component', metadata: { name: 'mock' } } as Entity} + `('with $title', ({ entityOrRef }) => { + describe('toggleStarredEntity', () => { + it('should toggle starred entity', () => { + mockStarredEntitiesApi.starredEntitie$.mockReturnValue(Observable.of()); + mockStarredEntitiesApi.toggleStarred.mockResolvedValue(); + + const { result } = renderHook(() => useStarredEntity(entityOrRef), { + wrapper, + }); + + result.current.toggleStarredEntity(); + + expect(mockStarredEntitiesApi.toggleStarred).toBeCalledTimes(1); + expect(mockStarredEntitiesApi.toggleStarred).toBeCalledWith( + 'component:default/mock', + ); + }); + }); + + describe('isStarredEntity', () => { + it('should return not starred entity', () => { + mockStarredEntitiesApi.starredEntitie$.mockReturnValue(Observable.of()); + mockStarredEntitiesApi.toggleStarred.mockResolvedValue(); + + const { result } = renderHook(() => useStarredEntity(entityOrRef), { + wrapper, + }); + + expect(result.current.isStarredEntity).toBe(false); + }); + + it('should return starred entity', async () => { + mockStarredEntitiesApi.starredEntitie$.mockReturnValue( + Observable.of(new Set(['component:default/mock'])), + ); + mockStarredEntitiesApi.toggleStarred.mockResolvedValue(); + + const { result, waitForNextUpdate } = renderHook( + () => useStarredEntity(entityOrRef), + { + wrapper, + }, + ); + + // the initial value will always be false because the observable triggers async + expect(result.current.isStarredEntity).toBe(false); + await waitForNextUpdate(); + + expect(result.current.isStarredEntity).toBe(true); + }); + }); + }); +}); diff --git a/plugins/catalog-react/src/hooks/useStarredEntity.ts b/plugins/catalog-react/src/hooks/useStarredEntity.ts new file mode 100644 index 0000000000..0233432196 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useStarredEntity.ts @@ -0,0 +1,61 @@ +/* + * 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, + EntityName, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { useCallback, useEffect, useState } from 'react'; +import { starredEntitiesApiRef } from '../apis'; + +function getEntityRef(entityOrRef: Entity | EntityName | string): string { + return typeof entityOrRef === 'string' + ? entityOrRef + : stringifyEntityRef(entityOrRef); +} + +export function useStarredEntity(entityOrRef: Entity | EntityName | string): { + toggleStarredEntity: () => void; + isStarredEntity: boolean; +} { + const starredEntitiesApi = useApi(starredEntitiesApiRef); + + const [isStarredEntity, setIsStarredEntity] = useState(false); + + useEffect(() => { + const subscription = starredEntitiesApi.starredEntitie$().subscribe({ + next(starredEntities: Set) { + setIsStarredEntity(starredEntities.has(getEntityRef(entityOrRef))); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }, [entityOrRef, starredEntitiesApi]); + + const toggleStarredEntity = useCallback( + () => starredEntitiesApi.toggleStarred(getEntityRef(entityOrRef)).then(), + [entityOrRef, starredEntitiesApi], + ); + + return { + toggleStarredEntity, + isStarredEntity, + }; +} 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/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx index d158315130..2c55ffdf88 100644 --- a/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx +++ b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React, { ComponentProps, useMemo } from 'react'; -import { useStarredEntities } from '@backstage/plugin-catalog-react'; -import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core'; -import StarBorder from '@material-ui/icons/StarBorder'; -import Star from '@material-ui/icons/Star'; import { Entity } from '@backstage/catalog-model'; +import { useStarredEntity } from '@backstage/plugin-catalog-react'; +import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core'; +import Star from '@material-ui/icons/Star'; +import StarBorder from '@material-ui/icons/StarBorder'; +import React, { ComponentProps } from 'react'; type Props = ComponentProps & { entity: Entity }; @@ -56,20 +56,18 @@ export const favouriteTemplateIcon = (isStarred: boolean) => */ export const FavouriteTemplate = (props: Props) => { const classes = useStyles(); - const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const isStarred = useMemo( - () => isStarredEntity(props.entity), - [isStarredEntity, props.entity], + const { toggleStarredEntity, isStarredEntity } = useStarredEntity( + props.entity, ); return ( toggleStarredEntity(props.entity)} + onClick={() => toggleStarredEntity()} > - - {favouriteTemplateIcon(isStarred)} + + {favouriteTemplateIcon(isStarredEntity)} ); diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 48ea67585b..1d8d7d2b04 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()], + [storageApiRef, storageApi], + [starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi })], ]); it('should render a TechDocs home page', async () => {