diff --git a/.changeset/add-withApis-core-compat-api.md b/.changeset/add-withApis-core-compat-api.md new file mode 100644 index 0000000000..135dcf87c7 --- /dev/null +++ b/.changeset/add-withApis-core-compat-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Added `withApis`, which is a Higher-Order Component for providing APIs as props to a component via `useApiHolder`. diff --git a/.changeset/deprecate-withApis-frontend-plugin-api.md b/.changeset/deprecate-withApis-frontend-plugin-api.md new file mode 100644 index 0000000000..5e0408fd04 --- /dev/null +++ b/.changeset/deprecate-withApis-frontend-plugin-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Deprecated `withApis`, use the `withApis` export from `@backstage/core-compat-api` instead. diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 605c6b2bd9..67ad3f1c97 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app-react": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", diff --git a/packages/core-compat-api/report.api.md b/packages/core-compat-api/report.api.md index ae202c96f7..13d0849000 100644 --- a/packages/core-compat-api/report.api.md +++ b/packages/core-compat-api/report.api.md @@ -22,11 +22,13 @@ import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { JSX as JSX_3 } from 'react/jsx-runtime'; +import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { SubRouteRef } from '@backstage/core-plugin-api'; import { SubRouteRef as SubRouteRef_2 } from '@backstage/frontend-plugin-api'; +import { TypesToApiRefs } from '@backstage/frontend-plugin-api'; // @public export function compatWrapper(element: ReactNode): JSX_3.Element; @@ -143,5 +145,15 @@ export type ToNewRouteRef = ? ExternalRouteRef_2 : never; +// @public +export function withApis( + apis: TypesToApiRefs, +): ( + WrappedComponent: ComponentType, +) => { + (props: PropsWithChildren>): JSX_3.Element; + displayName: string; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 6d8041ef44..b273fae9e4 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -31,3 +31,4 @@ export { convertLegacyRouteRefs, type ToNewRouteRef, } from './convertLegacyRouteRef'; +export { withApis } from './withApis'; diff --git a/packages/core-compat-api/src/withApis.test.tsx b/packages/core-compat-api/src/withApis.test.tsx new file mode 100644 index 0000000000..eb13ec76ee --- /dev/null +++ b/packages/core-compat-api/src/withApis.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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 { createApiRef } from '@backstage/frontend-plugin-api'; +import { + TestApiProvider, + withLogCollector, +} from '@backstage/frontend-test-utils'; +import { render, screen } from '@testing-library/react'; +import { withApis } from './withApis'; + +describe('withApis', () => { + type MyApi = () => string; + const myApiRef = createApiRef({ id: 'my-api' }); + + const MyComponent = withApis({ getMessage: myApiRef })(({ getMessage }) => { + return

message: {getMessage()}

; + }); + + it('should inject APIs as props and set display name', () => { + render( + 'hello']]}> + + , + ); + + expect(screen.getByText('message: hello')).toBeInTheDocument(); + expect(MyComponent.displayName).toBe('withApis(Component)'); + }); + + it('should ignore properties from the prototype', () => { + const otherRef = createApiRef({ id: 'other' }); + const proto = { other: otherRef }; + const props = { getMessage: { enumerable: true, value: myApiRef } }; + const obj = Object.create(proto, props) as { + getMessage: typeof myApiRef; + other: typeof otherRef; + }; + + const WeirdComponent = withApis(obj)(({ getMessage }) => { + return

message: {getMessage()}

; + }); + + render( + 'hello']]}> + + , + ); + + expect(screen.getByText('message: hello')).toBeInTheDocument(); + }); + + it('should throw NotImplementedError if the API is not available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{my-api}'); + }).error, + ).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'No implementation available for apiRef{my-api}', + ), + ]), + ); + }); +}); diff --git a/packages/core-compat-api/src/withApis.tsx b/packages/core-compat-api/src/withApis.tsx new file mode 100644 index 0000000000..fc7fc548ee --- /dev/null +++ b/packages/core-compat-api/src/withApis.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 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 { ComponentType, PropsWithChildren } from 'react'; +import { TypesToApiRefs, useApiHolder } from '@backstage/frontend-plugin-api'; +import { NotImplementedError } from '@backstage/errors'; + +/** + * Wrapper for giving component an API context. + * + * @param apis - APIs for the context. + * @public + */ +export function withApis(apis: TypesToApiRefs) { + return function withApisWrapper( + WrappedComponent: ComponentType, + ) { + const Hoc = (props: PropsWithChildren>) => { + const apiHolder = useApiHolder(); + + const impls = {} as T; + + for (const key in apis) { + if (Object.hasOwn(apis, key)) { + const ref = apis[key]; + + const api = apiHolder.get(ref); + if (!api) { + throw new NotImplementedError( + `No implementation available for ${ref}`, + ); + } + impls[key] = api; + } + } + + return ; + }; + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + Hoc.displayName = `withApis(${displayName})`; + + return Hoc; + }; +} diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index b73d5710aa..c91ec89fed 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -2312,7 +2312,7 @@ export const vmwareCloudAuthApiRef: ApiRef< SessionApi >; -// @public +// @public @deprecated export function withApis( apis: TypesToApiRefs, ): ( diff --git a/packages/frontend-plugin-api/src/apis/system/useApi.tsx b/packages/frontend-plugin-api/src/apis/system/useApi.tsx index 30e39033f7..5a8f5d5dab 100644 --- a/packages/frontend-plugin-api/src/apis/system/useApi.tsx +++ b/packages/frontend-plugin-api/src/apis/system/useApi.tsx @@ -57,6 +57,7 @@ export function useApi(apiRef: ApiRef): T { * Wrapper for giving component an API context. * * @param apis - APIs for the context. + * @deprecated Use `withApis` from `@backstage/core-compat-api` instead. * @public */ export function withApis(apis: TypesToApiRefs) { diff --git a/yarn.lock b/yarn.lock index 180d0d7521..b6c5872c67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3580,6 +3580,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^"