app-api,plugin-api: sync with core-api at master@e9e70677
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AlertApi, AlertMessage } from '@backstage/plugin-api';
|
||||
import { PublishSubject } from '../../../lib';
|
||||
import { PublishSubject } from '../../../lib/subjects';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { AppThemeApi, AppTheme } from '@backstage/plugin-api';
|
||||
import { BehaviorSubject } from '../../../lib';
|
||||
import { BehaviorSubject } from '../../../lib/subjects';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ErrorApi, ErrorContext } from '@backstage/plugin-api';
|
||||
import { PublishSubject } from '../../../lib';
|
||||
import { PublishSubject } from '../../../lib/subjects';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from '../../../lib';
|
||||
import { BehaviorSubject } from '../../../lib/subjects';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
type RequestQueueEntry<ResultType> = {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
AuthRequesterOptions,
|
||||
} from '@backstage/plugin-api';
|
||||
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
|
||||
import { BehaviorSubject } from '../../../lib';
|
||||
import { BehaviorSubject } from '../../../lib/subjects';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('ApiFactoryRegistry', () => {
|
||||
expect(registry.register('app', factory2)).toBe(true);
|
||||
expect(registry.get(ref1)).toEqual(factory2);
|
||||
expect(registry.get(ref2)).toEqual(factory2);
|
||||
expect(registry.getAllApis()).toEqual(new Set([ref2]));
|
||||
expect(registry.getAllApis()).not.toEqual(new Set([ref1]));
|
||||
expect(Array.from(registry.getAllApis())[0]).toBe(ref2);
|
||||
expect(Array.from(registry.getAllApis())[0]).not.toBe(ref1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,11 +116,11 @@ describe('ApiProvider', () => {
|
||||
withLogCollector(['error'], () => {
|
||||
expect(() => {
|
||||
render(<MyHookConsumer />);
|
||||
}).toThrow(/^No ApiProvider available in react context. /);
|
||||
}).toThrow(/^No provider available for api-context context/);
|
||||
}).error,
|
||||
).toEqual([
|
||||
expect.stringMatching(
|
||||
/^Error: Uncaught \[Error: No ApiProvider available in react context. /,
|
||||
/^Error: Uncaught \[Error: No provider available for api-context context/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^The above error occurred in the <MyHookConsumer> component/,
|
||||
@@ -131,11 +131,11 @@ describe('ApiProvider', () => {
|
||||
withLogCollector(['error'], () => {
|
||||
expect(() => {
|
||||
render(<MyHocConsumer />);
|
||||
}).toThrow(/^No ApiProvider available in react context. /);
|
||||
}).toThrow(/^No provider available for api-context context/);
|
||||
}).error,
|
||||
).toEqual([
|
||||
expect.stringMatching(
|
||||
/^Error: Uncaught \[Error: No ApiProvider available in react context. /,
|
||||
/^Error: Uncaught \[Error: No provider available for api-context context/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^The above error occurred in the <withApis\(Component\)> component/,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LocalStorageFeatureFlags } from '../apis';
|
||||
import { renderWithEffects, withLogCollector } from '@backstage/test-utils';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
@@ -22,6 +23,9 @@ import { BrowserRouter, Routes } from 'react-router-dom';
|
||||
import { createRoutableExtension } from '../extensions';
|
||||
import { defaultAppIcons } from './icons';
|
||||
import {
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
featureFlagsApiRef,
|
||||
createPlugin,
|
||||
useRouteRef,
|
||||
createExternalRouteRef,
|
||||
@@ -92,6 +96,12 @@ describe('Integration Test', () => {
|
||||
|
||||
const plugin1 = createPlugin({
|
||||
id: 'blob',
|
||||
// Both absolute and sub route refs should be assignable to the plugin routes
|
||||
routes: {
|
||||
ref1: plugin1RouteRef,
|
||||
ref2: plugin2RouteRef,
|
||||
ref3: subRouteRef1,
|
||||
},
|
||||
externalRoutes: {
|
||||
extRouteRef1,
|
||||
extRouteRef2,
|
||||
@@ -200,6 +210,9 @@ describe('Integration Test', () => {
|
||||
expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument();
|
||||
|
||||
// Plugins should be discovered through element tree
|
||||
expect(app.getPlugins()).toEqual([plugin1, plugin2]);
|
||||
});
|
||||
|
||||
it('runs happy paths without optional routes', async () => {
|
||||
@@ -245,6 +258,67 @@ describe('Integration Test', () => {
|
||||
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should wait for the config to load before calling feature flags', async () => {
|
||||
const storageFlags = new LocalStorageFeatureFlags();
|
||||
jest.spyOn(storageFlags, 'registerFlag');
|
||||
|
||||
const apis = [
|
||||
createApiFactory({
|
||||
api: featureFlagsApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory() {
|
||||
return storageFlags;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
apis,
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
},
|
||||
],
|
||||
icons: defaultAppIcons,
|
||||
plugins: [
|
||||
createPlugin({
|
||||
id: 'test',
|
||||
register: p => p.featureFlags.register('name'),
|
||||
}),
|
||||
],
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
extRouteRef2: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const Provider = app.getProvider();
|
||||
const Router = app.getRouter();
|
||||
|
||||
await renderWithEffects(
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo" />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(storageFlags.registerFlag).toHaveBeenCalledWith({
|
||||
name: 'name',
|
||||
pluginId: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw some error when the route has duplicate params', () => {
|
||||
const app = new PrivateAppImpl({
|
||||
apis: [],
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import React, {
|
||||
ComponentType,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
identityApiRef,
|
||||
BackstagePlugin,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from '@backstage/plugin-api';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
routeElementDiscoverer,
|
||||
traverseElementTree,
|
||||
} from '../extensions/traversal';
|
||||
import { pluginCollector } from '../plugins/collectors';
|
||||
import {
|
||||
routeObjectCollector,
|
||||
routeParentCollector,
|
||||
@@ -73,15 +77,13 @@ import {
|
||||
SignInResult,
|
||||
} from './types';
|
||||
|
||||
export function generateBoundRoutes(
|
||||
bindRoutes: AppOptions['bindRoutes'],
|
||||
): Map<ExternalRouteRef, RouteRef> {
|
||||
const result = new Map<ExternalRouteRef, RouteRef>();
|
||||
export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) {
|
||||
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
|
||||
|
||||
if (bindRoutes) {
|
||||
const bind: AppRouteBinder = (
|
||||
externalRoutes,
|
||||
targetRoutes: { [name: string]: RouteRef<any> },
|
||||
targetRoutes: { [name: string]: RouteRef | SubRouteRef },
|
||||
) => {
|
||||
for (const [key, value] of Object.entries(targetRoutes)) {
|
||||
const externalRoute = externalRoutes[key];
|
||||
@@ -168,7 +170,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
private readonly apis: Iterable<AnyApiFactory>;
|
||||
private readonly icons: NonNullable<AppOptions['icons']>;
|
||||
private readonly plugins: BackstagePlugin<any, any>[];
|
||||
private readonly plugins: Set<BackstagePlugin<any, any>>;
|
||||
private readonly components: AppComponents;
|
||||
private readonly themes: AppTheme[];
|
||||
private readonly configLoader?: AppConfigLoader;
|
||||
@@ -180,7 +182,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
constructor(options: FullAppOptions) {
|
||||
this.apis = options.apis;
|
||||
this.icons = options.icons;
|
||||
this.plugins = options.plugins;
|
||||
this.plugins = new Set(options.plugins);
|
||||
this.components = options.components;
|
||||
this.themes = options.themes;
|
||||
this.configLoader = options.configLoader;
|
||||
@@ -189,7 +191,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
|
||||
getPlugins(): BackstagePlugin<any, any>[] {
|
||||
return this.plugins;
|
||||
return Array.from(this.plugins);
|
||||
}
|
||||
|
||||
getSystemIcon(key: string): IconComponent | undefined {
|
||||
@@ -202,25 +204,6 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
getProvider(): ComponentType<{}> {
|
||||
const appContext = new AppContextImpl(this);
|
||||
const apiHolder = this.getApiHolder();
|
||||
|
||||
const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!;
|
||||
|
||||
for (const plugin of this.plugins.values()) {
|
||||
for (const output of plugin.output()) {
|
||||
switch (output.type) {
|
||||
case 'feature-flag': {
|
||||
featureFlagsApi.registerFlag({
|
||||
name: output.name,
|
||||
pluginId: plugin.getId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Provider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const appThemeApi = useMemo(
|
||||
@@ -236,11 +219,22 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
routePaths: routePathCollector,
|
||||
routeParents: routeParentCollector,
|
||||
routeObjects: routeObjectCollector,
|
||||
collectedPlugins: pluginCollector,
|
||||
},
|
||||
});
|
||||
|
||||
validateRoutes(result.routePaths, result.routeParents);
|
||||
|
||||
// TODO(Rugvip): Restructure the public API so that we can get an immediate view of
|
||||
// the app, rather than having to wait for the provider to render.
|
||||
// For now we need to push the additional plugins we find during
|
||||
// collection and then make sure we initialize things afterwards.
|
||||
result.collectedPlugins.forEach(plugin => this.plugins.add(plugin));
|
||||
this.verifyPlugins(this.plugins);
|
||||
|
||||
// Initialize APIs once all plugins are available
|
||||
this.getApiHolder();
|
||||
|
||||
return result;
|
||||
}, [children]);
|
||||
|
||||
@@ -250,15 +244,41 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
appThemeApi,
|
||||
);
|
||||
|
||||
const hasConfigApi = 'api' in loadedConfig;
|
||||
if (hasConfigApi) {
|
||||
const { api } = loadedConfig as { api: Config };
|
||||
this.configApi = api;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConfigApi) {
|
||||
const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!;
|
||||
|
||||
for (const plugin of this.plugins.values()) {
|
||||
for (const output of plugin.output()) {
|
||||
switch (output.type) {
|
||||
case 'feature-flag': {
|
||||
featureFlagsApi.registerFlag({
|
||||
name: output.name,
|
||||
pluginId: plugin.getId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [hasConfigApi, loadedConfig]);
|
||||
|
||||
if ('node' in loadedConfig) {
|
||||
// Loading or error
|
||||
return loadedConfig.node;
|
||||
}
|
||||
|
||||
this.configApi = loadedConfig.api;
|
||||
|
||||
return (
|
||||
<ApiProvider apis={apiHolder}>
|
||||
<ApiProvider apis={this.getApiHolder()}>
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<AppThemeProvider>
|
||||
<RoutingProvider
|
||||
@@ -413,10 +433,10 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
return this.apiHolder;
|
||||
}
|
||||
|
||||
verify() {
|
||||
private verifyPlugins(plugins: Iterable<BackstagePlugin>) {
|
||||
const pluginIds = new Set<string>();
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
for (const plugin of plugins) {
|
||||
const id = plugin.getId();
|
||||
if (pluginIds.has(id)) {
|
||||
throw new Error(`Duplicate plugin found '${id}'`);
|
||||
|
||||
@@ -128,7 +128,7 @@ export function createApp(options?: AppOptions) {
|
||||
];
|
||||
const configLoader = options?.configLoader ?? defaultConfigLoader;
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
return new PrivateAppImpl({
|
||||
apis,
|
||||
icons,
|
||||
plugins,
|
||||
@@ -138,8 +138,4 @@ export function createApp(options?: AppOptions) {
|
||||
defaultApis,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
});
|
||||
|
||||
app.verify();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { AppConfig } from '@backstage/config';
|
||||
import { AppIcons } from './icons';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config';
|
||||
step: 'load-config' | 'load-chunk';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
BackstagePlugin,
|
||||
RouteRef,
|
||||
useRouteRef,
|
||||
useApp,
|
||||
} from '@backstage/plugin-api';
|
||||
|
||||
type ComponentLoader<T> =
|
||||
@@ -31,8 +32,11 @@ type ComponentLoader<T> =
|
||||
sync: T;
|
||||
};
|
||||
|
||||
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
|
||||
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
|
||||
// making it impossible to make the usage of children type safe.
|
||||
export function createRoutableExtension<
|
||||
T extends (props: any) => JSX.Element
|
||||
T extends (props: any) => JSX.Element | null
|
||||
>(options: {
|
||||
component: () => Promise<T>;
|
||||
mountPoint: RouteRef;
|
||||
@@ -41,22 +45,44 @@ export function createRoutableExtension<
|
||||
return createReactExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
component().then(InnerComponent => {
|
||||
const RoutableExtensionWrapper = ((props: any) => {
|
||||
// Validate that the routing is wired up correctly in the App.tsx
|
||||
try {
|
||||
useRouteRef(mountPoint);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Routable extension component was not discovered in the app element tree. ' +
|
||||
'Routable extension components may not be rendered by other components and must be ' +
|
||||
'directly available as an element within the App provider component.',
|
||||
);
|
||||
}
|
||||
return <InnerComponent {...props} />;
|
||||
}) as T;
|
||||
return RoutableExtensionWrapper;
|
||||
}),
|
||||
component().then(
|
||||
InnerComponent => {
|
||||
const RoutableExtensionWrapper: any = (props: any) => {
|
||||
// Validate that the routing is wired up correctly in the App.tsx
|
||||
try {
|
||||
useRouteRef(mountPoint);
|
||||
} catch (error) {
|
||||
if (error?.message.startsWith('No path for ')) {
|
||||
throw new Error(
|
||||
`Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` +
|
||||
'Routable extension components may not be rendered by other components and must be ' +
|
||||
'directly available as an element within the App provider component.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return <InnerComponent {...props} />;
|
||||
};
|
||||
|
||||
const componentName =
|
||||
(InnerComponent as { displayName?: string }).displayName ||
|
||||
InnerComponent.name ||
|
||||
'LazyComponent';
|
||||
|
||||
RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`;
|
||||
|
||||
return RoutableExtensionWrapper as T;
|
||||
},
|
||||
error => {
|
||||
const RoutableExtensionWrapper: any = (_: any) => {
|
||||
const app = useApp();
|
||||
const { BootErrorPage } = app.getComponents();
|
||||
|
||||
return <BootErrorPage step="load-chunk" error={error} />;
|
||||
};
|
||||
return RoutableExtensionWrapper;
|
||||
},
|
||||
),
|
||||
},
|
||||
data: {
|
||||
'core.mountPoint': mountPoint,
|
||||
@@ -64,15 +90,21 @@ export function createRoutableExtension<
|
||||
});
|
||||
}
|
||||
|
||||
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
|
||||
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
|
||||
// making it impossible to make the usage of children type safe.
|
||||
export function createComponentExtension<
|
||||
T extends (props: any) => JSX.Element
|
||||
T extends (props: any) => JSX.Element | null
|
||||
>(options: { component: ComponentLoader<T> }): Extension<T> {
|
||||
const { component } = options;
|
||||
return createReactExtension({ component });
|
||||
}
|
||||
|
||||
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
|
||||
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
|
||||
// making it impossible to make the usage of children type safe.
|
||||
export function createReactExtension<
|
||||
T extends (props: any) => JSX.Element
|
||||
T extends (props: any) => JSX.Element | null
|
||||
>(options: {
|
||||
component: ComponentLoader<T>;
|
||||
data?: Record<string, unknown>;
|
||||
|
||||
@@ -37,7 +37,6 @@ export function traverseElementTree<Results>(options: {
|
||||
discoverers: Discoverer[];
|
||||
collectors: { [name in keyof Results]: Collector<Results[name], any> };
|
||||
}): Results {
|
||||
const visited = new Set();
|
||||
const collectors: {
|
||||
[name in string]: ReturnType<Collector<any, any>>;
|
||||
} = {};
|
||||
@@ -74,14 +73,6 @@ export function traverseElementTree<Results>(options: {
|
||||
if (!isValidElement(element)) {
|
||||
return;
|
||||
}
|
||||
if (visited.has(element)) {
|
||||
const anyType = element?.type as
|
||||
| { displayName?: string; name?: string }
|
||||
| undefined;
|
||||
const name = anyType?.displayName || anyType?.name || String(anyType);
|
||||
throw new Error(`Visited element ${name} twice`);
|
||||
}
|
||||
visited.add(element);
|
||||
|
||||
const nextContexts: QueueItem['contexts'] = {};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from '..';
|
||||
import { BehaviorSubject } from '../subjects';
|
||||
import { SessionState } from '@backstage/plugin-api';
|
||||
import { Observable } from '../../types';
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ export class PublishSubject<T>
|
||||
ZenObservable.SubscriptionObserver<T>
|
||||
>();
|
||||
|
||||
[Symbol.observable]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
get closed() {
|
||||
return this.isClosed;
|
||||
}
|
||||
@@ -148,6 +152,10 @@ export class BehaviorSubject<T>
|
||||
ZenObservable.SubscriptionObserver<T>
|
||||
>();
|
||||
|
||||
[Symbol.observable]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
get closed() {
|
||||
return this.isClosed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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, { PropsWithChildren } from 'react';
|
||||
import { createPlugin, createRouteRef } from '@backstage/plugin-api';
|
||||
import {
|
||||
createRoutableExtension,
|
||||
createComponentExtension,
|
||||
} from '../extensions';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import {
|
||||
traverseElementTree,
|
||||
childDiscoverer,
|
||||
routeElementDiscoverer,
|
||||
} from '../extensions/traversal';
|
||||
import { pluginCollector } from './collectors';
|
||||
|
||||
const mockConfig = () => ({ id: 'foo' });
|
||||
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
const pluginA = createPlugin({ id: 'my-plugin-a' });
|
||||
const pluginB = createPlugin({ id: 'my-plugin-b' });
|
||||
const pluginC = createPlugin({ id: 'my-plugin-c' });
|
||||
|
||||
const ref1 = createRouteRef(mockConfig());
|
||||
const ref2 = createRouteRef(mockConfig());
|
||||
|
||||
const Extension1 = pluginA.provide(
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref1,
|
||||
}),
|
||||
);
|
||||
const Extension2 = pluginB.provide(
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref2,
|
||||
}),
|
||||
);
|
||||
const Extension3 = pluginA.provide(
|
||||
createComponentExtension({ component: { sync: MockComponent } }),
|
||||
);
|
||||
const Extension4 = pluginB.provide(
|
||||
createComponentExtension({ component: { sync: MockComponent } }),
|
||||
);
|
||||
const Extension5 = pluginC.provide(
|
||||
createComponentExtension({ component: { sync: MockComponent } }),
|
||||
);
|
||||
|
||||
describe('collection', () => {
|
||||
it('should collect the plugins', () => {
|
||||
const root = (
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<div>
|
||||
<Extension2 path="/bar/:id">
|
||||
<div>
|
||||
<div />
|
||||
{[<Extension4 key={0} />]}
|
||||
Some text here shouldn't be a problem
|
||||
<div />
|
||||
{null}
|
||||
<div />
|
||||
<Extension3 />
|
||||
</div>
|
||||
</Extension2>
|
||||
{false}
|
||||
{true}
|
||||
{0}
|
||||
</div>
|
||||
</Extension1>
|
||||
<div>
|
||||
<Route path="/divsoup" element={<Extension5 />} />
|
||||
</div>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const { plugins } = traverseElementTree({
|
||||
root,
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
plugins: pluginCollector,
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { BackstagePlugin } from '@backstage/plugin-api';
|
||||
import { getComponentData } from '../extensions';
|
||||
import { createCollector } from '../extensions/traversal';
|
||||
|
||||
export const pluginCollector = createCollector(
|
||||
() => new Set<BackstagePlugin<any, any>>(),
|
||||
(acc, node) => {
|
||||
const plugin = getComponentData<BackstagePlugin<any, any>>(
|
||||
node,
|
||||
'core.plugin',
|
||||
);
|
||||
if (plugin) {
|
||||
acc.add(plugin);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { pluginCollector } from './collectors';
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
SubRouteRef,
|
||||
} from '@backstage/plugin-api';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { MATCH_ALL_ROUTE } from './collectors';
|
||||
|
||||
const element = () => null;
|
||||
const rest = { element, caseSensitive: false };
|
||||
const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] };
|
||||
|
||||
const ref1 = createRouteRef({ id: 'rr1' });
|
||||
const ref2 = createRouteRef({ id: 'rr2', params: ['x'] });
|
||||
@@ -111,6 +112,7 @@ describe('RouteResolver', () => {
|
||||
path: '/my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([ref1]), path: '/my-route', ...rest },
|
||||
],
|
||||
},
|
||||
@@ -142,6 +144,59 @@ describe('RouteResolver', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve the most specific match', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[ref1, '/deep'],
|
||||
[ref2, '/root/:x'],
|
||||
[ref3, '/sub/:y'],
|
||||
]),
|
||||
new Map<RouteRef, RouteRef>([
|
||||
[ref3, ref2],
|
||||
[ref1, ref3],
|
||||
]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: '/root/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([ref3]),
|
||||
path: '/sub/:y',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([ref1]),
|
||||
path: '/deep',
|
||||
...rest,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
|
||||
);
|
||||
|
||||
expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x');
|
||||
expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y');
|
||||
|
||||
expect(() => r.resolve(ref1, '/')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(() => r.resolve(ref1, '/root/x')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep');
|
||||
// Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here
|
||||
expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe(
|
||||
'/root/x/sub/y/deep',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route with multiple parents', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
@@ -159,11 +214,13 @@ describe('RouteResolver', () => {
|
||||
path: '/my-grandparent/:y',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: '/my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([ref1]), path: '/my-route', ...rest },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -88,13 +88,26 @@ function sortedEntries<T>(map: Map<RouteRef, T>): [RouteRef, T][] {
|
||||
);
|
||||
}
|
||||
|
||||
function routeObj(path: string, refs: RouteRef[], children: any[] = []) {
|
||||
function routeObj(
|
||||
path: string,
|
||||
refs: RouteRef[],
|
||||
children: any[] = [],
|
||||
type: 'mounted' | 'gathered' = 'mounted',
|
||||
) {
|
||||
return {
|
||||
path: path,
|
||||
caseSensitive: false,
|
||||
element: null,
|
||||
element: type,
|
||||
routeRefs: new Set(refs),
|
||||
children: children,
|
||||
children: [
|
||||
{
|
||||
path: '/*',
|
||||
caseSensitive: false,
|
||||
element: 'match-all',
|
||||
routeRefs: new Set(),
|
||||
},
|
||||
...children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,8 +277,12 @@ describe('discovery', () => {
|
||||
[ref5, ref3],
|
||||
]);
|
||||
expect(routeObjects).toEqual([
|
||||
routeObj('/foo', [ref1, ref2]),
|
||||
routeObj('/bar', [ref3], [routeObj('/baz', [ref4, ref5])]),
|
||||
routeObj('/foo', [ref1, ref2], [], 'gathered'),
|
||||
routeObj(
|
||||
'/bar',
|
||||
[ref3],
|
||||
[routeObj('/baz', [ref4, ref5], [], 'gathered')],
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -319,6 +336,7 @@ describe('discovery', () => {
|
||||
'/bar',
|
||||
[ref2, ref5],
|
||||
[routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])],
|
||||
'gathered',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -351,24 +369,4 @@ describe('discovery', () => {
|
||||
});
|
||||
}).toThrow('Mounted routable extension must have a path');
|
||||
});
|
||||
|
||||
it('should not visit the same element twice', () => {
|
||||
const element = <Extension3 path="/baz" />;
|
||||
|
||||
expect(() =>
|
||||
traverseElementTree({
|
||||
root: (
|
||||
<MemoryRouter>
|
||||
<Extension1 path="/foo">{element}</Extension1>
|
||||
<Extension2 path="/bar">{element}</Extension2>
|
||||
</MemoryRouter>
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routes: routePathCollector,
|
||||
routeParents: routeParentCollector,
|
||||
},
|
||||
}),
|
||||
).toThrow(`Visited element Extension(Component) twice`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,17 @@ export const routeParentCollector = createCollector(
|
||||
},
|
||||
);
|
||||
|
||||
// We always add a child that matches all subroutes but without any route refs. This makes
|
||||
// sure that we're always able to match each route no matter how deep the navigation goes.
|
||||
// The route resolver then takes care of selecting the most specific match in order to find
|
||||
// mount points that are as deep in the routing tree as possible.
|
||||
export const MATCH_ALL_ROUTE: BackstageRouteObject = {
|
||||
caseSensitive: false,
|
||||
path: '/*',
|
||||
element: 'match-all', // These elements aren't used, so we add in a bit of debug information
|
||||
routeRefs: new Set(),
|
||||
};
|
||||
|
||||
export const routeObjectCollector = createCollector(
|
||||
() => Array<BackstageRouteObject>(),
|
||||
(acc, node, parent, parentObj: BackstageRouteObject | undefined) => {
|
||||
@@ -127,9 +138,9 @@ export const routeObjectCollector = createCollector(
|
||||
const newObject: BackstageRouteObject = {
|
||||
caseSensitive,
|
||||
path,
|
||||
element: null,
|
||||
element: 'mounted',
|
||||
routeRefs: new Set([routeRef]),
|
||||
children: [],
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
};
|
||||
parentChildren.push(newObject);
|
||||
return newObject;
|
||||
@@ -149,9 +160,9 @@ export const routeObjectCollector = createCollector(
|
||||
const newObject: BackstageRouteObject = {
|
||||
caseSensitive,
|
||||
path,
|
||||
element: null,
|
||||
element: 'gathered',
|
||||
routeRefs: new Set(),
|
||||
children: [],
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
};
|
||||
parentChildren.push(newObject);
|
||||
return newObject;
|
||||
|
||||
@@ -39,9 +39,17 @@ export type Subscription = {
|
||||
/**
|
||||
* Value indicating whether the subscription is closed.
|
||||
*/
|
||||
readonly closed: Boolean;
|
||||
readonly closed: boolean;
|
||||
};
|
||||
|
||||
// Declares the global well-known Symbol.observable
|
||||
// We get the actual runtime polyfill from zen-observable
|
||||
declare global {
|
||||
interface SymbolConstructor {
|
||||
readonly observable: symbol;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable sequence of values and errors, see TC39.
|
||||
*
|
||||
@@ -51,12 +59,14 @@ export type Subscription = {
|
||||
* using many different observable implementations, such as zen-observable or RxJS 5.
|
||||
*/
|
||||
export type Observable<T> = {
|
||||
[Symbol.observable](): Observable<T>;
|
||||
|
||||
/**
|
||||
* Subscribes to this observable to start receiving new values.
|
||||
*/
|
||||
subscribe(observer: Observer<T>): Subscription;
|
||||
subscribe(
|
||||
onNext: (value: T) => void,
|
||||
onNext?: (value: T) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void,
|
||||
): Subscription;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IconComponent } from '../../icons';
|
||||
import { IconComponent } from '../../icons/types';
|
||||
import { Observable } from '../../types';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ProfileInfo } from '../apis/definitions';
|
||||
import { IconComponent } from '../icons';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config';
|
||||
step: 'load-config' | 'load-chunk';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('index', () => {
|
||||
useApiHolder: expect.any(Function),
|
||||
useApp: expect.any(Function),
|
||||
useRouteRef: expect.any(Function),
|
||||
useRouteRefParams: expect.any(Function),
|
||||
withApis: expect.any(Function),
|
||||
|
||||
createApiRef: expect.any(Function),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRef, ExternalRouteRef } from '../routing';
|
||||
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
|
||||
import { AnyApiFactory } from '../apis/system';
|
||||
|
||||
export type RouteOptions = {
|
||||
@@ -36,7 +36,7 @@ export type Extension<T> = {
|
||||
expose(plugin: BackstagePlugin<any, any>): T;
|
||||
};
|
||||
|
||||
export type AnyRoutes = { [name: string]: RouteRef<any> };
|
||||
export type AnyRoutes = { [name: string]: RouteRef | SubRouteRef };
|
||||
|
||||
export type AnyExternalRoutes = { [name: string]: ExternalRouteRef };
|
||||
|
||||
|
||||
@@ -19,3 +19,4 @@ export { createRouteRef } from './RouteRef';
|
||||
export { createSubRouteRef } from './SubRouteRef';
|
||||
export { createExternalRouteRef } from './ExternalRouteRef';
|
||||
export { useRouteRef } from './useRouteRef';
|
||||
export { useRouteRefParams } from './useRouteRefParams';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { useRouteRefParams } from './useRouteRefParams';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
|
||||
describe('useRouteRefParams', () => {
|
||||
it('should provide types params', () => {
|
||||
const routeRef = createRouteRef({
|
||||
id: 'ref1',
|
||||
params: ['a', 'b'],
|
||||
});
|
||||
|
||||
const Page = () => {
|
||||
const params: { a: string; b: string } = useRouteRefParams(routeRef);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{params.a}</span>
|
||||
<span>{params.b}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Route path="/:a/:b">
|
||||
<Page />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(getByText('foo')).toBeInTheDocument();
|
||||
expect(getByText('bar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { useParams } from 'react-router-dom';
|
||||
import { RouteRef, AnyParams, SubRouteRef } from './types';
|
||||
|
||||
export function useRouteRefParams<Params extends AnyParams>(
|
||||
_routeRef: RouteRef<Params> | SubRouteRef<Params>,
|
||||
): Params {
|
||||
return useParams() as Params;
|
||||
}
|
||||
Reference in New Issue
Block a user