components:{' '}
- {Object.entries(coreComponentRefs)
+ {Object.entries(defaultComponentRefs)
.map(
([name, ref]) =>
`${name}=${Boolean(components.getComponent(ref))}`,
@@ -118,7 +126,7 @@ describe('ForwardsCompatProvider', () => {
await renderInOldTestApp(compatWrapper(
));
expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(`
- "components: progress=true, notFoundErrorPage=true, errorBoundaryFallback=true
+ "components: progress=true, notFoundErrorPage=true, errorDisplay=true
icons: kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, user, warning, star, unstarred"
`);
});
diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx
index f6c4e163ad..650ea9cd58 100644
--- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx
+++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx
@@ -16,10 +16,9 @@
import { ComponentType } from 'react';
import {
+ SwappableComponentBlueprint,
ApiBlueprint,
- coreComponentRefs,
- CoreErrorBoundaryFallbackProps,
- createComponentExtension,
+ ErrorDisplayProps,
createExtension,
createFrontendModule,
ExtensionDefinition,
@@ -28,6 +27,9 @@ import {
RouterBlueprint,
SignInPageBlueprint,
ThemeBlueprint,
+ ErrorDisplay as SwappableErrorDisplay,
+ NotFoundErrorPage as SwappableNotFoundErrorPage,
+ Progress as SwappableProgress,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -154,36 +156,45 @@ export function convertLegacyAppOptions(
}
if (Progress) {
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.progress,
- loader: { sync: () => componentCompatWrapper(Progress) },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableProgress,
+ loader: () => componentCompatWrapper(Progress),
+ }),
}),
);
}
+
if (NotFoundErrorPage) {
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.notFoundErrorPage,
- loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableNotFoundErrorPage,
+ loader: () => componentCompatWrapper(NotFoundErrorPage),
+ }),
}),
);
}
+
if (ErrorBoundaryFallback) {
- const WrappedErrorBoundaryFallback = (
- props: CoreErrorBoundaryFallbackProps,
- ) =>
+ const WrappedErrorBoundaryFallback = (props: ErrorDisplayProps) =>
compatWrapper(
,
);
+
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.errorBoundaryFallback,
- loader: {
- sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback),
- },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableErrorDisplay,
+ loader: () =>
+ componentCompatWrapper(WrappedErrorBoundaryFallback),
+ }),
}),
);
}
diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md
index 62288eb65c..4f415b9fb9 100644
--- a/packages/core-components/CHANGELOG.md
+++ b/packages/core-components/CHANGELOG.md
@@ -1,5 +1,11 @@
# @backstage/core-components
+## 0.17.5-next.2
+
+### Patch Changes
+
+- 77467bb: Updated dependency `linkifyjs` to `4.3.2`.
+
## 0.17.5-next.1
### Patch Changes
diff --git a/packages/core-components/package.json b/packages/core-components/package.json
index a502bad903..8cf9e7e351 100644
--- a/packages/core-components/package.json
+++ b/packages/core-components/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/core-components",
- "version": "0.17.5-next.1",
+ "version": "0.17.5-next.2",
"description": "Core components used by Backstage plugins and apps",
"backstage": {
"role": "web-library"
diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx
index a21192c91b..6c430fe454 100644
--- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx
+++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx
@@ -124,6 +124,7 @@ export function AlertDisplay(props: AlertDisplayProps) {
{String(firstMessage.message)}
{messages.length > 1 && (
+ {' '}
{t('alertDisplay.message', {
count: messages.length - 1,
})}
diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md
index 9ae268b37c..86e9f280d4 100644
--- a/packages/create-app/CHANGELOG.md
+++ b/packages/create-app/CHANGELOG.md
@@ -1,5 +1,11 @@
# @backstage/create-app
+## 0.7.2-next.3
+
+### Patch Changes
+
+- 8b1bf6e: Updated the `app.packages` config setting now that it no longer is experimental
+
## 0.7.2-next.2
### Patch Changes
diff --git a/packages/create-app/package.json b/packages/create-app/package.json
index af5eb8d12d..9584c25acc 100644
--- a/packages/create-app/package.json
+++ b/packages/create-app/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/create-app",
- "version": "0.7.2-next.2",
+ "version": "0.7.2-next.3",
"description": "A CLI that helps you create your own Backstage app",
"backstage": {
"role": "cli"
diff --git a/packages/create-app/templates/default-app/playwright.config.ts b/packages/create-app/templates/default-app/playwright.config.ts
index 37c7fb14c7..0dc740b466 100644
--- a/packages/create-app/templates/default-app/playwright.config.ts
+++ b/packages/create-app/templates/default-app/playwright.config.ts
@@ -32,11 +32,17 @@ export default defineConfig({
? []
: [
{
- command: 'yarn start',
+ command: 'yarn start app',
port: 3000,
reuseExistingServer: true,
timeout: 60_000,
},
+ {
+ command: 'yarn start backend',
+ port: 7007,
+ reuseExistingServer: true,
+ timeout: 60_000,
+ },
],
forbidOnly: !!process.env.CI,
diff --git a/packages/create-app/templates/next-app/app-config.yaml.hbs b/packages/create-app/templates/next-app/app-config.yaml.hbs
index 7688aee89b..b80b980d10 100644
--- a/packages/create-app/templates/next-app/app-config.yaml.hbs
+++ b/packages/create-app/templates/next-app/app-config.yaml.hbs
@@ -2,8 +2,7 @@ app:
title: Scaffolded Backstage App
baseUrl: http://localhost:3000
- experimental:
- packages: all
+ packages: all
extensions:
# Disable the nav items that we're manually rendering in packages/app/src/modules/nav/Sidebar.tsx
diff --git a/packages/create-app/templates/next-app/package.json.hbs b/packages/create-app/templates/next-app/package.json.hbs
index 178c38403f..ae905dbd3f 100644
--- a/packages/create-app/templates/next-app/package.json.hbs
+++ b/packages/create-app/templates/next-app/package.json.hbs
@@ -38,7 +38,7 @@
"@backstage/cli/templates/plugin-common-library",
"@backstage/cli/templates/web-library",
"@backstage/cli/templates/node-library",
- "@backstage/cli/templates/scaffolder-backend-module",
+ "@backstage/cli/templates/scaffolder-backend-module"
]
}
}
diff --git a/packages/create-app/templates/next-app/playwright.config.ts b/packages/create-app/templates/next-app/playwright.config.ts
index 37c7fb14c7..0dc740b466 100644
--- a/packages/create-app/templates/next-app/playwright.config.ts
+++ b/packages/create-app/templates/next-app/playwright.config.ts
@@ -32,11 +32,17 @@ export default defineConfig({
? []
: [
{
- command: 'yarn start',
+ command: 'yarn start app',
port: 3000,
reuseExistingServer: true,
timeout: 60_000,
},
+ {
+ command: 'yarn start backend',
+ port: 7007,
+ reuseExistingServer: true,
+ timeout: 60_000,
+ },
],
forbidOnly: !!process.env.CI,
diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts
index c95b8294c7..6df58349df 100644
--- a/packages/e2e-test/src/commands/run.ts
+++ b/packages/e2e-test/src/commands/run.ts
@@ -61,7 +61,7 @@ export async function run(opts: OptionValues) {
print('Creating a Backstage Plugin');
const pluginId = 'test';
- await createPlugin({ appDir, pluginId, select: 'plugin' });
+ await createPlugin({ appDir, pluginId, select: 'frontend-plugin' });
print('Creating a Backstage Backend Plugin');
await createPlugin({ appDir, pluginId, select: 'backend-plugin' });
@@ -221,7 +221,7 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) {
*/
async function pinYarnVersion(dir: string) {
const yarnRc = await fs.readFile(paths.resolveOwnRoot('.yarnrc.yml'), 'utf8');
- const yarnRcLines = yarnRc.split('\n');
+ const yarnRcLines = yarnRc.split(/\r?\n/);
const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarnPath:'));
if (!yarnPathLine) {
throw new Error(`Unable to find 'yarnPath' in ${yarnRc}`);
@@ -379,7 +379,7 @@ async function createPlugin(options: {
}) {
const { appDir, pluginId, select } = options;
const child = spawnPiped(
- ['yarn', 'new', '--select', select, '--option', `id=${pluginId}`],
+ ['yarn', 'new', '--select', select, '--option', `pluginId=${pluginId}`],
{
cwd: appDir,
},
diff --git a/packages/frontend-app-api/CHANGELOG.md b/packages/frontend-app-api/CHANGELOG.md
index 9e857e53f2..c0b844be6c 100644
--- a/packages/frontend-app-api/CHANGELOG.md
+++ b/packages/frontend-app-api/CHANGELOG.md
@@ -1,5 +1,40 @@
# @backstage/frontend-app-api
+## 0.12.0-next.3
+
+### Minor Changes
+
+- 8e21c4d: Use an app plugin for built-in extension app node specs.
+- 8e21c4d: The `AppNodeSpec.plugin` property is now required.
+- 5e12252: **BREAKING**: Restructured some of option fields of `createApp` and `createSpecializedApp`.
+
+ - For `createApp`, all option fields _except_ `features` and `bindRoutes` have been moved into a new `advanced` object field.
+ - For `createSpecializedApp`, all option fields _except_ `features`, `config`, and `bindRoutes` have been moved into a new `advanced` object field.
+
+ This helps highlight that some options are meant to rarely be needed or used, and simplifies the usage of those options that are almost always required.
+
+ As an example, if you used to supply a custom config loader, you would update your code as follows:
+
+ ```diff
+ createApp({
+ features: [...],
+ - configLoader: new MyCustomLoader(),
+ + advanced: {
+ + configLoader: new MyCustomLoader(),
+ + },
+ })
+ ```
+
+### Patch Changes
+
+- f3f9d57: Renaming the `getNodesByRoutePath` parameter from `sourcePath` to `routePath`
+- 8b1bf6e: Deprecated new frontend system config setting `app.experimental.packages` to just `app.packages`. The old config will continue working for the time being, but may be removed in a future release.
+- fda1bbc: Added a default implementation of the `SwappableComponentsApi` and removing the legacy `ComponentsApi` implementation
+- 1c2cc37: Improved runtime error message clarity when extension factories don't return an iterable object.
+- Updated dependencies
+ - @backstage/frontend-plugin-api@0.11.0-next.2
+ - @backstage/frontend-defaults@0.3.0-next.3
+
## 0.12.0-next.2
### Minor Changes
diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts
index 3b86b5c1d4..7032cd378d 100644
--- a/packages/frontend-app-api/config.d.ts
+++ b/packages/frontend-app-api/config.d.ts
@@ -20,10 +20,27 @@ export interface Config {
/**
* @visibility frontend
* @deepVisibility frontend
+ * @deprecated This is no longer experimental; use `app.packages` instead.
*/
packages?: 'all' | { include?: string[]; exclude?: string[] };
};
+ /**
+ * Controls what packages are loaded by the new frontend system.
+ *
+ * @remarks
+ *
+ * When using the 'all' option, all feature packages that were added as
+ * dependencies to the app will be loaded automatically.
+ *
+ * The `include` and `exclude` options can be used to more finely control
+ * which individual package names to include or exclude.
+ *
+ * @visibility frontend
+ * @deepVisibility frontend
+ */
+ packages?: 'all' | { include?: string[]; exclude?: string[] };
+
routes?: {
/**
* Maps external route references to regular route references. Both the
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index ed71d61fab..998a396574 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/frontend-app-api",
- "version": "0.12.0-next.2",
+ "version": "0.12.0-next.3",
"backstage": {
"role": "web-library"
},
@@ -45,6 +45,7 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md
index 38f9647b79..039d0bb7b1 100644
--- a/packages/frontend-app-api/report.api.md
+++ b/packages/frontend-app-api/report.api.md
@@ -38,14 +38,14 @@ export type CreateSpecializedAppOptions = {
features?: FrontendFeature[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- apis?: ApiHolder;
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- flags?: {
+ advanced?: {
+ apis?: ApiHolder;
allowUnknownExtensionConfig?: boolean;
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+ pluginInfoResolver?: FrontendPluginInfoResolver;
};
- pluginInfoResolver?: FrontendPluginInfoResolver;
};
// @public
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx
deleted file mode 100644
index 7d06d55a92..0000000000
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx
+++ /dev/null
@@ -1,57 +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 { createComponentRef } from '@backstage/frontend-plugin-api';
-import { DefaultComponentsApi } from './DefaultComponentsApi';
-import { render, screen } from '@testing-library/react';
-
-const testRefA = createComponentRef({ id: 'test.a' });
-const testRefB1 = createComponentRef({ id: 'test.b' });
-const testRefB2 = createComponentRef({ id: 'test.b' });
-
-describe('DefaultComponentsApi', () => {
- it('should provide components', () => {
- const api = DefaultComponentsApi.fromComponents([
- {
- ref: testRefA,
- impl: () => test.a
,
- },
- ]);
-
- const ComponentA = api.getComponent(testRefA);
- render();
-
- expect(screen.getByText('test.a')).toBeInTheDocument();
- });
-
- it('should key extension refs by ID', () => {
- const api = DefaultComponentsApi.fromComponents([
- {
- ref: testRefB1,
- impl: () => test.b
,
- },
- ]);
-
- const ComponentB1 = api.getComponent(testRefB1);
- const ComponentB2 = api.getComponent(testRefB2);
-
- expect(ComponentB1).toBe(ComponentB2);
-
- render();
-
- expect(screen.getByText('test.b')).toBeInTheDocument();
- });
-});
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts
deleted file mode 100644
index 317233d797..0000000000
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts
+++ /dev/null
@@ -1,51 +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 { ComponentType } from 'react';
-import {
- ComponentRef,
- ComponentsApi,
- createComponentExtension,
-} from '@backstage/frontend-plugin-api';
-
-/**
- * Implementation for the {@linkComponentApi}
- *
- * @internal
- */
-export class DefaultComponentsApi implements ComponentsApi {
- #components: Map>;
-
- static fromComponents(
- components: Array,
- ) {
- return new DefaultComponentsApi(
- new Map(components.map(entry => [entry.ref.id, entry.impl])),
- );
- }
-
- constructor(components: Map) {
- this.#components = components;
- }
-
- getComponent(ref: ComponentRef): ComponentType {
- const impl = this.#components.get(ref.id);
- if (!impl) {
- throw new Error(`No implementation found for component ref ${ref}`);
- }
- return impl;
- }
-}
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
index 5fa22536f8..57ec7b23f3 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
@@ -27,6 +27,7 @@ import {
createExtensionBlueprint,
createExtensionDataRef,
createExtensionInput,
+ createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import {
createAppNodeInstance,
@@ -59,7 +60,7 @@ function makeSpec(
attachTo: extension.attachTo,
disabled: extension.disabled,
extension: extension as Extension,
- plugin: undefined,
+ plugin: createFrontendPlugin({ pluginId: 'app' }),
...spec,
};
}
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
index 979c851a47..83abcf3dc4 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
@@ -68,6 +68,9 @@ describe('resolveAppNodeSpecs', () => {
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: true,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
@@ -87,12 +90,18 @@ describe('resolveAppNodeSpecs', () => {
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'b',
extension: b,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
@@ -120,6 +129,9 @@ describe('resolveAppNodeSpecs', () => {
extension: b,
attachTo: { id: 'derp', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'test/a',
@@ -204,12 +216,18 @@ describe('resolveAppNodeSpecs', () => {
extension: b,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'a',
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
@@ -238,42 +256,63 @@ describe('resolveAppNodeSpecs', () => {
extension: e,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'd',
extension: d,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'c',
extension: c,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'a',
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: true,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'b',
extension: b,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'f',
extension: f,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'g',
extension: g,
attachTo: { id: 'root', input: 'default' },
disabled: true,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
index 4788734800..f9ce7387c2 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
@@ -14,7 +14,11 @@
* limitations under the License.
*/
-import { Extension, FrontendFeature } from '@backstage/frontend-plugin-api';
+import {
+ createFrontendPlugin,
+ Extension,
+ FrontendFeature,
+} from '@backstage/frontend-plugin-api';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
@@ -87,6 +91,12 @@ export function resolveAppNodeSpecs(options: {
);
}
+ const appPlugin =
+ plugins.find(plugin => plugin.id === 'app') ??
+ createFrontendPlugin({
+ pluginId: 'app',
+ });
+
const configuredExtensions = [
...pluginExtensions.map(({ plugin, ...extension }) => {
const internalExtension = toInternalExtension(extension);
@@ -106,8 +116,8 @@ export function resolveAppNodeSpecs(options: {
return {
extension: internalExtension,
params: {
- source: undefined,
- plugin: undefined,
+ source: appPlugin,
+ plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
config: undefined as unknown,
diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
index e93d311f8d..f1161b202e 100644
--- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
@@ -18,6 +18,7 @@ import {
coreExtensionData,
createExtension,
createExtensionInput,
+ createFrontendPlugin,
Extension,
} from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
@@ -37,6 +38,7 @@ const baseSpec = {
extension,
attachTo: { id: 'nonexistent', input: 'nonexistent' },
disabled: false,
+ plugin: createFrontendPlugin({ pluginId: 'app' }),
};
describe('buildAppTree', () => {
@@ -284,8 +286,20 @@ describe('buildAppTree', () => {
) as Extension;
const tree = resolveAppTree('a', [
- { attachTo: e1.attachTo, id: 'a', extension: e1, disabled: false },
- { attachTo: e2.attachTo, id: 'b', extension: e2, disabled: false },
+ {
+ attachTo: e1.attachTo,
+ id: 'a',
+ extension: e1,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
+ {
+ attachTo: e2.attachTo,
+ id: 'b',
+ extension: e2,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
]);
expect(tree.root).toMatchInlineSnapshot(`
@@ -352,9 +366,27 @@ describe('buildAppTree', () => {
) as Extension;
const tree = resolveAppTree('test-2', [
- { attachTo: e1.attachTo, id: e1.id, extension: e1, disabled: false },
- { attachTo: e2.attachTo, id: e2.id, extension: e2, disabled: false },
- { attachTo: e3.attachTo, id: e3.id, extension: e3, disabled: false },
+ {
+ attachTo: e1.attachTo,
+ id: e1.id,
+ extension: e1,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
+ {
+ attachTo: e2.attachTo,
+ id: e2.id,
+ extension: e2,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
+ {
+ attachTo: e3.attachTo,
+ id: e3.id,
+ extension: e3,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
]);
expect(tree.nodes.get('test-3')?.edges.attachedTo?.node).toBe(
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
index 146d3fdcf8..5622af0448 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
@@ -313,10 +313,12 @@ describe('createSpecializedApp', () => {
it('should use provided apis', async () => {
const app = createSpecializedApp({
- apis: TestApiRegistry.from([
- configApiRef,
- new ConfigReader({ anything: 'config' }),
- ]),
+ advanced: {
+ apis: TestApiRegistry.from([
+ configApiRef,
+ new ConfigReader({ anything: 'config' }),
+ ]),
+ },
features: [
createFrontendPlugin({
pluginId: 'test',
@@ -639,28 +641,30 @@ describe('createSpecializedApp', () => {
],
}),
],
- extensionFactoryMiddleware: [
- function* middleware(originalFactory, { config }) {
- const result = originalFactory({
- config: config && { text: `1-${config.text}` },
- });
- yield* result;
- const el = result.get(textDataRef);
- if (el) {
- yield textDataRef(`${el}-1`);
- }
- },
- function* middleware(originalFactory, { config }) {
- const result = originalFactory({
- config: config && { text: `2-${config.text}` },
- });
- yield* result;
- const el = result.get(textDataRef);
- if (el) {
- yield textDataRef(`${el}-2`);
- }
- },
- ],
+ advanced: {
+ extensionFactoryMiddleware: [
+ function* middleware(originalFactory, { config }) {
+ const result = originalFactory({
+ config: config && { text: `1-${config.text}` },
+ });
+ yield* result;
+ const el = result.get(textDataRef);
+ if (el) {
+ yield textDataRef(`${el}-1`);
+ }
+ },
+ function* middleware(originalFactory, { config }) {
+ const result = originalFactory({
+ config: config && { text: `2-${config.text}` },
+ });
+ yield* result;
+ const el = result.get(textDataRef);
+ if (el) {
+ yield textDataRef(`${el}-2`);
+ }
+ },
+ ],
+ },
});
const root = app.tree.root.instance!.getData(
@@ -774,12 +778,14 @@ describe('createSpecializedApp', () => {
const app = createSpecializedApp({
features: [plugin],
- async pluginInfoResolver(ctx) {
- const { info } = await ctx.defaultResolver({
- packageJson: await ctx.packageJson(),
- manifest: await ctx.manifest(),
- });
- return { info: { packageName: `decorated:${info.packageName}` } };
+ advanced: {
+ pluginInfoResolver: async ctx => {
+ const { info } = await ctx.defaultResolver({
+ packageJson: await ctx.packageJson(),
+ manifest: await ctx.manifest(),
+ });
+ return { info: { packageName: `decorated:${info.packageName}` } };
+ },
},
});
const info = await app.tree.nodes.get('test')?.spec.plugin?.info();
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
index ac441a97f9..7059140687 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
@@ -200,20 +200,72 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
}
/**
- * Options for `createSpecializedApp`.
+ * Options for {@link createSpecializedApp}.
*
* @public
*/
export type CreateSpecializedAppOptions = {
+ /**
+ * The list of features to load.
+ */
features?: FrontendFeature[];
+
+ /**
+ * The config API implementation to use. For most normal apps, this should be
+ * specified.
+ *
+ * If none is given, a new _empty_ config will be used during startup. In
+ * later stages of the app lifecycle, the config API in the API holder will be
+ * used.
+ */
config?: ConfigApi;
+
+ /**
+ * Allows for the binding of plugins' external route refs within the app.
+ */
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- apis?: ApiHolder;
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- flags?: { allowUnknownExtensionConfig?: boolean };
- pluginInfoResolver?: FrontendPluginInfoResolver;
+
+ /**
+ * Advanced, more rarely used options.
+ */
+ advanced?: {
+ /**
+ * A replacement API holder implementation to use.
+ *
+ * By default, a new API holder will be constructed automatically based on
+ * the other inputs. If you pass in a custom one here, none of that
+ * automation will take place - so you will have to take care to supply all
+ * those APIs yourself.
+ */
+ apis?: ApiHolder;
+
+ /**
+ * If set to true, the system will silently accept and move on if
+ * encountering config for extensions that do not exist. The default is to
+ * reject such config to help catch simple mistakes.
+ *
+ * This flag can be useful in some scenarios where you have a dynamic set of
+ * extensions enabled at different times, but also increases the risk of
+ * accidentally missing e.g. simple typos in your config.
+ */
+ allowUnknownExtensionConfig?: boolean;
+
+ /**
+ * Applies one or more middleware on every extension, as they are added to
+ * the application.
+ *
+ * This is an advanced use case for modifying extension data on the fly as
+ * it gets emitted by extensions being instantiated.
+ */
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+
+ /**
+ * Allows for customizing how plugin info is retrieved.
+ */
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
};
/**
@@ -229,7 +281,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
} {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []).map(
- createPluginInfoAttacher(config, options?.pluginInfoResolver),
+ createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
);
const tree = resolveAppTree(
@@ -241,7 +293,8 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
],
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
- allowUnknownExtensionConfig: options?.flags?.allowUnknownExtensionConfig,
+ allowUnknownExtensionConfig:
+ options?.advanced?.allowUnknownExtensionConfig,
}),
);
@@ -257,7 +310,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
const appIdentityProxy = new AppIdentityProxy();
const apis =
- options?.apis ??
+ options?.advanced?.apis ??
createApiHolder({
factories,
staticFactories: [
@@ -294,7 +347,9 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
instantiateAppNodeTree(
tree.root,
apis,
- mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware),
+ mergeExtensionFactoryMiddleware(
+ options?.advanced?.extensionFactoryMiddleware,
+ ),
);
const routeInfo = extractRouteInfoFromAppNode(
diff --git a/packages/frontend-defaults/CHANGELOG.md b/packages/frontend-defaults/CHANGELOG.md
index 27a0e8aed6..767debb9ae 100644
--- a/packages/frontend-defaults/CHANGELOG.md
+++ b/packages/frontend-defaults/CHANGELOG.md
@@ -1,5 +1,38 @@
# @backstage/frontend-defaults
+## 0.3.0-next.3
+
+### Minor Changes
+
+- 5e12252: **BREAKING**: Restructured some of option fields of `createApp` and `createSpecializedApp`.
+
+ - For `createApp`, all option fields _except_ `features` and `bindRoutes` have been moved into a new `advanced` object field.
+ - For `createSpecializedApp`, all option fields _except_ `features`, `config`, and `bindRoutes` have been moved into a new `advanced` object field.
+
+ This helps highlight that some options are meant to rarely be needed or used, and simplifies the usage of those options that are almost always required.
+
+ As an example, if you used to supply a custom config loader, you would update your code as follows:
+
+ ```diff
+ createApp({
+ features: [...],
+ - configLoader: new MyCustomLoader(),
+ + advanced: {
+ + configLoader: new MyCustomLoader(),
+ + },
+ })
+ ```
+
+### Patch Changes
+
+- 8b1bf6e: Deprecated new frontend system config setting `app.experimental.packages` to just `app.packages`. The old config will continue working for the time being, but may be removed in a future release.
+- e5a0a99: **BREAKING**: The `loadingComponent` option has been renamed to `loadingElement`, which is now found under `advanced.loadingElement`. The default loading element has also been switched to `` from `@backstage/core-components`. This is of course an improvement over the previous `"Loading..."` text, but also helps prevent flicker when the app loading is fast.
+- Updated dependencies
+ - @backstage/plugin-app@0.2.0-next.2
+ - @backstage/frontend-plugin-api@0.11.0-next.2
+ - @backstage/frontend-app-api@0.12.0-next.3
+ - @backstage/core-components@0.17.5-next.2
+
## 0.3.0-next.2
### Minor Changes
diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json
index 485f0f3497..e4a01111c7 100644
--- a/packages/frontend-defaults/package.json
+++ b/packages/frontend-defaults/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/frontend-defaults",
- "version": "0.3.0-next.2",
+ "version": "0.3.0-next.3",
"backstage": {
"role": "web-library"
},
@@ -32,6 +32,7 @@
},
"dependencies": {
"@backstage/config": "workspace:^",
+ "@backstage/core-components": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md
index 171cb40fbe..6fd2536251 100644
--- a/packages/frontend-defaults/report.api.md
+++ b/packages/frontend-defaults/report.api.md
@@ -20,25 +20,19 @@ export function createApp(options?: CreateAppOptions): {
// @public
export interface CreateAppOptions {
- // (undocumented)
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- // (undocumented)
- configLoader?: () => Promise<{
- config: ConfigApi;
- }>;
- // (undocumented)
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- // (undocumented)
- features?: (FrontendFeature | FrontendFeatureLoader)[];
- // (undocumented)
- flags?: {
+ advanced?: {
allowUnknownExtensionConfig?: boolean;
+ configLoader?: () => Promise<{
+ config: ConfigApi;
+ }>;
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+ loadingElement?: ReactNode;
+ pluginInfoResolver?: FrontendPluginInfoResolver;
};
- loadingComponent?: ReactNode;
- // (undocumented)
- pluginInfoResolver?: FrontendPluginInfoResolver;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ features?: (FrontendFeature | FrontendFeatureLoader)[];
}
// @public @deprecated (undocumented)
diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx
index 89ac0a5b39..351bc8f1b5 100644
--- a/packages/frontend-defaults/src/createApp.test.tsx
+++ b/packages/frontend-defaults/src/createApp.test.tsx
@@ -45,18 +45,20 @@ describe('createApp', () => {
it('should allow themes to be installed', async () => {
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config({
- data: {
- app: {
- extensions: [
- { 'theme:app/light': false },
- { 'theme:app/dark': false },
- ],
+ advanced: {
+ configLoader: async () => ({
+ config: mockApis.config({
+ data: {
+ app: {
+ extensions: [
+ { 'theme:app/light': false },
+ { 'theme:app/dark': false },
+ ],
+ },
},
- },
+ }),
}),
- }),
+ },
features: [
createFrontendPlugin({
pluginId: 'test',
@@ -85,7 +87,9 @@ describe('createApp', () => {
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
createFrontendPlugin({
pluginId: duplicatedFeatureId,
@@ -138,7 +142,6 @@ describe('createApp', () => {
}
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
@@ -153,12 +156,15 @@ describe('createApp', () => {
],
}),
],
- pluginInfoResolver: async () => {
- return {
- info: {
- packageName: '@test/test',
- },
- };
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ pluginInfoResolver: async () => {
+ return {
+ info: {
+ packageName: '@test/test',
+ },
+ };
+ },
},
});
@@ -187,9 +193,11 @@ describe('createApp', () => {
});
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config({ data: { key: 'config-value' } }),
- }),
+ advanced: {
+ configLoader: async () => ({
+ config: mockApis.config({ data: { key: 'config-value' } }),
+ }),
+ },
features: [appPlugin, loader],
});
@@ -208,9 +216,11 @@ describe('createApp', () => {
});
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config(),
- }),
+ advanced: {
+ configLoader: async () => ({
+ config: mockApis.config(),
+ }),
+ },
features: [loader],
});
@@ -221,7 +231,9 @@ describe('createApp', () => {
it('should register feature flags', async () => {
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
appPlugin.withOverrides({
extensions: [
@@ -273,15 +285,6 @@ describe('createApp', () => {
it('should allow unknown extension config if the flag is set', async () => {
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config({
- data: {
- app: {
- extensions: [{ 'unknown:lols/wut': false }],
- },
- },
- }),
- }),
features: [
appPlugin,
createFrontendPlugin({
@@ -296,7 +299,18 @@ describe('createApp', () => {
],
}),
],
- flags: { allowUnknownExtensionConfig: true },
+ advanced: {
+ allowUnknownExtensionConfig: true,
+ configLoader: async () => ({
+ config: mockApis.config({
+ data: {
+ app: {
+ extensions: [{ 'unknown:lols/wut': false }],
+ },
+ },
+ }),
+ }),
+ },
});
await renderWithEffects(app.createRoot());
@@ -307,7 +321,9 @@ describe('createApp', () => {
let appTreeApi: AppTreeApi | undefined = undefined;
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
appPlugin,
createFrontendPlugin({
@@ -366,16 +382,17 @@ describe('createApp', () => {
]
-
+
components [
-
-
-
+
+
+
]
-
+
+
]
app [
@@ -411,41 +428,49 @@ describe('createApp', () => {
`);
});
- it('should use "Loading..." as the default suspense fallback', async () => {
+ it('should use as the default suspense fallback', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
});
await renderWithEffects(app.createRoot());
- await expect(screen.findByText('Loading...')).resolves.toBeInTheDocument();
+ await expect(screen.findByTestId('progress')).resolves.toBeInTheDocument();
});
- it('should use no suspense fallback if the "loadingComponent" is null', async () => {
+ it('should use no suspense fallback if the loadingElement is null', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
- loadingComponent: null,
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ loadingElement: null,
+ },
});
await renderWithEffects(app.createRoot());
- expect(screen.queryByText('Loading...')).toBeNull();
+ expect(screen.queryByTestId('progress')).toBeNull();
});
- it('should use a custom "loadingComponent"', async () => {
+ it('should use a custom loadingElement', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
- loadingComponent: "Custom loading message",
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ loadingElement: Custom loading message,
+ },
});
await renderWithEffects(app.createRoot());
- expect(screen.queryByText('Custom loading message')).toBeNull();
+ expect(screen.queryByText('Custom loading message')).toBeInTheDocument();
});
it('should allow overriding the app plugin', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
features: [
appPlugin.withOverrides({
extensions: [
@@ -468,7 +493,6 @@ describe('createApp', () => {
it('should use a custom extensionFactoryMiddleware', async () => {
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
@@ -484,18 +508,21 @@ describe('createApp', () => {
],
}),
],
- *extensionFactoryMiddleware(originalFactory, context) {
- const output = originalFactory();
- yield* output;
- const element = output.get(coreExtensionData.reactElement);
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ *extensionFactoryMiddleware(originalFactory, context) {
+ const output = originalFactory();
+ yield* output;
+ const element = output.get(coreExtensionData.reactElement);
- if (element) {
- yield coreExtensionData.reactElement(
-
- {element}
-
,
- );
- }
+ if (element) {
+ yield coreExtensionData.reactElement(
+
+ {element}
+
,
+ );
+ }
+ },
},
});
@@ -522,7 +549,9 @@ describe('createApp', () => {
});
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
features: [mod],
});
@@ -549,7 +578,9 @@ describe('createApp', () => {
});
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
features: [mod],
});
diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx
index e6702a4fcc..2c8a80791f 100644
--- a/packages/frontend-defaults/src/createApp.tsx
+++ b/packages/frontend-defaults/src/createApp.tsx
@@ -22,6 +22,7 @@ import {
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
+import { Progress } from '@backstage/core-components';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -42,21 +43,63 @@ import { resolveAsyncFeatures } from './resolution';
* @public
*/
export interface CreateAppOptions {
- features?: (FrontendFeature | FrontendFeatureLoader)[];
- configLoader?: () => Promise<{ config: ConfigApi }>;
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
/**
- * The component to render while loading the app (waiting for config, features, etc)
- *
- * Is the text "Loading..." by default.
- * If set to "null" then no loading fallback component is rendered. *
+ * The list of features to load.
*/
- loadingComponent?: ReactNode;
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- pluginInfoResolver?: FrontendPluginInfoResolver;
- flags?: { allowUnknownExtensionConfig?: boolean };
+ features?: (FrontendFeature | FrontendFeatureLoader)[];
+
+ /**
+ * Allows for the binding of plugins' external route refs within the app.
+ */
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+
+ /**
+ * Advanced, more rarely used options.
+ */
+ advanced?: {
+ /**
+ * If set to true, the system will silently accept and move on if
+ * encountering config for extensions that do not exist. The default is to
+ * reject such config to help catch simple mistakes.
+ *
+ * This flag can be useful in some scenarios where you have a dynamic set of
+ * extensions enabled at different times, but also increases the risk of
+ * accidentally missing e.g. simple typos in your config.
+ */
+ allowUnknownExtensionConfig?: boolean;
+
+ /**
+ * Sets a custom config loader, replacing the builtin one.
+ *
+ * This can be used e.g. if you have the need to source config out of custom
+ * storages.
+ */
+ configLoader?: () => Promise<{ config: ConfigApi }>;
+
+ /**
+ * Applies one or more middleware on every extension, as they are added to
+ * the application.
+ *
+ * This is an advanced use case for modifying extension data on the fly as
+ * it gets emitted by extensions being instantiated.
+ */
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+
+ /**
+ * The element to render while loading the app (waiting for config, features, etc).
+ *
+ * This is the `` component from `@backstage/core-components` by default.
+ * If set to `null` then no loading fallback element is rendered at all.
+ */
+ loadingElement?: ReactNode;
+
+ /**
+ * Allows for customizing how plugin info is retrieved.
+ */
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
}
/**
@@ -67,14 +110,14 @@ export interface CreateAppOptions {
export function createApp(options?: CreateAppOptions): {
createRoot(): JSX.Element;
} {
- let suspenseFallback = options?.loadingComponent;
+ let suspenseFallback = options?.advanced?.loadingElement;
if (suspenseFallback === undefined) {
- suspenseFallback = 'Loading...';
+ suspenseFallback = ;
}
async function appLoader() {
const config =
- (await options?.configLoader?.().then(c => c.config)) ??
+ (await options?.advanced?.configLoader?.().then(c => c.config)) ??
ConfigReader.fromConfigs(
overrideBaseUrlConfigs(defaultConfigLoaderSync()),
);
@@ -87,12 +130,10 @@ export function createApp(options?: CreateAppOptions): {
});
const app = createSpecializedApp({
- config,
features: [appPlugin, ...loadedFeatures],
+ config,
bindRoutes: options?.bindRoutes,
- extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
- pluginInfoResolver: options?.pluginInfoResolver,
- flags: options?.flags,
+ advanced: options?.advanced,
});
const rootEl = app.tree.root.instance!.getData(
@@ -102,9 +143,10 @@ export function createApp(options?: CreateAppOptions): {
return { default: () => rootEl };
}
+ const LazyApp = lazy(appLoader);
+
return {
createRoot() {
- const LazyApp = lazy(appLoader);
return (
diff --git a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx
index 58a264e81d..7e12499a1c 100644
--- a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx
+++ b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx
@@ -30,7 +30,9 @@ describe('createPublicSignInApp', () => {
it('should render a sign-in page', async () => {
const app = createPublicSignInApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
createFrontendModule({
pluginId: 'app',
@@ -58,7 +60,9 @@ describe('createPublicSignInApp', () => {
.mockReturnValue();
const app = createPublicSignInApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
createFrontendModule({
pluginId: 'app',
diff --git a/packages/frontend-defaults/src/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts
index d1738554a4..b4e595890a 100644
--- a/packages/frontend-defaults/src/discovery.test.ts
+++ b/packages/frontend-defaults/src/discovery.test.ts
@@ -27,7 +27,7 @@ Object.defineProperty(global, '__@backstage/discovered__', {
});
const config = new ConfigReader({
- app: { experimental: { packages: 'all' } },
+ app: { packages: 'all' },
});
describe('discoverAvailableFeatures', () => {
diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts
index 6da6146389..71d831b58c 100644
--- a/packages/frontend-defaults/src/discovery.ts
+++ b/packages/frontend-defaults/src/discovery.ts
@@ -26,7 +26,10 @@ interface DiscoveryGlobal {
}
function readPackageDetectionConfig(config: Config) {
- const packages = config.getOptional('app.experimental.packages');
+ // The experimental key is deprecated, but supported still for backwards compatibility
+ const packages =
+ config.getOptional('app.packages') ??
+ config.getOptional('app.experimental.packages');
if (packages === undefined || packages === null) {
return undefined;
}
@@ -34,21 +37,16 @@ function readPackageDetectionConfig(config: Config) {
if (typeof packages === 'string') {
if (packages !== 'all') {
throw new Error(
- `Invalid app.experimental.packages mode, got '${packages}', expected 'all'`,
+ `Invalid app.packages mode, got '${packages}', expected 'all'`,
);
}
return {};
}
if (typeof packages !== 'object' || Array.isArray(packages)) {
- throw new Error(
- "Invalid config at 'app.experimental.packages', expected object",
- );
+ throw new Error("Invalid config at 'app.packages', expected object");
}
- const packagesConfig = new ConfigReader(
- packages,
- 'app.experimental.packages',
- );
+ const packagesConfig = new ConfigReader(packages, 'app.packages');
return {
include: packagesConfig.getOptionalStringArray('include'),
diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx
index 4b872a5414..b2f6486766 100644
--- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx
+++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx
@@ -127,10 +127,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -204,10 +202,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -330,10 +326,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -447,10 +441,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -540,10 +532,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -604,10 +594,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -653,10 +641,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -759,10 +745,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -889,10 +873,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -1002,10 +984,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -1112,10 +1092,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts
similarity index 53%
rename from packages/frontend-plugin-api/src/components/createComponentRef.test.tsx
rename to packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts
index 9a84fe08fa..2a5da3fa73 100644
--- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx
+++ b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 The Backstage Authors
+ * Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,12 +14,17 @@
* limitations under the License.
*/
-import { createComponentRef } from './createComponentRef';
+import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
+import { OpaqueType } from '@internal/opaque';
-describe('createComponentRef', () => {
- it('can be created and read', () => {
- const ref = createComponentRef({ id: 'foo' });
- expect(ref.id).toBe('foo');
- expect(String(ref)).toBe('ComponentRef{id=foo}');
- });
+export const OpaqueSwappableComponentRef = OpaqueType.create<{
+ public: SwappableComponentRef;
+ versions: {
+ readonly version: 'v1';
+ readonly transformProps?: (props: object) => object;
+ readonly defaultComponent: (props: object) => JSX.Element | null;
+ };
+}>({
+ versions: ['v1'],
+ type: '@backstage/SwappableComponentRef',
});
diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts
index 6a7cf35e27..b61294cb1f 100644
--- a/packages/frontend-internal/src/wiring/index.ts
+++ b/packages/frontend-internal/src/wiring/index.ts
@@ -15,5 +15,6 @@
*/
export { createExtensionDataContainer } from './createExtensionDataContainer';
+export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
export { OpaqueExtensionDefinition } from './InternalExtensionDefinition';
export { OpaqueFrontendPlugin } from './InternalFrontendPlugin';
diff --git a/packages/frontend-plugin-api/CHANGELOG.md b/packages/frontend-plugin-api/CHANGELOG.md
index 4a4202ed81..82e52147ed 100644
--- a/packages/frontend-plugin-api/CHANGELOG.md
+++ b/packages/frontend-plugin-api/CHANGELOG.md
@@ -1,5 +1,113 @@
# @backstage/frontend-plugin-api
+## 0.11.0-next.2
+
+### Minor Changes
+
+- fda1bbc: **BREAKING**: The component system has been overhauled to use `SwappableComponent` instead of `ComponentRef`. Several APIs have been removed and replaced:
+
+ - Removed: `createComponentRef`, `createComponentExtension`, `ComponentRef`, `ComponentsApi`, `componentsApiRef`, `useComponentRef`, `coreComponentRefs`
+ - Added: `createSwappableComponent`, `SwappableComponentBlueprint`, `SwappableComponentRef`, `SwappableComponentsApi`, `swappableComponentsApiRef`
+
+ **BREAKING**: The default `componentRefs` and exported `Core*Props` have been removed and have replacement `SwappableComponents` and revised type names instead.
+
+ - The `errorBoundaryFallback` component and `CoreErrorBoundaryFallbackProps` type have been replaced with `ErrorDisplay` swappable component and `CoreErrorDisplayProps` respectively.
+ - The `progress` component and `CoreProgressProps` type have been replaced with `Progress` swappable component and `ProgressProps` respectively.
+ - The `notFoundErrorPage` component and `CoreNotFoundErrorPageProps` type have been replaced with `NotFoundErrorPage` swappable component and `NotFoundErrorPageProps` respectively.
+
+ **Migration for creating swappable components:**
+
+ ```tsx
+ // OLD: Using createComponentRef and createComponentExtension
+ import {
+ createComponentRef,
+ createComponentExtension,
+ } from '@backstage/frontend-plugin-api';
+
+ const myComponentRef = createComponentRef<{ title: string }>({
+ id: 'my-plugin.my-component',
+ });
+
+ const myComponentExtension = createComponentExtension({
+ ref: myComponentRef,
+ loader: {
+ lazy: () => import('./MyComponent').then(m => m.MyComponent),
+ },
+ });
+
+ // NEW: Using createSwappableComponent and SwappableComponentBlueprint
+ import {
+ createSwappableComponent,
+ SwappableComponentBlueprint,
+ } from '@backstage/frontend-plugin-api';
+
+ const MySwappableComponent = createSwappableComponent({
+ id: 'my-plugin.my-component',
+ loader: () => import('./MyComponent').then(m => m.MyComponent),
+ });
+
+ const myComponentExtension = SwappableComponentBlueprint.make({
+ name: 'my-component',
+ params: {
+ component: MySwappableComponent,
+ loader: () => import('./MyComponent').then(m => m.MyComponent),
+ },
+ });
+ ```
+
+ **Migration for using components:**
+
+ ```tsx
+ // OLD: Using ComponentsApi and useComponentRef
+ import {
+ useComponentRef,
+ componentsApiRef,
+ useApi,
+ coreComponentRefs,
+ } from '@backstage/frontend-plugin-api';
+
+ const MyComponent = useComponentRef(myComponentRef);
+ const ProgressComponent = useComponentRef(coreComponentRefs.progress);
+
+
+ // NEW: Direct component usage
+ import { Progress } from '@backstage/frontend-plugin-api';
+
+ // Use directly as React Component
+
+
+ ```
+
+ **Migration for core component references:**
+
+ ```tsx
+ // OLD: Core component refs
+ import { coreComponentRefs } from '@backstage/frontend-plugin-api';
+
+ coreComponentRefs.progress
+ coreComponentRefs.notFoundErrorPage
+ coreComponentRefs.errorBoundaryFallback
+
+ // NEW: Direct swappable component imports
+ import { Progress, NotFoundErrorPage, ErrorDisplay } from '@backstage/frontend-plugin-api';
+
+ // Use directly as React components
+
+
+
+ ```
+
+- 6a75e00: **BREAKING**: Removed the deprecated `createFrontendPlugin` variant where the plugin ID is passed via an `id` option. To update existing code, switch to using the `pluginId` option instead.
+- 1e6410b: **BREAKING**: The `ResolveInputValueOverrides` type is no longer exported.
+
+### Patch Changes
+
+- 9831f4e: Adjusted the dialog API types to have more sensible defaults
+- 1c2cc37: Improved runtime error message clarity when extension factories don't return an iterable object.
+- 24558f0: Added inline documentation for `createExtension`, `createExtensionBlueprint`, `createFrontendPlugin`, and `createFrontendModule`.
+- Updated dependencies
+ - @backstage/core-components@0.17.5-next.2
+
## 0.11.0-next.1
### Minor Changes
diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json
index 38ac11275e..0e818bc4dc 100644
--- a/packages/frontend-plugin-api/package.json
+++ b/packages/frontend-plugin-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/frontend-plugin-api",
- "version": "0.11.0-next.1",
+ "version": "0.11.0-next.2",
"backstage": {
"role": "web-library"
},
diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md
index ae32fd0f50..469733987e 100644
--- a/packages/frontend-plugin-api/report.api.md
+++ b/packages/frontend-plugin-api/report.api.md
@@ -268,7 +268,7 @@ export interface AppNodeSpec {
// (undocumented)
readonly id: string;
// (undocumented)
- readonly plugin?: FrontendPlugin;
+ readonly plugin: FrontendPlugin;
}
// @public
@@ -350,21 +350,6 @@ export { bitbucketAuthApiRef };
export { bitbucketServerAuthApiRef };
-// @public (undocumented)
-export type ComponentRef = {
- id: string;
- T: T;
-};
-
-// @public
-export interface ComponentsApi {
- // (undocumented)
- getComponent(ref: ComponentRef): ComponentType;
-}
-
-// @public
-export const componentsApiRef: ApiRef;
-
export { ConfigApi };
export { configApiRef };
@@ -389,20 +374,6 @@ export interface ConfigurableExtensionDataRef<
>;
}
-// @public (undocumented)
-export const coreComponentRefs: {
- progress: ComponentRef;
- notFoundErrorPage: ComponentRef;
- errorBoundaryFallback: ComponentRef;
-};
-
-// @public (undocumented)
-export type CoreErrorBoundaryFallbackProps = {
- plugin?: FrontendPlugin;
- error: Error;
- resetError: () => void;
-};
-
// @public (undocumented)
export const coreExtensionData: {
reactElement: ConfigurableExtensionDataRef<
@@ -418,74 +389,11 @@ export const coreExtensionData: {
>;
};
-// @public (undocumented)
-export type CoreNotFoundErrorPageProps = {
- children?: ReactNode;
-};
-
-// @public (undocumented)
-export type CoreProgressProps = {};
-
export { createApiFactory };
export { createApiRef };
-// @public (undocumented)
-export function createComponentExtension(options: {
- ref: ComponentRef;
- name?: string;
- disabled?: boolean;
- loader:
- | {
- lazy: () => Promise>;
- }
- | {
- sync: () => ComponentType;
- };
-}): ExtensionDefinition<{
- config: {};
- configInput: {};
- output: ExtensionDataRef<
- {
- ref: ComponentRef;
- impl: ComponentType;
- },
- 'core.component.component',
- {}
- >;
- inputs: {
- [x: string]: ExtensionInput<
- ExtensionDataRef,
- {
- optional: boolean;
- singleton: boolean;
- }
- >;
- };
- params: never;
- kind: 'component';
- name: string;
-}>;
-
-// @public (undocumented)
-export namespace createComponentExtension {
- const // (undocumented)
- componentDataRef: ConfigurableExtensionDataRef<
- {
- ref: ComponentRef;
- impl: ComponentType;
- },
- 'core.component.component',
- {}
- >;
-}
-
-// @public (undocumented)
-export function createComponentRef(options: {
- id: string;
-}): ComponentRef;
-
-// @public (undocumented)
+// @public
export function createExtension<
UOutput extends ExtensionDataRef,
TInputs extends {
@@ -780,7 +688,7 @@ export interface CreateFrontendFeatureLoaderOptions {
>;
}
-// @public (undocumented)
+// @public
export function createFrontendModule<
TId extends string,
TExtensions extends readonly ExtensionDefinition[] = [],
@@ -799,7 +707,7 @@ export interface CreateFrontendModuleOptions<
pluginId: TPluginId;
}
-// @public (undocumented)
+// @public
export function createFrontendPlugin<
TId extends string,
TRoutes extends {
@@ -849,6 +757,32 @@ export function createSubRouteRef<
parent: RouteRef;
}): MakeSubRouteRef, ParentParams>;
+// @public
+export function createSwappableComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+>(
+ options: CreateSwappableComponentOptions<
+ TInnerComponentProps,
+ TExternalComponentProps
+ >,
+): {
+ (props: TExternalComponentProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public
+export type CreateSwappableComponentOptions<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+> = {
+ id: string;
+ loader?:
+ | (() => (props: TInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
+ transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
+};
+
export { createTranslationMessages };
export { createTranslationRef };
@@ -899,6 +833,19 @@ export { ErrorApiErrorContext };
export { errorApiRef };
+// @public (undocumented)
+export const ErrorDisplay: {
+ (props: ErrorDisplayProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public (undocumented)
+export type ErrorDisplayProps = {
+ plugin?: FrontendPlugin;
+ error: Error;
+ resetError: () => void;
+};
+
// @public (undocumented)
export interface Extension {
// (undocumented)
@@ -994,7 +941,7 @@ export interface ExtensionBlueprint<
: T['params'],
context?: {
config?: T['config'];
- inputs?: ResolveInputValueOverrides>;
+ inputs?: ResolvedInputValueOverrides>;
},
) => ExtensionDataContainer>,
context: {
@@ -1201,7 +1148,7 @@ export type ExtensionDefinition<
context?: Expand<
{
config?: T['config'];
- inputs?: ResolveInputValueOverrides>;
+ inputs?: ResolvedInputValueOverrides>;
} & ([T['params']] extends [never]
? {}
: {
@@ -1554,6 +1501,17 @@ export const NavItemBlueprint: ExtensionBlueprint<{
};
}>;
+// @public (undocumented)
+export const NotFoundErrorPage: {
+ (props: NotFoundErrorPageProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public (undocumented)
+export type NotFoundErrorPageProps = {
+ children?: ReactNode;
+};
+
export { OAuthApi };
export { OAuthRequestApi };
@@ -1638,6 +1596,15 @@ export { ProfileInfo };
export { ProfileInfoApi };
+// @public (undocumented)
+export const Progress: {
+ (props: ProgressProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public (undocumented)
+export type ProgressProps = {};
+
// @public
export type ResolvedExtensionInput<
TExtensionInput extends ExtensionInput,
@@ -1660,73 +1627,6 @@ export type ResolvedExtensionInputs<
: Expand | undefined>;
};
-// @public (undocumented)
-export type ResolveInputValueOverrides<
- TInputs extends {
- [inputName in string]: ExtensionInput<
- ExtensionDataRef,
- {
- optional: boolean;
- singleton: boolean;
- }
- >;
- } = {
- [inputName in string]: ExtensionInput<
- ExtensionDataRef,
- {
- optional: boolean;
- singleton: boolean;
- }
- >;
- },
-> = Expand<
- {
- [KName in keyof TInputs as TInputs[KName] extends ExtensionInput<
- any,
- {
- optional: infer IOptional extends boolean;
- singleton: boolean;
- }
- >
- ? IOptional extends true
- ? never
- : KName
- : never]: TInputs[KName] extends ExtensionInput<
- infer IDataRefs,
- {
- optional: boolean;
- singleton: infer ISingleton extends boolean;
- }
- >
- ? ISingleton extends true
- ? Iterable>
- : Array>>
- : never;
- } & {
- [KName in keyof TInputs as TInputs[KName] extends ExtensionInput<
- any,
- {
- optional: infer IOptional extends boolean;
- singleton: boolean;
- }
- >
- ? IOptional extends true
- ? KName
- : never
- : never]?: TInputs[KName] extends ExtensionInput<
- infer IDataRefs,
- {
- optional: boolean;
- singleton: infer ISingleton extends boolean;
- }
- >
- ? ISingleton extends true
- ? Iterable>
- : Array>>
- : never;
- }
->;
-
// @public
export type RouteFunc = (
...[params]: TParams extends undefined
@@ -1830,6 +1730,90 @@ export interface SubRouteRef<
readonly T: TParams;
}
+// @public
+export const SwappableComponentBlueprint: ExtensionBlueprint<{
+ kind: 'component';
+ params: [>(params: {
+ component: Ref extends SwappableComponentRef<
+ any,
+ infer IExternalComponentProps
+ >
+ ? {
+ ref: Ref;
+ } & ((props: IExternalComponentProps) => JSX.Element | null)
+ : never;
+ loader: Ref extends SwappableComponentRef
+ ?
+ | (() => (props: IInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
+ : never;
+ }) => ExtensionBlueprintParams<{
+ component: Ref extends SwappableComponentRef<
+ any,
+ infer IExternalComponentProps
+ >
+ ? {
+ ref: Ref;
+ } & ((props: IExternalComponentProps) => JSX.Element | null)
+ : never;
+ loader: Ref extends SwappableComponentRef
+ ?
+ | (() => (props: IInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
+ : never;
+ }>;
+ output: ExtensionDataRef<
+ {
+ ref: SwappableComponentRef;
+ loader:
+ | (() => (props: {}) => JSX.Element | null)
+ | (() => Promise<(props: {}) => JSX.Element | null>);
+ },
+ 'core.swappableComponent',
+ {}
+ >;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: {
+ component: ConfigurableExtensionDataRef<
+ {
+ ref: SwappableComponentRef;
+ loader:
+ | (() => (props: {}) => JSX.Element | null)
+ | (() => Promise<(props: {}) => JSX.Element | null>);
+ },
+ 'core.swappableComponent',
+ {}
+ >;
+ };
+}>;
+
+// @public (undocumented)
+export type SwappableComponentRef<
+ TInnerComponentProps extends {} = {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+> = {
+ id: string;
+ TProps: TInnerComponentProps;
+ TExternalProps: TExternalComponentProps;
+ $$type: '@backstage/SwappableComponentRef';
+};
+
+// @public
+export interface SwappableComponentsApi {
+ // (undocumented)
+ getComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+ >(
+ ref: SwappableComponentRef,
+ ): (props: TInnerComponentProps) => JSX.Element | null;
+}
+
+// @public
+export const swappableComponentsApiRef: ApiRef;
+
// @public
export const ThemeBlueprint: ExtensionBlueprint<{
kind: 'theme';
@@ -1906,11 +1890,6 @@ export { useApiHolder };
// @public
export function useAppNode(): AppNode | undefined;
-// @public
-export function useComponentRef(
- ref: ComponentRef,
-): ComponentType;
-
// @public
export function useRouteRef(
routeRef:
diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
index 3ce55e1525..f4fd2f3a6f 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
@@ -37,7 +37,7 @@ export interface AppNodeSpec {
readonly extension: Extension;
readonly disabled: boolean;
readonly config?: unknown;
- readonly plugin?: FrontendPlugin;
+ readonly plugin: FrontendPlugin;
}
/**
diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts
deleted file mode 100644
index c5e9d2c44f..0000000000
--- a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts
+++ /dev/null
@@ -1,49 +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 { ComponentType } from 'react';
-import { createApiRef, useApi } from '@backstage/core-plugin-api';
-import { ComponentRef } from '../../components';
-
-/**
- * API for looking up components based on component refs.
- *
- * @public
- */
-export interface ComponentsApi {
- // TODO: Should component refs also provide the default implementation so that we're guaranteed to get a component?
- getComponent(ref: ComponentRef): ComponentType;
-}
-
-/**
- * The `ApiRef` of {@link ComponentsApi}.
- *
- * @public
- */
-export const componentsApiRef = createApiRef({
- id: 'core.components',
-});
-
-/**
- * @public
- * Returns the component associated with the given ref.
- */
-export function useComponentRef(
- ref: ComponentRef,
-): ComponentType {
- const componentsApi = useApi(componentsApiRef);
- return componentsApi.getComponent(ref);
-}
diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts
new file mode 100644
index 0000000000..ed2b20ce80
--- /dev/null
+++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { SwappableComponentRef } from '../../components';
+import { createApiRef } from '@backstage/core-plugin-api';
+
+/**
+ * API for looking up components based on component refs.
+ *
+ * @public
+ */
+export interface SwappableComponentsApi {
+ getComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+ >(
+ ref: SwappableComponentRef,
+ ): (props: TInnerComponentProps) => JSX.Element | null;
+}
+
+/**
+ * The `ApiRef` of {@link SwappableComponentsApi}.
+ *
+ * @public
+ */
+export const swappableComponentsApiRef = createApiRef({
+ id: 'core.swappable-components',
+});
diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts
index 0da23fcacb..7533481d01 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/index.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts
@@ -34,7 +34,7 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
-export * from './ComponentsApi';
+export * from './SwappableComponentsApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx
new file mode 100644
index 0000000000..fae4bf7ebf
--- /dev/null
+++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { renderInTestApp } from '@backstage/frontend-test-utils';
+import { createSwappableComponent } from '../components';
+import { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
+import { PageBlueprint } from './PageBlueprint';
+import { waitFor, screen } from '@testing-library/react';
+
+describe('SwappableComponentBlueprint', () => {
+ it('should allow defining a component override for a component ref', () => {
+ const Component = createSwappableComponent({
+ id: 'test.component',
+ loader: () => (props: { hello: string }) => ]{props.hello}
,
+ });
+
+ const extension = SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: Component,
+ loader: () => props => {
+ // @ts-expect-error
+ const t: number = props.hello;
+
+ return Override {props.hello}
;
+ },
+ }),
+ });
+
+ expect(extension).toBeDefined();
+ });
+
+ it('should render default component refs in the app', async () => {
+ const TestComponent = createSwappableComponent({
+ id: 'test.component',
+ loader: () => (props: { hello: string }) => {props.hello}
,
+ });
+
+ renderInTestApp(, {
+ extensions: [
+ PageBlueprint.make({
+ params: define =>
+ define({
+ // todo(blam): there's a bug that this path cannot be `/`?
+ path: '/test',
+ loader: async () => ,
+ }),
+ }),
+ ],
+ initialRouteEntries: ['/test'],
+ });
+
+ await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
+ });
+
+ it('should render a component ref without a default implementation', async () => {
+ const TestComponent = createSwappableComponent({
+ id: 'test.component',
+ });
+
+ renderInTestApp(, {
+ extensions: [
+ PageBlueprint.make({
+ params: define =>
+ define({
+ path: '/test',
+ loader: async () => ,
+ }),
+ }),
+ ],
+ initialRouteEntries: ['/test'],
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('test.component')).toBeInTheDocument(),
+ );
+ });
+
+ it('should render a component ref with an async loader implementation', async () => {
+ const TestComponent = createSwappableComponent({
+ id: 'test.component',
+ loader: async () => (props: { hello: string }) =>
+ {props.hello}
,
+ });
+
+ renderInTestApp(, {
+ extensions: [
+ PageBlueprint.make({
+ params: define =>
+ define({
+ // todo(blam): there's a bug that this path cannot be `/`?
+ path: '/test',
+ loader: async () => ,
+ }),
+ }),
+ ],
+ initialRouteEntries: ['/test'],
+ });
+
+ await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
+ });
+
+ it('should render a component ref with an async loader implementation and prop transform', async () => {
+ const TestComponent = createSwappableComponent({
+ id: 'test.component',
+ loader: async () => (props: { hello: string }) =>
+ {props.hello}
,
+ transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
+ });
+
+ renderInTestApp(, {
+ extensions: [
+ PageBlueprint.make({
+ params: define =>
+ define({
+ // todo(blam): there's a bug that this path cannot be `/`?
+ path: '/test',
+ loader: async () => ,
+ }),
+ }),
+ ],
+ initialRouteEntries: ['/test'],
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('tr test!')).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts
new file mode 100644
index 0000000000..b72d14d87f
--- /dev/null
+++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { SwappableComponentRef } from '../components';
+import {
+ createExtensionBlueprint,
+ createExtensionBlueprintParams,
+ createExtensionDataRef,
+} from '../wiring';
+
+export const componentDataRef = createExtensionDataRef<{
+ ref: SwappableComponentRef;
+ loader:
+ | (() => (props: {}) => JSX.Element | null)
+ | (() => Promise<(props: {}) => JSX.Element | null>);
+}>().with({ id: 'core.swappableComponent' });
+
+/**
+ * Blueprint for creating swappable components from a SwappableComponentRef and a loader
+ *
+ * @public
+ */
+export const SwappableComponentBlueprint = createExtensionBlueprint({
+ kind: 'component',
+ attachTo: { id: 'api:app/swappable-components', input: 'components' },
+ output: [componentDataRef],
+ dataRefs: {
+ component: componentDataRef,
+ },
+ defineParams[>(params: {
+ component: Ref extends SwappableComponentRef<
+ any,
+ infer IExternalComponentProps
+ >
+ ? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null)
+ : never;
+ loader: Ref extends SwappableComponentRef
+ ?
+ | (() => (props: IInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
+ : never;
+ }) {
+ return createExtensionBlueprintParams(params);
+ },
+ factory: params => [
+ componentDataRef({
+ ref: params.component.ref,
+ loader: params.loader,
+ }),
+ ],
+});
diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts
index 0060571ca2..c8699b4232 100644
--- a/packages/frontend-plugin-api/src/blueprints/index.ts
+++ b/packages/frontend-plugin-api/src/blueprints/index.ts
@@ -33,3 +33,4 @@ export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
export { ThemeBlueprint } from './ThemeBlueprint';
export { TranslationBlueprint } from './TranslationBlueprint';
+export { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx
new file mode 100644
index 0000000000..7387bfad3c
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ ErrorDisplayProps,
+ NotFoundErrorPageProps,
+ ProgressProps,
+} from '../types';
+import { createSwappableComponent } from './createSwappableComponent';
+
+/**
+ * @public
+ */
+export const Progress = createSwappableComponent({
+ id: 'core-progress',
+});
+
+/**
+ * @public
+ */
+export const NotFoundErrorPage =
+ createSwappableComponent({
+ id: 'core-not-found-error-page',
+ });
+
+/**
+ * @public
+ */
+export const ErrorDisplay = createSwappableComponent({
+ id: 'core-error-display',
+ loader: () => props =>
+ ]{props.error.message}
,
+});
diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
index 8842ff55cc..4cde84ee93 100644
--- a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
@@ -14,13 +14,12 @@
* limitations under the License.
*/
-import { Component, ComponentType, PropsWithChildren } from 'react';
+import { Component, PropsWithChildren } from 'react';
import { FrontendPlugin } from '../wiring';
-import { CoreErrorBoundaryFallbackProps } from '../types';
+import { ErrorDisplay } from './DefaultSwappableComponents';
type ErrorBoundaryProps = PropsWithChildren<{
plugin?: FrontendPlugin;
- Fallback: ComponentType;
}>;
type ErrorBoundaryState = { error?: Error };
@@ -41,13 +40,15 @@ export class ErrorBoundary extends Component<
render() {
const { error } = this.state;
- const { plugin, children, Fallback } = this.props;
+ const { plugin, children } = this.props;
if (error) {
return (
-
);
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
index f653fa1742..848ea64a2c 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
@@ -25,10 +25,10 @@ import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import { ErrorBoundary } from './ErrorBoundary';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
-import { AppNode, useComponentRef } from '../apis';
-import { coreComponentRefs } from './coreComponentRefs';
+import { AppNode } from '../apis';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
+import { Progress } from './DefaultSwappableComponents';
type RouteTrackerProps = PropsWithChildren<{
enabled?: boolean;
@@ -66,8 +66,6 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
);
const plugin = node.spec.plugin;
- const Progress = useComponentRef(coreComponentRefs.progress);
- const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback);
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
const attributes = {
@@ -78,7 +76,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
return (
}>
-
+
{children}
diff --git a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts
deleted file mode 100644
index 64412b8710..0000000000
--- a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts
+++ /dev/null
@@ -1,43 +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 {
- CoreErrorBoundaryFallbackProps,
- CoreNotFoundErrorPageProps,
- CoreProgressProps,
-} from '../types';
-import { createComponentRef } from './createComponentRef';
-
-const coreProgressComponentRef = createComponentRef({
- id: 'core.components.progress',
-});
-
-const coreNotFoundErrorPageComponentRef =
- createComponentRef({
- id: 'core.components.notFoundErrorPage',
- });
-
-const coreErrorBoundaryFallbackComponentRef =
- createComponentRef({
- id: 'core.components.errorBoundaryFallback',
- });
-
-/** @public */
-export const coreComponentRefs = {
- progress: coreProgressComponentRef,
- notFoundErrorPage: coreNotFoundErrorPageComponentRef,
- errorBoundaryFallback: coreErrorBoundaryFallbackComponentRef,
-};
diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx
deleted file mode 100644
index 88181dd079..0000000000
--- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx
+++ /dev/null
@@ -1,34 +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.
- */
-
-/** @public */
-export type ComponentRef = {
- id: string;
- T: T;
-};
-
-/** @public */
-export function createComponentRef(options: {
- id: string;
-}): ComponentRef {
- const { id } = options;
- return {
- id,
- toString() {
- return `ComponentRef{id=${id}}`;
- },
- } as ComponentRef;
-}
diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx
new file mode 100644
index 0000000000..2a73a4da42
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx
@@ -0,0 +1,163 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+import { createSwappableComponent } from './createSwappableComponent';
+
+describe('createSwappableComponent', () => {
+ it('can be created and read', () => {
+ const { ref } = createSwappableComponent({ id: 'foo' });
+ expect(ref.id).toBe('foo');
+ expect(String(ref)).toBe('SwappableComponentRef{id=foo}');
+ });
+
+ it('should allow defining a default component implementation', () => {
+ const Test = () => test
;
+
+ createSwappableComponent<{ foo: string }, { bar: string }>({
+ id: 'foo',
+ loader:
+ () =>
+ ({ foo }) =>
+ ,
+ });
+
+ createSwappableComponent<{ foo: string }, { bar: string }>({
+ id: 'foo',
+ loader:
+ async () =>
+ ({ foo }) =>
+ ,
+ });
+
+ createSwappableComponent<{ foo: string }, { bar: string }>({
+ id: 'foo',
+ });
+
+ expect(Test).toBeDefined();
+ });
+
+ it('should allow transformings props', () => {
+ createSwappableComponent<{ foo: string }, { bar: string }>({
+ id: 'foo',
+ transformProps: props => ({ foo: props.bar }),
+ });
+
+ createSwappableComponent<{ foo: string }, { bar: string }>({
+ id: 'foo',
+ // @ts-expect-error - this should be an error as foo is not a string
+ transformProps: props => ({ foo: 1 }),
+ });
+
+ expect(true).toBe(true);
+ });
+
+ describe('sync', () => {
+ it('should create a component from a ref for sync component', async () => {
+ const Component = createSwappableComponent({
+ id: 'random',
+ loader: () => (props: { name: string }) => {
+ return {props.name}
;
+ },
+ transformProps: (props: { id: string }) => ({
+ name: props.id,
+ }),
+ });
+
+ render();
+
+ await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
+ 'test',
+ );
+ });
+
+ it('should render a fallback when theres no default implementation provided', async () => {
+ const Component = createSwappableComponent({
+ id: 'random',
+ });
+
+ render();
+ await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
+ });
+
+ it('should map props from external to internal', async () => {
+ const Component = createSwappableComponent({
+ id: 'random',
+ transformProps: (props: { name: string }) => ({
+ uppercase: props.name.toUpperCase(),
+ }),
+ loader: () => props => {
+ // @ts-expect-error as uppercase is types as a string
+ const test: number = props.uppercase;
+
+ return {props.uppercase}
;
+ },
+ });
+
+ render();
+
+ await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
+ 'TEST',
+ );
+ });
+ });
+
+ describe('async', () => {
+ it('should create a component from a ref for async component', async () => {
+ const Component = createSwappableComponent({
+ id: 'random',
+ loader: async () => (props: { name: string }) => {
+ return {props.name}
;
+ },
+ });
+
+ render();
+
+ await expect(screen.findByTestId('test')).resolves.toBeInTheDocument();
+ });
+
+ it('should render a fallback when theres no default implementation provided', async () => {
+ const Component = createSwappableComponent({
+ id: 'random',
+ });
+
+ render();
+
+ await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
+ });
+
+ it('should map props from external to internal', async () => {
+ const Component = createSwappableComponent({
+ id: 'random',
+ transformProps: (props: { name: string }) => ({
+ uppercase: props.name.toUpperCase(),
+ }),
+ loader: async () => props => {
+ // @ts-expect-error as uppercase is types as a string
+ const test: number = props.uppercase;
+
+ return {props.uppercase}
;
+ },
+ });
+
+ render();
+
+ await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
+ 'TEST',
+ );
+ });
+ });
+});
diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx
new file mode 100644
index 0000000000..716837f469
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx
@@ -0,0 +1,112 @@
+/*
+ * 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 { OpaqueSwappableComponentRef } from '@internal/frontend';
+import { swappableComponentsApiRef, useApi } from '../apis';
+import { lazy } from 'react';
+
+/** @public */
+export type SwappableComponentRef<
+ TInnerComponentProps extends {} = {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+> = {
+ id: string;
+ TProps: TInnerComponentProps;
+ TExternalProps: TExternalComponentProps;
+ $$type: '@backstage/SwappableComponentRef';
+};
+
+/**
+ * Options for creating an SwappableComponent.
+ *
+ * @public
+ */
+export type CreateSwappableComponentOptions<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+> = {
+ id: string;
+ loader?:
+ | (() => (props: TInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
+ transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
+};
+
+const useComponentRefApi = () => {
+ try {
+ return useApi(swappableComponentsApiRef);
+ } catch (e) {
+ return undefined;
+ }
+};
+
+/**
+ * Creates a SwappableComponent that can be used to render the component, optionally overridden by the app.
+ *
+ * @public
+ */
+export function createSwappableComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+>(
+ options: CreateSwappableComponentOptions<
+ TInnerComponentProps,
+ TExternalComponentProps
+ >,
+): {
+ (props: TExternalComponentProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+} {
+ const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
+
+ );
+
+ const ref = OpaqueSwappableComponentRef.createInstance('v1', {
+ id: options.id,
+ TProps: null as unknown as TInnerComponentProps,
+ TExternalProps: null as unknown as TExternalComponentProps,
+ toString() {
+ return `SwappableComponentRef{id=${options.id}}`;
+ },
+ defaultComponent: lazy(async () => {
+ const Component = (await options.loader?.()) ?? FallbackComponent;
+ return { default: Component };
+ }) as (typeof OpaqueSwappableComponentRef.TInternal)['defaultComponent'],
+ transformProps:
+ options.transformProps as (typeof OpaqueSwappableComponentRef.TInternal)['transformProps'],
+ });
+
+ const ComponentRefImpl = (props: TExternalComponentProps) => {
+ const api = useComponentRefApi();
+
+ if (!api) {
+ const internalRef = OpaqueSwappableComponentRef.toInternal(ref);
+ const Component = internalRef.defaultComponent;
+ const innerProps = internalRef.transformProps?.(props) ?? props;
+ return ;
+ }
+
+ const Component = api.getComponent(ref);
+ return ;
+ };
+
+ Object.assign(ComponentRefImpl, { ref });
+
+ return ComponentRefImpl as {
+ (props: TExternalComponentProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+ };
+}
diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts
index 31a53be24c..450224bdc4 100644
--- a/packages/frontend-plugin-api/src/components/index.ts
+++ b/packages/frontend-plugin-api/src/components/index.ts
@@ -18,6 +18,10 @@ export {
ExtensionBoundary,
type ExtensionBoundaryProps,
} from './ExtensionBoundary';
-export { coreComponentRefs } from './coreComponentRefs';
-export { createComponentRef, type ComponentRef } from './createComponentRef';
+export {
+ createSwappableComponent,
+ type CreateSwappableComponentOptions,
+ type SwappableComponentRef,
+} from './createSwappableComponent';
export { useAppNode } from './AppNodeProvider';
+export * from './DefaultSwappableComponents';
diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx
deleted file mode 100644
index f561a73dc6..0000000000
--- a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx
+++ /dev/null
@@ -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 { lazy, ComponentType } from 'react';
-import { createExtension, createExtensionDataRef } from '../wiring';
-import { ComponentRef } from '../components';
-
-/** @public */
-export function createComponentExtension(options: {
- ref: ComponentRef;
- name?: string;
- disabled?: boolean;
- loader:
- | {
- lazy: () => Promise>;
- }
- | {
- sync: () => ComponentType;
- };
-}) {
- return createExtension({
- kind: 'component',
- name: options.name ?? options.ref.id,
- attachTo: { id: 'api:app/components', input: 'components' },
- disabled: options.disabled,
- output: [createComponentExtension.componentDataRef],
- factory() {
- if ('sync' in options.loader) {
- return [
- createComponentExtension.componentDataRef({
- ref: options.ref,
- impl: options.loader.sync() as ComponentType,
- }),
- ];
- }
- const lazyLoader = options.loader.lazy;
- const ExtensionComponent = lazy(() =>
- lazyLoader().then(Component => ({
- default: Component,
- })),
- ) as unknown as ComponentType;
-
- return [
- createComponentExtension.componentDataRef({
- ref: options.ref,
- impl: ExtensionComponent,
- }),
- ];
- },
- });
-}
-
-/** @public */
-export namespace createComponentExtension {
- export const componentDataRef = createExtensionDataRef<{
- ref: ComponentRef;
- impl: ComponentType;
- }>().with({ id: 'core.component.component' });
-}
diff --git a/packages/frontend-plugin-api/src/extensions/index.ts b/packages/frontend-plugin-api/src/extensions/index.ts
deleted file mode 100644
index f75c7474ac..0000000000
--- a/packages/frontend-plugin-api/src/extensions/index.ts
+++ /dev/null
@@ -1,17 +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.
- */
-
-export { createComponentExtension } from './createComponentExtension';
diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts
index 957ca50bba..9117dc0ef9 100644
--- a/packages/frontend-plugin-api/src/index.ts
+++ b/packages/frontend-plugin-api/src/index.ts
@@ -24,7 +24,6 @@ export * from './analytics';
export * from './apis';
export * from './blueprints';
export * from './components';
-export * from './extensions';
export * from './icons';
export * from './routing';
export * from './schema';
@@ -33,7 +32,7 @@ export * from './translation';
export * from './wiring';
export type {
- CoreProgressProps,
- CoreNotFoundErrorPageProps,
- CoreErrorBoundaryFallbackProps,
+ ProgressProps,
+ NotFoundErrorPageProps,
+ ErrorDisplayProps,
} from './types';
diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts
index 9088a9d772..a8ddf43cad 100644
--- a/packages/frontend-plugin-api/src/types.ts
+++ b/packages/frontend-plugin-api/src/types.ts
@@ -18,15 +18,15 @@ import { ReactNode } from 'react';
import { FrontendPlugin } from './wiring';
/** @public */
-export type CoreProgressProps = {};
+export type ProgressProps = {};
/** @public */
-export type CoreNotFoundErrorPageProps = {
+export type NotFoundErrorPageProps = {
children?: ReactNode;
};
/** @public */
-export type CoreErrorBoundaryFallbackProps = {
+export type ErrorDisplayProps = {
plugin?: FrontendPlugin;
error: Error;
resetError: () => void;
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index 8a5cb9e8f2..0c0b75a572 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -17,7 +17,7 @@
import { ApiHolder, AppNode } from '../apis';
import { Expand } from '@backstage/types';
import {
- ResolveInputValueOverrides,
+ ResolvedInputValueOverrides,
resolveInputOverrides,
} from './resolveInputOverrides';
import { createExtensionDataContainer } from '@internal/frontend';
@@ -27,7 +27,12 @@ import { z } from 'zod';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { ExtensionDataContainer } from './types';
-import { ExtensionBlueprintDefineParams } from './createExtensionBlueprint';
+import {
+ ExtensionBlueprint,
+ ExtensionBlueprintDefineParams,
+} from './createExtensionBlueprint';
+import { FrontendPlugin } from './createFrontendPlugin';
+import { FrontendModule } from './createFrontendModule';
/**
* This symbol is used to pass parameter overrides from the extension override to the blueprint factory
@@ -221,7 +226,7 @@ export type ExtensionDefinition<
context?: Expand<
{
config?: T['config'];
- inputs?: ResolveInputValueOverrides>;
+ inputs?: ResolvedInputValueOverrides>;
} & ([T['params']] extends [never]
? {}
: {
@@ -281,7 +286,41 @@ export type ExtensionDefinition<
}>;
};
-/** @public */
+/**
+ * Creates a new extension definition for installation in a Backstage app.
+ *
+ * @remarks
+ *
+ * This is a low-level function for creation of extensions with arbitrary inputs
+ * and outputs and is typically only intended to be used for advanced overrides
+ * or framework-level extensions. For most extension creation needs, it is
+ * recommended to use existing {@link ExtensionBlueprint}s instead. You can find
+ * blueprints both in the `@backstage/frontend-plugin-api` package as well as
+ * other plugin libraries. There is also a list of
+ * {@link https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints | commonly used blueprints}
+ * in the frontend system documentation.
+ *
+ * Extension definitions that are created with this function can be installed in
+ * a Backstage app via a {@link FrontendPlugin} or {@link FrontendModule}.
+ *
+ * For more details on how extensions work, see the
+ * {@link https://backstage.io/docs/frontend-system/architecture/extensions | documentation for extensions}.
+ *
+ * @example
+ *
+ * ```ts
+ * const myExtension = createExtension({
+ * name: 'example',
+ * attachTo: { id: 'app', input: 'root' },
+ * output: [coreExtensionData.reactElement],
+ * factory() {
+ * return [coreExtensionData.reactElement(Hello, world!
)];
+ * },
+ * });
+ * ```
+ *
+ * @public
+ */
export function createExtension<
UOutput extends ExtensionDataRef,
TInputs extends {
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
index f94551755f..1a24eed712 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
@@ -30,10 +30,11 @@ import { ExtensionInput } from './createExtensionInput';
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
import { createExtensionDataContainer } from '@internal/frontend';
import {
- ResolveInputValueOverrides,
+ ResolvedInputValueOverrides,
resolveInputOverrides,
} from './resolveInputOverrides';
import { ExtensionDataContainer } from './types';
+import { PageBlueprint } from '../blueprints/PageBlueprint';
/**
* A function used to define a parameter mapping function in order to facilitate
@@ -214,7 +215,6 @@ type AnyParamsInput =
* @public
*/
export interface ExtensionBlueprint<
- // TParamsMapper extends (params: any) => object,
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
> {
dataRefs: T['dataRefs'];
@@ -286,7 +286,7 @@ export interface ExtensionBlueprint<
: T['params'],
context?: {
config?: T['config'];
- inputs?: ResolveInputValueOverrides>;
+ inputs?: ResolvedInputValueOverrides>;
},
) => ExtensionDataContainer>,
context: {
@@ -404,9 +404,52 @@ function unwrapParams(
}
/**
- * A simpler replacement for wrapping up `createExtension` inside a kind or type. This allows for a cleaner API for creating
- * types and instances of those types.
+ * Creates a new extension blueprint that encapsulates the creation of
+ * extensions of particular kinds.
*
+ * @remarks
+ *
+ * For details on how blueprints work, see the
+ * {@link https://backstage.io/docs/frontend-system/architecture/extension-blueprints | documentation for extension blueprints}
+ * in the frontend system documentation.
+ *
+ * Extension blueprints make it much easier for users to create new extensions
+ * for your plugin. Rather than letting them use {@link createExtension}
+ * directly, you can define a set of parameters and default factory for your
+ * blueprint, removing a lot of the boilerplate and complexity that is otherwise
+ * needed to create an extension.
+ *
+ * Each blueprint has its own `kind` that helps identify and group the
+ * extensions that have been created with it. For example the
+ * {@link PageBlueprint} has the kind `'page'`, and extensions created with it
+ * will be given the ID `'page:[/]'`. Blueprints should always
+ * be exported as `Blueprint`.
+ *
+ * When creating a blueprint the type of the parameters are inferred from the
+ * `factory` function that you provide. The exception to that is when you need
+ * your blueprint to include inferred type parameters, in which case you need to
+ * use the `defineParams` option. See the documentation for the `defineParams`
+ * option for more details on how that works.
+ *
+ * @example
+ * ```tsx
+ * // In your plugin library
+ * export const GreetingBlueprint = createExtensionBlueprint({
+ * kind: 'greeting',
+ * attachTo: { id: 'example', input: 'greetings' },
+ * output: [coreExtensionData.reactElement],
+ * factory(params: { greeting: string }) {
+ * return [coreExtensionData.reactElement({params.greeting}
)];
+ * },
+ * });
+ *
+ * // Someone using your blueprint in their plugin
+ * const exampleGreeting = GreetingBlueprint.make({
+ * params: {
+ * greeting: 'Hello, world!',
+ * },
+ * });
+ * ```
* @public
*/
export function createExtensionBlueprint<
diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts
index 63ce4cb284..50ea0749fd 100644
--- a/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts
@@ -46,7 +46,9 @@ function createTestAppRoot({
}) {
return createApp({
features: [...features],
- configLoader: async () => ({ config: mockApis.config({ data: config }) }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config({ data: config }) }),
+ },
}).createRoot();
}
diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts
index bece1539fa..7921f98635 100644
--- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts
+++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts
@@ -45,7 +45,45 @@ export interface InternalFrontendModule extends FrontendModule {
readonly featureFlags: FeatureFlagConfig[];
}
-/** @public */
+/**
+ * Creates a new module that can be installed in a Backstage app.
+ *
+ * @remarks
+ *
+ * Modules are used to add or override extensions for an existing plugin. If a
+ * module provides an extension with the same ID as one provided by the plugin,
+ * the extension provided by the module will always take precedence.
+ *
+ * Every module is created for a specific plugin by providing the
+ * unique ID of the plugin that the module should be installed for. If that
+ * plugin is not present in the app, the module will be ignored and have no
+ * effect.
+ *
+ * For more information on how modules work, see the
+ * {@link https://backstage.io/docs/frontend-system/architecture/extension-overrides#creating-a-frontend-module | documentation for modules}
+ * in the frontend system documentation.
+ *
+ * It is recommended to name the module variable of the form `Module`.
+ *
+ * @example
+ *
+ * ```tsx
+ * import { createFrontendModule } from '@backstage/frontend-plugin-api';
+ *
+ * export const exampleModuleCustomPage = createFrontendModule({
+ * pluginId: 'example',
+ * extensions: [
+ * // Overrides the default page for the 'example' plugin
+ * PageBlueprint.make({
+ * path: '/example',
+ * loader: () => import('./CustomPage').then(m => ),
+ * }),
+ * ],
+ * });
+ * ```
+ *
+ * @public
+ */
export function createFrontendModule<
TId extends string,
TExtensions extends readonly ExtensionDefinition[] = [],
diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts
index 46709c9e56..a3226721a1 100644
--- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts
@@ -128,7 +128,9 @@ function createTestAppRoot({
}) {
return createApp({
features: [...features],
- configLoader: async () => ({ config: mockApis.config({ data: config }) }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config({ data: config }) }),
+ },
}).createRoot();
}
diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts
index f0d923d3f9..f545195e81 100644
--- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts
+++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts
@@ -135,7 +135,36 @@ export interface PluginOptions<
info?: FrontendPluginInfoOptions;
}
-/** @public */
+/**
+ * Creates a new plugin that can be installed in a Backstage app.
+ *
+ * @remarks
+ *
+ * Every plugin is created with a unique ID and a set of extensions
+ * that are installed as part of the plugin.
+ *
+ * For more information on how plugins work, see the
+ * {@link https://backstage.io/docs/frontend-system/building-plugins/index | documentation for plugins}
+ * in the frontend system documentation.
+ *
+ * @example
+ *
+ * ```tsx
+ * import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
+ *
+ * export const examplePlugin = createFrontendPlugin({
+ * pluginId: 'example',
+ * extensions: [
+ * PageBlueprint.make({
+ * path: '/example',
+ * loader: () => import('./ExamplePage').then(m => ),
+ * }),
+ * ],
+ * });
+ * ```
+ *
+ * @public
+ */
export function createFrontendPlugin<
TId extends string,
TRoutes extends { [name in string]: RouteRef | SubRouteRef } = {},
diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts
index b2e65bee90..ec6f8c4ffa 100644
--- a/packages/frontend-plugin-api/src/wiring/index.ts
+++ b/packages/frontend-plugin-api/src/wiring/index.ts
@@ -69,4 +69,3 @@ export {
createExtensionBlueprint,
createExtensionBlueprintParams,
} from './createExtensionBlueprint';
-export { type ResolveInputValueOverrides } from './resolveInputOverrides';
diff --git a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts
index 9a2d54db9c..8ffec893ee 100644
--- a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts
+++ b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts
@@ -26,8 +26,8 @@ import {
import { ExtensionInput } from './createExtensionInput';
import { ExtensionDataContainer } from './types';
-/** @public */
-export type ResolveInputValueOverrides<
+/** @ignore */
+export type ResolvedInputValueOverrides<
TInputs extends {
[inputName in string]: ExtensionInput<
ExtensionDataRef,
@@ -101,7 +101,7 @@ export function resolveInputOverrides(
| ({ node: AppNode } & ExtensionDataContainer)
| Array<{ node: AppNode } & ExtensionDataContainer>;
},
- inputOverrides?: ResolveInputValueOverrides,
+ inputOverrides?: ResolvedInputValueOverrides,
) {
if (!declaredInputs || !inputs || !inputOverrides) {
return inputs;
diff --git a/packages/repo-tools/CHANGELOG.md b/packages/repo-tools/CHANGELOG.md
index 2b63bc1a48..49ee1bc1dc 100644
--- a/packages/repo-tools/CHANGELOG.md
+++ b/packages/repo-tools/CHANGELOG.md
@@ -1,5 +1,11 @@
# @backstage/repo-tools
+## 0.15.1-next.1
+
+### Patch Changes
+
+- 33060b5: Removed build-in ignore of the `packages/canon` package for knip reports.
+
## 0.15.1-next.0
### Patch Changes
diff --git a/packages/repo-tools/package.json b/packages/repo-tools/package.json
index 490a375192..1c38bed59c 100644
--- a/packages/repo-tools/package.json
+++ b/packages/repo-tools/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/repo-tools",
- "version": "0.15.1-next.0",
+ "version": "0.15.1-next.1",
"description": "CLI for Backstage repo tooling ",
"backstage": {
"role": "cli"
diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts
deleted file mode 100644
index 262862c657..0000000000
--- a/packages/ui/.storybook/main.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { StorybookConfig } from '@storybook/react-webpack5';
-import { join, dirname } from 'path';
-
-/**
- * This function is used to resolve the absolute path of a package.
- * It is needed in projects that use Yarn PnP or are set up within a monorepo.
- */
-function getAbsolutePath(value: string): string {
- return dirname(require.resolve(join(value, 'package.json')));
-}
-const config: StorybookConfig = {
- stories: ['../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
- staticDirs: ['../static'],
- addons: [
- getAbsolutePath('@storybook/addon-webpack5-compiler-swc'),
- getAbsolutePath('@storybook/addon-essentials'),
- getAbsolutePath('@storybook/addon-interactions'),
- ],
- framework: {
- name: getAbsolutePath('@storybook/react-webpack5'),
- options: {},
- },
- // https://storybook.js.org/docs/configure/integration/compilers#the-swc-compiler-doesnt-work-with-react
- swc: () => ({
- jsc: {
- transform: {
- react: {
- runtime: 'automatic',
- },
- },
- },
- }),
-};
-export default config;
diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx
deleted file mode 100644
index 14d86f9506..0000000000
--- a/packages/ui/.storybook/preview.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import React, { useEffect } from 'react';
-import type { Decorator, Preview, ReactRenderer } from '@storybook/react';
-import { useGlobals } from '@storybook/preview-api';
-
-// Default Backstage theme
-import '../src/css/styles.css';
-
-// Custom mocked styles
-import '../src/css/storybook.css';
-
-// Custom themes
-import './themes/spotify.css';
-
-export const withThemes: Decorator = StoryFn => {
- const [globals] = useGlobals();
- const selectedTheme = globals.themeMode || 'light';
- const selectedThemeName = globals.themeName || 'default';
-
- useEffect(() => {
- const htmlElement = document.documentElement;
-
- // Remove any existing theme data attributes
- htmlElement.removeAttribute('data-theme');
- htmlElement.removeAttribute('data-theme-name');
-
- // Add the selected theme data attribute
- htmlElement.setAttribute('data-theme', selectedTheme);
- htmlElement.setAttribute('data-theme-name', selectedThemeName);
-
- // Cleanup on unmount
- return () => {
- htmlElement.removeAttribute('data-theme');
- htmlElement.removeAttribute('data-theme-name');
- };
- }, [selectedTheme, selectedThemeName]);
-
- return ;
-};
-
-const preview: Preview = {
- globalTypes: {
- themeMode: {
- name: 'Theme Mode',
- description: 'Global theme mode for components',
- defaultValue: 'light',
- toolbar: {
- icon: 'circlehollow',
- items: [
- { value: 'light', icon: 'circlehollow', title: 'Light' },
- { value: 'dark', icon: 'circle', title: 'Dark' },
- ],
- showName: true,
- dynamicTitle: true,
- },
- },
- themeName: {
- name: 'Theme Name',
- description: 'Global theme name for components',
- defaultValue: 'default',
- toolbar: {
- icon: 'paintbrush',
- items: [
- { value: 'default', title: 'Default (Backstage)' },
- { value: 'spotify', title: 'Spotify' },
- ],
- showName: true,
- dynamicTitle: true,
- },
- },
- },
- initialGlobals: {
- themeMode: 'light',
- themeName: 'default',
- },
- parameters: {
- controls: {
- matchers: {
- color: /(background|color)$/i,
- date: /Date$/i,
- },
- },
- backgrounds: {
- disable: true,
- },
- options: {
- storySort: {
- method: 'alphabetical',
- order: ['Core Concepts', 'Components'],
- },
- },
- viewport: {
- viewports: {
- initial: {
- name: 'Initial',
- styles: {
- width: '320px',
- height: '100%',
- },
- },
- xs: {
- name: 'Extra Small',
- styles: {
- width: '640px',
- height: '100%',
- },
- },
- sm: {
- name: 'Small',
- styles: {
- width: '768px',
- height: '100%',
- },
- },
- md: {
- name: 'Medium',
- styles: {
- width: '1024px',
- height: '100%',
- },
- },
- lg: {
- name: 'Large',
- styles: {
- width: '1280px',
- height: '100%',
- },
- },
- xl: {
- name: 'Extra Large',
- styles: {
- width: '1536px',
- height: '100%',
- },
- },
- },
- },
- },
- decorators: [
- withThemes,
- Story => {
- document.body.style.backgroundColor = 'var(--bui-bg)';
-
- const docsStoryElements = document.getElementsByClassName('docs-story');
- Array.from(docsStoryElements).forEach(element => {
- (element as HTMLElement).style.backgroundColor = 'var(--bui-bg)';
- });
-
- return ;
- },
- ],
-};
-
-export default preview;
diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index 780bc2e741..7a2d95f6ae 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -1,5 +1,17 @@
# @backstage/ui
+## 0.7.0-next.3
+
+### Minor Changes
+
+- 0615e54: We are moving our DataTable component to React Aria. We removed our DataTable to only use Table as a single and opinionated option for tables. This new structure is made possible by using React Aria under the hood.
+
+### Patch Changes
+
+- 230b410: **Breaking change** Move breadcrumb to fit in the `HeaderPage` instead of the `Header` in Backstage UI.
+- 8bdc491: Remove stylesheet import from Select component.
+- 404b426: Add `startCollapsed` prop on the `SearchField` component in BUI.
+
## 0.7.0-next.2
### Patch Changes
diff --git a/packages/ui/css/styles.css b/packages/ui/css/styles.css
index b923073ee6..5d4011ff6d 100644
--- a/packages/ui/css/styles.css
+++ b/packages/ui/css/styles.css
@@ -9182,7 +9182,7 @@
--bui-animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;
}
-[data-theme="dark"] {
+[data-theme-mode="dark"] {
--bui-gray-1: #191919;
--bui-gray-2: #242424;
--bui-gray-3: #373737;
@@ -9517,36 +9517,6 @@
}
}
-.bui-DataTableRoot {
- gap: var(--bui-space-3);
- flex-direction: column;
- display: flex;
-}
-
-.bui-DataTablePagination {
- padding-top: var(--bui-space-5);
- justify-content: space-between;
- align-items: center;
- display: flex;
-}
-
-.bui-DataTablePagination--left {
- justify-content: space-between;
- align-items: center;
- display: flex;
-}
-
-.bui-DataTablePagination--right {
- justify-content: space-between;
- align-items: center;
- gap: var(--bui-space-2);
- display: flex;
-}
-
-.bui-DataTablePagination--select {
- min-width: 10.5rem;
-}
-
.bui-FieldError {
color: var(--bui-fg-danger);
font-size: var(--bui-font-size-2);
@@ -9593,10 +9563,7 @@
}
.bui-HeaderToolbar {
- z-index: 10;
margin-bottom: var(--bui-space-6);
- position: sticky;
- top: 0;
&:before {
content: "";
@@ -9667,38 +9634,6 @@
transform: translateY(-50%);
}
-.bui-HeaderBreadcrumbs {
- align-items: center;
- gap: var(--bui-space-2);
- font-size: var(--bui-font-size-3);
- font-weight: var(--bui-font-weight-regular);
- flex-direction: row;
- display: flex;
-}
-
-.bui-HeaderBreadcrumb {
- align-items: center;
- gap: var(--bui-space-2);
- flex-direction: row;
- display: flex;
-}
-
-.bui-HeaderBreadcrumbLink {
- color: var(--bui-fg-secondary);
- cursor: pointer;
- text-decoration: none;
-
- &[data-active="true"] {
- color: var(--bui-fg-primary);
- }
-}
-
-.bui-HeaderBreadcrumbSeparator {
- width: 16px;
- height: 16px;
- color: var(--bui-fg-secondary);
-}
-
.bui-HeaderTabsWrapper {
margin-bottom: var(--bui-space-4);
padding-inline: var(--bui-space-3);
@@ -9708,7 +9643,6 @@
.bui-HeaderPage {
gap: var(--bui-space-1);
- padding-inline: var(--bui-space-5);
margin-top: var(--bui-space-6);
margin-bottom: var(--bui-space-6);
flex-direction: column;
@@ -9725,7 +9659,7 @@
margin-left: -8px;
}
-.bui-HeaderPageControls {
+.bui-HeaderPageControls, .bui-HeaderPageBreadcrumbs {
align-items: center;
gap: var(--bui-space-2);
flex-direction: row;
@@ -10034,12 +9968,17 @@
}
}
-.bui-TableRoot {
+.bui-Table {
caption-side: bottom;
border-collapse: collapse;
width: 100%;
}
+.bui-TableHeader {
+ border-bottom: 1px solid var(--bui-border);
+ transition: color .2s ease-in-out;
+}
+
.bui-TableHead {
text-align: left;
padding: var(--bui-space-3);
@@ -10047,6 +9986,33 @@
color: var(--bui-fg-primary);
}
+.bui-TableHeadSortButton {
+ cursor: pointer;
+ user-select: none;
+ align-items: center;
+ gap: var(--bui-space-1);
+ display: inline-flex;
+
+ &:hover svg {
+ opacity: .5;
+ }
+
+ & svg {
+ opacity: 0;
+ transition: opacity .1s ease-in-out, transform .1s ease-in-out;
+ }
+
+ &[data-sort-order="asc"] svg {
+ opacity: 1;
+ transform: rotate(0);
+ }
+
+ &[data-sort-order="desc"] svg {
+ opacity: 1;
+ transform: rotate(180deg);
+ }
+}
+
.bui-TableBody {
color: var(--bui-fg-primary);
}
@@ -10054,6 +10020,10 @@
.bui-TableRow {
border-bottom: 1px solid var(--bui-border);
transition: color .2s ease-in-out;
+
+ &[data-react-aria-pressable="true"] {
+ cursor: pointer;
+ }
}
.bui-TableBody .bui-TableRow:hover {
@@ -10063,9 +10033,24 @@
.bui-TableCell {
padding: var(--bui-space-3);
font-size: var(--bui-font-size-3);
+ padding: var(--bui-space-3);
+ font-size: var(--bui-font-size-3);
}
-.bui-TableCellText, .bui-TableCellLink {
+.bui-TableCellContentWrapper {
+ align-items: center;
+ gap: var(--bui-space-2);
+ flex-direction: row;
+ display: inline-flex;
+}
+
+.bui-TableCellIcon, .bui-TableCellIcon svg {
+ color: var(--bui-fg-primary);
+ align-items: center;
+ display: inline-flex;
+}
+
+.bui-TableCellContent {
gap: var(--bui-space-0_5);
flex-direction: column;
display: flex;
@@ -10113,6 +10098,30 @@
display: flex;
}
+.bui-DataTablePagination {
+ padding-top: var(--bui-space-5);
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+}
+
+.bui-DataTablePagination--left {
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+}
+
+.bui-DataTablePagination--right {
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--bui-space-2);
+ display: flex;
+}
+
+.bui-DataTablePagination--select {
+ min-width: 10.5rem;
+}
+
.bui-Tabs {
--active-tab-left: 0px;
--active-tab-right: 0px;
@@ -10277,6 +10286,7 @@
.bui-TextField {
font-family: var(--bui-font-regular);
flex-direction: column;
+ flex-shrink: 0;
width: 100%;
display: flex;
}
@@ -10305,7 +10315,9 @@
left: var(--bui-space-3);
margin-right: var(--bui-space-1);
color: var(--bui-fg-primary);
+ pointer-events: none;
flex-shrink: 0;
+ transition: left .2s ease-in-out;
position: absolute;
top: 50%;
transform: translateY(-50%);
@@ -10323,7 +10335,7 @@
.bui-Input {
padding: 0 var(--bui-space-3);
- border-radius: var(--bui-radius-3);
+ border-radius: var(--bui-radius-2);
border: 1px solid var(--bui-border);
background-color: var(--bui-bg-surface-1);
font-size: var(--bui-font-size-3);
@@ -10370,8 +10382,55 @@
}
}
-.bui-SearchField[data-empty] .bui-InputClear {
- display: none;
+.bui-SearchField {
+ &[data-empty] {
+ & .bui-InputClear {
+ display: none;
+ }
+ }
+
+ &[data-start-collapsed="true"] {
+ padding: 0;
+ transition: width .3s ease-in-out;
+
+ &[data-collapsed="false"] {
+ cursor: pointer;
+
+ &[data-size="medium"] {
+ width: 2.5rem;
+ height: 2.5rem;
+ }
+
+ &[data-size="small"] {
+ width: 2rem;
+ height: 2rem;
+ }
+
+ &[data-size="medium"] .bui-Input {
+ padding-left: 0;
+
+ &::placeholder {
+ opacity: 0;
+ }
+ }
+
+ &[data-size="small"] .bui-Input {
+ padding-left: 0;
+
+ &::placeholder {
+ opacity: 0;
+ }
+ }
+
+ &[data-size="small"] .bui-InputIcon {
+ left: var(--bui-space-2);
+ }
+
+ &[data-size="medium"] .bui-InputIcon {
+ left: 10px;
+ }
+ }
+ }
}
.bui-InputClear {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4430865d90..398afbe5a9 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/ui",
- "version": "0.7.0-next.2",
+ "version": "0.7.0-next.3",
"backstage": {
"role": "web-library"
},
@@ -28,7 +28,6 @@
],
"scripts": {
"build": "yarn build:app && yarn build:css",
- "build-storybook": "storybook build",
"build:app": "backstage-cli package build",
"build:css": "node scripts/build-css.mjs",
"build:css:watch": "node scripts/build-css.mjs --watch",
@@ -36,33 +35,22 @@
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
- "start": "concurrently \"yarn build:css:watch\" \"yarn storybook\"",
- "storybook": "storybook dev -p 6006",
"test": "backstage-cli package test"
},
"dependencies": {
"@base-ui-components/react": "1.0.0-alpha.7",
- "@gsap/react": "^2.1.2",
"@remixicon/react": "^4.6.0",
+ "@storybook/test": "^8.6.12",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
- "gsap": "^3.13.0",
"react-aria-components": "^1.10.1"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
- "@storybook/addon-essentials": "^8.6.12",
- "@storybook/addon-interactions": "^8.6.12",
- "@storybook/addon-styling-webpack": "^1.0.1",
- "@storybook/addon-webpack5-compiler-swc": "^3.0.0",
- "@storybook/blocks": "^8.6.12",
"@storybook/react": "^8.6.12",
- "@storybook/react-webpack5": "^8.6.12",
- "@storybook/test": "^8.6.12",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"chalk": "^5.4.1",
- "concurrently": "^9.2.0",
"eslint-plugin-storybook": "^0.12.0",
"glob": "^11.0.1",
"globals": "^15.11.0",
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 38992bdac4..c099a31e05 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -5,13 +5,14 @@
```ts
import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
import { ButtonProps as ButtonProps_2 } from 'react-aria-components';
+import { CellProps as CellProps_2 } from 'react-aria-components';
import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsible';
+import { ColumnProps } from 'react-aria-components';
import { ComponentProps } from 'react';
import type { ComponentPropsWithRef } from 'react';
import { Context } from 'react';
import type { ElementType } from 'react';
import { ForwardRefExoticComponent } from 'react';
-import { HTMLAttributes } from 'react';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LinkProps as LinkProps_2 } from 'react-aria-components';
import { Menu as Menu_2 } from '@base-ui-components/react/menu';
@@ -21,21 +22,21 @@ import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RefAttributes } from 'react';
import type { RemixiconComponentType } from '@remixicon/react';
+import { RowProps } from 'react-aria-components';
import { ScrollArea as ScrollArea_2 } from '@base-ui-components/react/scroll-area';
import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-components';
import type { SelectProps as SelectProps_2 } from 'react-aria-components';
import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
-import { Table as Table_2 } from '@tanstack/react-table';
+import { TableBodyProps } from 'react-aria-components';
+import { TableHeaderProps } from 'react-aria-components';
+import { TableProps } from 'react-aria-components';
import type { TabListProps as TabListProps_2 } from 'react-aria-components';
import type { TabPanelProps as TabPanelProps_2 } from 'react-aria-components';
import type { TabProps as TabProps_2 } from 'react-aria-components';
import { TabsProps as TabsProps_2 } from 'react-aria-components';
-import { TdHTMLAttributes } from 'react';
import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
-import { ThHTMLAttributes } from 'react';
import { TooltipProps as TooltipProps_2 } from 'react-aria-components';
import { TooltipTriggerComponentProps } from 'react-aria-components';
-import type { useRender } from '@base-ui-components/react/use-render';
// @public (undocumented)
export type AlignItems = 'stretch' | 'start' | 'center' | 'end';
@@ -206,18 +207,33 @@ export const Card: ForwardRefExoticComponent<
CardProps & RefAttributes
>;
+// @public
+export const CardBody: ForwardRefExoticComponent<
+ CardBodyProps & RefAttributes
+>;
+
// @public
export interface CardBodyProps extends React.HTMLAttributes {
// (undocumented)
children?: React.ReactNode;
}
+// @public
+export const CardFooter: ForwardRefExoticComponent<
+ CardFooterProps & RefAttributes
+>;
+
// @public
export interface CardFooterProps extends React.HTMLAttributes {
// (undocumented)
children?: React.ReactNode;
}
+// @public
+export const CardHeader: ForwardRefExoticComponent<
+ CardHeaderProps & RefAttributes
+>;
+
// @public
export interface CardHeaderProps extends React.HTMLAttributes {
// (undocumented)
@@ -230,6 +246,43 @@ export interface CardProps extends React.HTMLAttributes {
children?: React.ReactNode;
}
+// @public (undocumented)
+export const Cell: {
+ (props: CellProps): JSX_2.Element;
+ displayName: string;
+};
+
+// @public (undocumented)
+export const CellProfile: (props: CellProfileProps) => JSX_2.Element;
+
+// @public (undocumented)
+export interface CellProfileProps extends CellProps_2 {
+ // (undocumented)
+ color?: 'primary' | 'secondary';
+ // (undocumented)
+ description?: string;
+ // (undocumented)
+ href?: string;
+ // (undocumented)
+ name?: string;
+ // (undocumented)
+ src?: string;
+}
+
+// @public (undocumented)
+export interface CellProps extends CellProps_2 {
+ // (undocumented)
+ color?: 'primary' | 'secondary';
+ // (undocumented)
+ description?: string;
+ // (undocumented)
+ href?: string;
+ // (undocumented)
+ leadingIcon?: React.ReactNode | null;
+ // (undocumented)
+ title: string;
+}
+
// @public (undocumented)
export const Checkbox: ForwardRefExoticComponent<
CheckboxProps & RefAttributes
@@ -281,6 +334,13 @@ export const Collapsible: {
>;
};
+// @public (undocumented)
+export const Column: (
+ props: Omit & {
+ children?: React.ReactNode;
+ },
+) => JSX_2.Element;
+
// @public (undocumented)
export type Columns = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 'auto';
@@ -392,10 +452,6 @@ export const componentDefinitions: {
readonly toolbarControls: 'bui-HeaderToolbarControls';
readonly toolbarIcon: 'bui-HeaderToolbarIcon';
readonly toolbarName: 'bui-HeaderToolbarName';
- readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
- readonly breadcrumb: 'bui-HeaderBreadcrumb';
- readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink';
- readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
};
};
@@ -403,6 +459,7 @@ export const componentDefinitions: {
readonly classNames: {
readonly root: 'bui-HeaderPage';
readonly content: 'bui-HeaderPageContent';
+ readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs';
readonly tabsWrapper: 'bui-HeaderPageTabsWrapper';
readonly controls: 'bui-HeaderPageControls';
};
@@ -482,6 +539,9 @@ export const componentDefinitions: {
readonly root: 'bui-SearchField';
readonly clear: 'bui-InputClear';
};
+ readonly dataAttributes: {
+ readonly startCollapsed: readonly [true, false];
+ };
};
readonly Select: {
readonly classNames: {
@@ -511,16 +571,17 @@ export const componentDefinitions: {
};
readonly Table: {
readonly classNames: {
- readonly root: 'bui-TableRoot';
+ readonly table: 'bui-Table';
readonly header: 'bui-TableHeader';
readonly body: 'bui-TableBody';
readonly row: 'bui-TableRow';
readonly head: 'bui-TableHead';
+ readonly headSortButton: 'bui-TableHeadSortButton';
readonly caption: 'bui-TableCaption';
readonly cell: 'bui-TableCell';
- readonly cellText: 'bui-TableCellText';
- readonly cellLink: 'bui-TableCellLink';
- readonly cellProfile: 'bui-TableCellProfile';
+ readonly cellContentWrapper: 'bui-TableCellContentWrapper';
+ readonly cellContent: 'bui-TableCellContent';
+ readonly cellIcon: 'bui-TableCellIcon';
readonly cellProfileAvatar: 'bui-TableCellProfileAvatar';
readonly cellProfileAvatarImage: 'bui-TableCellProfileAvatarImage';
readonly cellProfileAvatarFallback: 'bui-TableCellProfileAvatarFallback';
@@ -609,68 +670,6 @@ export type DataAttributesMap = Record;
// @public
export type DataAttributeValues = readonly (string | number | boolean)[];
-// @public
-export const DataTable: {
- Root: (
- props: {
- table: Table_2;
- } & React.HTMLAttributes,
- ) => JSX.Element;
- Pagination: ForwardRefExoticComponent<
- DataTablePaginationProps & RefAttributes
- >;
- Table: ForwardRefExoticComponent<
- DataTableTableProps & RefAttributes
- >;
- TableRoot: ForwardRefExoticComponent<
- Omit<
- HTMLAttributes & RefAttributes,
- 'ref'
- > &
- RefAttributes
- >;
- TableHeader: ForwardRefExoticComponent<
- HTMLAttributes &
- RefAttributes
- >;
- TableBody: ForwardRefExoticComponent<
- HTMLAttributes &
- RefAttributes
- >;
- TableRow: ForwardRefExoticComponent<
- HTMLAttributes & RefAttributes
- >;
- TableCell: ForwardRefExoticComponent<
- TdHTMLAttributes & RefAttributes
- >;
- TableCellText: ForwardRefExoticComponent<
- TableCellTextProps & RefAttributes
- >;
- TableCellLink: ForwardRefExoticComponent<
- TableCellLinkProps & RefAttributes
- >;
- TableCellProfile: ForwardRefExoticComponent<
- TableCellProfileProps & RefAttributes
- >;
- TableHead: ForwardRefExoticComponent<
- ThHTMLAttributes & RefAttributes
- >;
-};
-
-// @public (undocumented)
-export interface DataTablePaginationProps
- extends React.HTMLAttributes {}
-
-// @public (undocumented)
-export interface DataTableRootProps
- extends React.HTMLAttributes {
- table: Table_2;
-}
-
-// @public (undocumented)
-export interface DataTableTableProps
- extends React.HTMLAttributes {}
-
// @public (undocumented)
export type Display = 'none' | 'flex' | 'block' | 'inline';
@@ -977,14 +976,6 @@ export interface GridProps extends SpaceProps {
// @public
export const Header: (props: HeaderProps) => JSX_2.Element;
-// @public
-export interface HeaderBreadcrumb {
- // (undocumented)
- href: string;
- // (undocumented)
- label: string;
-}
-
// @public
export interface HeaderMenuItem {
// (undocumented)
@@ -998,8 +989,18 @@ export interface HeaderMenuItem {
// @public
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
+// @public
+export interface HeaderPageBreadcrumb {
+ // (undocumented)
+ href: string;
+ // (undocumented)
+ label: string;
+}
+
// @public
export interface HeaderPageProps {
+ // (undocumented)
+ breadcrumbs?: HeaderPageBreadcrumb[];
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
@@ -1012,8 +1013,6 @@ export interface HeaderPageProps {
// @public
export interface HeaderProps {
- // (undocumented)
- breadcrumbs?: HeaderBreadcrumb[];
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
@@ -1421,6 +1420,15 @@ export type ResponsivePropDef = RegularPropDef & {
responsive: true;
};
+// @public (undocumented)
+export function Row({
+ id,
+ columns,
+ children,
+ href,
+ ...otherProps
+}: RowProps): JSX_2.Element;
+
// @public (undocumented)
export const ScrollArea: {
Root: ForwardRefExoticComponent<
@@ -1453,6 +1461,7 @@ export interface SearchFieldProps
icon?: ReactNode | false;
placeholder?: string;
size?: 'small' | 'medium' | Partial>;
+ startCollapsed?: boolean;
}
// @public (undocumented)
@@ -1567,76 +1576,44 @@ export interface SwitchProps extends SwitchProps_2 {
// @public
export const Tab: (props: TabProps) => JSX_2.Element;
+// @public (undocumented)
+export const Table: (props: TableProps) => JSX_2.Element;
+
+// @public (undocumented)
+export const TableBody: (
+ props: TableBodyProps,
+) => JSX_2.Element;
+
+// @public (undocumented)
+export const TableHeader: ({
+ columns,
+ children,
+}: TableHeaderProps) => JSX_2.Element;
+
// @public
-export const Table: {
- Root: ForwardRefExoticComponent<
- HTMLAttributes & RefAttributes
- >;
- Header: ForwardRefExoticComponent<
- HTMLAttributes &
- RefAttributes
- >;
- Body: ForwardRefExoticComponent<
- HTMLAttributes &
- RefAttributes
- >;
- Head: ForwardRefExoticComponent<
- ThHTMLAttributes & RefAttributes
- >;
- Row: ForwardRefExoticComponent<
- HTMLAttributes & RefAttributes
- >;
- Cell: ForwardRefExoticComponent<
- TdHTMLAttributes & RefAttributes
- >;
- CellText: ForwardRefExoticComponent<
- TableCellTextProps & RefAttributes
- >;
- CellLink: ForwardRefExoticComponent<
- TableCellLinkProps & RefAttributes
- >;
- CellProfile: ForwardRefExoticComponent<
- TableCellProfileProps & RefAttributes
- >;
- Caption: ForwardRefExoticComponent<
- HTMLAttributes &
- RefAttributes
- >;
-};
+export function TablePagination(props: TablePaginationProps): JSX_2.Element;
// @public (undocumented)
-export interface TableCellLinkProps
+export interface TablePaginationProps
extends React.HTMLAttributes {
// (undocumented)
- description?: string;
+ offset?: number;
// (undocumented)
- href: string;
+ onNextPage?: () => void;
// (undocumented)
- render?: useRender.ComponentProps<'a'>['render'];
+ onPageSizeChange?: (pageSize: number) => void;
// (undocumented)
- title: string;
-}
-
-// @public (undocumented)
-export interface TableCellProfileProps
- extends React.HTMLAttributes {
+ onPreviousPage?: () => void;
// (undocumented)
- name?: string;
+ pageSize?: number;
// (undocumented)
- src?: string;
+ rowCount?: number;
// (undocumented)
- to?: string;
+ setOffset?: (offset: number) => void;
// (undocumented)
- withImage?: boolean;
-}
-
-// @public (undocumented)
-export interface TableCellTextProps
- extends React.HTMLAttributes {
+ setPageSize?: (pageSize: number) => void;
// (undocumented)
- description?: string;
- // (undocumented)
- title: string;
+ showPageSizeOptions?: boolean;
}
// @public
@@ -1766,6 +1743,50 @@ export const useBreakpoint: () => {
// @public (undocumented)
export const useIcons: () => IconContextProps;
+// @public
+export function useTable(
+ config?: UseTableConfig,
+): UseTableResult;
+
+// @public (undocumented)
+export interface UseTableConfig {
+ data?: T[];
+ pagination?: UseTablePaginationConfig;
+}
+
+// @public (undocumented)
+export interface UseTablePagination {
+ data?: T[];
+ nextPage: () => void;
+ offset: number;
+ pageSize: number;
+ paginationProps: TablePaginationProps;
+ previousPage: () => void;
+ setOffset: (offset: number) => void;
+ setPageSize: (pageSize: number) => void;
+}
+
+// @public (undocumented)
+export interface UseTablePaginationConfig {
+ defaultOffset?: number;
+ defaultPageSize?: number;
+ offset?: number;
+ onNextPage?: () => void;
+ onOffsetChange?: (offset: number) => void;
+ onPageSizeChange?: (pageSize: number) => void;
+ onPreviousPage?: () => void;
+ pageSize?: number;
+ rowCount?: number;
+ showPageSizeOptions?: boolean;
+}
+
+// @public (undocumented)
+export interface UseTableResult {
+ data?: T[];
+ pagination: UseTablePagination;
+ paginationProps: TablePaginationProps;
+}
+
// @public (undocumented)
export interface UtilityProps extends SpaceProps {
// (undocumented)
diff --git a/packages/ui/src/components/Avatar/Avatar.stories.tsx b/packages/ui/src/components/Avatar/Avatar.stories.tsx
index c0e8ddb2f5..69dcd5d369 100644
--- a/packages/ui/src/components/Avatar/Avatar.stories.tsx
+++ b/packages/ui/src/components/Avatar/Avatar.stories.tsx
@@ -19,7 +19,7 @@ import { Avatar } from './index';
import { Flex } from '../..';
const meta = {
- title: 'Components/Avatar',
+ title: 'Backstage UI/Avatar',
component: Avatar,
} satisfies Meta;
diff --git a/packages/ui/src/components/Box/Box.stories.tsx b/packages/ui/src/components/Box/Box.stories.tsx
index 620b2818a1..6c31f63f96 100644
--- a/packages/ui/src/components/Box/Box.stories.tsx
+++ b/packages/ui/src/components/Box/Box.stories.tsx
@@ -20,7 +20,7 @@ import { Box } from './Box';
import { Flex } from '../Flex';
const meta = {
- title: 'Layout/Box',
+ title: 'Backstage UI/Box',
component: Box,
argTypes: {
as: {
diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx
index 09174d0c68..962db2e898 100644
--- a/packages/ui/src/components/Button/Button.stories.tsx
+++ b/packages/ui/src/components/Button/Button.stories.tsx
@@ -21,7 +21,7 @@ import { Text } from '../Text';
import { Icon } from '../Icon';
const meta = {
- title: 'Components/Button',
+ title: 'Backstage UI/Button',
component: Button,
argTypes: {
size: {
diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx
index c0fb703c92..10e57fbb45 100644
--- a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx
+++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx
@@ -21,7 +21,7 @@ import { Text } from '../Text';
import { Icon } from '../Icon';
const meta = {
- title: 'Components/ButtonIcon',
+ title: 'Backstage UI/ButtonIcon',
component: ButtonIcon,
argTypes: {
size: {
diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx
index fb4b7e3629..0ded9ef741 100644
--- a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx
+++ b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx
@@ -21,7 +21,7 @@ import { Text } from '../Text';
import { Icon } from '../Icon';
const meta = {
- title: 'Components/ButtonLink',
+ title: 'Backstage UI/ButtonLink',
component: ButtonLink,
argTypes: {
size: {
diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx
index 10c25c0c93..2b8466f145 100644
--- a/packages/ui/src/components/Card/Card.stories.tsx
+++ b/packages/ui/src/components/Card/Card.stories.tsx
@@ -25,7 +25,7 @@ export interface ListItem {
}
const meta = {
- title: 'Components/Card',
+ title: 'Backstage UI/Card',
component: Card,
subcomponents: { CardHeader, CardBody, CardFooter },
} satisfies Meta;
diff --git a/packages/ui/src/components/Card/index.ts b/packages/ui/src/components/Card/index.ts
index 27bab991a4..4b710ee7f1 100644
--- a/packages/ui/src/components/Card/index.ts
+++ b/packages/ui/src/components/Card/index.ts
@@ -14,7 +14,8 @@
* limitations under the License.
*/
-export { Card } from './Card';
+export { Card, CardHeader, CardBody, CardFooter } from './Card';
+
export type {
CardProps,
CardHeaderProps,
diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
index c5da7083d9..a8aa46fc07 100644
--- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
+++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
@@ -20,7 +20,7 @@ import { Flex } from '../Flex';
import { Text } from '../Text';
const meta = {
- title: 'Components/Checkbox',
+ title: 'Backstage UI/Checkbox',
component: Checkbox,
} satisfies Meta;
diff --git a/packages/ui/src/components/Collapsible/Collapsible.stories.tsx b/packages/ui/src/components/Collapsible/Collapsible.stories.tsx
index 7b4f26e664..6f2c761519 100644
--- a/packages/ui/src/components/Collapsible/Collapsible.stories.tsx
+++ b/packages/ui/src/components/Collapsible/Collapsible.stories.tsx
@@ -22,7 +22,7 @@ import { Text } from '../Text';
import { Icon } from '../Icon';
const meta = {
- title: 'Components/Collapsible',
+ title: 'Backstage UI/Collapsible',
component: Collapsible.Root,
} satisfies Meta;
diff --git a/packages/ui/src/components/Container/Container.stories.tsx b/packages/ui/src/components/Container/Container.stories.tsx
index ca1165f44c..7dfbd9ea6e 100644
--- a/packages/ui/src/components/Container/Container.stories.tsx
+++ b/packages/ui/src/components/Container/Container.stories.tsx
@@ -19,7 +19,7 @@ import { Box } from '../Box/Box';
import { Container } from './Container';
const meta = {
- title: 'Layout/Container',
+ title: 'Backstage UI/Container',
component: Container,
parameters: {
layout: 'fullscreen',
diff --git a/packages/ui/src/components/DataTable/DataTable.stories.tsx b/packages/ui/src/components/DataTable/DataTable.stories.tsx
deleted file mode 100644
index 29f38e7bc2..0000000000
--- a/packages/ui/src/components/DataTable/DataTable.stories.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Copyright 2024 The Backstage Authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import type { Meta, StoryFn, StoryObj } from '@storybook/react';
-import { DataTable } from '.';
-import { data, DataProps } from './mocked-components';
-import { columns } from './mocked-columns';
-import {
- flexRender,
- getCoreRowModel,
- getPaginationRowModel,
- useReactTable,
- PaginationState,
-} from '@tanstack/react-table';
-import { useState } from 'react';
-import { MemoryRouter } from 'react-router-dom';
-
-const meta = {
- title: 'Components/DataTable',
- decorators: [
- (Story: StoryFn) => (
-
-
-
- ),
- ],
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Uncontrolled: Story = {
- render: () => {
- const table = useReactTable({
- data,
- columns,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- });
-
- return (
-
-
-
-
- );
- },
-};
-
-export const Controlled: Story = {
- render: () => {
- const [pagination, setPagination] = useState({
- pageIndex: 4,
- pageSize: 5,
- });
-
- const table = useReactTable({
- data,
- columns,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- state: {
- pagination,
- },
- onPaginationChange: setPagination,
- });
-
- return (
-
-
-
-
- );
- },
-};
-
-export const WithCustomTable: Story = {
- render: () => {
- const table = useReactTable({
- data,
- columns,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- });
-
- return (
-
-
-
- {table.getHeaderGroups().map(headerGroup => (
-
- {headerGroup.headers.map(header => {
- return (
-
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext(),
- )}
-
- );
- })}
-
- ))}
-
-
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map(row => (
-
- {row.getVisibleCells().map(cell => (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
-
- ))}
-
- ))
- ) : (
-
-
- No results.
-
-
- )}
-
-
-
-
- );
- },
-};
diff --git a/packages/ui/src/components/DataTable/DataTable.tsx b/packages/ui/src/components/DataTable/DataTable.tsx
deleted file mode 100644
index 5a587df56f..0000000000
--- a/packages/ui/src/components/DataTable/DataTable.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2024 The Backstage Authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { forwardRef } from 'react';
-import { Table } from '../Table';
-import { DataTableRoot } from './Root/DataTableRoot';
-import { DataTablePagination } from './Pagination/DataTablePagination';
-import { Table as TanstackTable } from '@tanstack/react-table';
-import { DataTableTable } from './Table/DataTableTable';
-
-const TableRoot = forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => );
-TableRoot.displayName = Table.Root.displayName;
-
-/**
- * DataTable component for displaying tabular data with pagination
- * @public
- */
-export const DataTable = {
- Root: DataTableRoot as (
- props: {
- table: TanstackTable;
- } & React.HTMLAttributes,
- ) => JSX.Element,
- Pagination: DataTablePagination,
- Table: DataTableTable,
- TableRoot: TableRoot,
- TableHeader: Table.Header,
- TableBody: Table.Body,
- TableRow: Table.Row,
- TableCell: Table.Cell,
- TableCellText: Table.CellText,
- TableCellLink: Table.CellLink,
- TableCellProfile: Table.CellProfile,
- TableHead: Table.Head,
-};
diff --git a/packages/ui/src/components/DataTable/Pagination/DataTablePagination.stories.tsx b/packages/ui/src/components/DataTable/Pagination/DataTablePagination.stories.tsx
deleted file mode 100644
index a2f931aed6..0000000000
--- a/packages/ui/src/components/DataTable/Pagination/DataTablePagination.stories.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright 2024 The Backstage Authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import type { Meta, StoryObj } from '@storybook/react';
-import { DataTablePagination } from './DataTablePagination';
-import {
- getCoreRowModel,
- getFilteredRowModel,
- getPaginationRowModel,
- getSortedRowModel,
- useReactTable,
-} from '@tanstack/react-table';
-import { data, DataProps } from '../mocked-components';
-import { columns } from '../mocked-columns';
-import { DataTable } from '../DataTable';
-
-const meta = {
- title: 'Components/DataTable/Pagination',
- component: DataTablePagination,
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj