Merge pull request #25879 from backstage/blam/nfs/migrate-extensions

NFS: Migrate existing `extensionCreators` to `Blueprints`
This commit is contained in:
Patrik Oldsberg
2024-08-09 16:56:10 +02:00
committed by GitHub
40 changed files with 2034 additions and 256 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Deprecate existing `ExtensionCreators` in favour of their new Blueprint counterparts.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/frontend-test-utils': patch
---
Refactor `.make` method on Blueprints into two different methods, `.make` and `.makeWithOverrides`.
When using `createExtensionBlueprint` you can define parameters for the factory function, if you wish to take advantage of these parameters you should use `.make` when creating an extension instance of a Blueprint. If you wish to override more things other than the standard `attachTo`, `name`, `namespace` then you should use `.makeWithOverrides` instead.
`.make` is reserved for simple creation of extension instances from Blueprints using higher level parameters, whereas `.makeWithOverrides` is lower level and you have more control over the final extension.
+348 -62
View File
@@ -189,6 +189,27 @@ export type AnyRoutes = {
[name in string]: RouteRef | SubRouteRef;
};
// @public
export const ApiBlueprint: ExtensionBlueprint<
'api',
undefined,
undefined,
{
factory: AnyApiFactory;
},
ConfigurableExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>,
{},
{},
{},
{
factory: ConfigurableExtensionDataRef<
AnyApiFactory,
'core.api.factory',
{}
>;
}
>;
export { ApiFactory };
export { ApiHolder };
@@ -240,6 +261,50 @@ export interface AppNodeSpec {
readonly source?: BackstagePlugin;
}
// @public
export const AppRootElementBlueprint: ExtensionBlueprint<
'app-root-element',
undefined,
undefined,
{
element: JSX.Element | (() => JSX.Element);
},
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{},
{},
{},
never
>;
// @public
export const AppRootWrapperBlueprint: ExtensionBlueprint<
'app-root-wrapper',
undefined,
undefined,
{
Component: ComponentType<PropsWithChildren<{}>>;
},
ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
'app.root.wrapper',
{}
>,
{},
{},
{},
{
component: ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
'app.root.wrapper',
{}
>;
}
>;
export { AppTheme };
export { AppThemeApi };
@@ -381,7 +446,7 @@ export type CoreNotFoundErrorPageProps = {
// @public (undocumented)
export type CoreProgressProps = {};
// @public (undocumented)
// @public @deprecated (undocumented)
export function createApiExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
@@ -425,7 +490,7 @@ export { createApiFactory };
export { createApiRef };
// @public
// @public @deprecated
export function createAppRootElementExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
@@ -447,7 +512,7 @@ export function createAppRootElementExtension<
}) => JSX_2.Element);
}): ExtensionDefinition<TConfig>;
// @public
// @public @deprecated
export function createAppRootWrapperExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
@@ -675,7 +740,7 @@ export type CreateExtensionBlueprintOptions<
},
> = {
kind: TKind;
namespace?: TNamespace | ((params: TParams) => TNamespace);
namespace?: TNamespace;
attachTo: {
id: string;
input: string;
@@ -683,9 +748,9 @@ export type CreateExtensionBlueprintOptions<
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
name?: TName | ((params: TParams) => TName);
name?: TName;
config?: {
schema: TConfigSchema | ((params: TParams) => TConfigSchema);
schema: TConfigSchema;
};
factory(
params: TParams,
@@ -824,7 +889,7 @@ export function createExternalRouteRef<
}
>;
// @public
// @public @deprecated
export function createNavItemExtension(options: {
namespace?: string;
name?: string;
@@ -859,7 +924,7 @@ export namespace createNavItemExtension {
>;
}
// @public
// @public @deprecated
export function createNavLogoExtension(options: {
name?: string;
namespace?: string;
@@ -888,7 +953,7 @@ export namespace createNavLogoExtension {
>;
}
// @public
// @public @deprecated
export function createPageExtension<
TConfig extends {
path: string;
@@ -958,7 +1023,7 @@ export function createRouteRef<
}
>;
// @public
// @public @deprecated
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
@@ -997,7 +1062,7 @@ export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput, TInput>;
// @public (undocumented)
// @public @deprecated (undocumented)
export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
@@ -1036,7 +1101,7 @@ export function createSubRouteRef<
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams>;
// @public (undocumented)
// @public @deprecated (undocumented)
export function createThemeExtension(
theme: AppTheme,
): ExtensionDefinition<
@@ -1049,7 +1114,7 @@ export function createThemeExtension(
string | undefined
>;
// @public (undocumented)
// @public @deprecated (undocumented)
export namespace createThemeExtension {
const // (undocumented)
themeDataRef: ConfigurableExtensionDataRef<
@@ -1059,7 +1124,7 @@ export namespace createThemeExtension {
>;
}
// @public (undocumented)
// @public @deprecated (undocumented)
export function createTranslationExtension(options: {
name?: string;
resource: TranslationResource | TranslationMessages;
@@ -1073,7 +1138,7 @@ export function createTranslationExtension(options: {
string | undefined
>;
// @public (undocumented)
// @public @deprecated (undocumented)
export namespace createTranslationExtension {
const // (undocumented)
translationDataRef: ConfigurableExtensionDataRef<
@@ -1153,9 +1218,31 @@ export interface ExtensionBlueprint<
> {
// (undocumented)
dataRefs: TDataRefs;
// (undocumented)
make<
TNewNamespace extends string | undefined,
TNewName extends string | undefined,
>(args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
params: TParams;
}): ExtensionDefinition<
TConfig,
TConfigInput,
UOutput,
TInputs,
TKind,
string | undefined extends TNewNamespace ? TNamespace : TNewNamespace,
string | undefined extends TNewName ? TName : TNewName
>;
makeWithOverrides<
TNewNamespace extends string | undefined,
TNewName extends string | undefined,
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
@@ -1170,55 +1257,48 @@ export interface ExtensionBlueprint<
}
>;
},
>(
args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: {
id: string;
input: string;
>(args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory(
originalFactory: (
params: TParams,
context?: {
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
};
} & (
| ({
factory(
originalFactory: (
params: TParams,
context?: {
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
UFactoryOutput
>)
| {
params: TParams;
}
),
): ExtensionDefinition<
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput> &
VerifyExtensionFactoryOutput<
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
UFactoryOutput
>;
}): ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -1616,6 +1696,73 @@ export interface LegacyExtensionInput<
export { microsoftAuthApiRef };
// @public
export const NavItemBlueprint: ExtensionBlueprint<
'nav-item',
undefined,
undefined,
{
title: string;
icon: IconComponent_2;
routeRef: RouteRef<undefined>;
},
ConfigurableExtensionDataRef<
{
title: string;
icon: IconComponent_2;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>,
{},
{},
{},
{
target: ConfigurableExtensionDataRef<
{
title: string;
icon: IconComponent_2;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>;
}
>;
// @public
export const NavLogoBlueprint: ExtensionBlueprint<
'nav-logo',
undefined,
undefined,
{
logoIcon: JSX.Element;
logoFull: JSX.Element;
},
ConfigurableExtensionDataRef<
{
logoIcon?: JSX.Element | undefined;
logoFull?: JSX.Element | undefined;
},
'core.nav-logo.logo-elements',
{}
>,
{},
{},
{},
{
logoElements: ConfigurableExtensionDataRef<
{
logoIcon?: JSX.Element | undefined;
logoFull?: JSX.Element | undefined;
},
'core.nav-logo.logo-elements',
{}
>;
}
>;
export { OAuthApi };
export { OAuthRequestApi };
@@ -1634,6 +1781,35 @@ export { oneloginAuthApiRef };
export { OpenIdConnectApi };
// @public
export const PageBlueprint: ExtensionBlueprint<
'page',
undefined,
undefined,
{
defaultPath: string;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
},
| ConfigurableExtensionDataRef<React_2.JSX.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
RouteRef<AnyRouteRefParams> & {
optional: true;
}
>,
{},
{
path: string | undefined;
},
{
path?: string | undefined;
},
never
>;
export { PendingOAuthRequest };
// @public (undocumented)
@@ -1699,6 +1875,35 @@ export type RouteFunc<TParams extends AnyRouteRefParams> = (
: readonly [params: TParams]
) => string;
// @public (undocumented)
export const RouterBlueprint: ExtensionBlueprint<
'app-router-component',
undefined,
undefined,
{
Component: ComponentType<PropsWithChildren<{}>>;
},
ConfigurableExtensionDataRef<
ComponentType<{
children?: ReactNode;
}>,
'app.router.wrapper',
{}
>,
{},
{},
{},
{
component: ConfigurableExtensionDataRef<
ComponentType<{
children?: ReactNode;
}>,
'app.router.wrapper',
{}
>;
}
>;
// @public
export interface RouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
@@ -1733,6 +1938,31 @@ export { SessionApi };
export { SessionState };
// @public
export const SignInPageBlueprint: ExtensionBlueprint<
'sign-in-page',
undefined,
undefined,
{
loader: () => Promise<ComponentType<SignInPageProps>>;
},
ConfigurableExtensionDataRef<
React_2.ComponentType<SignInPageProps>,
'core.sign-in-page.component',
{}
>,
{},
{},
{},
{
component: ConfigurableExtensionDataRef<
React_2.ComponentType<SignInPageProps>,
'core.sign-in-page.component',
{}
>;
}
>;
export { StorageApi };
export { storageApiRef };
@@ -1751,6 +1981,62 @@ export interface SubRouteRef<
readonly T: TParams;
}
// @public
export const ThemeBlueprint: ExtensionBlueprint<
'theme',
'app',
undefined,
{
theme: AppTheme;
},
ConfigurableExtensionDataRef<AppTheme, 'core.theme.theme', {}>,
{},
{},
{},
{
theme: ConfigurableExtensionDataRef<AppTheme, 'core.theme.theme', {}>;
}
>;
// @public
export const TranslationBlueprint: ExtensionBlueprint<
'translation',
undefined,
undefined,
{
resource: TranslationResource | TranslationMessages;
},
ConfigurableExtensionDataRef<
| TranslationResource<string>
| TranslationMessages<
string,
{
[x: string]: string;
},
boolean
>,
'core.translation.translation',
{}
>,
{},
{},
{},
{
translation: ConfigurableExtensionDataRef<
| TranslationResource<string>
| TranslationMessages<
string,
{
[x: string]: string;
},
boolean
>,
'core.translation.translation',
{}
>;
}
>;
export { TranslationMessages };
export { TranslationMessagesOptions };
@@ -0,0 +1,132 @@
/*
* Copyright 2024 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 { createExtensionInput } from '../wiring';
import { ApiBlueprint } from './ApiBlueprint';
import { createApiFactory, createApiRef } from '@backstage/core-plugin-api';
describe('ApiBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const api = createApiRef<{ foo: string }>({ id: 'test' });
const factory = createApiFactory({
api,
deps: {},
factory: () => ({ foo: 'bar' }),
});
const extension = ApiBlueprint.make({
params: {
factory,
},
namespace: 'test',
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app",
"input": "apis",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "api",
"name": undefined,
"namespace": "test",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should create an extension with custom factory', () => {
const api = createApiRef<{ foo: string }>({ id: 'test' });
const factory = jest.fn(() => ({ foo: 'bar' }));
const extension = ApiBlueprint.makeWithOverrides({
config: {
schema: {
test: z => z.string().default('test'),
},
},
inputs: {
test: createExtensionInput([ApiBlueprint.dataRefs.factory]),
},
namespace: api.id,
factory(originalFactory, { config: _config, inputs: _inputs }) {
return originalFactory({
factory: createApiFactory({
api,
deps: {},
factory,
}),
});
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app",
"input": "apis",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"test": {
"default": "test",
"type": "string",
},
},
"type": "object",
},
},
"disabled": false,
"factory": [Function],
"inputs": {
"test": {
"$$type": "@backstage/ExtensionInput",
"config": {
"optional": false,
"singleton": false,
},
"extensionData": [
[Function],
],
},
},
"kind": "api",
"name": undefined,
"namespace": "test",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
});
@@ -0,0 +1,36 @@
/*
* Copyright 2024 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 { createExtensionBlueprint } from '../wiring';
import { createApiExtension } from '../extensions/createApiExtension';
import { AnyApiFactory } from '@backstage/core-plugin-api';
/**
* Creates utility API extensions.
*
* @public
*/
export const ApiBlueprint = createExtensionBlueprint({
kind: 'api',
attachTo: { id: 'app', input: 'apis' },
output: [createApiExtension.factoryDataRef],
dataRefs: {
factory: createApiExtension.factoryDataRef,
},
*factory(params: { factory: AnyApiFactory }) {
yield createApiExtension.factoryDataRef(params.factory);
},
});
@@ -0,0 +1,49 @@
/*
* Copyright 2024 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 from 'react';
import { AppRootElementBlueprint } from './AppRootElementBlueprint';
describe('AppRootElementBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const extension = AppRootElementBlueprint.make({
params: {
element: <div />,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/root",
"input": "elements",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "app-root-element",
"name": undefined,
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
});
@@ -0,0 +1,33 @@
/*
* Copyright 2024 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, createExtensionBlueprint } from '../wiring';
/**
* Creates extensions that render a React element at the app root, outside of
* the app layout. This is useful for example for shared popups and similar.
*
* @public
*/
export const AppRootElementBlueprint = createExtensionBlueprint({
kind: 'app-root-element',
attachTo: { id: 'app/root', input: 'elements' },
output: [coreExtensionData.reactElement],
*factory(params: { element: JSX.Element | (() => JSX.Element) }) {
yield coreExtensionData.reactElement(
typeof params.element === 'function' ? params.element() : params.element,
);
},
});
@@ -0,0 +1,130 @@
/*
* Copyright 2024 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 from 'react';
import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { PageBlueprint } from './PageBlueprint';
import { waitFor } from '@testing-library/react';
import {
coreExtensionData,
createExtension,
createExtensionInput,
} from '../wiring';
describe('AppRootWrapperBlueprint', () => {
it('should return an extension with sensible defaults', () => {
const extension = AppRootWrapperBlueprint.make({
params: {
Component: () => <div>Hello</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/root",
"input": "wrappers",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "app-root-wrapper",
"name": undefined,
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should render the simple component wrapper', async () => {
const extension = AppRootWrapperBlueprint.make({
params: {
Component: () => <div>Hello</div>,
},
});
const { getByText } = createExtensionTester(
PageBlueprint.make({
params: {
defaultPath: '/',
loader: async () => <div />,
},
}),
)
.add(extension)
.render();
await waitFor(() => expect(getByText('Hello')).toBeInTheDocument());
});
it('should render the complex component wrapper', async () => {
const extension = AppRootWrapperBlueprint.makeWithOverrides({
namespace: 'ns',
name: 'test',
config: {
schema: {
name: z => z.string(),
},
},
inputs: {
children: createExtensionInput([coreExtensionData.reactElement]),
},
*factory(originalFactory, { inputs, config }) {
yield* originalFactory({
Component: ({ children }) => (
<div data-testid={`${config.name}-${inputs.children.length}`}>
{children}
{inputs.children.flatMap(c =>
c.get(coreExtensionData.reactElement),
)}
</div>
),
});
},
});
const { getByText, getByTestId } = createExtensionTester(
PageBlueprint.make({
params: {
defaultPath: '/',
loader: async () => <div>Hi</div>,
},
}),
)
.add(extension, { config: { name: 'Robin' } })
.add(
createExtension({
attachTo: { id: 'app-root-wrapper:ns/test', input: 'children' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>Its Me</div>)],
}),
)
.render();
await waitFor(() => {
expect(getByText('Hi')).toBeInTheDocument();
expect(getByTestId('Robin-1')).toBeInTheDocument();
expect(getByText('Its Me')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,44 @@
/*
* Copyright 2024 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 from 'react';
import { ComponentType, PropsWithChildren } from 'react';
import { createExtensionBlueprint } from '../wiring';
import { createAppRootWrapperExtension } from '../extensions/createAppRootWrapperExtension';
/**
* Creates a extensions that render a React wrapper at the app root, enclosing
* the app layout. This is useful for example for adding global React contexts
* and similar.
*
* @public
*/
export const AppRootWrapperBlueprint = createExtensionBlueprint({
kind: 'app-root-wrapper',
attachTo: { id: 'app/root', input: 'wrappers' },
output: [createAppRootWrapperExtension.componentDataRef],
dataRefs: {
component: createAppRootWrapperExtension.componentDataRef,
},
*factory(params: { Component: ComponentType<PropsWithChildren<{}>> }) {
// todo(blam): not sure that this wrapping is even necessary anymore.
const Component = (props: PropsWithChildren<{}>) => {
return <params.Component>{props.children}</params.Component>;
};
yield createAppRootWrapperExtension.componentDataRef(Component);
},
});
@@ -0,0 +1,106 @@
/*
* Copyright 2024 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 { createExtensionTester } from '@backstage/frontend-test-utils';
import { createRouteRef } from '../routing';
import { NavItemBlueprint } from './NavItemBlueprint';
describe('NavItemBlueprint', () => {
const mockRouteRef = createRouteRef();
const MockIcon = () => null;
it('should return an extension with sensible defaults', () => {
const extension = NavItemBlueprint.make({
params: {
icon: MockIcon,
routeRef: mockRouteRef,
title: 'TEST',
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/nav",
"input": "items",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
},
},
"type": "object",
},
},
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "nav-item",
"name": undefined,
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return the correct extension data', () => {
const extension = NavItemBlueprint.make({
params: {
icon: MockIcon,
routeRef: mockRouteRef,
title: 'TEST',
},
});
const tester = createExtensionTester(extension);
expect(tester.data(NavItemBlueprint.dataRefs.target)).toEqual({
title: 'TEST',
icon: MockIcon,
routeRef: mockRouteRef,
});
});
it('should allow overriding of the title using config', () => {
const extension = NavItemBlueprint.make({
params: {
icon: MockIcon,
routeRef: mockRouteRef,
title: 'TEST',
},
});
const tester = createExtensionTester(extension, {
config: { title: 'OVERRIDDEN' },
});
expect(tester.data(NavItemBlueprint.dataRefs.target)).toEqual({
title: 'OVERRIDDEN',
icon: MockIcon,
routeRef: mockRouteRef,
});
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2024 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 { IconComponent } from '@backstage/core-plugin-api';
import { RouteRef } from '../routing';
import { createExtensionBlueprint } from '../wiring';
import { createNavItemExtension } from '../extensions/createNavItemExtension';
/**
* Creates extensions that make up the items of the nav bar.
*
* @public
*/
export const NavItemBlueprint = createExtensionBlueprint({
kind: 'nav-item',
attachTo: { id: 'app/nav', input: 'items' },
output: [createNavItemExtension.targetDataRef],
dataRefs: {
target: createNavItemExtension.targetDataRef,
},
factory: (
{
icon,
routeRef,
title,
}: {
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
},
{ config },
) => [
createNavItemExtension.targetDataRef({
title: config.title ?? title,
icon,
routeRef,
}),
],
config: {
schema: {
title: z => z.string().optional(),
},
},
});
@@ -0,0 +1,72 @@
/*
* Copyright 2024 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 from 'react';
import { NavLogoBlueprint } from './NavLogoBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
describe('NavLogoBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const extension = NavLogoBlueprint.make({
params: {
logoFull: <div>Logo Full</div>,
logoIcon: <div>Logo Icon</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/nav",
"input": "logos",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "nav-logo",
"name": undefined,
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return a valid component ref', () => {
const logoFull = <div>Logo Full</div>;
const logoIcon = <div>Logo Icon</div>;
const extension = NavLogoBlueprint.make({
name: 'test',
params: {
logoFull,
logoIcon,
},
});
const tester = createExtensionTester(extension);
expect(tester.data(NavLogoBlueprint.dataRefs.logoElements)).toEqual({
logoFull,
logoIcon,
});
});
});
@@ -0,0 +1,44 @@
/*
* Copyright 2024 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 { createExtensionBlueprint } from '../wiring';
import { createNavLogoExtension } from '../extensions/createNavLogoExtension';
/**
* Creates an extension that replaces the logo in the nav bar with your own.
*
* @public
*/
export const NavLogoBlueprint = createExtensionBlueprint({
kind: 'nav-logo',
attachTo: { id: 'app/nav', input: 'logos' },
output: [createNavLogoExtension.logoElementsDataRef],
dataRefs: {
logoElements: createNavLogoExtension.logoElementsDataRef,
},
*factory({
logoIcon,
logoFull,
}: {
logoIcon: JSX.Element;
logoFull: JSX.Element;
}) {
yield createNavLogoExtension.logoElementsDataRef({
logoIcon,
logoFull,
});
},
});
@@ -0,0 +1,154 @@
/*
* Copyright 2024 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 from 'react';
import { createRouteRef } from '../routing';
import { PageBlueprint } from './PageBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import {
coreExtensionData,
createExtensionBlueprint,
createExtensionInput,
} from '../wiring';
import { waitFor } from '@testing-library/react';
describe('PageBlueprint', () => {
const mockRouteRef = createRouteRef();
it('should return an extension when calling make with sensible defaults', () => {
const myPage = PageBlueprint.make({
name: 'test-page',
params: {
loader: () => Promise.resolve(<div>Test</div>),
defaultPath: '/test',
routeRef: mockRouteRef,
},
});
expect(myPage).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/routes",
"input": "routes",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
},
},
"type": "object",
},
},
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "page",
"name": "test-page",
"namespace": undefined,
"output": [
[Function],
[Function],
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "core.routing.ref",
"optional": [Function],
"toString": [Function],
},
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should output a routeRef with the input routeRef', async () => {
const myPage = PageBlueprint.make({
name: 'test-page',
params: {
loader: () => Promise.resolve(<div data-testid="test">Test</div>),
defaultPath: '/test',
routeRef: mockRouteRef,
},
});
const tester = createExtensionTester(myPage);
// TODO(blam): test for the routePath output doesn't work, due to the the way the test harness works
// expect(tester.data(coreExtensionData.routePath)).toBe('/test');
expect(tester.data(coreExtensionData.routeRef)).toBe(mockRouteRef);
const { getByTestId } = tester.render();
await waitFor(() => expect(getByTestId('test')).toBeInTheDocument());
});
it('should allow defining additional inputs to the extension', async () => {
const myPage = PageBlueprint.makeWithOverrides({
name: 'test-page',
inputs: {
cards: createExtensionInput([coreExtensionData.reactElement], {
optional: false,
singleton: false,
}),
},
factory(originalFactory, { inputs }) {
return originalFactory({
loader: async () => (
<div data-testid="test">
{inputs.cards.map(c => c.get(coreExtensionData.reactElement))}
</div>
),
defaultPath: '/test',
routeRef: mockRouteRef,
});
},
});
const CardBlueprint = createExtensionBlueprint({
kind: 'card',
attachTo: { id: 'page:test-page', input: 'cards' },
output: [coreExtensionData.reactElement],
factory() {
return [
coreExtensionData.reactElement(
<div data-testid="card">I'm a lovely card</div>,
),
];
},
});
const tester = createExtensionTester(myPage).add(
CardBlueprint.make({ name: 'card', params: {} }),
);
const { getByTestId, getByText } = tester.render();
await waitFor(() => expect(getByTestId('card')).toBeInTheDocument());
await waitFor(() =>
expect(getByText("I'm a lovely card")).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,66 @@
/*
* Copyright 2024 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, { lazy } from 'react';
import { RouteRef } from '../routing';
import { coreExtensionData, createExtensionBlueprint } from '../wiring';
import { ExtensionBoundary } from '../components';
/**
* Createx extensions that are routable React page components.
*
* @public
*/
export const PageBlueprint = createExtensionBlueprint({
kind: 'page',
attachTo: { id: 'app/routes', input: 'routes' },
output: [
coreExtensionData.routePath,
coreExtensionData.reactElement,
coreExtensionData.routeRef.optional(),
],
config: {
schema: {
path: z => z.string().optional(),
},
},
*factory(
{
defaultPath,
loader,
routeRef,
}: {
defaultPath: string;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
},
{ config, node },
) {
const ExtensionComponent = lazy(() =>
loader().then(element => ({ default: () => element })),
);
yield coreExtensionData.routePath(config.path ?? defaultPath);
yield coreExtensionData.reactElement(
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>,
);
if (routeRef) {
yield coreExtensionData.routeRef(routeRef);
}
},
});
@@ -0,0 +1,170 @@
/*
* Copyright 2024 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 from 'react';
import { RouterBlueprint } from './RouterBlueprint';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
coreExtensionData,
createExtension,
createExtensionInput,
createExtensionOverrides,
} from '../wiring';
import { MockConfigApi } from '@backstage/test-utils';
import { PageBlueprint } from './PageBlueprint';
describe('RouterBlueprint', () => {
it('should return an extension when calling make with sensible defaults', () => {
const extension = RouterBlueprint.make({
params: {
Component: props => <div>{props.children}</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/root",
"input": "router",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "app-router-component",
"name": undefined,
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should work with simple options', async () => {
const extension = RouterBlueprint.make({
namespace: 'test',
params: {
Component: ({ children }) => (
<MemoryRouter>
<div data-testid="test-router">{children}</div>
</MemoryRouter>
),
},
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
PageBlueprint.make({
namespace: 'test',
params: {
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
},
}),
],
}),
],
});
const { getByTestId } = render(app.createRoot());
await waitFor(() => {
expect(getByTestId('test-contents')).toBeInTheDocument();
expect(getByTestId('test-router')).toBeInTheDocument();
});
});
it('should work with complex options and props', async () => {
const extension = RouterBlueprint.makeWithOverrides({
namespace: 'test',
name: 'test',
config: {
schema: {
name: z => z.string(),
},
},
inputs: {
children: createExtensionInput([coreExtensionData.reactElement]),
},
*factory(originalFactory, { inputs, config }) {
yield* originalFactory({
Component: ({ children }) => (
<MemoryRouter>
<div
data-testid={`test-router-${config.name}-${inputs.children.length}`}
>
{children}
</div>
</MemoryRouter>
),
});
},
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createExtension({
namespace: 'test',
attachTo: {
id: 'app-router-component:test/test',
input: 'children',
},
output: [coreExtensionData.reactElement],
*factory() {
yield coreExtensionData.reactElement(<div />);
},
}),
PageBlueprint.make({
namespace: 'test',
params: {
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
},
}),
],
}),
],
config: new MockConfigApi({
app: {
extensions: [
{
'app-router-component:test/test': { config: { name: 'Robin' } },
},
],
},
}),
});
const { getByTestId } = render(app.createRoot());
await waitFor(() => {
expect(getByTestId('test-contents')).toBeInTheDocument();
expect(getByTestId('test-router-Robin-1')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,31 @@
/*
* Copyright 2024 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 { ComponentType, PropsWithChildren } from 'react';
import { createExtensionBlueprint } from '../wiring';
import { createRouterExtension } from '../extensions/createRouterExtension';
/** @public */
export const RouterBlueprint = createExtensionBlueprint({
kind: 'app-router-component',
attachTo: { id: 'app/root', input: 'router' },
output: [createRouterExtension.componentDataRef],
dataRefs: {
component: createRouterExtension.componentDataRef,
},
*factory({ Component }: { Component: ComponentType<PropsWithChildren<{}>> }) {
yield createRouterExtension.componentDataRef(Component);
},
});
@@ -0,0 +1,82 @@
/*
* Copyright 2024 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 from 'react';
import { SignInPageBlueprint } from './SignInPageBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen, waitFor } from '@testing-library/react';
import { coreExtensionData, createExtension } from '../wiring';
describe('SignInPageBlueprint', () => {
it('should create an extension with sensible defaults', () => {
expect(
SignInPageBlueprint.make({
params: { loader: async () => () => <div /> },
}),
).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app/root",
"input": "signInPage",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "sign-in-page",
"name": undefined,
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return the component as the componentRef', async () => {
const MockSignInPage = () => <div data-testid="mock-sign-in" />;
const extension = SignInPageBlueprint.make({
name: 'test',
params: { loader: async () => () => <MockSignInPage /> },
});
const tester = createExtensionTester(extension);
expect(tester.data(SignInPageBlueprint.dataRefs.component)).toBeDefined();
createExtensionTester(
createExtension({
name: 'dummy',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
element: coreExtensionData.reactElement,
},
factory: () => ({ element: <div /> }),
}),
)
.add(extension)
.render();
await waitFor(() => {
expect(screen.getByTestId('mock-sign-in')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,52 @@
/*
* Copyright 2024 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, lazy } from 'react';
import { createExtensionBlueprint } from '../wiring';
import { createSignInPageExtension } from '../extensions/createSignInPageExtension';
import { SignInPageProps } from '@backstage/core-plugin-api';
import { ExtensionBoundary } from '../components';
/**
* Creates an extension that replaces the sign in page.
*
* @public
*/
export const SignInPageBlueprint = createExtensionBlueprint({
kind: 'sign-in-page',
attachTo: { id: 'app/root', input: 'signInPage' },
output: [createSignInPageExtension.componentDataRef],
dataRefs: {
component: createSignInPageExtension.componentDataRef,
},
*factory(
{
loader,
}: {
loader: () => Promise<ComponentType<SignInPageProps>>;
},
{ node },
) {
const ExtensionComponent = lazy(() =>
loader().then(component => ({ default: component })),
);
yield createSignInPageExtension.componentDataRef(props => (
<ExtensionBoundary node={node} routable>
<ExtensionComponent {...props} />
</ExtensionBoundary>
));
},
});
@@ -0,0 +1,62 @@
/*
* Copyright 2024 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 { AppTheme } from '@backstage/core-plugin-api';
import { ThemeBlueprint } from './ThemeBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
describe('ThemeBlueprint', () => {
const theme = {
id: 'light',
colors: { primary: 'blue' },
variant: 'dark',
title: 'lols',
Provider: (_: { children: React.ReactNode }) => null,
} as AppTheme;
it('should create an extension with sensible defaults', () => {
expect(ThemeBlueprint.make({ name: 'light', params: { theme } }))
.toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app",
"input": "themes",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "theme",
"name": "light",
"namespace": "app",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return the theme as an themeDataRef', async () => {
const extension = ThemeBlueprint.make({ params: { theme } });
expect(
createExtensionTester(extension).data(ThemeBlueprint.dataRefs.theme),
).toEqual(theme);
});
});
@@ -0,0 +1,37 @@
/*
* Copyright 2024 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 { AppTheme } from '@backstage/core-plugin-api';
import { createExtensionBlueprint } from '../wiring';
import { createThemeExtension } from '../extensions/createThemeExtension';
/**
* Creates an extension that adds/replaces an app theme.
*
* @public
*/
export const ThemeBlueprint = createExtensionBlueprint({
kind: 'theme',
namespace: 'app',
attachTo: { id: 'app', input: 'themes' },
output: [createThemeExtension.themeDataRef],
dataRefs: {
theme: createThemeExtension.themeDataRef,
},
factory: ({ theme }: { theme: AppTheme }) => [
createThemeExtension.themeDataRef(theme),
],
});
@@ -0,0 +1,84 @@
/*
* Copyright 2024 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 { createExtensionTester } from '@backstage/frontend-test-utils';
import {
createTranslationMessages,
createTranslationRef,
} from '../translation';
import { TranslationBlueprint } from './TranslationBlueprint';
describe('TranslationBlueprint', () => {
const translationRef = createTranslationRef({
id: 'translationRefId',
messages: {
test: 'test',
},
});
const messages = createTranslationMessages({
ref: translationRef,
messages: {
test: 'test2',
},
});
it('should return an extension instance with sane defaults', () => {
expect(
TranslationBlueprint.make({
name: 'blob',
params: {
resource: messages,
},
}),
).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app",
"input": "translations",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "translation",
"name": "blob",
"namespace": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should output a translation data ref', () => {
const extension = TranslationBlueprint.make({
name: 'blob',
params: {
resource: messages,
},
});
expect(
createExtensionTester(extension).data(
TranslationBlueprint.dataRefs.translation,
),
).toBe(messages);
});
});
@@ -0,0 +1,38 @@
/*
* Copyright 2024 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 { createExtensionBlueprint } from '../wiring';
import { createTranslationExtension } from '../extensions/createTranslationExtension';
import { TranslationMessages, TranslationResource } from '../translation';
/**
* Creates an extension that adds translations to your app.
*
* @public
*/
export const TranslationBlueprint = createExtensionBlueprint({
kind: 'translation',
attachTo: { id: 'app', input: 'translations' },
output: [createTranslationExtension.translationDataRef],
dataRefs: {
translation: createTranslationExtension.translationDataRef,
},
factory: ({
resource,
}: {
resource: TranslationResource | TranslationMessages;
}) => [createTranslationExtension.translationDataRef(resource)],
});
@@ -0,0 +1,27 @@
/*
* Copyright 2024 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 { ApiBlueprint } from './ApiBlueprint';
export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
export { IconBundleBlueprint } from './IconBundleBlueprint';
export { NavItemBlueprint } from './NavItemBlueprint';
export { NavLogoBlueprint } from './NavLogoBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
export { ThemeBlueprint } from './ThemeBlueprint';
export { TranslationBlueprint } from './TranslationBlueprint';
@@ -24,7 +24,10 @@ import {
import { AnyExtensionInputMap } from '../wiring/createExtension';
import { Expand } from '../types';
/** @public */
/**
* @public
* @deprecated Use {@link ApiBlueprint} instead.
*/
export function createApiExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
@@ -30,6 +30,7 @@ import {
* the app layout. This is useful for example for shared popups and similar.
*
* @public
* @deprecated Use {@link AppRootElementBlueprint} instead.
*/
export function createAppRootElementExtension<
TConfig extends {},
@@ -31,6 +31,7 @@ import { Expand } from '../types';
* and similar.
*
* @public
* @deprecated Use {@link AppRootWrapperBlueprint} instead.
*/
export function createAppRootWrapperExtension<
TConfig extends {},
@@ -21,7 +21,9 @@ import { RouteRef } from '../routing';
/**
* Helper for creating extensions for a nav item.
*
* @public
* @deprecated Use {@link NavItemBlueprint} instead.
*/
export function createNavItemExtension(options: {
namespace?: string;
@@ -18,7 +18,9 @@ import { createExtension, createExtensionDataRef } from '../wiring';
/**
* Helper for creating extensions for a nav logos.
*
* @public
* @deprecated Use {@link NavLogoBlueprint} instead.
*/
export function createNavLogoExtension(options: {
name?: string;
@@ -31,6 +31,7 @@ import { ExtensionDefinition } from '../wiring/createExtension';
* Helper for creating extensions for a routable React page component.
*
* @public
* @deprecated Use {@link PageBlueprint} instead.
*/
export function createPageExtension<
TConfig extends { path: string },
@@ -31,6 +31,7 @@ import { Expand } from '../types';
* MemoryRouter in tests, or to add additional props to a BrowserRouter.
*
* @public
* @deprecated Use {@link RouterBlueprint} instead.
*/
export function createRouterExtension<
TConfig extends {},
@@ -30,6 +30,7 @@ import { SignInPageProps } from '@backstage/core-plugin-api';
/**
*
* @public
* @deprecated Use {@link SignInPageBlueprint} instead.
*/
export function createSignInPageExtension<
TConfig extends {},
@@ -17,7 +17,10 @@
import { createExtension, createExtensionDataRef } from '../wiring';
import { AppTheme } from '@backstage/core-plugin-api';
/** @public */
/**
* @public
* @deprecated Use {@link ThemeBlueprint} instead.
*/
export function createThemeExtension(theme: AppTheme) {
return createExtension({
kind: 'theme',
@@ -31,7 +34,10 @@ export function createThemeExtension(theme: AppTheme) {
});
}
/** @public */
/**
* @public
* @deprecated Use {@link ThemeBlueprint} instead.
*/
export namespace createThemeExtension {
export const themeDataRef = createExtensionDataRef<AppTheme>().with({
id: 'core.theme.theme',
@@ -17,7 +17,10 @@
import { TranslationMessages, TranslationResource } from '../translation';
import { createExtension, createExtensionDataRef } from '../wiring';
/** @public */
/**
* @public
* @deprecated Use {@link TranslationBlueprint} instead.
*/
export function createTranslationExtension(options: {
name?: string;
resource: TranslationResource | TranslationMessages;
@@ -34,7 +37,10 @@ export function createTranslationExtension(options: {
});
}
/** @public */
/**
* @public
* @deprecated Use {@link TranslationBlueprint} instead.
*/
export namespace createTranslationExtension {
export const translationDataRef = createExtensionDataRef<
TranslationResource | TranslationMessages
@@ -25,4 +25,3 @@ export { createSignInPageExtension } from './createSignInPageExtension';
export { createThemeExtension } from './createThemeExtension';
export { createComponentExtension } from './createComponentExtension';
export { createTranslationExtension } from './createTranslationExtension';
export { IconBundleBlueprint } from './IconBundleBlueprint';
@@ -22,6 +22,7 @@
export * from './analytics';
export * from './apis';
export * from './blueprints';
export * from './components';
export * from './extensions';
export * from './icons';
@@ -127,7 +127,7 @@ describe('createExtensionBlueprint', () => {
},
});
const extension = TestExtensionBlueprint.make({
const extension = TestExtensionBlueprint.makeWithOverrides({
name: 'my-extension',
factory(origFactory) {
return origFactory({
@@ -185,11 +185,8 @@ describe('createExtensionBlueprint', () => {
},
});
const extension = TestExtensionBlueprint.make({
const extension = TestExtensionBlueprint.makeWithOverrides({
name: 'my-extension',
params: {
text: 'Hello, world!',
},
config: {
schema: {
something: z => z.string(),
@@ -236,7 +233,7 @@ describe('createExtensionBlueprint', () => {
},
});
TestExtensionBlueprint.make({
TestExtensionBlueprint.makeWithOverrides({
name: 'my-extension',
params: {
text: 'Hello, world!',
@@ -266,11 +263,8 @@ describe('createExtensionBlueprint', () => {
},
});
const extension = TestExtensionBlueprint.make({
const extension = TestExtensionBlueprint.makeWithOverrides({
name: 'my-extension',
params: {
text: 'Hello, world!',
},
config: {
schema: {
something: z => z.string(),
@@ -341,65 +335,6 @@ describe('createExtensionBlueprint', () => {
expect(true).toBe(true);
});
it('should allow providing callback for properties to set with params', () => {
type TestParams = { test: string };
const Blueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
name: (params: TestParams) => `${params.test}-name`,
output: [coreExtensionData.reactElement],
namespace: (props: TestParams) => props.test,
config: {
schema: (props: TestParams) => ({
test: z => z.string().default(props.test),
}),
},
factory(params: TestParams) {
return [coreExtensionData.reactElement(<div>{params.test}</div>)];
},
});
expect(Blueprint.make({ params: { test: 'hello' } }))
.toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "test",
"input": "default",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"test": {
"default": "hello",
"type": "string",
},
},
"type": "object",
},
},
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "test-extension",
"name": "hello-name",
"namespace": "hello",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
expect(true).toBe(true);
});
it('should allow merging of inputs', () => {
const blueprint = createExtensionBlueprint({
kind: 'test-extension',
@@ -418,7 +353,7 @@ describe('createExtensionBlueprint', () => {
},
});
blueprint.make({
blueprint.makeWithOverrides({
inputs: {
test2: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
@@ -453,7 +388,7 @@ describe('createExtensionBlueprint', () => {
},
});
blueprint.make({
blueprint.makeWithOverrides({
inputs: {
// @ts-expect-error
test: createExtensionInput([]), // Overrides are not allowed
@@ -480,7 +415,7 @@ describe('createExtensionBlueprint', () => {
});
const ext = toInternalExtensionDefinition(
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef2],
factory(origFactory) {
const parent = origFactory({});
@@ -509,7 +444,7 @@ describe('createExtensionBlueprint', () => {
expect(
factoryOutput(
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef1, testDataRef2],
*factory(origFactory) {
yield* origFactory({});
@@ -521,7 +456,7 @@ describe('createExtensionBlueprint', () => {
expect(
factoryOutput(
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef1, testDataRef2],
factory(origFactory) {
return [...origFactory({}), testDataRef2('bar')];
@@ -544,9 +479,9 @@ describe('createExtensionBlueprint', () => {
},
});
// @ts-expect-error
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef2.optional()],
// @ts-expect-error
*factory() {
yield testDataRef1('foo');
yield testDataRef2('bar');
@@ -555,7 +490,7 @@ describe('createExtensionBlueprint', () => {
expect(
factoryOutput(
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef2.optional()],
*factory() {
yield testDataRef2('bar');
@@ -578,9 +513,9 @@ describe('createExtensionBlueprint', () => {
},
});
// @ts-expect-error
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef1, testDataRef2],
// @ts-expect-error
*factory(origFactory) {
yield* origFactory({});
},
@@ -588,7 +523,7 @@ describe('createExtensionBlueprint', () => {
expect(
factoryOutput(
blueprint.make({
blueprint.makeWithOverrides({
output: [testDataRef1, testDataRef2],
*factory(origFactory) {
yield* origFactory({});
@@ -52,14 +52,14 @@ export type CreateExtensionBlueprintOptions<
TDataRefs extends { [name in string]: AnyExtensionDataRef },
> = {
kind: TKind;
namespace?: TNamespace | ((params: TParams) => TNamespace);
namespace?: TNamespace;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
name?: TName | ((params: TParams) => TName);
name?: TName;
config?: {
schema: TConfigSchema | ((params: TParams) => TConfigSchema);
schema: TConfigSchema;
};
factory(
params: TParams,
@@ -96,13 +96,32 @@ export interface ExtensionBlueprint<
> {
dataRefs: TDataRefs;
make<
TNewNamespace extends string | undefined,
TNewName extends string | undefined,
>(args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: { id: string; input: string };
disabled?: boolean;
params: TParams;
}): ExtensionDefinition<
TConfig,
TConfigInput,
UOutput,
TInputs,
TKind,
string | undefined extends TNewNamespace ? TNamespace : TNewNamespace,
string | undefined extends TNewName ? TName : TNewName
>;
/**
* Creates a new extension from the blueprint.
*
* You must either pass `params` directly, or define a `factory` that can
* optionally call the original factory with the same params.
*/
make<
makeWithOverrides<
TNewNamespace extends string | undefined,
TNewName extends string | undefined,
TExtensionConfigSchema extends {
@@ -116,52 +135,45 @@ export interface ExtensionBlueprint<
{ optional: boolean; singleton: boolean }
>;
},
>(
args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
>(args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory(
originalFactory: (
params: TParams,
context?: {
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
};
} & (
| ({
factory(
originalFactory: (
params: TParams,
context?: {
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
UFactoryOutput
>)
| {
params: TParams;
}
),
): ExtensionDefinition<
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput> &
VerifyExtensionFactoryOutput<
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
UFactoryOutput
>;
}): ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -243,7 +255,7 @@ class ExtensionBlueprintImpl<
dataRefs: TDataRefs;
public make<
public makeWithOverrides<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
@@ -264,11 +276,10 @@ class ExtensionBlueprintImpl<
disabled?: boolean;
inputs?: TExtraInputs;
output?: Array<UNewOutput>;
params?: TParams;
config?: {
schema: TExtensionConfigSchema;
};
factory?(
factory(
originalFactory: (
params: TParams,
context?: {
@@ -312,77 +323,82 @@ class ExtensionBlueprintImpl<
>
>
> {
const optionsSchema =
typeof this.options.config?.schema === 'function'
? this.options.config?.schema(args.params!)
: this.options.config?.schema;
const schema = {
...optionsSchema,
...this.options.config?.schema,
...args.config?.schema,
} as TConfigSchema & TExtensionConfigSchema;
const namespace =
typeof this.options.namespace === 'function'
? this.options.namespace(args.params!)
: this.options.namespace;
const name =
typeof this.options.name === 'function'
? this.options.name(args.params!)
: this.options.name;
return createExtension({
kind: this.options.kind,
namespace: args.namespace ?? namespace,
name: args.name ?? name,
namespace: args.namespace ?? this.options.namespace,
name: args.name ?? this.options.name,
attachTo: args.attachTo ?? this.options.attachTo,
disabled: args.disabled ?? this.options.disabled,
inputs: { ...args.inputs, ...this.options.inputs },
output: args.output ?? this.options.output,
config: Object.keys(schema).length === 0 ? undefined : { schema },
factory: ({ node, config, inputs }) => {
if (args.factory) {
return args.factory(
(
innerParams: TParams,
innerContext?: {
config?: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): ExtensionDataContainer<UOutput> => {
return createDataContainer<UOutput>(
this.options.factory(innerParams, {
node,
config: innerContext?.config ?? config,
inputs: (innerContext?.inputs ?? inputs) as any, // TODO: Fix the way input values are overridden
}),
);
return args.factory(
(
innerParams: TParams,
innerContext?: {
config?: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
{
node,
config,
inputs,
},
);
} else if (args.params) {
return this.options.factory(args.params, {
): ExtensionDataContainer<UOutput> => {
return createDataContainer<UOutput>(
this.options.factory(innerParams, {
node,
config: innerContext?.config ?? config,
inputs: (innerContext?.inputs ?? inputs) as any, // TODO: Fix the way input values are overridden
}),
);
},
{
node,
config,
// TODO: Figure out types once legacy data map input type is gone
inputs: inputs as unknown as Expand<
ResolvedExtensionInputs<TInputs>
>,
});
}
throw new Error('Either params or factory must be provided');
inputs,
},
);
},
} as CreateExtensionOptions<TKind, string | undefined extends TNewNamespace ? TNamespace : TNewNamespace, string | undefined extends TNewName ? TName : TNewName, AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, TInputs & TExtraInputs, TConfigSchema & TExtensionConfigSchema, UFactoryOutput>);
}
public make<
TNewNamespace extends string | undefined = undefined,
TNewName extends string | undefined = undefined,
>(args: {
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: { id: string; input: string };
disabled?: boolean;
params: TParams;
}): ExtensionDefinition<
{
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>
> {
return createExtension({
kind: this.options.kind,
namespace: args.namespace ?? this.options.namespace,
name: args.name ?? this.options.name,
attachTo: args.attachTo ?? this.options.attachTo,
disabled: args.disabled ?? this.options.disabled,
inputs: this.options.inputs,
output: this.options.output,
config: this.options.config,
factory: ctx => this.options.factory(args.params, ctx),
} as CreateExtensionOptions<TKind, string | undefined extends TNewNamespace ? TNamespace : TNewNamespace, string | undefined extends TNewName ? TName : TNewName, UOutput, TInputs, TConfigSchema, any>);
}
}
/**
@@ -19,7 +19,6 @@ import { MemoryRouter, Link } from 'react-router-dom';
import { RenderResult, render } from '@testing-library/react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDataValue,
AppNode,
AppTree,
Extension,
@@ -169,9 +168,7 @@ export class ExtensionTester {
: [...internal.output, coreExtensionData.routePath],
factory: params => {
const parentOutput = Array.from(
internal.factory(params as any) as Iterable<
ExtensionDataValue<any, any>
>,
internal.factory(params as any),
).filter(val => val.id !== coreExtensionData.routePath.id);
return [...parentOutput, coreExtensionData.routePath('/')];