add router to the app root

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-01-12 13:58:56 +01:00
parent 2dd5da2c82
commit e8ce05959e
10 changed files with 352 additions and 159 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
'@backstage/frontend-app-api': patch
---
Added `elements` and `wrappers` inputs to `app/root`, that let you add things to the root of the React tree above the layout. You can use the `createAppRootElementExtension` and `createAppRootWrapperExtension` extension creator, respectively, to conveniently create such extensions.
Added `elements`, `wrappers`, and `router` inputs to `app/root`, that let you add things to the root of the React tree above the layout. You can use the `createAppRootElementExtension`, `createAppRootWrapperExtension`, and `createRouterExtension` extension creator, respectively, to conveniently create such extensions. These are all optional, and if you do not supply a router a default one will be used (`BrowserRouter` in regular runs, `MemoryRouter` in tests/CI).
@@ -17,6 +17,7 @@
import React, {
ComponentType,
Fragment,
PropsWithChildren,
ReactNode,
useContext,
useState,
@@ -26,6 +27,7 @@ import {
createAppRootWrapperExtension,
createExtension,
createExtensionInput,
createRouterExtension,
createSignInPageExtension,
} from '@backstage/frontend-plugin-api';
import {
@@ -46,6 +48,10 @@ export const AppRoot = createExtension({
name: 'root',
attachTo: { id: 'app', input: 'root' },
inputs: {
router: createExtensionInput(
{ component: createRouterExtension.componentDataRef },
{ singleton: true, optional: true },
),
signInPage: createExtensionInput(
{ component: createSignInPageExtension.componentDataRef },
{ singleton: true, optional: true },
@@ -80,7 +86,10 @@ export const AppRoot = createExtension({
return {
element: (
<AppRouter SignInPageComponent={inputs.signInPage?.output.component}>
<AppRouter
SignInPageComponent={inputs.signInPage?.output.component}
RouterComponent={inputs.router?.output.component}
>
{content}
</AppRouter>
),
@@ -133,12 +142,18 @@ function SignInPageWrapper({
export interface AppRouterProps {
children?: ReactNode;
SignInPageComponent?: ComponentType<SignInPageProps>;
RouterComponent?: ComponentType<PropsWithChildren<{}>>;
}
function DefaultRouter(props: PropsWithChildren<{}>) {
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
return <BrowserRouter basename={basePath}>{props.children}</BrowserRouter>;
}
/**
* App router and sign-in page wrapper.
*
* @public
* @remarks
*
* The AppRouter provides the routing context and renders the sign-in page.
@@ -147,7 +162,11 @@ export interface AppRouterProps {
* the app, while providing routing and route tracking for the app.
*/
export function AppRouter(props: AppRouterProps) {
const { children, SignInPageComponent } = props;
const {
children,
SignInPageComponent,
RouterComponent = DefaultRouter,
} = props;
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
@@ -183,15 +202,15 @@ export function AppRouter(props: AppRouterProps) {
);
return (
<BrowserRouter basename={basePath}>
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
{children}
</BrowserRouter>
</RouterComponent>
);
}
return (
<BrowserRouter basename={basePath}>
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
@@ -199,6 +218,6 @@ export function AppRouter(props: AppRouterProps) {
>
{children}
</SignInPageWrapper>
</BrowserRouter>
</RouterComponent>
);
}
@@ -687,6 +687,39 @@ export function createRouteRef<
}
>;
// @public
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig>;
// @public (undocumented)
export namespace createRouterExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
{}
>;
}
// @public (undocumented)
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
+16 -16
View File
@@ -22,6 +22,21 @@
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.4",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.21.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
@@ -33,20 +48,5 @@
},
"files": [
"dist"
],
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.4",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.21.4"
}
]
}
@@ -0,0 +1,167 @@
/*
* Copyright 2023 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 { createSpecializedApp } from '@backstage/frontend-app-api';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MockConfigApi } from '@backstage/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createExtensionOverrides } from '../wiring/createExtensionOverrides';
import { createPageExtension } from './createPageExtension';
import { createRouterExtension } from './createRouterExtension';
describe('createRouterExtension', () => {
it('works with simple options and no props', async () => {
const extension = createRouterExtension({
namespace: 'test',
Component: ({ children }) => (
<MemoryRouter>
<div data-testid="test-router">{children}</div>
</MemoryRouter>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-router-component',
namespace: 'test',
attachTo: { id: 'app/root', input: 'router' },
disabled: false,
inputs: {},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createPageExtension({
namespace: 'test',
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
}),
],
}),
],
});
render(app.createRoot());
await expect(
screen.findByTestId('test-contents'),
).resolves.toBeInTheDocument();
await expect(
screen.findByTestId('test-router'),
).resolves.toBeInTheDocument();
});
it('works with complex options and props', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createRouterExtension({
namespace: 'test',
name: 'test',
configSchema: schema,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
Component: ({ inputs, config, children }) => (
<MemoryRouter>
<div
data-testid={`test-router-${config.name}-${inputs.children.length}`}
>
{children}
</div>
</MemoryRouter>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-router-component',
namespace: 'test',
name: 'test',
attachTo: { id: 'app/root', input: 'router' },
configSchema: schema,
disabled: false,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createExtension({
namespace: 'test',
attachTo: {
id: 'app-router-component:test/test',
input: 'children',
},
output: { element: coreExtensionData.reactElement }, // doesn't matter
factory: () => ({ element: <div /> }),
}),
createPageExtension({
namespace: 'test',
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
}),
],
}),
],
config: new MockConfigApi({
app: {
extensions: [
{
'app-router-component:test/test': { config: { name: 'Robin' } },
},
],
},
}),
});
render(app.createRoot());
await expect(
screen.findByTestId('test-contents'),
).resolves.toBeInTheDocument();
await expect(
screen.findByTestId('test-router-Robin-1'),
).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2023 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 React, { ComponentType, PropsWithChildren } from 'react';
import { PortableSchema } from '../schema/types';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
import { createExtensionDataRef } from '../wiring/createExtensionDataRef';
import { Expand } from '../types';
/**
* Creates an extension that replaces the router implementation at the app root.
* This is useful to be able to for example replace the BrowserRouter with a
* MemoryRouter in tests, or to add additional props to a BrowserRouter.
*
* @public
*/
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-router-component',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'router' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
component: createRouterExtension.componentDataRef,
},
factory({ inputs, config }) {
const Component = (props: PropsWithChildren<{}>) => {
return (
<options.Component inputs={inputs} config={config}>
{props.children}
</options.Component>
);
};
return {
component: Component,
};
},
});
}
/** @public */
export namespace createRouterExtension {
export const componentDataRef =
createExtensionDataRef<ComponentType<PropsWithChildren<{}>>>(
'app.router.wrapper',
);
}
@@ -17,6 +17,7 @@
export { createApiExtension } from './createApiExtension';
export { createAppRootElementExtension } from './createAppRootElementExtension';
export { createAppRootWrapperExtension } from './createAppRootWrapperExtension';
export { createRouterExtension } from './createRouterExtension';
export { createPageExtension } from './createPageExtension';
export { createNavItemExtension } from './createNavItemExtension';
export { createNavLogoExtension } from './createNavLogoExtension';
@@ -219,10 +219,14 @@ describe('createExtensionTester', () => {
fireEvent.click(await screen.findByRole('button', { name: 'See details' }));
await waitFor(() =>
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'See details',
}),
expect(analyticsApiMock.getEvents()).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: 'click',
subject: 'See details',
}),
]),
),
);
});
});
@@ -14,29 +14,20 @@
* limitations under the License.
*/
import React, {
ComponentType,
Fragment,
ReactNode,
useContext,
useState,
} from 'react';
import React from 'react';
import { MemoryRouter, Link } from 'react-router-dom';
import { RenderResult, render } from '@testing-library/react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDefinition,
IconComponent,
IdentityApi,
RouteRef,
configApiRef,
coreExtensionData,
createAppRootWrapperExtension,
createExtension,
createExtensionInput,
createExtensionOverrides,
createNavItemExtension,
useApi,
createRouterExtension,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
@@ -44,15 +35,7 @@ import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createSignInPageExtension } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { InternalAppContext } from '../../../frontend-app-api/src/wiring/InternalAppContext';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { SignInPageProps } from '../../../core-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { getBasePath } from '../../../core-app-api/src/app/AppRouter';
const NavItem = (props: {
routeRef: RouteRef<undefined>;
@@ -102,113 +85,6 @@ const TestAppNavExtension = createExtension({
},
});
const AuthenticationProvider = (props: {
signInPage?: ComponentType<SignInPageProps>;
children: ReactNode;
}) => {
const { signInPage: SignInPage, children } = props;
const configApi = useApi(configApiRef);
const signOutTargetUrl = getBasePath(configApi) || '/';
const internalAppContext = useContext(InternalAppContext);
if (!internalAppContext) {
throw new Error('AppRouter must be rendered within the AppProvider');
}
const { appIdentityProxy } = internalAppContext;
const [identityApi, setIdentityApi] = useState<IdentityApi>();
if (!SignInPage) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl },
);
return children;
}
if (!identityApi) {
return <SignInPage onSignInSuccess={setIdentityApi} />;
}
appIdentityProxy.setTarget(identityApi, {
signOutTargetUrl,
});
return children;
};
const TestAppRootExtension = createExtension({
namespace: 'app',
name: 'root',
attachTo: { id: 'app', input: 'root' },
inputs: {
signInPage: createExtensionInput(
{ component: createSignInPageExtension.componentDataRef },
{ singleton: true, optional: true },
),
children: createExtensionInput(
{ element: coreExtensionData.reactElement },
{ singleton: true },
),
elements: createExtensionInput(
{ element: coreExtensionData.reactElement },
{ optional: true },
),
wrappers: createExtensionInput(
{ component: createAppRootWrapperExtension.componentDataRef },
{ optional: true },
),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
const SignInPage = inputs.signInPage?.output.component;
let content: React.ReactNode = (
<>
{inputs.elements.map(el => (
<Fragment key={el.node.spec.id}>{el.output.element}</Fragment>
))}
{inputs.children.output.element}
</>
);
for (const wrapper of inputs.wrappers) {
content = <wrapper.output.component>{content}</wrapper.output.component>;
}
return {
element: (
<MemoryRouter>
<AuthenticationProvider signInPage={SignInPage}>
{content}
</AuthenticationProvider>
</MemoryRouter>
),
};
},
});
/** @public */
export class ExtensionTester {
/** @internal */
@@ -302,7 +178,12 @@ export class ExtensionTester {
extensions: [
...this.#extensions.map(extension => extension.definition),
TestAppNavExtension,
TestAppRootExtension,
createRouterExtension({
namespace: 'test',
Component: ({ children }) => (
<MemoryRouter>{children}</MemoryRouter>
),
}),
],
}),
],
@@ -56,9 +56,13 @@ describe('renderInTestApp', () => {
fireEvent.click(screen.getByRole('link', { name: 'See details' }));
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'See details',
});
expect(analyticsApiMock.getEvents()).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: 'click',
subject: 'See details',
}),
]),
);
});
});