Merge pull request #21807 from backstage/docs/di-extensions
Docs: Continued work on extension documentation for DI
This commit is contained in:
@@ -60,7 +60,7 @@ Extensions are created using the `createExtension` function from `@backstage/fro
|
||||
|
||||
```tsx
|
||||
const extension = createExtension({
|
||||
id: 'my-extension',
|
||||
name: 'my-extension',
|
||||
// This is the attachment point, `id` is the ID of the parent extension,
|
||||
// while `input` is the name of the input to attach to.
|
||||
attachTo: { id: 'my-parent', input: 'content' },
|
||||
@@ -71,10 +71,10 @@ const extension = createExtension({
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
// This factory is called to instantiate the extensions and produce its output.
|
||||
factory({ bind }) {
|
||||
bind({
|
||||
factory() {
|
||||
return {
|
||||
element: <div>Hello World</div>,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -85,68 +85,288 @@ Note that while the `createExtension` is public API and used in many places, it
|
||||
|
||||
## Extension Data
|
||||
|
||||
TODO
|
||||
Communication between extensions happens in one direction, from one child extension through the attachment point to its parent. The child extension outputs data which is then passed as inputs to the parent extension. This data is called Extension Data, where the shape of each individual piece of data is described by an Extension Data Reference. These references are created separately from the extensions themselves, and can be shared across multiple different kinds of extensions. Each reference consists of an ID and a TypeScript type that the data needs to conform to, and represents one type of data that can be shared between extensions.
|
||||
|
||||
### Extension Data References
|
||||
|
||||
To create a new extension data reference to represent a type of shared extension data you use the `createExtensionDataRef` function. When defining a new reference you need to provide an ID and a TypeScript type, for example:
|
||||
|
||||
```ts
|
||||
export const reactElementExtensionDataRef =
|
||||
createExtensionDataRef<React.JSX.Element>('my-plugin.reactElement');
|
||||
```
|
||||
|
||||
The `ExtensionDataRef` can then be used to describe an output property of the extension. This will enforce typing on the return value of the extension factory:
|
||||
|
||||
```tsx
|
||||
const extension = createExtension({
|
||||
// ...
|
||||
output: {
|
||||
element: reactElementExtensionDataRef,
|
||||
},
|
||||
factory() {
|
||||
return {
|
||||
element: <div>Hello World</div>,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Extension Data Uniqueness
|
||||
|
||||
Note that the key used in the output map, in this case `element`, is only used internally within the definition of the extension itself. That actual identifier for the data when consumed by other extensions is the ID of the reference, in this case `core.reactElement`. This means that you can not output multiple different values for the same extension data reference, as they would conflict with each other. That in turn makes overly generic extension data references a bad idea, for example a generic "string" type. Instead create separate references for each type of data that you want to share.
|
||||
|
||||
```tsx
|
||||
const extension = createExtension({
|
||||
// ...
|
||||
output: {
|
||||
// ❌ Bad example - outputting values of same type
|
||||
element1: reactElementExtensionDataRef,
|
||||
element2: reactElementExtensionDataRef,
|
||||
},
|
||||
factory() {
|
||||
return {
|
||||
element1: <div>Hello World</div>,
|
||||
element2: <div>Hello World</div>,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Core Extension Data
|
||||
|
||||
We provide default `coreExtensionData`, which provides commonly used `ExtensionDataRef`s - e.g. for `React.JSX.Element` and `RouteRef`. They can be used when creating your own extension. For example, the React Element extension data that we defined above is already provided as `coreExtensionData.reactElement`.
|
||||
|
||||
<!-- For a full list and explanations of all types of core extension data, see the [core extension data reference](#TODO). -->
|
||||
|
||||
### Optional Extension Data
|
||||
|
||||
By default all extension data is required, meaning that the extension factory must provide a value for each output. However, it is possible to make extension data optional by calling the `.optional()` method. This makes it optional for the factory function to return a value as part of its output. When calling the `.optional()` method you create a new copy of the extension data reference, it does not mutate the existing reference.
|
||||
|
||||
```tsx
|
||||
const extension = createExtension({
|
||||
// ...
|
||||
output: {
|
||||
element: coreExtensionData.reactElement.optional(),
|
||||
},
|
||||
factory() {
|
||||
return {
|
||||
element:
|
||||
Math.random() < 0.5 ? <img src="./assets/logo.png" /> : undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Extension Inputs
|
||||
|
||||
<!--
|
||||
The Extension Data can be passed up to other extensions through their extension inputs. Similar to the outputs seen before, let's create an example an extension with a extension input:
|
||||
|
||||
Introduce the concept of extension inputs and how they use extension data to reference types just like outputs
|
||||
```tsx
|
||||
const navigationExtension = createExtension({
|
||||
// ...
|
||||
inputs: {
|
||||
// [1]: Input
|
||||
logo: createExtensionInput(
|
||||
{
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
},
|
||||
factory({ inputs }) {
|
||||
return {
|
||||
element: (
|
||||
<nav>{inputs.logo.output?.element ?? <span>Backstage</span>}</nav>
|
||||
),
|
||||
};
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Show how to use the `createExtensionInput` API to create an input for an extension.
|
||||
The input (see [1] above) is an object that we create using `createExtensionInput`. The first argument is the set of extension data that we accept via this input, and works just like the `output` option. The second argument is optional, and it allows us to put constraints on the extensions that are attached to our input. If the `singleton: true` option is set, only a single extension can attached at a time, and unless the `optional: true` option is set it will also be required that there is exactly on attached extension.
|
||||
|
||||
- Example of creating a single input
|
||||
So how can we now attach the output to the parent extension's input? If we think about a navigation component, like the Sidebar in Backstage, there might be plugins that want to attach a link to their plugin to this navigation component. In this case the plugin only needs to know the extension `id` and the name of the extension `input` to attach the extension `output` returned by the `factory` to the specified extension:
|
||||
|
||||
Talk about different types of inputs, singleton vs array + optional vs required.
|
||||
```tsx
|
||||
const navigationItemExtension = createExtension({
|
||||
// ...
|
||||
attachTo: { id: 'app/nav', input: 'items' },
|
||||
factory() {
|
||||
return {
|
||||
element: <Link to="/home">Home</Link>,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
- Example of creating an optional singleton input - not the value is no longer an array
|
||||
const navigationExtension = createExtension({
|
||||
// ...
|
||||
// [2]: Extension `id` will be `app/nav` following the extension naming pattern
|
||||
namespace: 'app',
|
||||
name: 'nav',
|
||||
output: {
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
inputs: {
|
||||
items: createExtensionInput({
|
||||
element: coreExtensionData.reactElement,
|
||||
}),
|
||||
},
|
||||
factory({ inputs }) {
|
||||
return {
|
||||
element: (
|
||||
<nav>
|
||||
<ul>
|
||||
{inputs.items.map(item => {
|
||||
return <li>{item.output.element}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
),
|
||||
};
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Talk about the shape of the input values and that you have access to the AppNode node in addition to the data, but discourage use of AppNode.
|
||||
In this case the extension input `items` is an array, where each individual item is an extension that attached itself to the extension inputs of this `id`.
|
||||
|
||||
- Example of how to access the AppNode next to the input data
|
||||
With the `inputs` not only the `output` of an extensions item is passed to the extension, but also the `node`. However, it is discouraged to consume the `node` here unless needed. If we are looking at the `factory` function from the example above we could access the `node` like the following:
|
||||
|
||||
-->
|
||||
```tsx
|
||||
// ...
|
||||
factory({ inputs }) {
|
||||
return {
|
||||
element: (
|
||||
<nav>
|
||||
<ul>
|
||||
{inputs.items.map(({output, node}) => {
|
||||
const _node: AppNode = node;
|
||||
return <li>{output.element}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
),
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
## Extension Configuration
|
||||
|
||||
<!--
|
||||
With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefor we created a possibility to configure each extension individually through config. The extension config schema is created using the [`zod`](https://zod.dev/) library, which in addition to TypeScript type checking also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following:
|
||||
|
||||
Introduce the concept of extension configuration, highlight that it's different from app config
|
||||
```tsx
|
||||
const navigationExtension = createExtension({
|
||||
// ...
|
||||
namespace: 'app',
|
||||
name: 'nav',
|
||||
// [3]: Extension `id` will be `app/nav` following the extension naming pattern
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
title: z.string().default('Sidebar Title'),
|
||||
}),
|
||||
),
|
||||
factory({ config }) {
|
||||
return {
|
||||
element: (
|
||||
<nav>
|
||||
<span>{config.title}</span>
|
||||
<ul>{/* ... */}</ul>
|
||||
</nav>
|
||||
),
|
||||
};
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Show how to define a configuration schema for an extension, talk about it using zod (link to zod), in general explain everything there is to know about the schema
|
||||
To now change the text of the title from "Sidebar Title" to "Backstage" we can look at the `id` of the extension & add the following to the `app-config.yaml`:
|
||||
|
||||
- Example that creates a configuration schema for an extension
|
||||
|
||||
Show how to provide configuration for an extension - keep this part short, and instead link to the architecture section that talks about writing configuration
|
||||
|
||||
- One example of a YAML configurations for the above extension
|
||||
|
||||
-->
|
||||
```yaml
|
||||
app:
|
||||
# ...
|
||||
extensions:
|
||||
# ...
|
||||
- app/nav:
|
||||
config:
|
||||
title: 'Backstage'
|
||||
```
|
||||
|
||||
## Extension Creators
|
||||
|
||||
<!--
|
||||
With creating an extension by using `createExtension(...)` you have the advantage that the extension can be anything in your Backstage application. We realised that this comes with the trade-off of having to repeat boilerplate code for similar building blocks. Here extension creators come into play for covering common building blocks in Backstage like pages using `createPageExtension`, themes using the `createThemeExtension` or items for the navigation using `createNavItemExtension`.
|
||||
|
||||
Introduce the concept of extension creators, talk about how they are used to provide an opinionated way of creating extensions for specific use cases. Mention a few of the built-in creators, link to the reference section on built-in extension kinds (TBD).
|
||||
If we follow the example from above all items of the navigation have similarities, like they all want to be attached to the same extension with the same input as well as rendering the same navigation item component. Therefor `createExtension` can be abstracted for this use case to `createNavItemExtension` and if we add the extension to the app it will end up in the right place & looks like we expect a navigation item to look.
|
||||
|
||||
- Example of using an existing extension creator to create an extension
|
||||
```tsx
|
||||
export const HomeNavIcon = createNavItemExtension({
|
||||
routeRef: routeRefForTheHomePage,
|
||||
title: 'Home',
|
||||
icon: HomeIcon,
|
||||
});
|
||||
```
|
||||
|
||||
Explain that each extension creator creates a specific "kind" of extensions - introduce the concept of extension kinds. Show an example of how the kind is used to build up the ID.
|
||||
### Extension Kind
|
||||
|
||||
- Example of creating a new extension creator for creating a specific kind of extension
|
||||
With the example `HomeNavIcon` will end up on the extension input `items` of the extensions with the id `app/nav`. It raises the question what the `id` of the `HomeNavIcon` itself is. The extension creator for the navigation item has a defined `kind`, which by convention matches the own name. So in this example `createNavItemExtension` sets the kind to `nav-item`.
|
||||
|
||||
Explain that extension creators should be exported from library packages (`-react`) rather than plugin packages.
|
||||
The `id` of the extension is then build out of `namespace`, `name` & `kind` like the following - where `namespace` & `name` are optional properties that can be passed to the extension creator:
|
||||
|
||||
-->
|
||||
```
|
||||
id: kind:namespace/name
|
||||
```
|
||||
|
||||
For more information on naming of extension refer to the [naming patterns documentation](./08-naming-patterns.md).
|
||||
|
||||
### Extension Creators in libraries
|
||||
|
||||
Extension creators should be exported from frontend library packages (e.g. `*-react`) rather than plugin packages.
|
||||
|
||||
If an extension is only for in-house tweaks, it's okay to put it in the plugin package. But if you want other open source plugins to use it, or you already have a `-react` package, always put extension creators in the `-react` package.
|
||||
|
||||
## Extension Boundary
|
||||
|
||||
<!--
|
||||
The `ExtensionBoundary` wraps extensions with several React contexts for different purposes
|
||||
|
||||
Introduce the need for extension specific React contexts, for example error boundaries and analytics.
|
||||
### Suspense
|
||||
|
||||
Any React elements that are rendered by extension creators should be wrapped in the `ExtensionBoundary`
|
||||
All React elements rendered by extension creators should be wrapped in the extension boundary. With `Suspense` the extension can than load resources asynchronously with having a loading fallback. It also allows to lazy load the whole extension similar to how plugins are currently lazy loaded in Backstage.
|
||||
|
||||
- Example of how to use the `ExtensionBoundary` in an extension creator
|
||||
### Error Boundary
|
||||
|
||||
-->
|
||||
Similar to plugins the `ErrorBoundary` for extension allows to pass in a fallback component in case there is an uncaught error inside of the component. With this the error can be isolated & it would prevent the rest of the plugin to crash.
|
||||
|
||||
### Analytics
|
||||
|
||||
Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`.
|
||||
|
||||
The `ExtensionBoundary` can be used like the following in an extension creator:
|
||||
|
||||
```tsx
|
||||
export function createSomeExtension<
|
||||
TConfig extends {},
|
||||
TInputs extends AnyExtensionInputMap,
|
||||
>(options): ExtensionDefinition<TConfig> {
|
||||
return createExtension({
|
||||
// ...
|
||||
factory({ config, inputs, node }) {
|
||||
const ExtensionComponent = lazy(() =>
|
||||
options
|
||||
.loader({ config, inputs })
|
||||
.then(element => ({ default: () => element })),
|
||||
);
|
||||
|
||||
return {
|
||||
path: config.path,
|
||||
routeRef: options.routeRef,
|
||||
element: (
|
||||
<ExtensionBoundary node={node} routable>
|
||||
<ExtensionComponent />
|
||||
</ExtensionBoundary>
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user