frontend-plugin-api: add useAppNode
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user