Merge pull request #26076 from backstage/rugvip/nold

frontend-plugin-api: remove support for v1 extensions
This commit is contained in:
Patrik Oldsberg
2024-08-20 16:46:13 +02:00
committed by GitHub
55 changed files with 692 additions and 3661 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
**BREAKING**: Removed support for "v1" extensions. This means that it is no longer possible to declare inputs and outputs as objects when using `createExtension`. In addition, all extension creators except for `createComponentExtension` have been removed, use the equivalent blueprint instead. See the [1.30 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations/#130) for more information on this change.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': minor
---
Removed support for testing "v1" extensions, where outputs are defined as an object rather than an array.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-search-react': patch
'@backstage/plugin-catalog': patch
---
The `/alpha` export no longer export extension creators for the new frontend system, existing usage should be switched to use the equivalent extension blueprint instead. For more information see the [new frontend system 1.30 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#130).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Internal refactor following removal of v1 extension support. The app implementation itself still supports v1 extensions at runtime.
@@ -29,12 +29,8 @@ const authRedirectExtension = createExtension({
namespace: 'app',
name: 'layout',
attachTo: { id: 'app/root', input: 'children' },
output: {
element: coreExtensionData.reactElement,
},
factory: () => ({
element: <CookieAuthRedirect />,
}),
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<CookieAuthRedirect />)],
});
const app = createApp({
@@ -20,8 +20,8 @@ import {
coreExtensionData,
createExtensionInput,
useRouteRef,
createNavItemExtension,
createNavLogoExtension,
NavItemBlueprint,
NavLogoBlueprint,
} from '@backstage/frontend-plugin-api';
import { makeStyles } from '@material-ui/core/styles';
import {
@@ -53,7 +53,7 @@ const useSidebarLogoStyles = makeStyles({
});
const SidebarLogo = (
props: (typeof createNavLogoExtension.logoElementsDataRef)['T'],
props: (typeof NavLogoBlueprint.dataRefs.logoElements)['T'],
) => {
const classes = useSidebarLogoStyles();
const { isOpen } = useSidebarOpenState();
@@ -70,7 +70,7 @@ const SidebarLogo = (
};
const SidebarNavItem = (
props: (typeof createNavItemExtension.targetDataRef)['T'],
props: (typeof NavItemBlueprint.dataRefs.target)['T'],
) => {
const { icon: Icon, title, routeRef } = props;
const link = useRouteRef(routeRef);
@@ -86,8 +86,8 @@ export const AppNav = createExtension({
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
inputs: {
items: createExtensionInput([createNavItemExtension.targetDataRef]),
logos: createExtensionInput([createNavLogoExtension.logoElementsDataRef], {
items: createExtensionInput([NavItemBlueprint.dataRefs.target]),
logos: createExtensionInput([NavLogoBlueprint.dataRefs.logoElements], {
singleton: true,
optional: true,
}),
@@ -97,12 +97,12 @@ export const AppNav = createExtension({
coreExtensionData.reactElement(
<Sidebar>
<SidebarLogo
{...inputs.logos?.get(createNavLogoExtension.logoElementsDataRef)}
{...inputs.logos?.get(NavLogoBlueprint.dataRefs.logoElements)}
/>
<SidebarDivider />
{inputs.items.map((item, index) => (
<SidebarNavItem
{...item.get(createNavItemExtension.targetDataRef)}
{...item.get(NavItemBlueprint.dataRefs.target)}
key={index}
/>
))}
@@ -15,14 +15,15 @@
*/
import {
AnyExtensionDataRef,
AppNode,
Extension,
ExtensionInput,
PortableSchema,
ResolvedExtensionInput,
createExtension,
createExtensionDataRef,
createExtensionInput,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import {
createAppNodeInstance,
@@ -31,7 +32,12 @@ import {
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import {
InternalExtension,
resolveExtensionDefinition,
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createSchemaFromZod } from '../../../frontend-plugin-api/src/schema/createSchemaFromZod';
import { withLogCollector } from '@backstage/test-utils';
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
@@ -80,28 +86,63 @@ function makeInstanceWithId<TConfig, TConfigInput>(
};
}
function createV1ExtensionInput(
extensionData: Record<string, AnyExtensionDataRef>,
options: { singleton?: boolean; optional?: boolean } = {},
) {
return {
$$type: '@backstage/ExtensionInput' as const,
extensionData,
config: {
singleton: options.singleton ?? false,
optional: options.optional ?? false,
},
};
}
function createV1Extension(opts: {
namespace: string;
name?: string;
attachTo?: { id: string; input: string };
inputs?: Record<string, ReturnType<typeof createV1ExtensionInput>>;
output: Record<string, AnyExtensionDataRef>;
configSchema?: PortableSchema<any, any>;
factory: (ctx: { inputs: any; config: any }) => any;
}): Extension<any, any> {
const ext: InternalExtension<any, any> = {
$$type: '@backstage/Extension',
version: 'v1',
id: opts.name ? `${opts.namespace}/${opts.name}` : opts.namespace,
disabled: false,
attachTo: opts.attachTo ?? { id: 'ignored', input: 'ignored' },
inputs: opts.inputs ?? {},
output: opts.output,
configSchema: opts.configSchema,
factory: opts.factory,
};
return ext;
}
describe('instantiateAppNodeTree', () => {
describe('v1', () => {
const simpleExtension = resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
other: otherDataRef.optional(),
},
configSchema: createSchemaFromZod(z =>
z.object({
output: z.string().default('test'),
other: z.number().optional(),
}),
),
factory({ config }) {
return { test: config.output, other: config.other };
},
}),
);
const simpleExtension = createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
other: otherDataRef.optional(),
},
configSchema: createSchemaFromZod(z =>
z.object({
output: z.string().default('test'),
other: z.number().optional(),
}),
),
factory({ config }) {
return { test: config.output, other: config.other };
},
});
it('should instantiate a single node', () => {
const tree = resolveAppTree('root-node', [
@@ -129,21 +170,19 @@ describe('instantiateAppNodeTree', () => {
it('should instantiate a node with attachments', () => {
const tree = resolveAppTree('root-node', [
makeSpec(
resolveExtensionDefinition(
createExtension({
namespace: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
createV1Extension({
namespace: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createV1ExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
makeSpec(simpleExtension, {
id: 'child-node',
@@ -175,21 +214,19 @@ describe('instantiateAppNodeTree', () => {
const tree = resolveAppTree('root-node', [
{
...makeSpec(
resolveExtensionDefinition(
createExtension({
namespace: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
createV1Extension({
namespace: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createV1ExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
},
{
@@ -262,46 +299,44 @@ describe('instantiateAppNodeTree', () => {
const instance = createAppNodeInstance({
attachments,
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
optionalSingletonPresent: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
optionalSingletonMissing: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
singleton: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true },
),
many: createExtensionInput({
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
optionalSingletonPresent: createV1ExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
}),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
},
{ singleton: true, optional: true },
),
optionalSingletonMissing: createV1ExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
singleton: createV1ExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true },
),
many: createV1ExtensionInput({
test: testDataRef,
other: otherDataRef.optional(),
}),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
});
@@ -344,19 +379,17 @@ describe('instantiateAppNodeTree', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {
const error = new Error('NOPE');
error.name = 'NopeError';
throw error;
},
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {
const error = new Error('NOPE');
error.name = 'NopeError';
throw error;
},
}),
),
attachments: new Map(),
}),
@@ -369,20 +402,18 @@ describe('instantiateAppNodeTree', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test1: testDataRef,
test2: testDataRef,
},
factory({}) {
return { test1: 'test', test2: 'test2' };
},
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test1: testDataRef,
test2: testDataRef,
},
factory({}) {
return { test1: 'test', test2: 'test2' };
},
}),
),
attachments: new Map(),
}),
@@ -395,19 +426,17 @@ describe('instantiateAppNodeTree', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
},
factory({}) {
return { nonexistent: 'test' } as any;
},
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
},
factory({}) {
return { nonexistent: 'test' } as any;
},
}),
),
attachments: new Map(),
}),
@@ -420,23 +449,21 @@ describe('instantiateAppNodeTree', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createV1ExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
attachments: new Map(),
}),
@@ -467,20 +494,18 @@ describe('instantiateAppNodeTree', () => {
],
]),
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'parent',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
declared: createExtensionInput({
test: testDataRef,
}),
},
output: {},
factory: () => ({}),
}),
),
createV1Extension({
namespace: 'app',
name: 'parent',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
declared: createV1ExtensionInput({
test: testDataRef,
}),
},
output: {},
factory: () => ({}),
}),
),
}),
);
@@ -507,15 +532,13 @@ describe('instantiateAppNodeTree', () => {
],
]),
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'parent',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory: () => ({}),
}),
),
createV1Extension({
namespace: 'app',
name: 'parent',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory: () => ({}),
}),
),
}),
);
@@ -539,23 +562,21 @@ describe('instantiateAppNodeTree', () => {
],
]),
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createV1ExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
}),
).toThrow(
@@ -576,23 +597,21 @@ describe('instantiateAppNodeTree', () => {
],
]),
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true, optional: true },
),
},
output: {},
factory: () => ({}),
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createV1ExtensionInput(
{
test: testDataRef,
},
{ singleton: true, optional: true },
),
},
output: {},
factory: () => ({}),
}),
),
}),
).toThrow(
@@ -607,23 +626,21 @@ describe('instantiateAppNodeTree', () => {
['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
]),
node: makeNode(
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
other: otherDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
createV1Extension({
namespace: 'app',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createV1ExtensionInput(
{
other: otherDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
}),
).toThrowErrorMatchingInlineSnapshot(
@@ -15,9 +15,7 @@
*/
import {
AnyExtensionDataMap,
AnyExtensionDataRef,
AnyExtensionInputMap,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
@@ -32,8 +30,10 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
function resolveInputDataMap(
dataMap: AnyExtensionDataMap,
function resolveV1InputDataMap(
dataMap: {
[name in string]: AnyExtensionDataRef;
},
attachment: AppNode,
inputName: string,
) {
@@ -134,9 +134,17 @@ function reportUndeclaredAttachments(
}
function resolveV1Inputs(
inputMap: AnyExtensionInputMap,
inputMap: {
[inputName in string]: {
$$type: '@backstage/ExtensionInput';
extensionData: {
[name in string]: AnyExtensionDataRef;
};
config: { optional: boolean; singleton: boolean };
};
},
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<AnyExtensionInputMap> {
) {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
@@ -158,7 +166,7 @@ function resolveV1Inputs(
}
return {
node: attachedNodes[0],
output: resolveInputDataMap(
output: resolveV1InputDataMap(
input.extensionData,
attachedNodes[0],
inputName,
@@ -168,9 +176,16 @@ function resolveV1Inputs(
return attachedNodes.map(attachment => ({
node: attachment,
output: resolveInputDataMap(input.extensionData, attachment, inputName),
output: resolveV1InputDataMap(input.extensionData, attachment, inputName),
}));
}) as ResolvedExtensionInputs<AnyExtensionInputMap>;
}) as {
[inputName in string]: {
node: AppNode;
output: {
[name in string]: unknown;
};
};
};
}
function resolveV2Inputs(
+30 -466
View File
@@ -89,8 +89,6 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { vmwareCloudAuthApiRef } from '@backstage/core-plugin-api';
import { withApis } from '@backstage/core-plugin-api';
import { z } from 'zod';
import { ZodSchema } from 'zod';
import { ZodTypeDef } from 'zod';
export { AlertApi };
@@ -147,11 +145,6 @@ export { AnyApiFactory };
export { AnyApiRef };
// @public @deprecated (undocumented)
export type AnyExtensionDataMap = {
[name in string]: AnyExtensionDataRef;
};
// @public (undocumented)
export type AnyExtensionDataRef = ExtensionDataRef<
unknown,
@@ -161,17 +154,6 @@ export type AnyExtensionDataRef = ExtensionDataRef<
}
>;
// @public @deprecated (undocumented)
export type AnyExtensionInputMap = {
[inputName in string]: LegacyExtensionInput<
AnyExtensionDataMap,
{
optional: boolean;
singleton: boolean;
}
>;
};
// @public (undocumented)
export type AnyExternalRoutes = {
[name in string]: ExternalRouteRef;
@@ -456,141 +438,50 @@ export type CoreNotFoundErrorPageProps = {
// @public (undocumented)
export type CoreProgressProps = {};
// @public @deprecated (undocumented)
export function createApiExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
api: AnyApiRef;
factory: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => AnyApiFactory;
}
| {
factory: AnyApiFactory;
}
) & {
configSchema?: PortableSchema<TConfig>;
inputs?: TInputs;
},
): ExtensionDefinition<
TConfig,
TConfig,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @public @deprecated (undocumented)
export namespace createApiExtension {
const // @deprecated (undocumented)
factoryDataRef: ConfigurableExtensionDataRef<
AnyApiFactory,
'core.api.factory',
{}
>;
}
export { createApiFactory };
export { createApiRef };
// @public @deprecated
export function createAppRootElementExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
element:
| JSX_2.Element
| ((options: {
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}) => JSX_2.Element);
}): ExtensionDefinition<TConfig>;
// @public @deprecated
export function createAppRootWrapperExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig>;
// @public @deprecated (undocumented)
export namespace createAppRootWrapperExtension {
const // @deprecated (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
'app.root.wrapper',
{}
>;
}
// @public (undocumented)
export function createComponentExtension<
TProps extends {},
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
export function createComponentExtension<TProps extends {}>(options: {
ref: ComponentRef<TProps>;
name?: string;
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader:
| {
lazy: (values: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<ComponentType<TProps>>;
lazy: () => Promise<ComponentType<TProps>>;
}
| {
sync: (values: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => ComponentType<TProps>;
sync: () => ComponentType<TProps>;
};
}): ExtensionDefinition<
TConfig,
TConfig,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
[x: string]: any;
},
{
[x: string]: any;
},
ConfigurableExtensionDataRef<
{
ref: ComponentRef;
impl: ComponentType;
},
'core.component.component',
{}
>,
{
[x: string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
},
{
kind: 'component';
namespace: string;
name: string;
}
>;
@@ -659,21 +550,6 @@ export function createExtension<
}
>;
// @public @deprecated (undocumented)
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
>(
options: LegacyCreateExtensionOptions<
TOutput,
TInputs,
TConfig,
TConfigInput
>,
): ExtensionDefinition<TConfig, TConfigInput, never, never>;
// @public
export function createExtensionBlueprint<
TParams,
@@ -795,24 +671,6 @@ export function createExtensionDataRef<TData>(): {
}): ConfigurableExtensionDataRef<TData, TId>;
};
// @public @deprecated (undocumented)
export function createExtensionInput<
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends {
singleton?: boolean;
optional?: boolean;
},
>(
extensionData: TExtensionDataMap,
config?: TConfig,
): LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
// @public (undocumented)
export function createExtensionInput<
UExtensionData extends ExtensionDataRef<
@@ -926,105 +784,6 @@ export function createFrontendPlugin<
}
>;
// @public @deprecated
export function createNavItemExtension(options: {
namespace?: string;
name?: string;
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent_2;
}): ExtensionDefinition<
{
title: string;
},
{
title?: string | undefined;
},
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @public @deprecated (undocumented)
export namespace createNavItemExtension {
const // @deprecated (undocumented)
targetDataRef: ConfigurableExtensionDataRef<
{
title: string;
icon: IconComponent_2;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>;
}
// @public @deprecated
export function createNavLogoExtension(options: {
name?: string;
namespace?: string;
logoIcon: JSX.Element;
logoFull: JSX.Element;
}): ExtensionDefinition<
unknown,
unknown,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @public @deprecated (undocumented)
export namespace createNavLogoExtension {
const // @deprecated (undocumented)
logoElementsDataRef: ConfigurableExtensionDataRef<
{
logoIcon?: JSX.Element | undefined;
logoFull?: JSX.Element | undefined;
},
'core.nav-logo.logo-elements',
{}
>;
}
// @public @deprecated
export function createPageExtension<
TConfig extends {
path: string;
},
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
},
): ExtensionDefinition<TConfig>;
// @public @deprecated (undocumented)
export const createPlugin: typeof createFrontendPlugin;
@@ -1048,75 +807,6 @@ export function createRouteRef<
}
>;
// @public @deprecated
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig>;
// @public @deprecated (undocumented)
export namespace createRouterExtension {
const // @deprecated (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
'app.router.wrapper',
{}
>;
}
// @public @deprecated (undocumented)
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput, TInput>;
// @public @deprecated (undocumented)
export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
loader: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): ExtensionDefinition<TConfig>;
// @public @deprecated (undocumented)
export namespace createSignInPageExtension {
const // @deprecated (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
React_2.ComponentType<SignInPageProps>,
'core.sign-in-page.component',
{}
>;
}
// @public
export function createSubRouteRef<
Path extends string,
@@ -1126,62 +816,6 @@ export function createSubRouteRef<
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams>;
// @public @deprecated (undocumented)
export function createThemeExtension(theme: AppTheme): ExtensionDefinition<
unknown,
unknown,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @public @deprecated (undocumented)
export namespace createThemeExtension {
const // (undocumented)
themeDataRef: ConfigurableExtensionDataRef<
AppTheme,
'core.theme.theme',
{}
>;
}
// @public @deprecated (undocumented)
export function createTranslationExtension(options: {
name?: string;
resource: TranslationResource | TranslationMessages;
}): ExtensionDefinition<
unknown,
unknown,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @public @deprecated (undocumented)
export namespace createTranslationExtension {
const // (undocumented)
translationDataRef: ConfigurableExtensionDataRef<
| TranslationResource<string>
| TranslationMessages<
string,
{
[x: string]: string;
},
boolean
>,
'core.translation.translation',
{}
>;
}
export { createTranslationMessages };
export { createTranslationRef };
@@ -1429,21 +1063,6 @@ export type ExtensionDataValue<TData, TId extends string> = {
readonly value: TData;
};
// @public @deprecated
export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TExtensionData[DataName]['T'];
} & {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TExtensionData[DataName]['T'];
};
// @public (undocumented)
export interface ExtensionDefinition<
TConfig,
@@ -1696,56 +1315,6 @@ export { IdentityApi };
export { identityApiRef };
// @public @deprecated (undocumented)
export interface LegacyCreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
> {
// (undocumented)
attachTo: {
id: string;
input: string;
};
// (undocumented)
configSchema?: PortableSchema<TConfig, TConfigInput>;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind?: string;
// (undocumented)
name?: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
// @public @deprecated (undocumented)
export interface LegacyExtensionInput<
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends {
singleton: boolean;
optional: boolean;
},
> {
// (undocumented)
$$type: '@backstage/ExtensionInput';
// (undocumented)
config: TConfig;
// (undocumented)
extensionData: TExtensionDataMap;
}
export { microsoftAuthApiRef };
// @public
@@ -1906,17 +1475,12 @@ export type ResolvedExtensionInput<
? {
node: AppNode;
} & ExtensionDataContainer<TExtensionInput['extensionData'][number]>
: TExtensionInput['extensionData'] extends AnyExtensionDataMap
? {
node: AppNode;
output: ExtensionDataValues<TExtensionInput['extensionData']>;
}
: never;
// @public
export type ResolvedExtensionInputs<
TInputs extends {
[name in string]: ExtensionInput<any, any> | LegacyExtensionInput<any, any>;
[name in string]: ExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
@@ -1,92 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiExtension } from './createApiExtension';
import { createApiFactory, createApiRef } from '@backstage/core-plugin-api';
describe('createApiExtension', () => {
it('fills in the expected values for an existing factory', () => {
const api = createApiRef<{ foo: string }>({ id: 'test' });
const factory = createApiFactory({
api,
deps: {},
factory: () => ({ foo: 'bar' }),
});
expect(
createApiExtension({
factory,
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'api',
namespace: 'test',
attachTo: { id: 'app', input: 'apis' },
disabled: false,
configSchema: undefined,
inputs: {},
output: {
api: expect.objectContaining({
$$type: '@backstage/ExtensionDataRef',
id: 'core.api.factory',
config: {},
}),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
});
it('fills in the expected values for a ref and custom factory', () => {
const api = createApiRef<{ foo: string }>({ id: 'test' });
const factory = jest.fn(() => ({ foo: 'bar' }));
const extension = createApiExtension({
api,
inputs: {},
factory({ config: _config, inputs: _inputs }) {
return createApiFactory({
api,
deps: {},
factory,
});
},
});
// boo
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'api',
namespace: 'test',
attachTo: { id: 'app', input: 'apis' },
disabled: false,
configSchema: undefined,
inputs: {},
output: {
api: expect.objectContaining({
$$type: '@backstage/ExtensionDataRef',
id: 'core.api.factory',
config: {},
}),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
});
});
@@ -1,82 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnyApiFactory, AnyApiRef } from '@backstage/core-plugin-api';
import { PortableSchema } from '../schema';
import { ResolvedExtensionInputs, createExtension } from '../wiring';
import { AnyExtensionInputMap } from '../wiring/createExtension';
import { Expand } from '../types';
import { ApiBlueprint } from '../blueprints/ApiBlueprint';
/**
* @public
* @deprecated Use {@link ApiBlueprint} instead.
*/
export function createApiExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
api: AnyApiRef;
factory: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => AnyApiFactory;
}
| {
factory: AnyApiFactory;
}
) & {
configSchema?: PortableSchema<TConfig>;
inputs?: TInputs;
},
) {
const { factory, configSchema, inputs: extensionInputs } = options;
const apiRef =
'api' in options ? options.api : (factory as { api: AnyApiRef }).api;
return createExtension({
kind: 'api',
// Since ApiRef IDs use a global namespace we use the namespace here in order to override
// potential plugin IDs and always end up with the format `api:<api-ref-id>`
namespace: apiRef.id,
attachTo: { id: 'app', input: 'apis' },
inputs: extensionInputs,
configSchema,
output: {
api: ApiBlueprint.dataRefs.factory,
},
factory({ config, inputs }) {
if (typeof factory === 'function') {
return { api: factory({ config, inputs }) };
}
return { api: factory };
},
});
}
/**
* @public
* @deprecated Use {@link ApiBlueprint} instead.
*/
export namespace createApiExtension {
/**
* @deprecated Use {@link ApiBlueprint} instead.
*/
export const factoryDataRef = ApiBlueprint.dataRefs.factory;
}
@@ -1,109 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createAppRootElementExtension } from './createAppRootElementExtension';
describe('createAppRootElementExtension', () => {
it('works with simple options and just an element', async () => {
const extension = createAppRootElementExtension({
element: <div>Hello</div>,
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-element',
attachTo: { id: 'app/root', input: 'elements' },
disabled: false,
inputs: {},
output: {
element: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
createExtensionTester(extension).render();
await expect(screen.findByText('Hello')).resolves.toBeInTheDocument();
});
it('works with complex options and a callback', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createAppRootElementExtension({
namespace: 'ns',
name: 'test',
configSchema: schema,
attachTo: { id: 'other', input: 'slot' },
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
element: ({ inputs, config }) => (
<div>
Hello, {config.name}, {inputs.children.length}
</div>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-element',
namespace: 'ns',
name: 'test',
attachTo: { id: 'other', input: 'slot' },
configSchema: schema,
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
element: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
createExtensionTester(extension, { config: { name: 'Robin' } })
.add(
createExtension({
attachTo: { id: 'app-root-element:ns/test', input: 'children' },
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div /> }),
}),
)
.render();
await expect(
screen.findByText('Hello, Robin, 1'),
).resolves.toBeInTheDocument();
});
});
@@ -1,72 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JSX } from 'react';
import { PortableSchema } from '../schema/types';
import { Expand } from '../types';
import { coreExtensionData } from '../wiring/coreExtensionData';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
/**
* Creates an extension that renders a React element at the app root, outside of
* the app layout. This is useful for example for shared popups and similar.
*
* @public
* @deprecated Use {@link AppRootElementBlueprint} instead.
*/
export function createAppRootElementExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
element:
| JSX.Element
| ((options: {
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}) => JSX.Element);
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-root-element',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'elements' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs, config }) {
return {
element:
typeof options.element === 'function'
? options.element({ inputs, config })
: options.element,
};
},
});
}
@@ -1,121 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createAppRootWrapperExtension } from './createAppRootWrapperExtension';
import { createPageExtension } from './createPageExtension';
describe('createAppRootWrapperExtension', () => {
it('works with simple options and no props', async () => {
const extension = createAppRootWrapperExtension({
Component: () => <div>Hello</div>,
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-wrapper',
attachTo: { id: 'app/root', input: 'wrappers' },
disabled: false,
inputs: {},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
createExtensionTester(
createPageExtension({
defaultPath: '/',
loader: async () => <div />,
}),
)
.add(extension)
.render();
await expect(screen.findByText('Hello')).resolves.toBeInTheDocument();
});
it('works with complex options and props', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createAppRootWrapperExtension({
namespace: 'ns',
name: 'test',
configSchema: schema,
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
Component: ({ inputs, config, children }) => (
<div data-testid={`${config.name}-${inputs.children.length}`}>
{children}
</div>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-wrapper',
namespace: 'ns',
name: 'test',
attachTo: { id: 'app/root', input: 'wrappers' },
configSchema: schema,
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
override: expect.any(Function),
toString: expect.any(Function),
});
createExtensionTester(
createPageExtension({
defaultPath: '/',
loader: async () => <div>Hello</div>,
}),
)
.add(extension, { config: { name: 'Robin' } })
.add(
createExtension({
attachTo: { id: 'app-root-wrapper:ns/test', input: 'children' },
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div /> }),
}),
)
.render();
await expect(screen.findByText('Hello')).resolves.toBeInTheDocument();
await expect(screen.findByTestId('Robin-1')).resolves.toBeInTheDocument();
});
});
@@ -1,88 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, PropsWithChildren } from 'react';
import { PortableSchema } from '../schema/types';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
import { Expand } from '../types';
import { AppRootWrapperBlueprint } from '../blueprints/AppRootWrapperBlueprint';
/**
* Creates an extension that renders a React wrapper at the app root, enclosing
* the app layout. This is useful for example for adding global React contexts
* and similar.
*
* @public
* @deprecated Use {@link AppRootWrapperBlueprint} instead.
*/
export function createAppRootWrapperExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-root-wrapper',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'wrappers' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
component: AppRootWrapperBlueprint.dataRefs.component,
},
factory({ inputs, config }) {
const Component = (props: PropsWithChildren<{}>) => {
return (
<options.Component inputs={inputs} config={config}>
{props.children}
</options.Component>
);
};
return {
component: Component,
};
},
});
}
/**
* @public
* @deprecated Use {@link AppRootWrapperBlueprint} instead.
*/
export namespace createAppRootWrapperExtension {
/**
* @deprecated Use {@link AppRootWrapperBlueprint} instead.
*/
export const componentDataRef = AppRootWrapperBlueprint.dataRefs.component;
}
@@ -15,41 +15,20 @@
*/
import { lazy, ComponentType } from 'react';
import {
AnyExtensionInputMap,
ResolvedExtensionInputs,
createExtension,
createExtensionDataRef,
} from '../wiring';
import { Expand } from '../types';
import { PortableSchema } from '../schema';
import { createExtension, createExtensionDataRef } from '../wiring';
import { ComponentRef } from '../components';
/** @public */
export function createComponentExtension<
TProps extends {},
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
export function createComponentExtension<TProps extends {}>(options: {
ref: ComponentRef<TProps>;
name?: string;
disabled?: boolean;
/** @deprecated these will be removed in the future */
inputs?: TInputs;
/** @deprecated these will be removed in the future */
configSchema?: PortableSchema<TConfig>;
loader:
| {
lazy: (values: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<ComponentType<TProps>>;
lazy: () => Promise<ComponentType<TProps>>;
}
| {
sync: (values: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => ComponentType<TProps>;
sync: () => ComponentType<TProps>;
};
}) {
return createExtension({
@@ -57,34 +36,30 @@ export function createComponentExtension<
namespace: options.ref.id,
name: options.name,
attachTo: { id: 'app', input: 'components' },
inputs: options.inputs,
disabled: options.disabled,
configSchema: options.configSchema,
output: {
component: createComponentExtension.componentDataRef,
},
factory({ config, inputs }) {
output: [createComponentExtension.componentDataRef],
factory() {
if ('sync' in options.loader) {
return {
component: {
return [
createComponentExtension.componentDataRef({
ref: options.ref,
impl: options.loader.sync({ config, inputs }) as ComponentType,
},
};
impl: options.loader.sync() as ComponentType,
}),
];
}
const lazyLoader = options.loader.lazy;
const ExtensionComponent = lazy(() =>
lazyLoader({ config, inputs }).then(Component => ({
lazyLoader().then(Component => ({
default: Component,
})),
) as unknown as ComponentType;
return {
component: {
return [
createComponentExtension.componentDataRef({
ref: options.ref,
impl: ExtensionComponent,
},
};
}),
];
},
});
}
@@ -1,69 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IconComponent } from '@backstage/core-plugin-api';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { createExtension } from '../wiring';
import { RouteRef } from '../routing';
import { NavItemBlueprint } from '../blueprints/NavItemBlueprint';
/**
* Helper for creating extensions for a nav item.
*
* @public
* @deprecated Use {@link NavItemBlueprint} instead.
*/
export function createNavItemExtension(options: {
namespace?: string;
name?: string;
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent;
}) {
const { routeRef, title, icon, namespace, name } = options;
return createExtension({
namespace,
name,
kind: 'nav-item',
attachTo: { id: 'app/nav', input: 'items' },
configSchema: createSchemaFromZod(z =>
z.object({
title: z.string().default(title),
}),
),
output: {
navTarget: NavItemBlueprint.dataRefs.target,
},
factory: ({ config }) => ({
navTarget: {
title: config.title,
icon,
routeRef,
},
}),
});
}
/**
* @public
* @deprecated Use {@link NavItemBlueprint} instead.
*/
export namespace createNavItemExtension {
/**
* @deprecated Use {@link NavItemBlueprint} instead.
*/
export const targetDataRef = NavItemBlueprint.dataRefs.target;
}
@@ -1,48 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { createNavLogoExtension } from './createNavLogoExtension';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
}));
describe('createNavLogoExtension', () => {
it('creates the extension properly', () => {
expect(
createNavLogoExtension({
name: 'test',
logoFull: <div>Logo Full</div>,
logoIcon: <div>Logo Icon</div>,
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'nav-logo',
name: 'test',
attachTo: { id: 'app/nav', input: 'logos' },
disabled: false,
inputs: {},
output: {
logos: expect.anything(),
},
factory: expect.any(Function),
override: expect.any(Function),
toString: expect.any(Function),
});
});
});
@@ -1,61 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExtension } from '../wiring';
import { NavLogoBlueprint } from '../blueprints/NavLogoBlueprint';
/**
* Helper for creating extensions for a nav logos.
*
* @public
* @deprecated Use {@link NavLogoBlueprint} instead.
*/
export function createNavLogoExtension(options: {
name?: string;
namespace?: string;
logoIcon: JSX.Element;
logoFull: JSX.Element;
}) {
const { logoIcon, logoFull } = options;
return createExtension({
kind: 'nav-logo',
name: options?.name,
namespace: options?.namespace,
attachTo: { id: 'app/nav', input: 'logos' },
output: {
logos: NavLogoBlueprint.dataRefs.logoElements,
},
factory: () => {
return {
logos: {
logoIcon,
logoFull,
},
};
},
});
}
/**
* @public
* @deprecated Use {@link NavLogoBlueprint} instead.
*/
export namespace createNavLogoExtension {
/**
* @deprecated Use {@link NavLogoBlueprint} instead.
*/
export const logoElementsDataRef = NavLogoBlueprint.dataRefs.logoElements;
}
@@ -1,145 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useAnalytics } from '@backstage/core-plugin-api';
import { waitFor } from '@testing-library/react';
import { PortableSchema } from '../schema';
import { coreExtensionData, createExtensionInput } from '../wiring';
import { createPageExtension } from './createPageExtension';
import { createExtensionTester } from '@backstage/frontend-test-utils';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useAnalytics: jest.fn(),
}));
describe('createPageExtension', () => {
it('creates the extension properly', () => {
const configSchema: PortableSchema<{ path: string }> = {
parse: jest.fn(),
schema: {} as any,
};
expect(
createPageExtension({
name: 'test',
configSchema,
loader: async () => <div />,
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
name: 'test',
kind: 'page',
attachTo: { id: 'app/routes', input: 'routes' },
configSchema: expect.anything(),
disabled: false,
inputs: {},
output: {
element: expect.anything(),
path: expect.anything(),
routeRef: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
expect(
createPageExtension({
name: 'test',
attachTo: { id: 'other', input: 'place' },
disabled: true,
configSchema,
inputs: {
first: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async () => <div />,
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
name: 'test',
kind: 'page',
attachTo: { id: 'other', input: 'place' },
configSchema: expect.anything(),
override: expect.any(Function),
disabled: true,
inputs: {
first: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
element: expect.anything(),
path: expect.anything(),
routeRef: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
expect(
createPageExtension({
name: 'test',
defaultPath: '/here',
loader: async () => <div />,
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
name: 'test',
kind: 'page',
attachTo: { id: 'app/routes', input: 'routes' },
configSchema: expect.anything(),
disabled: false,
inputs: {},
output: {
element: expect.anything(),
path: expect.anything(),
routeRef: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
override: expect.any(Function),
});
});
it('capture page view event in analytics', async () => {
const captureEvent = jest.fn();
(useAnalytics as jest.Mock).mockReturnValue({
captureEvent,
});
createExtensionTester(
createPageExtension({
defaultPath: '/',
loader: async () => <div>Component</div>,
}),
).render();
await waitFor(() =>
expect(captureEvent).toHaveBeenCalledWith(
'_ROUTABLE-EXTENSION-RENDERED',
'',
),
);
});
});
@@ -1,98 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { lazy } from 'react';
import { ExtensionBoundary } from '../components';
import { createSchemaFromZod, PortableSchema } from '../schema';
import {
coreExtensionData,
createExtension,
ResolvedExtensionInputs,
AnyExtensionInputMap,
} from '../wiring';
import { RouteRef } from '../routing';
import { Expand } from '../types';
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 },
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
},
): ExtensionDefinition<TConfig> {
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({ path: z.string().default(options.defaultPath) }),
) as PortableSchema<TConfig>);
return createExtension({
kind: 'page',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/routes', input: 'routes' },
configSchema,
inputs: options.inputs,
disabled: options.disabled,
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
},
factory({ config, inputs, node }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
return {
path: config.path,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
};
},
});
}
@@ -1,169 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createSpecializedApp } from '@backstage/frontend-app-api';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MockConfigApi } from '@backstage/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createExtensionOverrides } from '../wiring/createExtensionOverrides';
import { createPageExtension } from './createPageExtension';
import { createRouterExtension } from './createRouterExtension';
describe('createRouterExtension', () => {
it('works with simple options and no props', async () => {
const extension = createRouterExtension({
namespace: 'test',
Component: ({ children }) => (
<MemoryRouter>
<div data-testid="test-router">{children}</div>
</MemoryRouter>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-router-component',
namespace: 'test',
attachTo: { id: 'app/root', input: 'router' },
disabled: false,
inputs: {},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
override: expect.any(Function),
toString: expect.any(Function),
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createPageExtension({
namespace: 'test',
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
}),
],
}),
],
});
render(app.createRoot());
await expect(
screen.findByTestId('test-contents'),
).resolves.toBeInTheDocument();
await expect(
screen.findByTestId('test-router'),
).resolves.toBeInTheDocument();
});
it('works with complex options and props', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createRouterExtension({
namespace: 'test',
name: 'test',
configSchema: schema,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
Component: ({ inputs, config, children }) => (
<MemoryRouter>
<div
data-testid={`test-router-${config.name}-${inputs.children.length}`}
>
{children}
</div>
</MemoryRouter>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-router-component',
namespace: 'test',
name: 'test',
attachTo: { id: 'app/root', input: 'router' },
configSchema: schema,
disabled: false,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
override: expect.any(Function),
toString: expect.any(Function),
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createExtension({
namespace: 'test',
attachTo: {
id: 'app-router-component:test/test',
input: 'children',
},
output: { element: coreExtensionData.reactElement }, // doesn't matter
factory: () => ({ element: <div /> }),
}),
createPageExtension({
namespace: 'test',
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
}),
],
}),
],
config: new MockConfigApi({
app: {
extensions: [
{
'app-router-component:test/test': { config: { name: 'Robin' } },
},
],
},
}),
});
render(app.createRoot());
await expect(
screen.findByTestId('test-contents'),
).resolves.toBeInTheDocument();
await expect(
screen.findByTestId('test-router-Robin-1'),
).resolves.toBeInTheDocument();
});
});
@@ -1,88 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, PropsWithChildren } from 'react';
import { PortableSchema } from '../schema/types';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
import { Expand } from '../types';
import { RouterBlueprint } from '../blueprints/RouterBlueprint';
/**
* Creates an extension that replaces the router implementation at the app root.
* This is useful to be able to for example replace the BrowserRouter with a
* MemoryRouter in tests, or to add additional props to a BrowserRouter.
*
* @public
* @deprecated Use {@link RouterBlueprint} instead.
*/
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-router-component',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'router' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
component: RouterBlueprint.dataRefs.component,
},
factory({ inputs, config }) {
const Component = (props: PropsWithChildren<{}>) => {
return (
<options.Component inputs={inputs} config={config}>
{props.children}
</options.Component>
);
};
return {
component: Component,
};
},
});
}
/**
* @public
* @deprecated Use {@link RouterBlueprint} instead.
*/
export namespace createRouterExtension {
/**
* @deprecated Use {@link RouterBlueprint} instead.
*/
export const componentDataRef = RouterBlueprint.dataRefs.component;
}
@@ -1,47 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import { createSignInPageExtension } from './createSignInPageExtension';
import { coreExtensionData, createExtension } from '../wiring';
describe('createSignInPageExtension', () => {
it('renders a sign-in page', async () => {
const SignInPage = createSignInPageExtension({
name: 'test',
loader: async () => () => <div data-testid="sign-in-page" />,
});
createExtensionTester(
createExtension({
name: 'dummy',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
element: coreExtensionData.reactElement,
},
factory: () => ({ element: <div /> }),
}),
)
.add(SignInPage)
.render();
await expect(
screen.findByTestId('sign-in-page'),
).resolves.toBeInTheDocument();
});
});
@@ -1,88 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, lazy } from 'react';
import { ExtensionBoundary } from '../components';
import { PortableSchema } from '../schema';
import {
createExtension,
ResolvedExtensionInputs,
AnyExtensionInputMap,
ExtensionDefinition,
} from '../wiring';
import { Expand } from '../types';
import { SignInPageProps } from '@backstage/core-plugin-api';
import { SignInPageBlueprint } from '../blueprints';
/**
*
* @public
* @deprecated Use {@link SignInPageBlueprint} instead.
*/
export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
loader: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'sign-in-page',
namespace: options?.namespace,
name: options?.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'signInPage' },
configSchema: options.configSchema,
inputs: options.inputs,
disabled: options.disabled,
output: {
component: createSignInPageExtension.componentDataRef,
},
factory({ config, inputs, node }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.then(component => ({ default: component })),
);
return {
component: props => (
<ExtensionBoundary node={node} routable>
<ExtensionComponent {...props} />
</ExtensionBoundary>
),
};
},
});
}
/**
* @public
* @deprecated Use {@link SignInPageBlueprint} instead.
*/
export namespace createSignInPageExtension {
/**
* @deprecated Use {@link SignInPageBlueprint} instead.
*/
export const componentDataRef = SignInPageBlueprint.dataRefs.component;
}
@@ -1,44 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ThemeBlueprint } from '../blueprints/ThemeBlueprint';
import { createExtension } from '../wiring';
import { AppTheme } from '@backstage/core-plugin-api';
/**
* @public
* @deprecated Use {@link ThemeBlueprint} instead.
*/
export function createThemeExtension(theme: AppTheme) {
return createExtension({
kind: 'theme',
namespace: 'app',
name: theme.id,
attachTo: { id: 'app', input: 'themes' },
output: {
theme: ThemeBlueprint.dataRefs.theme,
},
factory: () => ({ theme }),
});
}
/**
* @public
* @deprecated Use {@link ThemeBlueprint} instead.
*/
export namespace createThemeExtension {
export const themeDataRef = ThemeBlueprint.dataRefs.theme;
}
@@ -1,137 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createTranslationRef,
createTranslationMessages,
createTranslationResource,
} from '@backstage/core-plugin-api/alpha';
import { createTranslationExtension } from './createTranslationExtension';
const translationRef = createTranslationRef({
id: 'test',
messages: {
a: 'a',
b: 'b',
},
});
describe('createTranslationExtension', () => {
it('creates a translation message extension', () => {
const messages = createTranslationMessages({
ref: translationRef,
messages: {
a: 'A',
},
});
const extension = createTranslationExtension({ resource: messages });
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'translation',
namespace: 'test',
attachTo: { id: 'app', input: 'translations' },
disabled: false,
override: expect.any(Function),
inputs: {},
output: {
resource: createTranslationExtension.translationDataRef,
},
factory: expect.any(Function),
toString: expect.any(Function),
});
expect((extension as any).factory({} as any)).toEqual({
resource: messages,
});
});
it('creates a translation resource extension', () => {
const resource = createTranslationResource({
ref: translationRef,
translations: {
sv: () =>
Promise.resolve({
default: createTranslationMessages({
ref: translationRef,
messages: {
a: 'Ä',
b: 'Ö',
},
}),
}),
},
});
const extension = createTranslationExtension({ resource });
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'translation',
namespace: 'test',
attachTo: { id: 'app', input: 'translations' },
disabled: false,
override: expect.any(Function),
inputs: {},
output: {
resource: createTranslationExtension.translationDataRef,
},
factory: expect.any(Function),
toString: expect.any(Function),
});
expect((extension as any).factory({} as any)).toEqual({ resource });
});
it('creates a translation resource extension with a name', () => {
expect(
createTranslationExtension({
name: 'sv',
resource: createTranslationResource({
ref: translationRef,
translations: {
sv: () =>
Promise.resolve({
default: createTranslationMessages({
ref: translationRef,
messages: {
a: 'Ä',
b: 'Ö',
},
}),
}),
},
}),
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'translation',
namespace: 'test',
override: expect.any(Function),
name: 'sv',
attachTo: { id: 'app', input: 'translations' },
disabled: false,
inputs: {},
output: {
resource: createTranslationExtension.translationDataRef,
},
factory: expect.any(Function),
toString: expect.any(Function),
});
});
});
@@ -1,47 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TranslationBlueprint } from '../blueprints/TranslationBlueprint';
import { TranslationMessages, TranslationResource } from '../translation';
import { createExtension } from '../wiring';
/**
* @public
* @deprecated Use {@link TranslationBlueprint} instead.
*/
export function createTranslationExtension(options: {
name?: string;
resource: TranslationResource | TranslationMessages;
}) {
return createExtension({
kind: 'translation',
namespace: options.resource.id,
name: options.name,
attachTo: { id: 'app', input: 'translations' },
output: {
resource: TranslationBlueprint.dataRefs.translation,
},
factory: () => ({ resource: options.resource }),
});
}
/**
* @public
* @deprecated Use {@link TranslationBlueprint} instead.
*/
export namespace createTranslationExtension {
export const translationDataRef = TranslationBlueprint.dataRefs.translation;
}
@@ -14,14 +14,4 @@
* limitations under the License.
*/
export { createApiExtension } from './createApiExtension';
export { createAppRootElementExtension } from './createAppRootElementExtension';
export { createAppRootWrapperExtension } from './createAppRootWrapperExtension';
export { createRouterExtension } from './createRouterExtension';
export { createPageExtension } from './createPageExtension';
export { createNavItemExtension } from './createNavItemExtension';
export { createNavLogoExtension } from './createNavLogoExtension';
export { createSignInPageExtension } from './createSignInPageExtension';
export { createThemeExtension } from './createThemeExtension';
export { createComponentExtension } from './createComponentExtension';
export { createTranslationExtension } from './createTranslationExtension';
@@ -20,8 +20,7 @@ import zodToJsonSchema from 'zod-to-json-schema';
import { PortableSchema } from './types';
/**
* @public
* @deprecated Use the `config.schema` option of `createExtension` instead, or use `createExtensionBlueprint`.
* @internal
*/
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export { createSchemaFromZod } from './createSchemaFromZod';
export { type PortableSchema } from './types';
@@ -21,6 +21,9 @@ import { createExtensionInput } from './createExtensionInput';
const stringDataRef = createExtensionDataRef<string>().with({ id: 'string' });
const numberDataRef = createExtensionDataRef<number>().with({ id: 'number' });
const booleanDataRef = createExtensionDataRef<boolean>().with({
id: 'boolean',
});
function unused(..._any: any[]) {}
@@ -29,42 +32,35 @@ describe('createExtension', () => {
const baseConfig = {
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringDataRef,
},
output: [stringDataRef],
};
const extension = createExtension({
...baseConfig,
factory() {
return {
foo: 'bar',
};
return [stringDataRef('bar')];
},
});
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
expect(extension).toMatchObject({ version: 'v2', namespace: 'test' });
// When declared as an error function without a block the TypeScript errors
// are a more specific and will often point at the property that is problematic.
// Member arrow function declaration
createExtension({
...baseConfig,
factory: () => [
stringDataRef(
// @ts-expect-error
3,
),
],
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => ({
foo: 3,
}),
factory: () => [numberDataRef(3)],
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({
bar: 'bar',
}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({}),
factory: () => [],
});
createExtension({
...baseConfig,
@@ -79,39 +75,37 @@ describe('createExtension', () => {
'bar',
});
// When declared as a function with a block the TypeScript error will instead
// be tied to the factory function declaration itself, but the error messages
// is still helpful and points to part of the return type that is problematic.
// Method declaration
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {
foo: 3,
};
return [
stringDataRef(
// @ts-expect-error
3,
),
];
},
});
// @ts-expect-error
createExtension({
...baseConfig,
factory() {
return [numberDataRef(3)];
},
});
// @ts-expect-error
createExtension({
...baseConfig,
factory() {
return [];
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {
bar: 'bar',
};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {};
return;
},
});
createExtension({
@@ -122,36 +116,37 @@ describe('createExtension', () => {
},
});
// Member function declaration
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {
foo: 3,
};
return [
stringDataRef(
// @ts-expect-error
3,
),
];
},
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => {
return [numberDataRef(3)];
},
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => {
return [];
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {
bar: 'bar',
};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {};
return;
},
});
createExtension({
@@ -167,52 +162,27 @@ describe('createExtension', () => {
const baseConfig = {
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringDataRef,
bar: stringDataRef.optional(),
},
output: [stringDataRef, numberDataRef.optional()],
};
const extension = createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
}),
factory: () => [stringDataRef('bar')],
});
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
expect(extension).toMatchObject({ version: 'v2', namespace: 'test' });
createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
bar: 'baz',
}),
factory: () => [stringDataRef('bar'), numberDataRef(3)],
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => ({
foo: 3,
}),
factory: () => [numberDataRef(3)],
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
bar: 3,
}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({ bar: 'bar' }),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({}),
factory: () => [],
});
createExtension({
...baseConfig,
@@ -238,57 +208,48 @@ describe('createExtension', () => {
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
inputs: {
mixed: createExtensionInput({
required: stringDataRef,
optional: stringDataRef.optional(),
}),
onlyRequired: createExtensionInput({
required: stringDataRef,
}),
onlyOptional: createExtensionInput({
optional: stringDataRef.optional(),
}),
},
output: {
foo: stringDataRef,
mixed: createExtensionInput([stringDataRef, numberDataRef.optional()]),
onlyRequired: createExtensionInput([stringDataRef]),
onlyOptional: createExtensionInput([stringDataRef.optional()]),
},
output: [stringDataRef],
factory({ inputs }) {
const a1: string = inputs.mixed?.[0].output.required;
const a1: string = inputs.mixed?.[0].get(stringDataRef);
// @ts-expect-error
const a2: number = inputs.mixed?.[0].output.required;
const a2: number = inputs.mixed?.[0].get(stringDataRef);
// @ts-expect-error
const a3: any = inputs.mixed?.[0].output.nonExistent;
const a3: any = inputs.mixed?.[0].get(booleanDataRef);
unused(a1, a2, a3);
const b1: string | undefined = inputs.mixed?.[0].output.optional;
const b1: number | undefined = inputs.mixed?.[0].get(numberDataRef);
// @ts-expect-error
const b2: string = inputs.mixed?.[0].output.optional;
const b2: string = inputs.mixed?.[0].get(numberDataRef);
// @ts-expect-error
const b3: number = inputs.mixed?.[0].output.optional;
const b3: number = inputs.mixed?.[0].get(numberDataRef);
// @ts-expect-error
const b4: number | undefined = inputs.mixed?.[0].output.optional;
const b4: string | undefined = inputs.mixed?.[0].get(numberDataRef);
unused(b1, b2, b3, b4);
const c1: string = inputs.onlyRequired?.[0].output.required;
const c1: string = inputs.onlyRequired?.[0].get(stringDataRef);
// @ts-expect-error
const c2: number = inputs.onlyRequired?.[0].output.required;
const c2: number = inputs.onlyRequired?.[0].get(stringDataRef);
unused(c1, c2);
const d1: string | undefined = inputs.onlyOptional?.[0].output.optional;
const d1: string | undefined =
inputs.onlyOptional?.[0].get(stringDataRef);
// @ts-expect-error
const d2: string = inputs.onlyOptional?.[0].output.optional;
const d2: string = inputs.onlyOptional?.[0].get(stringDataRef);
// @ts-expect-error
const d3: number = inputs.onlyOptional?.[0].output.optional;
const d3: number = inputs.onlyOptional?.[0].get(stringDataRef);
// @ts-expect-error
const d4: number | undefined = inputs.onlyOptional?.[0].output.optional;
const d4: number | undefined =
inputs.onlyOptional?.[0].get(stringDataRef);
unused(d1, d2, d3, d4);
return {
foo: 'bar',
};
return [stringDataRef('bar')];
},
});
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
expect(extension).toMatchObject({ version: 'v2', namespace: 'test' });
expect(String(extension)).toBe(
'ExtensionDefinition{namespace=test,attachTo=root@default}',
);
@@ -15,7 +15,7 @@
*/
import { AppNode } from '../apis';
import { PortableSchema, createSchemaFromZod } from '../schema';
import { PortableSchema } from '../schema';
import { Expand } from '../types';
import {
ResolveInputValueOverrides,
@@ -29,46 +29,9 @@ import {
AnyExtensionDataRef,
ExtensionDataValue,
} from './createExtensionDataRef';
import { ExtensionInput, LegacyExtensionInput } from './createExtensionInput';
import { ExtensionInput } from './createExtensionInput';
import { z } from 'zod';
/**
* @public
* @deprecated Extension data maps will be removed.
*/
export type AnyExtensionDataMap = {
[name in string]: AnyExtensionDataRef;
};
/**
* @public
* @deprecated This type will be removed.
*/
export type AnyExtensionInputMap = {
[inputName in string]: LegacyExtensionInput<
AnyExtensionDataMap,
{ optional: boolean; singleton: boolean }
>;
};
/**
* Converts an extension data map into the matching concrete data values type.
* @public
* @deprecated Extension data maps will be removed.
*/
export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TExtensionData[DataName]['T'];
} & {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TExtensionData[DataName]['T'];
};
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
/**
* Convert a single extension input into a matching resolved input.
@@ -80,11 +43,6 @@ export type ResolvedExtensionInput<
? {
node: AppNode;
} & ExtensionDataContainer<TExtensionInput['extensionData'][number]>
: TExtensionInput['extensionData'] extends AnyExtensionDataMap
? {
node: AppNode;
output: ExtensionDataValues<TExtensionInput['extensionData']>;
}
: never;
/**
@@ -93,7 +51,7 @@ export type ResolvedExtensionInput<
*/
export type ResolvedExtensionInputs<
TInputs extends {
[name in string]: ExtensionInput<any, any> | LegacyExtensionInput<any, any>;
[name in string]: ExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
@@ -103,31 +61,6 @@ export type ResolvedExtensionInputs<
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
};
/**
* @public
* @deprecated This way of structuring the options is deprecated, this type will be removed in the future
*/
export interface LegacyCreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
> {
kind?: string;
namespace?: string;
name?: string;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: TOutput;
configSchema?: PortableSchema<TConfig, TConfigInput>;
factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
}
type ToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
@@ -326,13 +259,27 @@ export type InternalExtensionDefinition<
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
readonly inputs: {
[inputName in string]: {
$$type: '@backstage/ExtensionInput';
extensionData: {
[name in string]: AnyExtensionDataRef;
};
config: { optional: boolean; singleton: boolean };
};
};
readonly output: {
[name in string]: AnyExtensionDataRef;
};
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
inputs: {
[inputName in string]: unknown;
};
}): {
[inputName in string]: unknown;
};
}
| {
readonly version: 'v2';
@@ -419,23 +366,6 @@ export function createExtension<
name: string | undefined extends TName ? undefined : TName;
}
>;
/**
* @public
* @deprecated - use the array format of `output` instead, see TODO-doc-link
*/
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
>(
options: LegacyCreateExtensionOptions<
TOutput,
TInputs,
TConfig,
TConfigInput
>,
): ExtensionDefinition<TConfig, TConfigInput, never, never>;
export function createExtension<
const TKind extends string | undefined,
const TNamespace extends string | undefined,
@@ -447,43 +377,31 @@ export function createExtension<
{ optional: boolean; singleton: boolean }
>;
},
TLegacyInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
options:
| CreateExtensionOptions<
TKind,
TNamespace,
TName,
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
>
| LegacyCreateExtensionOptions<
AnyExtensionDataMap,
TLegacyInputs,
TConfig,
TConfigInput
>,
options: CreateExtensionOptions<
TKind,
TNamespace,
TName,
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
>,
): ExtensionDefinition<
TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
}),
TConfigInput &
(string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>),
string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>,
UOutput,
TInputs,
{
@@ -492,29 +410,20 @@ export function createExtension<
name: TName;
}
> {
if ('configSchema' in options && 'config' in options) {
throw new Error(`Cannot provide both configSchema and config.schema`);
}
let configSchema: PortableSchema<any, any> | undefined;
if ('configSchema' in options) {
configSchema = options.configSchema;
}
if ('config' in options) {
const newConfigSchema = options.config?.schema;
configSchema =
newConfigSchema &&
createSchemaFromZod(innerZ =>
innerZ.object(
Object.fromEntries(
Object.entries(newConfigSchema).map(([k, v]) => [k, v(innerZ)]),
),
const schemaDeclaration = options.config?.schema;
const configSchema =
schemaDeclaration &&
createSchemaFromZod(innerZ =>
innerZ.object(
Object.fromEntries(
Object.entries(schemaDeclaration).map(([k, v]) => [k, v(innerZ)]),
),
);
}
),
);
return {
$$type: '@backstage/ExtensionDefinition',
version: Symbol.iterator in options.output ? 'v2' : 'v1',
version: 'v2',
kind: options.kind,
namespace: options.namespace,
name: options.name,
@@ -687,22 +596,18 @@ export function createExtension<
} as CreateExtensionOptions<TKind, TNamespace, TName, AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, TInputs & TExtraInputs, TConfigSchema & TExtensionConfigSchema, UOverrideFactoryOutput>);
},
} as InternalExtensionDefinition<
TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
}),
TConfigInput &
(string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>),
string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>,
UOutput,
TInputs,
{
@@ -15,11 +15,7 @@
*/
import { createExtensionDataRef } from './createExtensionDataRef';
import {
ExtensionInput,
LegacyExtensionInput,
createExtensionInput,
} from './createExtensionInput';
import { ExtensionInput, createExtensionInput } from './createExtensionInput';
const stringDataRef = createExtensionDataRef<string>().with({ id: 'str' });
const numberDataRef = createExtensionDataRef<number>().with({ id: 'num' });
@@ -130,40 +126,4 @@ describe('createExtensionInput', () => {
createExtensionInput([stringDataRef, stringDataRef], { singleton: true }),
).toThrow("ExtensionInput may not have duplicate data refs: 'str'");
});
describe('old api', () => {
it('should create a regular input', () => {
const input = createExtensionInput({
str: stringDataRef,
num: numberDataRef,
});
expect(input).toEqual({
$$type: '@backstage/ExtensionInput',
extensionData: { str: stringDataRef, num: numberDataRef },
config: { singleton: false, optional: false },
});
const x1: LegacyExtensionInput<
{ str: typeof stringDataRef; num: typeof numberDataRef },
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x2: LegacyExtensionInput<
{ str: typeof numberDataRef; num: typeof stringDataRef }, // switched
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x3: LegacyExtensionInput<
{ str: typeof stringDataRef; num: typeof numberDataRef },
{ singleton: true; optional: false }
> = input;
// @ts-expect-error
const x4: LegacyExtensionInput<
{ str: typeof stringDataRef; num: typeof numberDataRef },
{ singleton: false; optional: true }
> = input;
unused(x1, x2, x3, x4);
});
});
});
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { AnyExtensionDataMap } from './createExtension';
import { ExtensionDataRef } from './createExtensionDataRef';
/** @public */
@@ -27,36 +26,6 @@ export interface ExtensionInput<
config: TConfig;
}
/**
* @public
* @deprecated This type will be removed. Use `ExtensionInput` instead.
*/
export interface LegacyExtensionInput<
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends { singleton: boolean; optional: boolean },
> {
$$type: '@backstage/ExtensionInput';
extensionData: TExtensionDataMap;
config: TConfig;
}
/**
* @public
* @deprecated Use the following form instead: `createExtensionInput([dataRef1, dataRef2])`
*/
export function createExtensionInput<
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: TExtensionDataMap,
config?: TConfig,
): LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
/** @public */
export function createExtensionInput<
UExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
@@ -73,26 +42,17 @@ export function createExtensionInput<
>;
export function createExtensionInput<
TExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: Array<TExtensionData> | TExtensionDataMap,
extensionData: Array<TExtensionData>,
config?: TConfig,
):
| LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>
| ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
> {
): ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
> {
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(extensionData)) {
const seen = new Set();
@@ -124,19 +84,11 @@ export function createExtensionInput<
? true
: false,
},
} as
| LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>
| ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
} as ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
}
@@ -40,22 +40,22 @@ describe('createExtensionOverrides', () => {
createExtension({
name: 'a',
attachTo: { id: 'app', input: 'apis' },
output: {},
factory: () => ({}),
output: [],
factory: () => [],
}),
createExtension({
namespace: 'b',
attachTo: { id: 'app', input: 'apis' },
output: {},
factory: () => ({}),
output: [],
factory: () => [],
}),
createExtension({
kind: 'k',
namespace: 'c',
name: 'n',
attachTo: { id: 'app', input: 'apis' },
output: {},
factory: () => ({}),
output: [],
factory: () => [],
}),
],
}),
@@ -74,9 +74,9 @@ describe('createExtensionOverrides', () => {
"factory": [Function],
"id": "a",
"inputs": {},
"output": {},
"output": [],
"toString": [Function],
"version": "v1",
"version": "v2",
},
{
"$$type": "@backstage/Extension",
@@ -89,9 +89,9 @@ describe('createExtensionOverrides', () => {
"factory": [Function],
"id": "b",
"inputs": {},
"output": {},
"output": [],
"toString": [Function],
"version": "v1",
"version": "v2",
},
{
"$$type": "@backstage/Extension",
@@ -104,9 +104,9 @@ describe('createExtensionOverrides', () => {
"factory": [Function],
"id": "k:c/n",
"inputs": {},
"output": {},
"output": [],
"toString": [Function],
"version": "v1",
"version": "v2",
},
],
"featureFlags": [],
@@ -122,8 +122,8 @@ describe('createExtensionOverrides', () => {
createExtension({
namespace: 'a',
attachTo: { id: 'app', input: 'apis' },
output: {},
factory: () => ({}),
output: [],
factory: () => [],
}),
],
});
@@ -17,7 +17,6 @@
import React from 'react';
import { createApp } from '@backstage/frontend-app-api';
import { screen } from '@testing-library/react';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { createFrontendPlugin } from './createFrontendPlugin';
import { JsonObject } from '@backstage/types';
import { createExtension } from './createExtension';
@@ -43,14 +42,14 @@ const Extension1 = createExtension({
const Extension2 = createExtension({
name: '2',
attachTo: { id: 'test/output', input: 'names' },
output: {
name: nameExtensionDataRef,
output: [nameExtensionDataRef],
config: {
schema: {
name: z => z.string().default('extension-2'),
},
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('extension-2') }),
),
factory({ config }) {
return { name: config.name };
return [nameExtensionDataRef(config.name)];
},
});
@@ -58,45 +57,45 @@ const Extension3 = createExtension({
name: '3',
attachTo: { id: 'test/output', input: 'names' },
inputs: {
addons: createExtensionInput({
name: nameExtensionDataRef,
}),
},
output: {
name: nameExtensionDataRef,
addons: createExtensionInput([nameExtensionDataRef]),
},
output: [nameExtensionDataRef],
factory({ inputs }) {
return {
name: `extension-3:${inputs.addons.map(n => n.output.name).join('-')}`,
};
return [
nameExtensionDataRef(
`extension-3:${inputs.addons
.map(n => n.get(nameExtensionDataRef))
.join('-')}`,
),
];
},
});
const Child = createExtension({
name: 'child',
attachTo: { id: 'test/3', input: 'addons' },
output: {
name: nameExtensionDataRef,
output: [nameExtensionDataRef],
config: {
schema: {
name: z => z.string().default('child'),
},
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('child') }),
),
factory({ config }) {
return { name: config.name };
return [nameExtensionDataRef(config.name)];
},
});
const Child2 = createExtension({
name: 'child2',
attachTo: { id: 'test/3', input: 'addons' },
output: {
name: nameExtensionDataRef,
output: [nameExtensionDataRef],
config: {
schema: {
name: z => z.string().default('child2'),
},
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('child2') }),
),
factory({ config }) {
return { name: config.name };
return [nameExtensionDataRef(config.name)];
},
});
@@ -104,19 +103,19 @@ const outputExtension = createExtension({
name: 'output',
attachTo: { id: 'app', input: 'root' },
inputs: {
names: createExtensionInput({
name: nameExtensionDataRef,
}),
},
output: {
element: coreExtensionData.reactElement,
names: createExtensionInput([nameExtensionDataRef]),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
return {
element: React.createElement('span', {}, [
`Names: ${inputs.names.map(n => n.output.name).join(', ')}`,
]),
};
return [
coreExtensionData.reactElement(
React.createElement('span', {}, [
`Names: ${inputs.names
.map(n => n.get(nameExtensionDataRef))
.join(', ')}`,
]),
),
];
},
});
@@ -19,17 +19,12 @@ export {
createExtension,
type ExtensionDefinition,
type CreateExtensionOptions,
type ExtensionDataValues,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
type LegacyCreateExtensionOptions,
type AnyExtensionInputMap,
type AnyExtensionDataMap,
} from './createExtension';
export {
createExtensionInput,
type ExtensionInput,
type LegacyExtensionInput,
} from './createExtensionInput';
export { type ExtensionDataContainer } from './createExtensionDataContainer';
export {
@@ -16,9 +16,6 @@
import { AppNode } from '../apis';
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataValues,
ExtensionDefinition,
ResolvedExtensionInputs,
toInternalExtensionDefinition,
@@ -47,13 +44,27 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(options: {
readonly inputs: {
[inputName in string]: {
$$type: '@backstage/ExtensionInput';
extensionData: {
[name in string]: AnyExtensionDataRef;
};
config: { optional: boolean; singleton: boolean };
};
};
readonly output: {
[name in string]: AnyExtensionDataRef;
};
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
inputs: {
[inputName in string]: unknown;
};
}): {
[inputName in string]: unknown;
};
}
| {
readonly version: 'v2';
@@ -18,10 +18,10 @@ import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import {
ApiBlueprint,
analyticsApiRef,
configApiRef,
coreExtensionData,
createApiExtension,
createApiFactory,
createExtension,
createExtensionDataRef,
@@ -64,8 +64,8 @@ describe('createExtensionTester', () => {
it("should fail to render an extension that doesn't output a react element", async () => {
const extension = createExtension({
...defaultDefinition,
output: { path: coreExtensionData.routePath },
factory: () => ({ path: '/foo' }),
output: [coreExtensionData.routePath],
factory: () => [coreExtensionData.routePath('/foo')],
});
const tester = createExtensionTester(extension);
expect(() => tester.render()).toThrowErrorMatchingInlineSnapshot(
@@ -122,7 +122,7 @@ describe('createExtensionTester', () => {
const appTitle = configApi.getOptionalString('app.title');
return (
<div>
<h2>{appTitle ?? 'Backstafe app'}</h2>
<h2>{appTitle ?? 'Backstage app'}</h2>
<h3>{config.title ?? 'Index page'}</h3>
<Link to="/details">See details</Link>
</div>
@@ -186,12 +186,14 @@ describe('createExtensionTester', () => {
// Mocking the analytics api implementation
const analyticsApiMock = new MockAnalyticsApi();
const analyticsApiOverride = createApiExtension({
factory: createApiFactory({
api: analyticsApiRef,
deps: {},
factory: () => analyticsApiMock,
}),
const analyticsApiOverride = ApiBlueprint.make({
params: {
factory: createApiFactory({
api: analyticsApiRef,
deps: {},
factory: () => analyticsApiMock,
}),
},
});
const indexPageExtension = createExtension({
@@ -26,13 +26,13 @@ import {
ExtensionDataRef,
ExtensionDefinition,
IconComponent,
NavItemBlueprint,
RouteRef,
RouterBlueprint,
coreExtensionData,
createExtension,
createExtensionInput,
createExtensionOverrides,
createNavItemExtension,
createRouterExtension,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { Config, ConfigReader } from '@backstage/config';
@@ -74,30 +74,29 @@ const TestAppNavExtension = createExtension({
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
inputs: {
items: createExtensionInput({
target: createNavItemExtension.targetDataRef,
}),
},
output: {
element: coreExtensionData.reactElement,
items: createExtensionInput([NavItemBlueprint.dataRefs.target]),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
return {
element: (
return [
coreExtensionData.reactElement(
<nav>
<ul>
{inputs.items.map((item, index) => (
<NavItem
key={index}
icon={item.output.target.icon}
title={item.output.target.title}
routeRef={item.output.target.routeRef}
/>
))}
{inputs.items.map((item, index) => {
const target = item.get(NavItemBlueprint.dataRefs.target);
return (
<NavItem
key={index}
icon={target.icon}
title={target.title}
routeRef={target.routeRef}
/>
);
})}
</ul>
</nav>
</nav>,
),
};
];
},
});
@@ -253,18 +252,7 @@ export class ExtensionTester<UOutput extends AnyExtensionDataRef> {
let subjectOverride;
// attaching to app/routes to render as index route
if (subjectInternal.version === 'v1') {
subjectOverride = createExtension({
...subjectInternal,
attachTo: { id: 'app/routes', input: 'routes' },
output: {
...subjectInternal.output,
path: coreExtensionData.routePath,
},
factory: params => ({
...subjectInternal.factory(params as any),
path: '/',
}),
});
throw new Error('The extension tester does not support v1 extensions');
} else if (subjectInternal.version === 'v2') {
subjectOverride = createExtension({
...subjectInternal,
@@ -282,6 +270,7 @@ export class ExtensionTester<UOutput extends AnyExtensionDataRef> {
return [...parentOutput, coreExtensionData.routePath('/')];
},
});
(subjectOverride as any).configSchema = subjectInternal.configSchema;
} else {
throw new Error('Unsupported extension version');
}
@@ -293,11 +282,13 @@ export class ExtensionTester<UOutput extends AnyExtensionDataRef> {
subjectOverride,
...this.#extensions.slice(1).map(extension => extension.definition),
TestAppNavExtension,
createRouterExtension({
RouterBlueprint.make({
namespace: 'test',
Component: ({ children }) => (
<MemoryRouter>{children}</MemoryRouter>
),
params: {
Component: ({ children }) => (
<MemoryRouter>{children}</MemoryRouter>
),
},
}),
],
}),
@@ -29,8 +29,8 @@ import {
useRouteRef,
createExtensionInput,
IconComponent,
createNavItemExtension,
RouterBlueprint,
NavItemBlueprint,
} from '@backstage/frontend-plugin-api';
/**
@@ -86,7 +86,7 @@ export const TestAppNavExtension = createExtension({
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
inputs: {
items: createExtensionInput([createNavItemExtension.targetDataRef]),
items: createExtensionInput([NavItemBlueprint.dataRefs.target]),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
@@ -96,7 +96,7 @@ export const TestAppNavExtension = createExtension({
<ul>
{inputs.items.map((item, index) => {
const { icon, title, routeRef } = item.get(
createNavItemExtension.targetDataRef,
NavItemBlueprint.dataRefs.target,
);
return (
-98
View File
@@ -5,7 +5,6 @@
```ts
/// <reference types="react" />
import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
@@ -13,31 +12,10 @@ import { Entity } from '@backstage/catalog-model';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { ResolvedExtensionInputs } from '@backstage/frontend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha @deprecated (undocumented)
export const catalogExtensionData: {
entityContentTitle: ConfigurableExtensionDataRef<
string,
'catalog.entity-content-title',
{}
>;
entityFilterFunction: ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{}
>;
entityFilterExpression: ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{}
>;
};
// @alpha (undocumented)
export const catalogReactTranslationRef: TranslationRef<
'catalog-react',
@@ -120,82 +98,6 @@ export function convertLegacyEntityContentExtension(
},
): ExtensionDefinition<any>;
// @alpha @deprecated (undocumented)
export function createEntityCardExtension<
TConfig extends {
filter?: string;
},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
loader: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}): ExtensionDefinition<
TConfig,
TConfig,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @alpha @deprecated (undocumented)
export function createEntityContentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
defaultPath: string;
defaultTitle: string;
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
loader: (options: {
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}): ExtensionDefinition<
{
title: string;
path: string;
filter?: string | undefined;
},
{
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
},
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @alpha
export const EntityCardBlueprint: ExtensionBlueprint<
{
@@ -19,7 +19,10 @@ import {
coreExtensionData,
createExtensionBlueprint,
} from '@backstage/frontend-plugin-api';
import { catalogExtensionData } from '../extensions';
import {
entityFilterFunctionDataRef,
entityFilterExpressionDataRef,
} from './extensionData';
/**
* @alpha
@@ -30,12 +33,12 @@ export const EntityCardBlueprint = createExtensionBlueprint({
attachTo: { id: 'entity-content:catalog/overview', input: 'cards' },
output: [
coreExtensionData.reactElement,
catalogExtensionData.entityFilterFunction.optional(),
catalogExtensionData.entityFilterExpression.optional(),
entityFilterFunctionDataRef.optional(),
entityFilterExpressionDataRef.optional(),
],
dataRefs: {
filterFunction: catalogExtensionData.entityFilterFunction,
filterExpression: catalogExtensionData.entityFilterExpression,
filterFunction: entityFilterFunctionDataRef,
filterExpression: entityFilterExpressionDataRef,
},
config: {
schema: {
@@ -49,19 +52,19 @@ export const EntityCardBlueprint = createExtensionBlueprint({
}: {
loader: () => Promise<JSX.Element>;
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
},
{ node, config },
) {
yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
if (config.filter) {
yield catalogExtensionData.entityFilterExpression(config.filter);
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof filter === 'string') {
yield catalogExtensionData.entityFilterExpression(filter);
yield entityFilterExpressionDataRef(filter);
} else if (typeof filter === 'function') {
yield catalogExtensionData.entityFilterFunction(filter);
yield entityFilterFunctionDataRef(filter);
}
},
});
@@ -20,7 +20,11 @@ import {
ExtensionBoundary,
RouteRef,
} from '@backstage/frontend-plugin-api';
import { catalogExtensionData } from '../extensions';
import {
entityContentTitleDataRef,
entityFilterFunctionDataRef,
entityFilterExpressionDataRef,
} from './extensionData';
/**
* @alpha
@@ -32,15 +36,15 @@ export const EntityContentBlueprint = createExtensionBlueprint({
output: [
coreExtensionData.reactElement,
coreExtensionData.routePath,
catalogExtensionData.entityContentTitle,
entityContentTitleDataRef,
coreExtensionData.routeRef.optional(),
catalogExtensionData.entityFilterFunction.optional(),
catalogExtensionData.entityFilterExpression.optional(),
entityFilterFunctionDataRef.optional(),
entityFilterExpressionDataRef.optional(),
],
dataRefs: {
title: catalogExtensionData.entityContentTitle,
filterFunction: catalogExtensionData.entityFilterFunction,
filterExpression: catalogExtensionData.entityFilterExpression,
title: entityContentTitleDataRef,
filterFunction: entityFilterFunctionDataRef,
filterExpression: entityFilterExpressionDataRef,
},
config: {
schema: {
@@ -62,8 +66,8 @@ export const EntityContentBlueprint = createExtensionBlueprint({
defaultTitle: string;
routeRef?: RouteRef;
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
},
{ node, config },
) {
@@ -74,18 +78,18 @@ export const EntityContentBlueprint = createExtensionBlueprint({
yield coreExtensionData.routePath(path);
yield catalogExtensionData.entityContentTitle(title);
yield entityContentTitleDataRef(title);
if (routeRef) {
yield coreExtensionData.routeRef(routeRef);
}
if (config.filter) {
yield catalogExtensionData.entityFilterExpression(config.filter);
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof filter === 'string') {
yield catalogExtensionData.entityFilterExpression(filter);
yield entityFilterExpressionDataRef(filter);
} else if (typeof filter === 'function') {
yield catalogExtensionData.entityFilterFunction(filter);
yield entityFilterFunctionDataRef(filter);
}
},
});
@@ -0,0 +1,34 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { createExtensionDataRef } from '@backstage/frontend-plugin-api';
/** @internal */
export const entityContentTitleDataRef = createExtensionDataRef<string>().with({
id: 'catalog.entity-content-title',
});
/** @internal */
export const entityFilterFunctionDataRef = createExtensionDataRef<
(entity: Entity) => boolean
>().with({ id: 'catalog.entity-filter-function' });
/** @internal */
export const entityFilterExpressionDataRef =
createExtensionDataRef<string>().with({
id: 'catalog.entity-filter-expression',
});
@@ -1,213 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AnyExtensionInputMap,
ExtensionBoundary,
PortableSchema,
ResolvedExtensionInputs,
RouteRef,
coreExtensionData,
createExtension,
createExtensionDataRef,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import React, { lazy } from 'react';
import { Entity } from '@backstage/catalog-model';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { Expand } from '../../../../packages/frontend-plugin-api/src/types';
export { useEntityPermission } from '../hooks/useEntityPermission';
export { isOwnerOf } from '../utils';
export * from '../translation';
/**
* @alpha
* @deprecated use `dataRefs` property on either {@link EntityCardBlueprint} or {@link EntityContentBlueprint} instead
*/
export const catalogExtensionData = {
entityContentTitle: createExtensionDataRef<string>().with({
id: 'catalog.entity-content-title',
}),
entityFilterFunction: createExtensionDataRef<
(entity: Entity) => boolean
>().with({ id: 'catalog.entity-filter-function' }),
entityFilterExpression: createExtensionDataRef<string>().with({
id: 'catalog.entity-filter-expression',
}),
};
// TODO: Figure out how to merge with provided config schema
/**
* @alpha
* @deprecated use {@link EntityCardBlueprint} instead
*/
export function createEntityCardExtension<
TConfig extends { filter?: string },
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
loader: (options: {
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}) {
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({
filter: z.string().optional(),
}),
) as PortableSchema<TConfig>);
return createExtension({
kind: 'entity-card',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? {
id: 'entity-content:catalog/overview',
input: 'cards',
},
disabled: options.disabled,
output: {
element: coreExtensionData.reactElement,
filterFunction: catalogExtensionData.entityFilterFunction.optional(),
filterExpression: catalogExtensionData.entityFilterExpression.optional(),
},
inputs: options.inputs,
configSchema,
factory({ config, inputs, node }) {
const ExtensionComponent = lazy(() =>
options
.loader({ inputs, config })
.then(element => ({ default: () => element })),
);
return {
element: (
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
...mergeFilters({ config, options }),
};
},
});
}
/**
* @alpha
* @deprecated use {@link EntityContentBlueprint} instead
*/
export function createEntityContentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
defaultPath: string;
defaultTitle: string;
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
loader: (options: {
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}) {
return createExtension({
kind: 'entity-content',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? {
id: 'page:catalog/entity',
input: 'contents',
},
disabled: options.disabled,
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: catalogExtensionData.entityContentTitle,
filterFunction: catalogExtensionData.entityFilterFunction.optional(),
filterExpression: catalogExtensionData.entityFilterExpression.optional(),
},
inputs: options.inputs,
configSchema: createSchemaFromZod(z =>
z.object({
path: z.string().default(options.defaultPath),
title: z.string().default(options.defaultTitle),
filter: z.string().optional(),
}),
),
factory({ config, inputs, node }) {
const ExtensionComponent = lazy(() =>
options
.loader({ inputs })
.then(element => ({ default: () => element })),
);
return {
path: config.path,
title: config.title,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
...mergeFilters({ config, options }),
};
},
});
}
/**
* Decides what filter outputs to produce, given some options and config
*/
function mergeFilters(inputs: {
options: {
filter?:
| typeof catalogExtensionData.entityFilterFunction.T
| typeof catalogExtensionData.entityFilterExpression.T;
};
config: {
filter?: string;
};
}): {
filterFunction?: typeof catalogExtensionData.entityFilterFunction.T;
filterExpression?: typeof catalogExtensionData.entityFilterExpression.T;
} {
const { options, config } = inputs;
if (config.filter) {
return { filterExpression: config.filter };
} else if (typeof options.filter === 'string') {
return { filterExpression: options.filter };
} else if (typeof options.filter === 'function') {
return { filterFunction: options.filter };
}
return {};
}
+3 -1
View File
@@ -15,5 +15,7 @@
*/
export * from './blueprints';
export * from './extensions';
export * from './converters';
export { catalogReactTranslationRef } from '../translation';
export { isOwnerOf } from '../utils/isOwnerOf';
export { useEntityPermission } from '../hooks/useEntityPermission';
-24
View File
@@ -7,7 +7,6 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api';
import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
@@ -18,7 +17,6 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha';
import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha';
@@ -126,28 +124,6 @@ export const catalogTranslationRef: TranslationRef<
}
>;
// @alpha @deprecated (undocumented)
export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
TConfig,
>(options: {
namespace?: string;
name?: string;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: { config: TConfig }) => Promise<JSX.Element>;
}): ExtensionDefinition<
TConfig,
TConfig,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @alpha (undocumented)
const _default: BackstagePlugin<
{
@@ -1,66 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { lazy } from 'react';
import {
AnyExtensionInputMap,
ExtensionBoundary,
PortableSchema,
coreExtensionData,
createExtension,
} from '@backstage/frontend-plugin-api';
/**
* @alpha
* @deprecated Use {@link CatalogFilterBlueprint} instead
*/
export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
TConfig,
>(options: {
namespace?: string;
name?: string;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: { config: TConfig }) => Promise<JSX.Element>;
}) {
return createExtension({
kind: 'catalog-filter',
namespace: options.namespace,
name: options.name,
attachTo: { id: 'page:catalog', input: 'filters' },
inputs: options.inputs ?? {},
configSchema: options.configSchema,
output: {
element: coreExtensionData.reactElement,
},
factory({ config, node }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config })
.then(element => ({ default: () => element })),
);
return {
element: (
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
};
},
});
}
-1
View File
@@ -15,7 +15,6 @@
*/
export { default } from './plugin';
export { createCatalogFilterExtension } from './createCatalogFilterExtension';
export * from './blueprints';
export * from './translation';
-53
View File
@@ -7,9 +7,7 @@
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ListItemProps } from '@material-ui/core/ListItem';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { SearchDocument } from '@backstage/plugin-search-common';
import { SearchResult } from '@backstage/plugin-search-common';
@@ -19,38 +17,6 @@ export type BaseSearchResultListItemProps<T = {}> = T & {
result?: SearchDocument;
} & Omit<ListItemProps, 'button'>;
// @alpha @deprecated
export function createSearchResultListItemExtension<
TConfig extends {
noTrack?: boolean;
},
>(
options: SearchResultItemExtensionOptions<TConfig>,
): ExtensionDefinition<
TConfig,
TConfig,
never,
never,
{
kind?: string | undefined;
namespace?: string | undefined;
name?: string | undefined;
}
>;
// @alpha @deprecated (undocumented)
export namespace createSearchResultListItemExtension {
const // @deprecated (undocumented)
itemDataRef: ConfigurableExtensionDataRef<
{
predicate?: SearchResultItemExtensionPredicate | undefined;
component: SearchResultItemExtensionComponent;
},
'search.search-result-list-item.item',
{}
>;
}
// @alpha (undocumented)
export type SearchResultItemExtensionComponent = <
P extends BaseSearchResultListItemProps,
@@ -58,25 +24,6 @@ export type SearchResultItemExtensionComponent = <
props: P,
) => JSX.Element | null;
// @alpha @deprecated (undocumented)
export type SearchResultItemExtensionOptions<
TConfig extends {
noTrack?: boolean;
},
> = {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
component: (options: {
config: TConfig;
}) => Promise<SearchResultItemExtensionComponent>;
predicate?: SearchResultItemExtensionPredicate;
};
// @alpha (undocumented)
export type SearchResultItemExtensionPredicate = (
result: SearchResult,
@@ -1,171 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createExtensionInput,
createPageExtension,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { SearchResult } from '@backstage/plugin-search-common';
import { screen } from '@testing-library/react';
import React from 'react';
import { createSearchResultListItemExtension } from './extensions';
import { BaseSearchResultListItemProps } from './blueprints';
describe('createSearchResultListItemExtension', () => {
it('Should use the correct result component', async () => {
type TechDocsSearchResultListItemProps = BaseSearchResultListItemProps<{
lineClamp: number;
}>;
const TechDocsSearchResultItemComponent = (
props: TechDocsSearchResultListItemProps,
) => (
<div>
TechDocs - Rank: {props.rank} - Line clamp: {props.lineClamp}
</div>
);
const TechDocsSearchResultItemExtension =
createSearchResultListItemExtension({
namespace: 'techdocs',
configSchema: createSchemaFromZod(z =>
z
.object({
noTrack: z.boolean().default(true),
lineClamp: z.number().default(5),
})
.default({}),
),
predicate: result => result.type === 'techdocs',
component:
async ({ config }) =>
props =>
<TechDocsSearchResultItemComponent {...props} {...config} />,
});
const ExploreSearchResultItemComponent = (
props: BaseSearchResultListItemProps,
) => <div>Explore - Rank: {props.rank}</div>;
const ExploreSearchResultItemExtension =
createSearchResultListItemExtension({
namespace: 'explore',
predicate: result => result.type === 'explore',
component: async () => ExploreSearchResultItemComponent,
});
const SearchPageExtension = createPageExtension({
namespace: 'search',
defaultPath: '/',
inputs: {
items: createExtensionInput({
item: createSearchResultListItemExtension.itemDataRef,
}),
},
loader: async ({ inputs }) => {
const results = [
{
type: 'techdocs',
rank: 1,
document: {
title: 'Title1',
text: 'Text1',
location: '/location1',
},
},
{
type: 'explore',
rank: 2,
document: {
title: 'Title2',
text: 'Text2',
location: '/location2',
},
},
{
type: 'other',
rank: 3,
document: {
title: 'Title3',
text: 'Text3',
location: '/location3',
},
},
];
const DefaultResultItem = (props: BaseSearchResultListItemProps) => (
<div>Default - Rank: {props.rank}</div>
);
const getResultItemComponent = (result: SearchResult) => {
const value = inputs.items.find(item =>
item?.output.item.predicate?.(result),
);
return value?.output.item.component ?? DefaultResultItem;
};
const Component = () => {
return (
<div>
<h1>Search Page</h1>
<ul>
{results.map((result, index) => {
const SearchResultListItem = getResultItemComponent(result);
return (
<SearchResultListItem
key={index}
rank={result.rank}
result={result.document}
/>
);
})}
</ul>
</div>
);
};
return <Component />;
},
});
createExtensionTester(SearchPageExtension)
.add(TechDocsSearchResultItemExtension, {
// TODO(Rugvip): We need to make the config input type available for use here
config: {
lineClamp: 3,
} as any,
})
.add(ExploreSearchResultItemExtension)
.render();
expect(await screen.findByText(/Search Page/)).toBeInTheDocument();
expect(
await screen.findByText(/TechDocs - Rank: 1 - Line clamp: 3/, {
exact: false,
}),
).toBeInTheDocument();
expect(
await screen.findByText(/Explore - Rank: 2/, { exact: false }),
).toBeInTheDocument();
expect(
await screen.findByText(/Default - Rank: 3/, { exact: false }),
).toBeInTheDocument();
});
});
@@ -1,133 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { lazy } from 'react';
import {
ExtensionBoundary,
PortableSchema,
createExtension,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import {
SearchResultItemExtensionComponent,
SearchResultItemExtensionPredicate,
} from './blueprints';
import { SearchResultListItemExtension } from '../extensions';
import { searchResultListItemDataRef } from './blueprints/types';
/**
* @alpha
* @deprecated Use {@link SearchResultListItemBlueprint} instead
*/
export type SearchResultItemExtensionOptions<
TConfig extends { noTrack?: boolean },
> = {
/**
* The extension namespace.
*/
namespace?: string;
/**
* The extension name.
*/
name?: string;
/**
* The extension attachment point (e.g., search modal or page).
*/
attachTo?: { id: string; input: string };
/**
* Optional extension config schema.
*/
configSchema?: PortableSchema<TConfig>;
/**
* The extension component.
*/
component: (options: {
config: TConfig;
}) => Promise<SearchResultItemExtensionComponent>;
/**
* When an extension defines a predicate, it returns true if the result should be rendered by that extension.
* Defaults to a predicate that returns true, which means it renders all sorts of results.
*/
predicate?: SearchResultItemExtensionPredicate;
};
/**
* Creates items for the search result list.
*
* @alpha
* @deprecated Use {@link SearchResultListItemBlueprint} instead
*/
export function createSearchResultListItemExtension<
TConfig extends { noTrack?: boolean },
>(options: SearchResultItemExtensionOptions<TConfig>) {
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({
noTrack: z.boolean().default(false),
}),
) as PortableSchema<TConfig>);
return createExtension({
kind: 'search-result-list-item',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? {
id: 'page:search',
input: 'items',
},
configSchema,
output: {
item: createSearchResultListItemExtension.itemDataRef,
},
factory({ config, node }) {
const ExtensionComponent = lazy(() =>
options
.component({ config })
.then(component => ({ default: component })),
) as unknown as SearchResultItemExtensionComponent;
return {
item: {
predicate: options.predicate,
component: props => (
<ExtensionBoundary node={node}>
<SearchResultListItemExtension
rank={props.rank}
result={props.result}
noTrack={config.noTrack}
>
<ExtensionComponent {...props} />
</SearchResultListItemExtension>
</ExtensionBoundary>
),
},
};
},
});
}
/**
* @alpha
* @deprecated Use {@link SearchResultListItemBlueprint} instead
*/
export namespace createSearchResultListItemExtension {
/**
* @deprecated Use {@link SearchResultListItemBlueprint#dataRefs.item} instead
*/
export const itemDataRef = searchResultListItemDataRef;
}
-1
View File
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './extensions';
export * from './blueprints';