Merge pull request #33836 from backstage/rugvip/explore-standard-schema-decoupling
frontend-plugin-api: decouple zod dependency using Standard Schema
This commit is contained in:
@@ -71,30 +71,25 @@ app:
|
||||
|
||||
### Customizations
|
||||
|
||||
Plugin developers can use the `createSearchResultItemExtension` factory provided by the `@backstage/plugin-search-react` for building their own custom `Search` result item extensions.
|
||||
Plugin developers can use the `SearchResultListItemBlueprint` from `@backstage/plugin-search-react/alpha` for building their own custom search result item extensions.
|
||||
|
||||
_Example creating a custom `TechDocsSearchResultItemExtension`_
|
||||
|
||||
```tsx
|
||||
// plugins/techdocs/alpha.tsx
|
||||
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
|
||||
// plugins/techdocs/src/alpha.tsx
|
||||
import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha';
|
||||
|
||||
/** @alpha */
|
||||
export const TechDocsSearchResultListItemExtension =
|
||||
createSearchResultListItemExtension({
|
||||
id: 'techdocs',
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
noTrack: z.boolean().default(false),
|
||||
lineClamp: z.number().default(5),
|
||||
}),
|
||||
),
|
||||
predicate: result => result.type === 'techdocs',
|
||||
component: async ({ config }) => {
|
||||
const { TechDocsSearchResultListItem } = await import(
|
||||
'./components/TechDocsSearchResultListItem'
|
||||
);
|
||||
return props => <TechDocsSearchResultListItem {...props} {...config} />;
|
||||
SearchResultListItemBlueprint.make({
|
||||
name: 'techdocs',
|
||||
params: {
|
||||
predicate: result => result.type === 'techdocs',
|
||||
component: async ({ config }) => {
|
||||
const { TechDocsSearchResultListItem } = await import(
|
||||
'./components/TechDocsSearchResultListItem'
|
||||
);
|
||||
return props => <TechDocsSearchResultListItem {...props} {...config} />;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -107,110 +102,42 @@ When a Backstage adopter doesn't want to use the custom `TechDocs` search result
|
||||
# app-config.yaml
|
||||
app:
|
||||
extensions:
|
||||
- plugin.search.result.item.techdocs: false # ✨
|
||||
- search-result-list-item:techdocs: false
|
||||
```
|
||||
|
||||
Because a configuration schema was provided to the extension factory, Backstage adopters will be able to customize `TechDocs` search results **line clamp** that defaults to 3 and also **disable automatic analytics events tracking**:
|
||||
The `SearchResultListItemBlueprint` includes a built-in `noTrack` config option that can be used to **disable automatic analytics events tracking**:
|
||||
|
||||
```yaml
|
||||
# app-config.yaml
|
||||
app:
|
||||
extensions:
|
||||
- plugin.search.result.item.techdocs:
|
||||
config: # ✨
|
||||
- search-result-list-item:techdocs:
|
||||
config:
|
||||
noTrack: true
|
||||
lineClamp: 3
|
||||
```
|
||||
|
||||
[comment]: <> (TODO: Extract this explanation to a more central place in the future)
|
||||
The `createSearchResultItemExtension` function returns a Backstage's extension representation as follows:
|
||||
To complete the development cycle for creating a custom search result item extension, provide the extension via the `TechDocs` plugin. You can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha/index.tsx) of a custom `TechDocs` search result item:
|
||||
|
||||
```ts
|
||||
{
|
||||
"$$type": "@backstage/Extension", // [1]
|
||||
"id": "plugin.search.result.item.techdocs", // [2]
|
||||
"at": "plugin.search.page/items", // [3]
|
||||
"inputs": {} // [4️]
|
||||
"output": { // [5️]
|
||||
"item": {
|
||||
"$$type": "@backstage/ExtensionDataRef",
|
||||
"id": "plugin.search.result.item.data",
|
||||
"config": {}
|
||||
}
|
||||
},
|
||||
"configSchema": { // [6️]
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"noTrack": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"lineClamp": {
|
||||
"type": "number",
|
||||
"default": 5
|
||||
}
|
||||
```tsx
|
||||
// plugins/techdocs/src/alpha.tsx
|
||||
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha';
|
||||
|
||||
const TechDocsSearchResultListItemExtension =
|
||||
SearchResultListItemBlueprint.make({
|
||||
name: 'techdocs',
|
||||
params: {
|
||||
predicate: result => result.type === 'techdocs',
|
||||
component: async ({ config }) => {
|
||||
const { TechDocsSearchResultListItem } = await import(
|
||||
'./components/TechDocsSearchResultListItem'
|
||||
);
|
||||
return props => <TechDocsSearchResultListItem {...props} {...config} />;
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
},
|
||||
"disabled": false, // [7️]
|
||||
}
|
||||
```
|
||||
|
||||
In this object, you can see exactly what will happen once the custom extension is installed:
|
||||
|
||||
- **[1] `$$type`**: declares that the object represents an extension;
|
||||
- **[2] `id`**: Is a unique identification for the extension, the `plugin.search.result.item.techdocs` is the key used to configure the extension in the `app-config.yaml` file;
|
||||
- **[3] `at`**: It represents the extension attachment point, so the value `plugin.search.page/items` says that the `TechDocs`'s search result item output will be injected as input on the "items" attachment expected by the search page extension;
|
||||
- **[4] `inputs`**: in this case is an empty object because this extension doesn't expect inputs;
|
||||
- **[5] `output`**: Object representing the artifact produced by the `TechDocs` result item extension, on the example, it is a react component reference;
|
||||
- **[6] `configSchema`**: represents the `TechDocs` search result item configuration definition, this is the same schema that adopters will use for customizing the extension via `app-config.yaml` file;
|
||||
- **[7] `disable`**: Says that the result item extension will be enable by default when the `TechDocs` plugin is installed in the app.
|
||||
|
||||
To complete the development cycle for creating a custom search result item extension, we should provide the extension via `TechDocs` plugin:
|
||||
|
||||
```tsx
|
||||
// plugins/techdocs/alpha.tsx
|
||||
import { createPlugin } from "@backstage/frontend-plugin-api";
|
||||
|
||||
// plugins should be always exported as default
|
||||
export default createPlugin({
|
||||
id: 'techdocs'
|
||||
extensions: [TechDocsSearchResultItemExtension]
|
||||
})
|
||||
```
|
||||
|
||||
Here is the `plugins/techdocs/alpha/index.tsx` final version, and you can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha/index.tsx) of a custom `TechDocs` search result item:
|
||||
|
||||
```tsx
|
||||
// plugins/techdocs/alpha.tsx
|
||||
import { createPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
|
||||
|
||||
/** @alpha */
|
||||
export const TechDocsSearchResultListItemExtension =
|
||||
createSearchResultListItemExtension({
|
||||
id: 'techdocs',
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
noTrack: z.boolean().default(false),
|
||||
lineClamp: z.number().default(5),
|
||||
}),
|
||||
),
|
||||
predicate: result => result.type === 'techdocs',
|
||||
component: async ({ config }) => {
|
||||
const { TechDocsSearchResultListItem } = await import(
|
||||
'./components/TechDocsSearchResultListItem'
|
||||
);
|
||||
return props => <TechDocsSearchResultListItem {...props} {...config} />;
|
||||
},
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export default createPlugin({
|
||||
// plugins should be always exported as default
|
||||
export default createFrontendPlugin({
|
||||
id: 'techdocs',
|
||||
extensions: [TechDocsSearchResultListItemExtension],
|
||||
});
|
||||
|
||||
@@ -275,18 +275,18 @@ In addition to being able to access data passed through the input, you also have
|
||||
|
||||
## 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. Therefore 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:
|
||||
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. Therefore we created a possibility to configure each extension individually through config. The extension config schema is created using any schema library that implements the [Standard Schema](https://github.com/standard-schema/standard-schema) interface with JSON Schema support, such as [`zod`](https://zod.dev/) v4 (or `import { z } from 'zod/v4'` from the zod v3 package). In addition to TypeScript type checking, the schema 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:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
const navigationExtension = createExtension({
|
||||
// ...
|
||||
namespace: 'app',
|
||||
name: 'nav',
|
||||
// [3]: Extension `id` will be `app/nav` following the extension naming pattern
|
||||
config: {
|
||||
schema: {
|
||||
title: z => z.string().default('Sidebar Title'),
|
||||
},
|
||||
configSchema: {
|
||||
title: z.string().default('Sidebar Title'),
|
||||
},
|
||||
factory({ config }) {
|
||||
return [
|
||||
@@ -321,12 +321,12 @@ In all examples so far we have defined the extension factory as a regular functi
|
||||
For example, this is how we could define an extension where its output depends on the configuration:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
const exampleExtension = createExtension({
|
||||
// ...
|
||||
config: {
|
||||
schema: {
|
||||
disableIcon: z.boolean().default(false),
|
||||
},
|
||||
configSchema: {
|
||||
disableIcon: z.boolean().default(false),
|
||||
},
|
||||
output: [coreExtensionData.reactElement, iconDataRef.optional()],
|
||||
*factory({ config }) {
|
||||
|
||||
@@ -31,12 +31,12 @@ Every extension blueprint also provides a `makeWithOverrides` method. It is usef
|
||||
The following is an example of how one might use the blueprint `makeWithOverrides` method to create a new extension:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
const myPageExtension = PageBlueprint.makeWithOverrides({
|
||||
// This defines additional configuration options for the extension.
|
||||
config: {
|
||||
schema: {
|
||||
layout: z => z.enum(['grid', 'rows']).default('grid'),
|
||||
},
|
||||
configSchema: {
|
||||
layout: z.enum(['grid', 'rows']).default('grid'),
|
||||
},
|
||||
// This defines additional inputs for the extension.
|
||||
inputs: {
|
||||
@@ -118,10 +118,8 @@ export interface MyWidgetBlueprintParams {
|
||||
export const MyWidgetBlueprint = createExtensionBlueprint({
|
||||
kind: 'my-widget',
|
||||
attachTo: { id: 'page:my-plugin', input: 'widgets' },
|
||||
config: {
|
||||
schema: {
|
||||
title: z.string().optional(),
|
||||
},
|
||||
configSchema: {
|
||||
title: z.string().optional(),
|
||||
},
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory(params: MyWidgetBlueprintParams, { config }) {
|
||||
@@ -196,10 +194,8 @@ const widgetTitleRef = createExtensionDataRef<string>().with({
|
||||
export const MyWidgetBlueprint = createExtensionBlueprint({
|
||||
kind: 'my-widget',
|
||||
attachTo: { id: 'page:my-plugin', input: 'widgets' },
|
||||
config: {
|
||||
schema: {
|
||||
title: z.string().optional(),
|
||||
},
|
||||
configSchema: {
|
||||
title: z.string().optional(),
|
||||
},
|
||||
output: [widgetTitleRef, coreExtensionData.reactElement],
|
||||
factory(params: MyWidgetBlueprintParams, { config }) {
|
||||
|
||||
@@ -182,20 +182,18 @@ const myOverrideExtension = myExtension.override({
|
||||
Overriding the configuration schema works very similarly to overriding the declared inputs. You can define new configuration fields that will be merged with the existing ones, but you can not re-declare existing fields. The following example shows how to override an extension and add a new configuration field:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
const exampleExtension = createExtension({
|
||||
config: {
|
||||
schema: {
|
||||
foo: z => z.string(),
|
||||
},
|
||||
configSchema: {
|
||||
foo: z.string(),
|
||||
},
|
||||
// ...
|
||||
});
|
||||
|
||||
const overrideExtension = exampleExtension.override({
|
||||
config: {
|
||||
schema: {
|
||||
bar: z => z.string(),
|
||||
},
|
||||
configSchema: {
|
||||
bar: z.string(),
|
||||
},
|
||||
factory(originalFactory, { config }) {
|
||||
//
|
||||
@@ -210,12 +208,12 @@ const overrideExtension = exampleExtension.override({
|
||||
In all examples so far we have called the `originalFactory` callback without any arguments. It is however possible to override parts of the factory context for the original factory using the first parameter of the original factory. This can be useful if you want to override the provided configuration or change the inputs in some way. Note that if you are implementing a `factory` for a blueprint, the override factory context will instead be the second parameter of the original factory function. The following is an example of how to override the configuration for the original factory:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
const exampleExtension = createExtension({
|
||||
name: 'example',
|
||||
config: {
|
||||
schema: {
|
||||
layout: z => z.enum(['grid', 'list']).optional(),
|
||||
},
|
||||
configSchema: {
|
||||
layout: z.enum(['grid', 'list']).optional(),
|
||||
},
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ config }) => [
|
||||
|
||||
@@ -11,6 +11,86 @@ This section provides migration guides for different versions of the frontend sy
|
||||
|
||||
This guide is intended for app and plugin authors who have already migrated their code to the new frontend system, and are looking to keep it up to date with the latest changes. These guides do not cover trivial migrations that can be explained in a deprecation message, such as a renamed export.
|
||||
|
||||
## 1.50
|
||||
|
||||
### New `configSchema` option for extension config
|
||||
|
||||
The `config.schema` option for `createExtension` and `createExtensionBlueprint` is now deprecated in favor of a new top-level `configSchema` option. The new option accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library with JSON Schema support, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed.
|
||||
|
||||
The `configSchema` option requires schemas that implement the Standard Schema interface with JSON Schema support. This means you need to use [zod v4](https://zod.dev/) or the `zod/v4` subpath export from the zod v3 package (v3.25+). Direct zod v3 schemas are **not** supported by the new `configSchema` option — they are only supported through the deprecated `config.schema` callback format.
|
||||
|
||||
For example, an extension previously declared like this:
|
||||
|
||||
```tsx
|
||||
createExtension({
|
||||
// ...
|
||||
config: {
|
||||
schema: {
|
||||
title: z => z.string().default('Hello'),
|
||||
count: z => z.number().optional(),
|
||||
},
|
||||
},
|
||||
factory({ config }) {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Should now look like this, using zod v4 or the `zod/v4` subpath:
|
||||
|
||||
```tsx
|
||||
// Either import from zod v4 directly:
|
||||
import { z } from 'zod';
|
||||
// Or use the v4 subpath from the zod v3 package:
|
||||
// import { z } from 'zod/v4';
|
||||
|
||||
createExtension({
|
||||
// ...
|
||||
configSchema: {
|
||||
title: z.string().default('Hello'),
|
||||
count: z.number().optional(),
|
||||
},
|
||||
factory({ config }) {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The same applies to `createExtensionBlueprint`:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
const MyBlueprint = createExtensionBlueprint({
|
||||
// ...
|
||||
configSchema: {
|
||||
title: z.string().default('Hello'),
|
||||
},
|
||||
factory(params, { config }) {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
And when adding config through `makeWithOverrides`:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
MyBlueprint.makeWithOverrides({
|
||||
configSchema: {
|
||||
extra: z.string(),
|
||||
},
|
||||
factory(originalFactory, { config }) {
|
||||
return originalFactory({
|
||||
// ...
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Each field in the `configSchema` record is a standalone schema value rather than a factory function. This decouples the config schema declaration from any specific zod version, and lets you use any schema library that implements the Standard Schema interface with JSON Schema support.
|
||||
|
||||
## 1.31
|
||||
|
||||
### `namespace` parameter should be removed
|
||||
|
||||
@@ -94,11 +94,11 @@ The extension ID of the work API will be the kind `api:` followed by the plugin
|
||||
Here we will describe how to amend a utility API with the capability of having extension config, which is driven by [your app-config](../../conf/writing.md). You do this by giving an extension config schema to your API extension factory function. Let's refactor the example above to also accept configuration, which will require us to use the [override method of the blueprint](../architecture/23-extension-blueprints.md#creating-an-extension-from-a-blueprint-with-overrides).
|
||||
|
||||
```tsx title="in @internal/plugin-example"
|
||||
import { z } from 'zod';
|
||||
|
||||
const exampleWorkApi = ApiBlueprint.makeWithOverrides({
|
||||
config: {
|
||||
schema: {
|
||||
goSlow: z => z.boolean().default(false),
|
||||
},
|
||||
configSchema: {
|
||||
goSlow: z.boolean().default(false),
|
||||
},
|
||||
factory(originalFactory, { config }) {
|
||||
return originalFactory({
|
||||
|
||||
Reference in New Issue
Block a user