diff --git a/.changeset/sour-eggs-kick.md b/.changeset/sour-eggs-kick.md new file mode 100644 index 0000000000..f796b173fb --- /dev/null +++ b/.changeset/sour-eggs-kick.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-react': patch +--- + +Added the following deprecations to the `catalog-react` package: + +- **DEPRECATION**: `useEntity` will now warn if the entity has not yet been loaded, and will soon throw errors instead. If you're using the default implementation of `EntityLayout` and `EntitySwitch` then these components will ensure that there is an entity loaded before rendering children. If you're implementing your own `EntityLayout` or `EntitySwitch` or something that operates outside or adjacent to them, then use `useAsyncEntity`. + +- **DEPRECATION**: the `loading`, `error` and `refresh` properties that are returned from `useEntity` have been deprecated, and are available on `useAsyncEntity` instead. diff --git a/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx b/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx index 34d12aa032..e6355d861f 100644 --- a/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx +++ b/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx @@ -23,6 +23,7 @@ import { setupRequestMockHandlers, renderInTestApp, } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; describe('ExampleComponent', () => { const server = setupServer(); @@ -39,7 +40,15 @@ describe('ExampleComponent', () => { it('should render', async () => { const rendered = await renderInTestApp( - + + + , ); expect(rendered.getByText('Missing Annotation')).toBeInTheDocument(); diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 2d2b56b634..9468074455 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -210,8 +210,8 @@ export const EntityListProvider: ({ }: PropsWithChildren<{}>) => JSX.Element; // @public (undocumented) -export type EntityLoadingStatus = { - entity?: Entity; +export type EntityLoadingStatus = { + entity?: TEntity; loading: boolean; error?: Error; refresh?: VoidFunction; @@ -507,11 +507,16 @@ export type UnregisterEntityDialogProps = { }; // @public -export function useEntity(): { - entity: T; +export function useAsyncEntity< + TEntity extends Entity = Entity, +>(): EntityLoadingStatus; + +// @public +export function useEntity(): { + entity: TEntity; loading: boolean; - error: Error | undefined; - refresh: VoidFunction | undefined; + error?: Error; + refresh?: VoidFunction; }; // @public @deprecated diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 3e6ef7c945..da23d54ad7 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -18,6 +18,7 @@ export { useEntityFromUrl, EntityProvider, AsyncEntityProvider, + useAsyncEntity, } from './useEntity'; export type { EntityLoadingStatus, diff --git a/plugins/catalog-react/src/hooks/useEntity.tsx b/plugins/catalog-react/src/hooks/useEntity.tsx index 5cb67cb79a..da3fb8f3a5 100644 --- a/plugins/catalog-react/src/hooks/useEntity.tsx +++ b/plugins/catalog-react/src/hooks/useEntity.tsx @@ -27,8 +27,8 @@ import { catalogApiRef } from '../api'; import { useEntityCompoundName } from './useEntityCompoundName'; /** @public */ -export type EntityLoadingStatus = { - entity?: Entity; +export type EntityLoadingStatus = { + entity?: TEntity; loading: boolean; error?: Error; refresh?: VoidFunction; @@ -130,24 +130,25 @@ export const useEntityFromUrl = (): EntityLoadingStatus => { }; /** - * Grab the current entity from the context and its current loading state. + * Grab the current entity from the context, throws if the entity has not yet been loaded + * or is not available. * * @public */ -export function useEntity() { +export function useEntity(): { + entity: TEntity; + /** @deprecated use {@link useAsyncEntity} instead */ + loading: boolean; + /** @deprecated use {@link useAsyncEntity} instead */ + error?: Error; + /** @deprecated use {@link useAsyncEntity} instead */ + refresh?: VoidFunction; +} { const versionedHolder = useVersionedContext<{ 1: EntityLoadingStatus }>('entity-context'); if (!versionedHolder) { - // TODO(Rugvip): Throw this once we fully migrate to the new context - // throw new Error('Entity context is not available'); - - return { - entity: undefined as unknown as T, - loading: true, - error: undefined, - refresh: () => {}, - }; + throw new Error('Entity context is not available'); } const value = versionedHolder.atVersion(1); @@ -155,6 +156,40 @@ export function useEntity() { throw new Error('EntityContext v1 not available'); } + if (!value.entity) { + // Once we have removed the additional fields from being returned we can drop this deprecation + // and move to the error instead. + // throw new Error('useEntity hook is being called outside of an EntityLayout where the entity has not been loaded. If this is intentional, please use useAsyncEntity instead.'); + + // eslint-disable-next-line no-console + console.warn( + 'DEPRECATION: useEntity hook is being called outside of an EntityLayout where the entity has not been loaded. If this is intentional, please use useAsyncEntity instead. This warning will be replaced with an error in future releases.', + ); + } + const { entity, loading, error, refresh } = value; - return { entity: entity as T, loading, error, refresh }; + return { entity: entity as TEntity, loading, error, refresh }; +} + +/** + * Grab the current entity from the context, provides loading state and errors, and the ability to refresh. + * + * @public + */ +export function useAsyncEntity< + TEntity extends Entity = Entity, +>(): EntityLoadingStatus { + const versionedHolder = + useVersionedContext<{ 1: EntityLoadingStatus }>('entity-context'); + + if (!versionedHolder) { + throw new Error('Entity context is not available'); + } + const value = versionedHolder.atVersion(1); + if (!value) { + throw new Error('EntityContext v1 not available'); + } + + const { entity, loading, error, refresh } = value; + return { entity: entity as TEntity, loading, error, refresh }; } diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 634662f386..40c4f9e873 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -40,7 +40,7 @@ import { getEntityRelations, InspectEntityDialog, UnregisterEntityDialog, - useEntity, + useAsyncEntity, useEntityCompoundName, } from '@backstage/plugin-catalog-react'; import { Box, TabProps } from '@material-ui/core'; @@ -177,7 +177,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { children, } = props; const { kind, namespace, name } = useEntityCompoundName(); - const { entity, loading, error } = useEntity(); + const { entity, loading, error } = useAsyncEntity(); const location = useLocation(); const routes = useElementFilter( children, @@ -190,7 +190,9 @@ export const EntityLayout = (props: EntityLayoutProps) => { }) .getElements() // all nodes, element data, maintain structure or not? .flatMap(({ props: elementProps }) => { - if (elementProps.if && entity && !elementProps.if(entity)) { + if (!entity) { + return []; + } else if (elementProps.if && !elementProps.if(entity)) { return []; } diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index 4433517247..1b8667ce2a 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; +import { useAsyncEntity } from '@backstage/plugin-catalog-react'; import React, { ReactNode, ReactElement } from 'react'; import { attachComponentData, @@ -63,7 +63,7 @@ export interface EntitySwitchProps { /** @public */ export const EntitySwitch = (props: EntitySwitchProps) => { - const { entity } = useEntity(); + const { entity } = useAsyncEntity(); const apis = useApiHolder(); const results = useElementFilter( props.children, @@ -75,6 +75,9 @@ export const EntitySwitch = (props: EntitySwitchProps) => { }) .getElements() .flatMap((element: ReactElement) => { + if (!entity) { + return []; + } const { if: condition, children: elementsChildren } = element.props as EntitySwitchCase; return [ diff --git a/plugins/catalog/src/components/EntitySwitch/conditions.ts b/plugins/catalog/src/components/EntitySwitch/conditions.ts index a02170cc6f..907aa45afe 100644 --- a/plugins/catalog/src/components/EntitySwitch/conditions.ts +++ b/plugins/catalog/src/components/EntitySwitch/conditions.ts @@ -27,7 +27,7 @@ function strCmp(a: string | undefined, b: string | undefined): boolean { * @public */ export function isKind(kind: string) { - return (entity: Entity) => strCmp(entity?.kind, kind); + return (entity: Entity) => strCmp(entity.kind, kind); } /** @@ -36,7 +36,7 @@ export function isKind(kind: string) { */ export function isComponentType(type: string) { return (entity: Entity) => { - if (!strCmp(entity?.kind, 'component')) { + if (!strCmp(entity.kind, 'component')) { return false; } const componentEntity = entity as ComponentEntity; @@ -49,5 +49,5 @@ export function isComponentType(type: string) { * @public */ export function isNamespace(namespace: string) { - return (entity: Entity) => strCmp(entity?.metadata?.namespace, namespace); + return (entity: Entity) => strCmp(entity.metadata?.namespace, namespace); } diff --git a/plugins/todo/src/plugin.test.tsx b/plugins/todo/src/plugin.test.tsx index 57b9be291d..af4704c16f 100644 --- a/plugins/todo/src/plugin.test.tsx +++ b/plugins/todo/src/plugin.test.tsx @@ -19,6 +19,7 @@ import { Route } from 'react-router'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { todoPlugin, EntityTodoContent } from './plugin'; import { todoApiRef } from './api'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; describe('todo', () => { it('should export plugin', () => { @@ -47,7 +48,15 @@ describe('todo', () => { ], ]} > - } /> + + } /> + , );