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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}</>;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user