Merge remote-tracking branch 'origin/master' into bui-toast

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor

# Conflicts:
#	packages/frontend-defaults/src/createPublicSignInApp.test.tsx
#	yarn.lock
This commit is contained in:
Patrik Oldsberg
2026-03-17 12:05:59 +01:00
2867 changed files with 84530 additions and 39061 deletions
+76
View File
@@ -1,5 +1,81 @@
# @backstage/plugin-app
## 0.4.1-next.2
### Patch Changes
- 12d8afe: Added `BUIProvider` from `@backstage/ui` to the app root, enabling BUI components to fire analytics events through the Backstage analytics system.
- Updated dependencies
- @backstage/ui@0.13.0-next.2
- @backstage/frontend-plugin-api@0.15.0-next.1
- @backstage/core-plugin-api@1.12.4-next.1
- @backstage/core-components@0.18.8-next.1
- @backstage/plugin-app-react@0.2.1-next.1
## 0.4.1-next.1
### Patch Changes
- Updated dependencies
- @backstage/ui@0.13.0-next.1
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/integration-react@1.2.16-next.1
- @backstage/theme@0.7.2
- @backstage/types@1.2.2
- @backstage/version-bridge@1.0.12
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
## 0.4.1-next.0
### Patch Changes
- 909c742: Switched translation API imports (`translationApiRef`, `appLanguageApiRef`) from the alpha `@backstage/core-plugin-api/alpha` path to the stable `@backstage/frontend-plugin-api` export. This has no effect on runtime behavior.
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/types@1.2.2
- @backstage/version-bridge@1.0.12
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
## 0.4.0
### Minor Changes
- ef6916e: Added `SubPageBlueprint` for creating sub-page tabs, `PluginHeaderActionBlueprint` and `PluginHeaderActionsApi` for plugin-scoped header actions, and `PageLayout` as a swappable component. The `PageBlueprint` now supports sub-pages with tabbed navigation, page title, icon, and header actions. Plugins can now specify a `title` and `icon` in `createFrontendPlugin`.
- 7edb810: **BREAKING**: Extensions created with the following blueprints must now be provided via an override or a module for the `app` plugin. Extensions from other plugins will now trigger a warning in the app and be ignored.
- `IconBundleBlueprint`
- `NavContentBlueprint`
- `RouterBlueprint`
- `SignInPageBlueprint`
- `SwappableComponentBlueprint`
- `ThemeBlueprint`
- `TranslationBlueprint`
### Patch Changes
- a2133be: Added new `NavContentNavItem`, `NavContentNavItems`, and `navItems` prop to `NavContentComponentProps` for auto-discovering navigation items from page extensions. The new `navItems` collection supports `take(id)` and `rest()` methods for placing specific items in custom sidebar positions, as well as `withComponent(Component)` which returns a `NavContentNavItemsWithComponent` for rendering items directly as elements. The existing `items` prop is now deprecated in favor of `navItems`.
- a7e0d50: Updated `react-router-dom` peer dependency to `^6.30.2` and explicitly disabled v7 future flags to suppress deprecation warnings.
- 69d880e: Bump to latest zod to ensure it has the latest features
- Updated dependencies
- @backstage/ui@0.12.0
- @backstage/core-components@0.18.7
- @backstage/theme@0.7.2
- @backstage/frontend-plugin-api@0.14.0
- @backstage/plugin-app-react@0.2.0
- @backstage/core-plugin-api@1.12.3
- @backstage/integration-react@1.2.15
- @backstage/plugin-permission-react@0.4.40
- @backstage/version-bridge@1.0.12
## 0.4.0-next.2
### Patch Changes
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app",
"version": "0.4.0-next.2",
"version": "0.4.1-next.2",
"backstage": {
"role": "frontend-plugin",
"pluginId": "app",
@@ -59,6 +59,7 @@
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/ui": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
+84 -6
View File
@@ -14,10 +14,12 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { NavContentComponent } from '@backstage/plugin-app-react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SignInPageProps } from '@backstage/plugin-app-react';
@@ -476,7 +478,7 @@ const appPlugin: OverridableFrontendPlugin<
icons: ExtensionInput<
ConfigurableExtensionDataRef<
{
[x: string]: IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
@@ -588,6 +590,30 @@ const appPlugin: OverridableFrontendPlugin<
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/plugin-header-actions': OverridableExtensionDefinition<{
config: {};
configInput: {};
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
inputs: {
actions: ExtensionInput<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
kind: 'api';
name: 'plugin-header-actions';
params: <
TApi,
TImpl extends TApi,
TDeps extends { [name in string]: unknown },
>(
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/plugin-wrapper': OverridableExtensionDefinition<{
config: {};
configInput: {};
@@ -595,11 +621,7 @@ const appPlugin: OverridableFrontendPlugin<
inputs: {
wrappers: ExtensionInput<
ConfigurableExtensionDataRef<
() => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>,
() => Promise<PluginWrapperDefinition>,
'core.plugin-wrapper.loader',
{}
>,
@@ -937,6 +959,62 @@ const appPlugin: OverridableFrontendPlugin<
: never;
}>;
}>;
'component:app/core-page-layout': OverridableExtensionDefinition<{
kind: 'component';
name: 'core-page-layout';
config: {};
configInput: {};
output: ExtensionDataRef<
{
ref: SwappableComponentRef;
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
},
'core.swappableComponent',
{}
>;
inputs: {};
params: <Ref extends SwappableComponentRef<any>>(params: {
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? {
ref: Ref;
} & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<
infer IInnerComponentProps,
any
>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<
(props: IInnerComponentProps) => JSX.Element | null
>)
: never;
}) => ExtensionBlueprintParams<{
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? {
ref: Ref;
} & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<
infer IInnerComponentProps,
any
>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<
(props: IInnerComponentProps) => JSX.Element | null
>)
: never;
}>;
}>;
'component:app/core-progress': OverridableExtensionDefinition<{
kind: 'component';
name: 'core-progress';
@@ -0,0 +1,72 @@
/*
* 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 { render, screen } from '@testing-library/react';
import { DefaultPluginHeaderActionsApi } from './DefaultPluginHeaderActionsApi';
describe('DefaultPluginHeaderActionsApi', () => {
it('should return actions for a specific plugin', () => {
const api = DefaultPluginHeaderActionsApi.fromActions([
{
element: <button>Action A</button>,
pluginId: 'plugin-a',
},
{
element: <button>Action B</button>,
pluginId: 'plugin-b',
},
]);
expect(api.getPluginHeaderActions('plugin-a')).toHaveLength(1);
expect(api.getPluginHeaderActions('plugin-b')).toHaveLength(1);
render(<>{api.getPluginHeaderActions('plugin-a')}</>);
expect(
screen.getByRole('button', { name: 'Action A' }),
).toBeInTheDocument();
});
it('should return an empty array for unknown plugins', () => {
const api = DefaultPluginHeaderActionsApi.fromActions([
{
element: <span>Action</span>,
pluginId: 'plugin-a',
},
]);
expect(api.getPluginHeaderActions('unknown-plugin')).toEqual([]);
});
it('should group multiple actions by plugin', () => {
const api = DefaultPluginHeaderActionsApi.fromActions([
{
element: <button>First</button>,
pluginId: 'plugin-a',
},
{
element: <button>Second</button>,
pluginId: 'plugin-a',
},
]);
const actions = api.getPluginHeaderActions('plugin-a');
expect(actions).toHaveLength(2);
render(<>{actions}</>);
expect(screen.getByRole('button', { name: 'First' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Second' })).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
/*
* 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 { JSX } from 'react';
import { type PluginHeaderActionsApi } from '@backstage/frontend-plugin-api';
// Stable reference
const EMPTY_ACTIONS = new Array<JSX.Element | null>();
type ActionInput = {
element: JSX.Element;
pluginId: string;
};
/**
* Default implementation of PluginHeaderActionsApi.
*
* @internal
*/
export class DefaultPluginHeaderActionsApi implements PluginHeaderActionsApi {
constructor(
private readonly actionsByPlugin: Map<string, Array<JSX.Element | null>>,
) {}
getPluginHeaderActions(pluginId: string): Array<JSX.Element | null> {
return this.actionsByPlugin.get(pluginId) ?? EMPTY_ACTIONS;
}
static fromActions(
actions: Array<ActionInput>,
): DefaultPluginHeaderActionsApi {
const actionsByPlugin = new Map<string, Array<JSX.Element | null>>();
for (const action of actions) {
let pluginActions = actionsByPlugin.get(action.pluginId);
if (!pluginActions) {
pluginActions = [];
actionsByPlugin.set(action.pluginId, pluginActions);
}
pluginActions.push(action.element);
}
return new DefaultPluginHeaderActionsApi(actionsByPlugin);
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { DefaultPluginHeaderActionsApi } from './DefaultPluginHeaderActionsApi';
@@ -16,6 +16,34 @@
import { render, screen } from '@testing-library/react';
import { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi';
import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api';
import { ReactNode, useState } from 'react';
import userEvent from '@testing-library/user-event';
type TestInc = { count: number; increment: () => void };
function useTestInc(): TestInc {
const [value, setValue] = useState(0);
return {
count: value,
increment: () => setValue(val => val + 1),
};
}
function makeTestIncWrapper(
label: string = '',
renderSpy?: () => void,
): (props: { children: ReactNode; value: TestInc }) => JSX.Element {
return ({ children, value }: { children: ReactNode; value: TestInc }) => {
renderSpy?.();
return (
<div>
Wrapper{label}#{value.count} {children}
<button onClick={value.increment}>Increment{label}</button>
</div>
);
};
}
describe('DefaultPluginWrapperApi', () => {
it('should wrap multiple components with a single wrapper', async () => {
@@ -36,8 +64,10 @@ describe('DefaultPluginWrapperApi', () => {
expect(Wrapper2).toBeDefined();
expect(Wrapper3).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<>
<RootWrapper>
<div>
<Wrapper1>1</Wrapper1>
</div>
@@ -47,7 +77,7 @@ describe('DefaultPluginWrapperApi', () => {
<div>
<Wrapper3>3</Wrapper3>
</div>
</>,
</RootWrapper>,
);
await expect(screen.findByText('Wrapper(1)')).resolves.toBeInTheDocument();
@@ -77,15 +107,17 @@ describe('DefaultPluginWrapperApi', () => {
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<>
<RootWrapper>
<div>
<Wrapper1>1</Wrapper1>
</div>
<div>
<Wrapper2>2</Wrapper2>
</div>
</>,
</RootWrapper>,
);
await expect(
@@ -95,4 +127,153 @@ describe('DefaultPluginWrapperApi', () => {
screen.findByText('WrapperB(WrapperA(2))'),
).resolves.toBeInTheDocument();
});
it('should share a single value across multiple wrappers', async () => {
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async (): Promise<PluginWrapperDefinition<string>> => ({
component: ({ children, value }) => (
<>
Wrapper({children}:{value})
</>
),
useWrapperValue: () => 'foo',
}),
pluginId: 'plugin-1',
},
]);
const Wrapper1 = api.getPluginWrapper('plugin-1')!;
const Wrapper2 = api.getPluginWrapper('plugin-1')!;
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<RootWrapper>
<div>
<Wrapper1>1</Wrapper1>
</div>
<div>
<Wrapper2>2</Wrapper2>
</div>
</RootWrapper>,
);
await expect(
screen.findByText('Wrapper(1:foo)'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Wrapper(2:foo)'),
).resolves.toBeInTheDocument();
});
it('should share a single stateful value across multiple wrappers', async () => {
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async (): Promise<PluginWrapperDefinition<TestInc>> => ({
component: makeTestIncWrapper(),
useWrapperValue: useTestInc,
}),
pluginId: 'plugin-1',
},
]);
const Wrapper1 = api.getPluginWrapper('plugin-1')!;
const Wrapper2 = api.getPluginWrapper('plugin-1')!;
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<RootWrapper>
<Wrapper1>X</Wrapper1>
<Wrapper2>Y</Wrapper2>
</RootWrapper>,
);
await expect(screen.findByText('Wrapper#0 X')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper#0 Y')).resolves.toBeInTheDocument();
await userEvent.click(screen.getAllByText('Increment')[0]);
await expect(screen.findByText('Wrapper#1 X')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper#1 Y')).resolves.toBeInTheDocument();
await userEvent.click(screen.getAllByText('Increment')[1]);
await expect(screen.findByText('Wrapper#2 X')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper#2 Y')).resolves.toBeInTheDocument();
});
it('should not rerender adjacent hooks on update', async () => {
let renderCountA = 0;
let renderCountB = 0;
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async (): Promise<PluginWrapperDefinition<TestInc>> => ({
component: makeTestIncWrapper('A', () => {
renderCountA += 1;
}),
useWrapperValue: useTestInc,
}),
pluginId: 'plugin-a',
},
{
loader: async (): Promise<PluginWrapperDefinition<TestInc>> => ({
component: makeTestIncWrapper('B', () => {
renderCountB += 1;
}),
useWrapperValue: useTestInc,
}),
pluginId: 'plugin-b',
},
]);
const WrapperA = api.getPluginWrapper('plugin-a')!;
const WrapperB = api.getPluginWrapper('plugin-b')!;
expect(WrapperA).toBeDefined();
expect(WrapperB).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<RootWrapper>
<WrapperA>X</WrapperA>
<WrapperB>Y</WrapperB>
</RootWrapper>,
);
await expect(
screen.findByText('WrapperA#0 X'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('WrapperB#0 Y'),
).resolves.toBeInTheDocument();
expect(renderCountA).toBe(1);
expect(renderCountB).toBe(1);
await userEvent.click(screen.getByText('IncrementA'));
expect(screen.getByText('WrapperA#1 X')).toBeInTheDocument();
expect(screen.getByText('WrapperB#0 Y')).toBeInTheDocument();
expect(renderCountA).toBe(2);
expect(renderCountB).toBe(1);
await userEvent.click(screen.getByText('IncrementB'));
expect(screen.getByText('WrapperA#1 X')).toBeInTheDocument();
expect(screen.getByText('WrapperB#1 Y')).toBeInTheDocument();
expect(renderCountA).toBe(2);
expect(renderCountB).toBe(2);
});
});
@@ -14,11 +14,36 @@
* limitations under the License.
*/
import { PluginWrapperApi } from '@backstage/frontend-plugin-api/alpha';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import {
PluginWrapperApi,
PluginWrapperDefinition,
} from '@backstage/frontend-plugin-api';
import {
ComponentType,
ReactNode,
createContext,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
interface HookStore {
getSnapshot: () => { value: unknown } | undefined;
subscribe: (listener: () => void) => () => void;
}
interface HookRegistryContextValue {
registerHook: (key: any, hook: () => unknown) => HookStore;
}
const HookRegistryContext = createContext<HookRegistryContextValue | undefined>(
undefined,
);
type WrapperInput = {
loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>;
loader: () => Promise<PluginWrapperDefinition<any>>;
pluginId: string;
};
@@ -29,12 +54,17 @@ type WrapperInput = {
*/
export class DefaultPluginWrapperApi implements PluginWrapperApi {
constructor(
private readonly rootWrapper: ComponentType<{ children: ReactNode }>,
private readonly pluginWrappers: Map<
string,
ComponentType<{ children: ReactNode }>
>,
) {}
getRootWrapper(): ComponentType<{ children: ReactNode }> {
return this.rootWrapper;
}
getPluginWrapper(
pluginId: string,
): ComponentType<{ children: ReactNode }> | undefined {
@@ -44,9 +74,7 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
static fromWrappers(wrappers: Array<WrapperInput>): DefaultPluginWrapperApi {
const loadersByPlugin = new Map<
string,
Array<
() => Promise<{ component: ComponentType<{ children: ReactNode }> }>
>
Array<() => Promise<PluginWrapperDefinition<any>>>
>();
for (const wrapper of wrappers) {
@@ -68,6 +96,45 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
continue;
}
const WrapperWithState = ({
loader,
component: WrapperComponent,
useWrapperValue,
children,
}: {
loader: () => Promise<PluginWrapperDefinition>;
component: ComponentType<{
children: ReactNode;
value: unknown;
}>;
useWrapperValue: () => unknown;
children: ReactNode;
}) => {
const hookContext = useContext(HookRegistryContext);
if (!hookContext) {
throw new Error(
'Attempted to render a wrapped plugin component without a root wrapper context',
);
}
const store = useMemo(() => {
return hookContext.registerHook(loader, useWrapperValue);
}, [hookContext, loader, useWrapperValue]);
const container = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
);
if (!container) {
return null;
}
return (
<WrapperComponent value={container.value}>
{children}
</WrapperComponent>
);
};
const ComposedWrapper = (props: { children: ReactNode }) => {
const [loadedWrappers, setLoadedWrappers] = useState<
Array<ComponentType<{ children: ReactNode }>> | undefined
@@ -77,7 +144,27 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
useEffect(() => {
Promise.all(loaders.map(loader => loader()))
.then(results => {
setLoadedWrappers(results.map(r => r.component));
const normalizedResults = results.map(
({ component, useWrapperValue }, index) => {
const loader = loaders[index];
if (!useWrapperValue) {
return component as ComponentType<{ children: ReactNode }>;
}
return ({ children }: { children: ReactNode }) => (
<WrapperWithState
loader={loader}
component={component}
useWrapperValue={useWrapperValue}
>
{children}
</WrapperWithState>
);
},
);
setLoadedWrappers(normalizedResults);
})
.catch(setError);
}, []);
@@ -86,24 +173,107 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
throw error;
}
return useMemo(() => {
if (!loadedWrappers) {
return null;
}
if (!loadedWrappers) {
return null;
}
let current = props.children;
let content = props.children;
for (const Wrapper of loadedWrappers) {
current = <Wrapper>{current}</Wrapper>;
}
for (const Wrapper of loadedWrappers) {
content = <Wrapper>{content}</Wrapper>;
}
return current;
}, [loadedWrappers, props.children]);
return <>{content}</>;
};
composedWrappers.set(pluginId, ComposedWrapper);
}
return new DefaultPluginWrapperApi(composedWrappers);
return new DefaultPluginWrapperApi(
DefaultPluginWrapperApi.createRootWrapper(),
composedWrappers,
);
}
/**
* Creates the root wrapper component that is responsible for rendering and
* forwarding the values of the common `useWrapperValue` hooks.
*/
static createRootWrapper() {
const renderers = new Map<any, HookStore>();
const renderUpdateListeners = new Set<() => void>();
let renderElements = new Array<JSX.Element>();
const createHookRenderer = (hook: () => unknown): HookStore => {
const listeners = new Set<() => void>();
let container: { value: unknown } | undefined = undefined;
const HookRenderer = () => {
container = { value: hook() };
useEffect(() => {
for (const listener of listeners) {
listener();
}
});
return null;
};
renderElements = [
...renderElements,
<HookRenderer key={`hook-renderer-${renderElements.length + 1}`} />,
];
return {
getSnapshot: () => container,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
};
const registerHook = (key: any, hook: () => unknown) => {
let renderer = renderers.get(key);
if (!renderer) {
renderer = createHookRenderer(hook);
renderers.set(key, renderer);
queueMicrotask(() => {
for (const listener of renderUpdateListeners) {
listener();
}
});
}
return renderer;
};
const subscribeToRenderUpdates = (listener: () => void) => {
renderUpdateListeners.add(listener);
return () => renderUpdateListeners.delete(listener);
};
const getRenderElements = () => renderElements;
const RootWrapper = (props: { children: ReactNode }) => {
const elements = useSyncExternalStore(
subscribeToRenderUpdates,
getRenderElements,
);
return (
<>
<>{elements}</>
<HookRegistryContext.Provider
value={{
registerHook,
}}
>
{props.children}
</HookRegistryContext.Provider>
</>
);
};
return RootWrapper;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppLanguageSelector } from '../../../../packages/core-app-api/src/apis/implementations/AppLanguageApi';
import { appLanguageApiRef } from '@backstage/core-plugin-api/alpha';
import { appLanguageApiRef } from '@backstage/frontend-plugin-api';
import { ApiBlueprint } from '@backstage/frontend-plugin-api';
export const AppLanguageApi = ApiBlueprint.makeWithOverrides({
+153 -20
View File
@@ -20,55 +20,117 @@ import {
createExtensionInput,
NavItemBlueprint,
routeResolutionApiRef,
appTreeApiRef,
IconComponent,
IconElement,
RouteRef,
RouteResolutionApi,
useApi,
} from '@backstage/frontend-plugin-api';
import {
NavContentBlueprint,
NavContentComponent,
NavContentComponentProps,
NavContentNavItem,
NavContentNavItems,
} from '@backstage/plugin-app-react';
import { Sidebar, SidebarItem } from '@backstage/core-components';
import { useMemo } from 'react';
class NavItemBag implements NavContentNavItems {
readonly #items: NavContentNavItem[];
readonly #index: Map<string, NavContentNavItem>;
readonly #taken: Set<string>;
constructor(items: NavContentNavItem[], taken?: Iterable<string>) {
this.#items = items;
this.#index = new Map(items.map(item => [item.node.spec.id, item]));
this.#taken = new Set(taken);
}
take(id: string): NavContentNavItem | undefined {
const item = this.#index.get(id);
if (item) {
this.#taken.add(id);
}
return item;
}
rest(): NavContentNavItem[] {
return this.#items.filter(item => !this.#taken.has(item.node.spec.id));
}
clone(): NavContentNavItems {
return new NavItemBag(this.#items, this.#taken);
}
withComponent(Component: (props: NavContentNavItem) => JSX.Element) {
return {
take: (id: string) => {
const item = this.take(id);
return item ? <Component {...item} /> : null;
},
rest: (options?: { sortBy?: 'title' }) => {
const items = this.rest();
if (options?.sortBy === 'title') {
items.sort((a, b) => a.title.localeCompare(b.title));
}
return items.map(item => (
<Component key={item.node.spec.id} {...item} />
));
},
};
}
}
function DefaultNavContent(props: NavContentComponentProps) {
const items = props.navItems.rest();
return (
<Sidebar>
{props.items.map((item, index) => (
{items.map(item => (
<SidebarItem
to={item.to}
icon={item.icon}
text={item.text}
key={index}
to={item.href}
icon={() => item.icon}
text={item.title}
key={item.node.spec.id}
/>
))}
</Sidebar>
);
}
// This helps defer rendering until the app is being rendered, which is needed
// because the RouteResolutionApi can't be called until the app has been fully initialized.
// Tries to resolve a routeRef to a link path, returning undefined if it
// can't be resolved (e.g. parameterized routes).
function tryResolveLink(
routeResolutionApi: RouteResolutionApi,
routeRef: RouteRef,
): string | undefined {
try {
const link = routeResolutionApi.resolve(routeRef);
return link?.();
} catch {
return undefined;
}
}
// Defers rendering until the app is fully initialized so that APIs like
// RouteResolutionApi and AppTreeApi are available.
function NavContentRenderer(props: {
Content: NavContentComponent;
items: Array<{
legacyNavItems: Array<{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
}>;
}) {
const appTreeApi = useApi(appTreeApiRef);
const routeResolutionApi = useApi(routeResolutionApiRef);
const items = useMemo(() => {
return props.items.flatMap(item => {
// Deprecated items: just resolve nav item routeRefs to paths, no page discovery.
const legacyItems = useMemo(() => {
return props.legacyNavItems.flatMap(item => {
const link = routeResolutionApi.resolve(item.routeRef);
if (!link) {
// eslint-disable-next-line no-console
console.warn(
`NavItemBlueprint: unable to resolve route ref ${item.routeRef}`,
);
return [];
}
if (!link) return [];
return [
{
to: link(),
@@ -79,9 +141,80 @@ function NavContentRenderer(props: {
},
];
});
}, [props.items, routeResolutionApi]);
}, [props.legacyNavItems, routeResolutionApi]);
return <props.Content items={items} />;
// New navItems: discover pages from the extension tree, merged with nav items.
const navItems = useMemo(() => {
const { tree } = appTreeApi.getTree();
const routesNode = tree.nodes.get('app/routes');
if (!routesNode) return new NavItemBag([]);
// Index nav items by routeRef for matching against pages
const navItemsByRouteRef = new Map<
RouteRef,
{ title: string; icon: IconComponent }
>(props.legacyNavItems.map(item => [item.routeRef, item]));
const pageNodes = routesNode.edges.attachments.get('routes') ?? [];
const items = pageNodes.flatMap((node): NavContentNavItem[] => {
if (!node.instance || node.spec.disabled) {
return [];
}
const routeRef = node.instance.getData(coreExtensionData.routeRef);
if (!routeRef) {
return [];
}
const matchingNavItem = navItemsByRouteRef.get(routeRef);
// PageBlueprint resolves title as: config.title ?? params.title ?? plugin.title ?? pluginId
// We want the priority: page (config/params) -> nav item -> plugin -> pluginId
const resolvedTitle = node.instance.getData(coreExtensionData.title);
const pluginTitle = node.spec.plugin.title;
const pluginIcon = node.spec.plugin.icon;
const pluginId = node.spec.plugin.pluginId;
const hasExplicitPageTitle =
resolvedTitle !== undefined &&
resolvedTitle !== pluginTitle &&
resolvedTitle !== pluginId;
const title = hasExplicitPageTitle
? resolvedTitle
: matchingNavItem?.title ?? pluginTitle ?? pluginId;
// PageBlueprint resolves icon as: params.icon ?? plugin.icon
// We want the priority: page (params) -> nav item -> plugin -> (excluded)
const resolvedIcon = node.instance.getData(coreExtensionData.icon);
const hasExplicitPageIcon = resolvedIcon && !node.spec.plugin.icon;
const NavItemIcon = matchingNavItem?.icon;
let icon: IconElement | undefined;
if (hasExplicitPageIcon) {
icon = resolvedIcon;
} else if (NavItemIcon) {
icon = <NavItemIcon />;
} else if (resolvedIcon) {
icon = resolvedIcon;
} else if (pluginIcon) {
icon = pluginIcon;
}
if (!title || !icon) {
return [];
}
const to = tryResolveLink(routeResolutionApi, routeRef);
if (!to) {
return [];
}
return [{ node, href: to, title, icon, routeRef }];
});
return new NavItemBag(items);
}, [appTreeApi, routeResolutionApi, props.legacyNavItems]);
return <props.Content navItems={navItems} items={legacyItems} />;
}
export const AppNav = createExtension({
@@ -103,7 +236,7 @@ export const AppNav = createExtension({
yield coreExtensionData.reactElement(
<NavContentRenderer
items={inputs.items.map(item =>
legacyNavItems={inputs.items.map(item =>
item.get(NavItemBlueprint.dataRefs.target),
)}
Content={Content}
+24 -11
View File
@@ -29,12 +29,15 @@ import {
createExtension,
createExtensionInput,
routeResolutionApiRef,
pluginWrapperApiRef,
useAnalytics,
} from '@backstage/frontend-plugin-api';
import {
AppRootWrapperBlueprint,
RouterBlueprint,
SignInPageBlueprint,
} from '@backstage/plugin-app-react';
import { BUIProvider } from '@backstage/ui';
import {
DiscoveryApi,
ErrorApi,
@@ -113,6 +116,12 @@ export const AppRoot = createExtension({
}
}
const pluginWrapperApi = apis.get(pluginWrapperApiRef);
const RootWrapper = pluginWrapperApi?.getRootWrapper();
if (RootWrapper) {
content = <RootWrapper>{content}</RootWrapper>;
}
return [
coreExtensionData.reactElement(
<AppRouter
@@ -269,23 +278,27 @@ export function AppRouter(props: AppRouterProps) {
return (
<RouterComponent>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
{children}
<BUIProvider useAnalytics={useAnalytics}>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
{children}
</BUIProvider>
</RouterComponent>
);
}
return (
<RouterComponent>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{children}
</SignInPageWrapper>
<BUIProvider useAnalytics={useAnalytics}>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{children}
</SignInPageWrapper>
</BUIProvider>
</RouterComponent>
);
}
@@ -0,0 +1,49 @@
/*
* 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 {
coreExtensionData,
pluginHeaderActionsApiRef,
createExtensionInput,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';
import { DefaultPluginHeaderActionsApi } from '../apis/PluginHeaderActionsApi';
/**
* Contains the plugin-scoped header actions installed into the app.
*/
export const PluginHeaderActionsApi = ApiBlueprint.makeWithOverrides({
name: 'plugin-header-actions',
inputs: {
actions: createExtensionInput([coreExtensionData.reactElement]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
defineParams({
api: pluginHeaderActionsApiRef,
deps: {},
factory: () => {
return DefaultPluginHeaderActionsApi.fromActions(
inputs.actions.map(actionInput => ({
element: actionInput.get(coreExtensionData.reactElement),
pluginId: actionInput.node.spec.plugin.pluginId,
})),
);
},
}),
);
},
});
@@ -17,8 +17,6 @@
import {
PluginWrapperBlueprint,
pluginWrapperApiRef,
} from '@backstage/frontend-plugin-api/alpha';
import {
createExtensionInput,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';
@@ -21,7 +21,7 @@ import { TranslationBlueprint } from '@backstage/plugin-app-react';
import {
appLanguageApiRef,
translationApiRef,
} from '@backstage/core-plugin-api/alpha';
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { I18nextTranslationApi } from '../../../../packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
+40
View File
@@ -17,6 +17,8 @@ import {
NotFoundErrorPage as SwappableNotFoundErrorPage,
Progress as SwappableProgress,
ErrorDisplay as SwappableErrorDisplay,
PageLayout as SwappablePageLayout,
type PageLayoutProps,
} from '@backstage/frontend-plugin-api';
import { SwappableComponentBlueprint } from '@backstage/plugin-app-react';
import {
@@ -24,7 +26,9 @@ import {
ErrorPanel,
Progress as ProgressComponent,
} from '@backstage/core-components';
import { PluginHeader } from '@backstage/ui';
import Button from '@material-ui/core/Button';
import { useMemo } from 'react';
export const Progress = SwappableComponentBlueprint.make({
name: 'core-progress',
@@ -63,3 +67,39 @@ export const ErrorDisplay = SwappableComponentBlueprint.make({
},
}),
});
export const PageLayout = SwappableComponentBlueprint.make({
name: 'core-page-layout',
params: define =>
define({
component: SwappablePageLayout,
loader: () => (props: PageLayoutProps) => {
const { title, icon, noHeader, headerActions, tabs, children } = props;
const tabsWithMatchStrategy = useMemo(
() =>
tabs?.map(tab => ({
...tab,
matchStrategy: 'prefix' as const,
})),
[tabs],
);
if (tabsWithMatchStrategy) {
return (
<>
{!noHeader && (
<PluginHeader
title={title}
icon={icon}
tabs={tabsWithMatchStrategy}
customActions={headerActions}
/>
)}
{children}
</>
);
}
return <>{children}</>;
},
}),
});
+7 -1
View File
@@ -31,5 +31,11 @@ export {
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
} from './elements';
export { Progress, NotFoundErrorPage, ErrorDisplay } from './components';
export {
Progress,
NotFoundErrorPage,
ErrorDisplay,
PageLayout,
} from './components';
export { PluginWrapperApi } from './PluginWrapperApi';
export { PluginHeaderActionsApi } from './PluginHeaderActionsApi';
+4
View File
@@ -29,6 +29,7 @@ import {
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
PluginHeaderActionsApi,
TranslationsApi,
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
@@ -37,6 +38,7 @@ import {
Progress,
NotFoundErrorPage,
ErrorDisplay,
PageLayout,
LegacyComponentsApi,
} from './extensions';
import { apis } from './defaultApis';
@@ -60,6 +62,7 @@ export const appPlugin = createFrontendPlugin({
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
PluginHeaderActionsApi,
TranslationsApi,
DefaultSignInPage,
oauthRequestDialogAppRootElement,
@@ -68,6 +71,7 @@ export const appPlugin = createFrontendPlugin({
Progress,
NotFoundErrorPage,
ErrorDisplay,
PageLayout,
LegacyComponentsApi,
],
});