Introduce a useStarredEntity hook to check if a single entity is starred

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-10-13 13:25:15 +02:00
parent 1dbaaf7801
commit 0366c9b667
7 changed files with 201 additions and 22 deletions
+7
View File
@@ -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.
+8
View File
@@ -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
@@ -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<typeof IconButton> & { 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 (
<IconButton
color="inherit"
{...props}
onClick={() => toggleStarredEntity(props.entity)}
onClick={() => toggleStarredEntity()}
>
<Tooltip title={favoriteEntityTooltip(isStarred)}>
{favoriteEntityIcon(isStarred)}
<Tooltip title={favoriteEntityTooltip(isStarredEntity)}>
{favoriteEntityIcon(isStarredEntity)}
</Tooltip>
</IconButton>
);
+1
View File
@@ -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';
@@ -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<StarredEntitiesApi> = {
toggleStarred: jest.fn(),
starredEntitie$: jest.fn(),
};
let wrapper: React.ComponentType;
beforeEach(() => {
wrapper = ({ children }: PropsWithChildren<{}>) => (
<ApiProvider
apis={ApiRegistry.with(starredEntitiesApiRef, mockStarredEntitiesApi)}
>
{children}
</ApiProvider>
);
});
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);
});
});
});
});
@@ -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<string>) {
setIsStarredEntity(starredEntities.has(getEntityRef(entityOrRef)));
},
});
return () => {
subscription.unsubscribe();
};
}, [entityOrRef, starredEntitiesApi]);
const toggleStarredEntity = useCallback(
() => starredEntitiesApi.toggleStarred(getEntityRef(entityOrRef)).then(),
[entityOrRef, starredEntitiesApi],
);
return {
toggleStarredEntity,
isStarredEntity,
};
}
@@ -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<typeof IconButton> & { 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 (
<IconButton
color="inherit"
className={classes.starButton}
{...props}
onClick={() => toggleStarredEntity(props.entity)}
onClick={() => toggleStarredEntity()}
>
<Tooltip title={favouriteTemplateTooltip(isStarred)}>
{favouriteTemplateIcon(isStarred)}
<Tooltip title={favouriteTemplateTooltip(isStarredEntity)}>
{favouriteTemplateIcon(isStarredEntity)}
</Tooltip>
</IconButton>
);