docs/composability: the rest of the f***ing owl

This commit is contained in:
Patrik Oldsberg
2021-01-13 23:52:10 +01:00
parent 492258d2a1
commit 70d8653bda
+346 -57
View File
@@ -16,15 +16,17 @@ composability system.
The core principle of the new composability system is that plugins should have
clear boundaries and connections. It should isolate crashes within a plugin, but
allow navigation between them. It should allow for plugins to be loaded only
when needed, and enable plugins to provide extension point for other plugins to
when needed, and enable plugins to provide extension points for other plugins to
build upon. The composability system is also built with an app-first mindset,
prioritizing simplicity and clarity in the app over plugins and core APIs.
prioritizing simplicity and clarity in the app over that in the plugins and core
APIs.
The new composability system isn't a single new API surface. It is a collection
of patterns, new primitives, new APIs, and old APIs used in new ways. At the
core is the new concept of Extensions, are exported by plugins for use in the
app. There is a new primitive called component data, which is used to connect
plugin and the app, and a new hook that provides a practical use of .
of patterns, primitives, new APIs, and old APIs used in new ways. At the core is
the new concept of extensions, which are exported by plugins for use in the app.
There is also a new primitive called component data, which assists in the
conversion to a more declarative app. The `RouteRef`s now have a clear purpose
as well, and can be used route to pages in a flexible way.
## New Concepts
@@ -36,7 +38,7 @@ in place to support the new composability system.
Component data is a new composability primitive that is introduced as a way to
provide a new data dimension for React components. Data is attached to React
components using a key, and is then readable from any JSX elements created with
those components using the same key, as illustrated by the following example:
those components, using the same key, as illustrated by the following example:
```tsx
const MyComponent = () => <h1>This is my component</h1>;
@@ -51,26 +53,26 @@ The purpose of component data is to provide a method for embedding data that can
be inspected before rendering elements. Element inspection is a pattern that is
quite common among React libraries, and used for example by `react-router` and
`material-ui` to discover properties of the child elements before rendering.
Though in those libraries only the element type and props are typically
Although in those libraries only the element type and props are typically
inspected, while our component data adds more structured access and simplifies
evolution by allowing for multiple different versions of a piece of data to be
used at once.
used and interpreted at once.
The initial use-cases for component data is support route and plugin discovery
through elements in the app. Through this we allow for the React element tree in
the app to be the source of truth, both for which plugins are used and all
top-level plugin routes in the app. The use of component data is not limited to
these use-cases though, as it can be used as a primitive to create new
the app to be the source of truth, both for which plugins are used, as well as
all top-level plugin routes in the app. The use of component data is not limited
to these use-cases though, as it can be used as a primitive to create new
abstractions as well.
### Extensions
Extensions are what plugins export for use in an app. Most typically they are
React components, but in practice they can be any kind of value. They are
created using `create*Extension` functions, and wrapped with `plugin.provide()`
in order to create the actual exported extension.
React components, but in practice they can be any kind of JavaScript value. They
are created using `create*Extension` functions, and wrapped with
`plugin.provide()` in order to create the actual exported extension.
The extension type is dead simple:
The extension type is a simple one:
```ts
export type Extension<T> = {
@@ -86,14 +88,14 @@ usage of the extension.
The Backstage core API currently provides two different types of extension
creators, `createComponentExtension`, and `createRoutableExtension`. Component
extensions are plain react component with no particular requirements, such as
cards for entity overview pages. The component will be exported more or less as
is, but is wrapped up to provide things like an error boundary, lazy loading,
and a plugin context.
extensions are plain React component with no particular requirements, for
example a card for an entity overview page. The component will be exported more
or less as is, but is wrapped to provide things like an error boundary, lazy
loading, and a plugin context.
Routable extensions build on top of component extensions and are used for any
component that should be rendered at a specific route path, such as full pages
or entity page tab content. When creating a routable extension you need to
component that should be rendered at a specific route path, such as top-level
pages or entity page tab content. When creating a routable extension you need to
supply a `RouteRef` as `mountPoint`. The mount point will be the handle of the
component for the outside world, and is used by other components and plugins
that wish to link to the routable component.
@@ -129,7 +131,8 @@ export const EntityFooCard = plugin.provide(
);
```
Routable extensions even enforce lazy loading, for example:
Routable extensions even enforce lazy loading, as it is the only way to provide
a component:
```ts
export const FooPage = plugin.provide(
@@ -144,8 +147,8 @@ export const FooPage = plugin.provide(
Right now all extensions are modelled as React components. The usage of these
extension is like regular usage of any React components, with one important
difference. Extensions must be all be part of a single React element tree
spanning from the root `AppProvider`.
difference. Extensions must all be part of a single React element tree spanning
from the root `AppProvider`.
For example, the following app code does **NOT** work:
@@ -168,8 +171,8 @@ const App = () => (
);
```
But it is simple to fix! Simply make sure that you don't create any intermediate
components in the app, for example like this:
But in this case it is simple to fix! Simply be sure to not create any
intermediate components in the app, for example like this:
```tsx
const appRoutes = (
@@ -198,10 +201,12 @@ concrete `path` for each `RouteRef` is discovered based on the element tree in
the app. Let's consider the following example:
```tsx
<Routes>
<Route path="/foo" element={<FooPage />} />
<Route path="/bar" element={<BarPage />} />
</Routes>
const appRoutes = (
<Routes>
<Route path="/foo" element={<FooPage />} />
<Route path="/bar" element={<BarPage />} />
</Routes>
);
```
We'll assume that `FooPage` and `BarPage` are routable extensions, exported by
@@ -214,24 +219,32 @@ Given the above example, the `fooRootRouteRef` will be associated with the
`RouteRef` though, as the routing structure is tied to the app's react tree. We
instead use the new `useRouteRef` hook if we want to create a concrete link to
the page. The `useRouteRef` hook takes a single `RouteRef` as its only
parameter, and returns a function that is called to create the URL.
parameter, and returns a function that is called to create the URL. For example
like this:
```tsx
const MyComponent = () => {
const fooRoute = useRouteRef(fooRouteRef);
return <a href={fooRoute()}>Link to Foo</a>;
};
```
Now let's assume that we want to link from the `BarPage` to the `FooPage`.
Before the introduction of the new composability system, we would do this by
importing the `fooRootRouteRef` from the `fooPlugin`. This created an
unnecessary dependency on the plugin, and also provided little flexibility
allowing the app to tie plugins together rather than the plugins themselves. To
handle this, we introduce the concept of `ExternalRouteRef`s. Much like regular
route refs, they can be passed to `useRouteRef` to create concrete URLs, but
they can not be used as mount points in routable component and instead have to
be associated with an actual using route bindings in the app.
unnecessary dependency on the plugin, and also provided little flexibility in
allowing the app to tie plugins together, with the links instead being dictated
by the plugins themselves. To solve this, we introduce `ExternalRouteRef`s. Much
like regular route references, they can be passed to `useRouteRef` to create
concrete URLs, but they can not be used as mount points in routable component
and instead have to be associated with a target route using route bindings in
the app.
The `ExternalRouteRef` inside the `barPlugin` should also not be opinionated
about what it is linking to either, allowing the app to decide the final target.
It should however provide context in how the link is presented or used, to make
it easier to understand the flow of the app. If the `BarPage` for example wants
to link to an external page in the header, it might declare an
`ExternalRouteRef` similar to this:
We create a new `ExternalRouteRef` inside the `barPlugin`, using a neutral name
that describes its role in the plugin rather than a specific plugin page that it
might be linking to, allowing the app to decide the final target. If the
`BarPage` for example wants to link to an external page in the header, it might
declare an `ExternalRouteRef` similar to this:
```ts
const headerLinkRouteRef = createExternalRouteRef();
@@ -239,10 +252,11 @@ const headerLinkRouteRef = createExternalRouteRef();
### Binding External Routes in the App
The association of external routes are controlled by the app. Each
`ExternalRouteRef` of a plugin is bound to an actual `RouteRef`, usually from
another plugin. The binding process happens once att app startup, and is then
used through the lifetime of the app to help resolve concrete route paths.
The association of external routes is controlled by the app. Each
`ExternalRouteRef` of a plugin should be<- bound to an actual `RouteRef`,
usually from another plugin. The binding process happens once att app startup,
and is then used through the lifetime of the app to help resolve concrete route
paths.
Using the above example of the `BarPage` linking to the `FooPage`, we might do
something like this in the app:
@@ -257,14 +271,15 @@ createApp({
});
```
Given the above binding, using `useRouteRef(external)`
Given the above binding, using `useRouteRef(headerLinkRouteRef)` within the
`barPlugin` will let us create a link whatever path the `FooPage` is mounted at.
Note that we are not importing and using the `RouteRef`s directly, and instead
rely on the plugin instance to access routes of the plugins. This is a new
convention that was introduced to provide better namespacing and discoverability
of routes, as well as reduce the number of different things exported from each
plugin package. The route references would be supplied to `createPlugin` like
this:
Note that we are not importing and using the `RouteRef`s directly in the app,
and instead rely on the plugin instance to access routes of the plugins. This is
a new convention that was introduced to provide better namespacing and
discoverability of routes, as well as reduce the number of separate exports from
each plugin package. The route references would be supplied to `createPlugin`
like this:
```ts
// In foo-plugin
@@ -284,12 +299,286 @@ export const barPlugin = createPlugin({
})
```
Also note that you almost always want to create the route references themselves
in a different file than the one that creates the plugin instance, for example a
top-level `routes.ts`. This is to avoid circular imports when you use the route
references from other parts of the app.
### Parameterized Routes
A new addition to `RouteRef`s is the possibility of adding named and typed
parameters. Parameters are declared at creation, and will enforce presence of
the parameters in the path in the app, and require them as a parameter when
using `useRouteRef`.
The following is an example of creation and usage of a parameterized route:
```tsx
// Creation of a parameterized route
const myRouteRef = createRouteRef({
title: 'My Named Route',
params: ['name']
})
// In the app, where MyPage is a routable extension with myRouteRef set as mountPoint
<Route path='/my-page/:name' element={<MyPage />}/>
// Usage within a component
const myRoute = useRouteRef(myRouteRef)
return (
<div>
<a href={myRoute({name: 'a'})}>A</a>
<a href={myRoute({name: 'b'})}>B</a>
</div>
)
```
It is currently not possible to have parameterized `ExternalRouteRef`s, or to
bind an external route to a parameterized route, although this may be added in
the future if needed.
### New Catalog Components
EntitySwitch & EntityLayout, and how to use those in the app
The established pattern for selecting what plugins should be available on each
catalog page is to use custom components in the app, with logic embedded in the
render function. Typically this takes form as a component that either receives
the entity via props or uses the `useEntity` hook to retrieve the selected
entity. A `switch` or `if` / `else if` chain is then used to select what
children should be rendered based on information in the entity.
TODO
This pattern will no longer work with the new composability system, and in
general is very difficult to build any form declarative model around, as it
depends on runtime execution. To help replace existing code, a new
`EntitySwitch` component has been added to the `@backstage/catalog` plugin,
which grabs the selected entity from context, and selects at most one element to
render using a list of `EntitySwitch.Case`s children.
For example, if you want all entities of kind `"Template"` to be rendered with a
`MyTemplate` component, and all other entities to be rendered with a `MyOther`
component, you would do the following:
```tsx
<EntitySwitch>
<EntitySwitch.Case if={isKind('template')}>
<MyTemplate />
</EntitySwitch.Case>
<EntitySwitch.Case>
<MyTemplate />
</EntitySwitch.Case>
</EntitySwitch>
// Shorter form if desired:
<EntitySwitch>
<EntitySwitch.Case if={isKind('template')} children={<MyTemplate />}/>
<EntitySwitch.Case children={<MyTemplate />}/>
</EntitySwitch>
```
The `EntitySwitch` component will render the children of the first
`EntitySwitch.Case` that returns `true` when the selected entity is passed to
the function of the `if` prop. If none of the cases match, no children will be
rendered, and if a case doesn't specify an `if` filter function, it will always
match. The `if` property is simply a function of the type
`(entity: Entity) => boolean`, for example, `isKind` can be implemented like
this:
```ts
function isKind(kind: string) {
return (entity: Entity) => entity.kind.toLowerCase() === kind.toLowerCase();
}
```
The `@backstage/catalog` plugin provides a couple of built-in conditions,
`isKind`, `isComponentType`, and `isNamespace`.
In addition to the `EntitySwitch` component, the catalog plugin also exports a
new `EntityLayout` component. It is a tweaked version and replacement for the
`EntityPageLayout` component, and is introduced more in depth in the app
migration section below.
## Porting Existing Plugins
There are a couple of high-level steps to porting an existing plugin to the new
composability system:
- Remove usage of `router.addRoute` or `router.registerRoute` within
`createPlugin`, and export the page components as routable extensions instead.
- Switch any `Router` export to instead be a routable extension.
- Change any plain component exports, such as catalog overview cards, to be
component extensions.
- Stop exporting `RouteRef`s and instead pass them to `createPlugin`.
- Stop accepting `RouteRef`s as props or importing them from other plugins,
instead create an `ExternalRouteRef` as a replacement, and pass it to
`createPlugin.`
- Rename any other exported symbols according to the naming pattern table below.
Note that removing the existing exports and configuration is a breaking change
in any plugin. If backwards compatibility is needed the existing code be
deprecated while making the new additions, to then be removed at a later point.
### Naming Patterns
Many export naming patterns have been changed to avoid import aliases and to
clarify intent. Refer to the following table to formulate the new name:
| Description | Existing Pattern | New Pattern | Examples |
| -------------------- | -------------------------- | --------------- | ---------------------------------------------- |
| Top-level Pages | Router | \*Page | CatalogIndexPage, SettingsPage, LighthousePage |
| Entity Tab Content | Router | Entity\*Content | EntityJenkinsContent, EntityKubernetesContent |
| Entity Overview Card | \*Card | Entity\*Card | EntitySentryCard, EntityPagerDutyCard |
| Entity Conditional | isPluginApplicableToEntity | is\*Available | isPagerDutyAvailable, isJenkinsAvailable |
| Plugin Instance | plugin | \*Plugin | jenkinsPlugin, catalogPlugin |
## Porting Existing Apps
The first step of porting any app is to replace the root `Routes` component with
`FlatRoutes` from `@backstage/core`. As opposed to the `Routes` component,
`FlatRoutes` only considers the first level of `Route` components in its
children, and provides any additional children to the outlet of the route. It
also removes the need to append `"/*"` to paths, as it is added automatically.
```diff
const AppRoutes = () => (
- <Routes>
+ <FlatRoutes>
...
- <Route path="/docs/*" element={<DocsRouter />} />
+ <Route path="/docs" element={<DocsRouter />} />
...
- </Routes>
+ </FlatRoutes>
);
```
The next step should be to switch from using `EntityPageLayout` to
`EntityLayout`, as this can also be done without waiting for plugins to be
ported. You should also replace the top-level `Router` from the catalog plugin
with the separate `CatalogIndexPage` and `CatalogEntityPage` extensions that
have been added to the catalog:
```diff
-<Route
- path={`${catalogRouteRef.path}/*`}
- element={<CatalogRouter EntityPage={EntityPage} />}
-/>
+<Route path="/catalog" element={<CatalogIndexPage />} />
+<Route
+ path="/catalog/:namespace/:kind/:name"
+ element={<CatalogEntityPage />}
+>
+ <EntityPage />
+</Route>
```
At that point you should flatten out the element tree as much as possible in the
app, removing any intermediate components. At the top level this should usually
be straightforward, but when reaching the catalog entity pages you may need to
wait for some plugins to be migrated. This is because it is no longer possible
to pass in the selected entity through component props, and it should be picked
up from context inside the plugin instead. See the sections below for how to
carry out migrations of some common entity page patterns.
Once the app element tree doesn't contain any intermediate components, and all
plugin imports have been switched to extensions rather than plain components,
the app has been fully ported.
### Switching from EntityPageLayout to EntityLayout
The existing `EntityPageLayout` is replaced by the new `EntityLayout` component,
which has a slightly different pattern for expressing the contents and paths.
Porting from the old to the new API is just a matter of moving some things
around. For example, given the following existing code:
```tsx
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<ComponentOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
element={<SentryRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/kubernetes/*"
title="Kubernetes"
element={<KubernetesRouter entity={entity} />}
/>
</EntityPageLayout>
```
It would be ported to this:
```tsx
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<ComponentOverviewContent entity={entity} />}
</EntityLayout.Route>
<EntityLayout.Route path="/sentry" title="Sentry">
<SentryRouter entity={entity} />}
</EntityLayout.Route>
<EntityLayout.Route path="/kubernetes" title="Kubernetes">
<KubernetesRouter entity={entity} />}
</EntityLayout.Route>
</EntityLayout>
```
In addition to the renaming, the `element` prop has been moved to `children`.
Also note that the `/*` suffix has been remove from the `"/kubernetes"` path, as
it's now added automatically.
Usage of the `EntityLayout` component is required to be able to properly
discover routes, and so it is required to apply this change before you can start
using routable entity content extensions from plugins.
### Porting Entity Pages
The established pattern in the app is to use custom components in order to
select what plugin components to render for a given entity. The new
`EntitySwitch` component introduced above is what is intended to replace this
pattern, now that the entire app needs to be rendered as a single element tree.
For example, given the following existing code:
```tsx
export const EntityPage = () => {
const { entity } = useEntity();
switch (entity?.kind?.toLowerCase()) {
case 'component':
return <ComponentEntityPage entity={entity} />;
case 'api':
return <ApiEntityPage entity={entity} />;
case 'group':
return <GroupEntityPage entity={entity} />;
case 'user':
return <UserEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
};
```
It would be migrated to this:
```tsx
export const entityPage = (
<EntitySwitch>
<EntitySwitch.Case if={isKind('component')} children={componentPage} />
<EntitySwitch.Case if={isKind('api')} children={apiPage} />
<EntitySwitch.Case if={isKind('group')} children={groupPage} />
<EntitySwitch.Case if={isKind('user')} children={userPage} />
<EntitySwitch.Case children={defaultPage} />
</EntitySwitch>
);
```
Note that for example `<ComponentEntityPage ... />` has been changed to simply
`componentPage`, that is because just like the `EntityPage` component, the
`ComponentEntityPage` also needs to be ported to be an element rather a
component in a similar way.