Skip to content

Commit

Permalink
feat: add resolveFields API for dynamic fields
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisvxd committed Apr 24, 2024
1 parent aedd401 commit 0a18bdb
Show file tree
Hide file tree
Showing 11 changed files with 429 additions and 59 deletions.
10 changes: 10 additions & 0 deletions apps/demo/config/blocks/Hero/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ export const Hero: ComponentConfig<HeroProps> = {
readOnly: { title: true, description: true },
};
},
resolveFields: async (data, { fields }) => {
if (data.props.align === "center") {
return {
...fields,
image: undefined,
};
}

return fields;
},
render: ({ align, title, description, buttons, padding, image }) => {
// Empty state allows us to test that components support hooks
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand Down
121 changes: 113 additions & 8 deletions apps/docs/pages/docs/api-reference/configuration/component-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ const config = {

## Params

| Param | Example | Type | Status |
| ------------------------------------------ | ----------------------------------------------- | -------- | -------- |
| [`render()`](#renderprops) | `render: () => <div />` | Function | Required |
| [`fields`](#fields) | `fields: { title: { type: "text"} }` | Object | - |
| [`defaultProps`](#defaultprops) | `defaultProps: { title: "Hello, world" }` | Object | - |
| [`label`](#label) | `label: "Heading Block"` | String | - |
| [`resolveData()`](#resolvedatadata-params) | `resolveData: async ({ props }) => ({ props })` | Object | - |
| Param | Example | Type | Status |
| ---------------------------------------------- | ----------------------------------------------- | -------- | -------- |
| [`render()`](#renderprops) | `render: () => <div />` | Function | Required |
| [`fields`](#fields) | `fields: { title: { type: "text"} }` | Object | - |
| [`defaultProps`](#defaultprops) | `defaultProps: { title: "Hello, world" }` | Object | - |
| [`label`](#label) | `label: "Heading Block"` | String | - |
| [`resolveData()`](#resolvedatadata-params) | `resolveData: async ({ props }) => ({ props })` | Object | - |
| [`resolveFields()`](#resolvefieldsdata-params) | `resolveFields: async ({ props }) => ({})}` | Object | - |

## Required params

Expand Down Expand Up @@ -259,7 +260,7 @@ The fields currently set to read-only for this component.

##### `params.changed`

An object describing which fields have changed on this component since the last time `resolveData` was called.
An object describing which props have changed on this component since the last time `resolveData` was called.

```tsx copy {2-4} /changed/1 filename="Example only updating 'resolvedTitle' when 'title' changes"
const resolveData = async ({ props }, { changed }) => {
Expand Down Expand Up @@ -303,3 +304,107 @@ const resolveData = async ({ props }) => {
};
};
```

### `resolveFields(data, params)`

Dynamically set the fields for this component. Can be used to make asynchronous calls.

This function is triggered when the component data changes.

```tsx {4-15} copy filename="Example changing one field based on another"
const config = {
components: {
MyComponent: {
resolveFields: (data) => ({
fieldType: {
type: "radio",
options: [
{ label: "Text", value: "text" },
{ label: "Textarea", value: "textarea" },
],
},
title: {
type: data.props.fieldType,
},
}),
render: ({ title }) => <h1>{title}</h1>,
},
},
};
```

<ConfigPreview
label='Try changing the "title" field'
componentConfig={{
resolveFields: (data) => ({
fieldType: {
type: "radio",
options: [
{ label: "Text", value: "text" },
{ label: "Textarea", value: "textarea" },
],
},
title: {
type: data.props.fieldType,
},
}),
defaultProps: {
fieldType: "text",
title: "Hello, world",
},
render: ({ title }) => <p>{title}</p>,
}}
/>

#### Args

| Prop | Example | Type |
| -------- | ------------------------------------------------------------------------- | ------ |
| `data` | `{ props: { title: "Hello, world" }, readOnly: {} }` | Object |
| `params` | `{ appState: {}, changed: {}, fields: {}, lastData: {}, lastFields: {} }` | Object |

##### `data.props`

The current props for the selected component.

##### `data.readOnly`

The fields currently set to read-only for this component.

##### `params.appState`

An object describing the [AppState](/docs/api-reference/app-state).

##### `params.changed`

An object describing which props have changed on this component since the last time this function was called.

```tsx copy {2-4} /changed/1 filename="Example only updating the fields when 'fieldType' changes"
const resolveFields = async ({ props }, { changed, lastFields }) => {
if (!changed.fieldType) {
return lastFields;
}

return {
title: {
type: fieldType,
},
};
};
```

##### `params.fields`

The static fields for this component as defined by [`fields`](#fields).

##### `params.lastData`

The data object from the previous run of this function.

##### `params.lastFields`

The last fields object created by the previous run of this function.

#### Returns

A [`fields`](#fields) object.
1 change: 1 addition & 0 deletions apps/docs/pages/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Puck is also licensed under MIT, making it suitable for both internal systems an
| [Multi-column Layouts](/docs/integrating-puck/multi-column-layouts) | Use DropZones to build more multi-column layouts by nesting components. |
| [Categories](/docs/integrating-puck/categories) | Group your components in the side bar. |
| [Dynamic Props](/docs/integrating-puck/dynamic-props) | Dynamically set props after user input and mark fields as read-only |
| [Dynamic Fields](/docs/integrating-puck/dynamic-fields) | Dynamically set fields based on user input |
| [External Data Sources](/docs/integrating-puck/external-data-sources) | Load content from a third-party CMS or other data source |
| [Server Components](/docs/integrating-puck/server-components) | Opt-in support for React Server Components |
| [Data Migration](/docs/integrating-puck/data-migration) | Migrate between breaking Puck releases and your own breaking prop changes |
Expand Down
1 change: 1 addition & 0 deletions apps/docs/pages/docs/integrating-puck/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"multi-column-layouts": {},
"categories": {},
"dynamic-props": {},
"dynamic-fields": {},
"external-data-sources": {},
"server-components": {},
"data-migration": {},
Expand Down
141 changes: 141 additions & 0 deletions apps/docs/pages/docs/integrating-puck/dynamic-fields.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { ConfigPreview } from "@/docs/components/Preview";

# Dynamic Fields

Dynamic field resolution allows you to change the [field configuration](/docs/api-reference/configuration/component-config#fields) for a component based on the current component props.

## Dynamic component fields

The [`resolveFields` function](/docs/api-reference/configuration/component-config#resolvefieldsdata-params) allows you to make synchronous and asynchronous changes to the field configuration.

For example, we can set the configuration of one field based on the prop value of another:

```tsx {4-15} showLineNumbers copy
const config = {
components: {
MyComponent: {
resolveFields: (data) => ({
fieldType: {
type: "radio",
options: [
{ label: "Number", value: "number" },
{ label: "Text", value: "text" },
],
},
value: {
type: data.props.fieldType,
},
}),
render: ({ value }) => <h1>{value}</h1>,
},
},
};
```

<ConfigPreview
label='Try changing the "title" field'
componentConfig={{
resolveFields: (data) => ({
fieldType: {
type: "radio",
options: [
{ label: "Text", value: "text" },
{ label: "Textarea", value: "textarea" },
],
},
title: {
type: data.props.fieldType,
},
}),
defaultProps: {
fieldType: "text",
title: "Hello, world",
},
render: ({ title }) => <p>{title}</p>,
}}
/>

### Making asynchronous calls

The [`resolveFields` function](/docs/api-reference/configuration/component-config#resolvefieldsdata-params) also enables asynchronous calls.

Here's an example populating the options for a [`select` field](/docs/api-reference/fields/select) based on a [`radio` field](/docs/api-reference/fields/radio)

```tsx {4-24} showLineNumbers copy
const config = {
components: {
MyComponent: {
resolveFields: async (data, { changed, lastFields }) => {
// Don't call the API unless `category` has changed
if (!changed.category) return lastFields;

// Make an asynchronous API call to get the options
const options = await getOptions(data.category);

return {
category: {
type: "radio",
options: [
{ label: "Fruit", value: "fruit" },
{ label: "Vegetables", value: "vegetables" },
],
},
item: {
type: "select",
options,
},
};
},
render: ({ value }) => <h1>{value}</h1>,
},
},
};
```

<ConfigPreview
label='Try changing the "category" field'
componentConfig={{
resolveFields: async (data, { changed, lastFields }) => {
if (!changed.category) return lastFields;

await new Promise((resolve) => setTimeout(resolve, 500));

return {
category: {
type: "radio",
options: [
{ label: "Fruit", value: "fruit" },
{ label: "Vegetables", value: "vegetables" },
],
},
item: {
type: "select",
options:
data.props.category === "fruit"
? [
{ label: "Select a fruit", value: "" },
{ label: "Apple", value: "apple" },
{ label: "Orange", value: "orange" },
{ label: "Tomato", value: "tomato" }
] : [
{ label: "Select a vegetable", value: "" },
{ label: "Broccoli", value: "broccoli" },
{ label: "Cauliflower", value: "cauliflower" },
{ label: "Mushroom", value: "mushroom" },
],
},
};
},

defaultProps: {
category: "fruit",
item: "",
},
render: ({ item }) => <p>{item}</p>,

}}
/>

## Further reading

- [`resolveFields` API reference](/docs/api-reference/configuration/component-config#resolvefieldsdata-params)
Loading

0 comments on commit 0a18bdb

Please sign in to comment.