frontend-plugin-api: add useAppNode

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-05-15 09:36:32 +02:00
parent c38c9e8169
commit 6f48f718b0
6 changed files with 179 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new `useAppNode` hook, which can be used to get a reference to the `AppNode` from by the closest `ExtensionBoundary`.
@@ -1830,6 +1830,9 @@ export { useApi };
export { useApiHolder };
// @public
export function useAppNode(): AppNode | undefined;
// @public
export function useComponentRef<T extends {}>(
ref: ComponentRef<T>,
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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 {
createVersionedContext,
createVersionedValueMap,
} from '@backstage/version-bridge';
import { AppNode } from '../apis';
import { renderHook } from '@testing-library/react';
import { AppNodeProvider, useAppNode } from './AppNodeProvider';
import { withLogCollector } from '@backstage/test-utils';
describe('AppNodeProvider', () => {
it('should provide app node context to children', () => {
const node = { id: 'test' } as unknown as AppNode;
const { result } = renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<AppNodeProvider node={node}>{children}</AppNodeProvider>
),
});
expect(result.current).toBe(node);
});
it('should return undefined when used outside provider', () => {
const { result } = renderHook(() => useAppNode());
expect(result.current).toBeUndefined();
});
it('should return the closest app node', () => {
const node1 = { id: 'test1' } as unknown as AppNode;
const node2 = { id: 'test2' } as unknown as AppNode;
const { result } = renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<AppNodeProvider node={node1}>
<AppNodeProvider node={node2}>{children}</AppNodeProvider>
</AppNodeProvider>
),
});
expect(result.current).toBe(node2);
});
it('should throw error for invalid context version', () => {
const node = { id: 'test' } as unknown as AppNode;
const Context = createVersionedContext('app-node-context');
const value = createVersionedValueMap({ 2: { node } });
const { error } = withLogCollector(() => {
expect(() =>
renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<Context.Provider value={value}>{children}</Context.Provider>
),
}),
).toThrow('AppNodeContext v1 not available');
});
expect(error).toEqual([
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.stringContaining(
'The above error occurred in the <TestComponent> component:',
),
]);
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2025 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 {
createVersionedContext,
createVersionedValueMap,
useVersionedContext,
} from '@backstage/version-bridge';
import { AppNode } from '../apis';
import { ReactNode } from 'react';
const CONTEXT_KEY = 'app-node-context';
type AppNodeContextV1 = {
node?: AppNode;
};
type AppNodeContextMap = {
1: AppNodeContextV1;
};
const AppNodeContext = createVersionedContext<AppNodeContextMap>(CONTEXT_KEY);
/** @internal */
export function AppNodeProvider({
node,
children,
}: {
node: AppNode;
children: ReactNode;
}) {
const versionedValue = createVersionedValueMap({ 1: { node } });
return <AppNodeContext.Provider value={versionedValue} children={children} />;
}
/**
* React hook providing access to the current {@link AppNode}.
*
* @public
* @remarks
*
* This hook will return the {@link AppNode} for the closest extension. This
* relies on the extension using the {@link (ExtensionBoundary:function)} component in its
* implementation, which is included by default for all common blueprints.
*
* If the current component is not inside an {@link (ExtensionBoundary:function)}, it will
* return `undefined`.
*/
export function useAppNode(): AppNode | undefined {
const versionedContext = useVersionedContext<AppNodeContextMap>(CONTEXT_KEY);
if (!versionedContext) {
return undefined;
}
const context = versionedContext.atVersion(1);
if (!context) {
throw new Error('AppNodeContext v1 not available');
}
return context.node;
}
@@ -28,6 +28,7 @@ import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/ana
import { AppNode, useComponentRef } from '../apis';
import { coreComponentRefs } from './coreComponentRefs';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
type RouteTrackerProps = PropsWithChildren<{
disableTracking?: boolean;
@@ -80,15 +81,17 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
};
return (
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
<AppNodeProvider node={node}>
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
</AppNodeProvider>
);
}
@@ -20,3 +20,4 @@ export {
} from './ExtensionBoundary';
export { coreComponentRefs } from './coreComponentRefs';
export { createComponentRef, type ComponentRef } from './createComponentRef';
export { useAppNode } from './AppNodeProvider';