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/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 2486cdd6e8..e28b8b0d32 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -894,6 +894,14 @@ export function useStarredEntities(): { 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: // // src/types.d.ts:6:49 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag 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/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/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)} );