Support resolving slot fields dynamically
Description
When using the resolveFields API to define slot fields dynamically, the slots are not passed correctly to the render function. The resolved slot fields appear in console.log output from resolveFields, but are missing or undefined in the render props.
This was originally a bug with the following reproduction steps, but it is now an official limitation:
- Use the following config with a component that resolves a slot dynamically:
Container: {
resolveFields: (data, params) => {
let newFields = {
numberSlots: { type: "number" },
};
if (params.changed.numberSlots && data.props.numberSlots > 0) {
newFields.Items = { type: "slot" };
}
console.log(newFields); // Has the slot
return newFields;
},
render: ({ numberSlots, Items }) => {
console.log(Items); // is undefined
return <Items />;
},
}
- Drag and drop a Container and increment its numberSlots field
What happens
The dynamically resolved slot is present in the output of resolveFields, but it does not appear in the render function. It's undefined, and cannot be used.
What I expect to happen
Slot fields defined dynamically in resolveFields should be passed correctly to the render function, just like statically defined fields.
Considerations
- resolveFields only currently gets called when visually showing the field UI
- Adding / removing slots dynamically makes reasoning about the current shape of the data extremely difficult
Using fields alongside with resolve fields works fine:
Just have the Items slot declared in the fields object. It's not visible in <Fields /> component so won't matter where it is declared.
import React from "react";
import { ComponentConfig, Fields, Slot } from "@measured/puck";
export type BaseProps = {
type: "number" | "string";
value: number | string;
hasChild: true;
items: Slot;
};
export const Base: ComponentConfig<BaseProps> = {
label: "Nested Component",
fields: {
type: {
type: "radio",
label: "Type",
options: [
{ label: "String", value: "string" },
{ label: "Number", value: "number" },
],
},
value: {
type: "text",
label: "Value",
},
hasChild: {
type: "radio",
label: "Has Child",
options: [
{ label: "Yes", value: true },
{ label: "No", value: false },
],
},
items: {
type: "slot",
},
},
resolveFields: ({ props }, { fields }) => {
if (props.type === "number") {
fields.value.type = "number";
} else {
fields.value.type = "text";
}
return fields;
},
inline: true,
defaultProps: {
type: "string",
value: "Hi There",
hasChild: true,
items: [],
},
render: ({ value, hasChild, puck, items: Items }) => {
return (
<div ref={puck.dragRef}>
<div>{hasChild ? "Child" : value}</div>
{hasChild && <Items />}
</div>
);
},
};
https://github.com/user-attachments/assets/90ae8dc8-fcd2-45da-b368-132dfcb808ac
Hey @OsamuBlack,
Nice workaround! And yes, defining slots statically works. What doesn’t work is defining them dynamically, which, as you pointed out, might not be necessary for many use cases since your approach works well.
In the end, we decided to keep this as a limitation of slot fields, as mentioned here.
@FedericoBonel I would like to return to the topic of dynamic slots:
The current limitation prevents (as I understand it) the structure from coming from an API.
For example: I have a “grid” component with a “columns” field. A grid with slots is now to be displayed using “columns.” This does not currently work because the fields in the slots cannot be dynamic.
Currently, I get around this by always defining 12 slots for a grid in the fields-property:
if (component.type === "grid") {
// the real-slot count is the component.section.columns
// as dynamic-slots are currently not supported (https://puckeditor.com/docs/integrating-puck/dynamic-fields#limitations)
// we use "12" slots per grid as default
for (let i = 0; i < 12; i++) {
fields[`slot-${i + 1}`] = {
type: "slot"
};
}
}
In der render() callback I match those idents back:
render: ({ id, puck, ...slots }) => {
// ...
const _slots = {};
Object.keys(slots).forEach(name => {
if (name.startsWith("slot-")) {
const Slot = slots[name];
const slotNumber = name.replace("slot-", "");
_slots[slotNumber] = <Slot key={`${name}`} />;
}
});
This works. The only issue I got currently is, that there are always 12 Slots defined in the overview:
Are there really no plans to support dynamic fields for slots?
Hey @domhaas!
While it’s true you can’t dynamically set slot fields with resolveFields, you can support dynamic arrays of slots. The trick is to use an array field that contains slot fields inside it, that way, when you add an array item, it creates a new slot.
Since your setup uses another field to control the number of columns though, you’d likely need a hidden array field and then use resolveData to keep it in sync. Something like this:
MultiColumns: {
fields: {
// Define the array field with nested slots
columns: {
type: "array",
arrayFields: {
content: { type: "slot" },
},
// Hide the field so the user doesn’t see it
visible: false,
},
// Field to control the number of slots
numZones: { type: "number" },
},
defaultProps: {
columns: [],
numZones: 0,
},
resolveData: (data, params) => {
// Skip if numZones hasn’t changed or is invalid
if (!params.changed.numZones || data.props.numZones < 0) {
return data;
}
const newData = { ...data, props: { ...data.props } };
newData.props.columns = [];
// Rebuild slots based on numZones
for (let i = 0; i < newData.props.numZones; i++) {
newData.props.columns.push(data.props.columns[i] ?? { content: [] });
}
return newData;
},
render: ({ columns }) => (
<div>
{columns.map(({ content: Content }, index) => (
<Content key={index} />
))}
</div>
),
}
Would this solve your problem?
Hey @domhaas!
While it’s true you can’t dynamically set slot fields with
resolveFields, you can support dynamic arrays of slots. The trick is to use an array field that contains slot fields inside it, that way, when you add an array item, it creates a new slot.Since your setup uses another field to control the number of columns though, you’d likely need a hidden array field and then use
resolveDatato keep it in sync. Something like this:MultiColumns: { fields: { // Define the array field with nested slots columns: { type: "array", arrayFields: { content: { type: "slot" }, }, // Hide the field so the user doesn’t see it visible: false, }, // Field to control the number of slots numZones: { type: "number" }, }, defaultProps: { columns: [], numZones: 0, }, resolveData: (data, params) => { // Skip if numZones hasn’t changed or is invalid if (!params.changed.numZones || data.props.numZones < 0) { return data; }
const newData = { ...data, props: { ...data.props } }; newData.props.columns = []; // Rebuild slots based on numZones for (let i = 0; i < newData.props.numZones; i++) { newData.props.columns.push(data.props.columns[i] ?? { content: [] }); } return newData;}, render: ({ columns }) => (
{columns.map(({ content: Content }, index) => ( <Content key={index} /> ))}), } Would this solve your problem?
@FedericoBonel First of all, thank you very much for your quick help.
I didn't mention that I store the props completely separately from the puck data—the reason for this is that the configuration of the individual components comes from an API. Ultimately, I only use the structure with Puck and then dynamically resolve the rendering for each component.
This works really well, except for the case with the slots. Here, the information about the number of columns also comes from the API.
I tested your proposed solution with the array. The problem here is that I can't work with “params.changed,” for example, so I have a timing problem with the onChange() event and a save call to the API triggered by it.
This is all very proprietary and perhaps even an absolute edge case. But I still think that dynamic fields would be the right way to go for the slots, since a slot is ultimately just a type.
Nevertheless, as I said, thank you very much for the great work and your help. I'll keep experimenting ;)
We’re definitely planning to take this on at some point, because we agree, it’s odd that this is the only field not supported by the resolveFields API.
If the issue is that, for some reason (which I can’t fully understand without seeing your implementation), you can’t use params.changed, you could still resolve your props from the API inside the resolveData function. That was the original purpose of this function anyway, and it supports async callbacks.
You could then compare the values you receive from the API with the previous field values, and if they’re different, you know they’ve changed. That section of the code is mostly an optimization, so in theory it should still work. But again, without looking at your implementation, it’s hard to know exactly where this limitation is coming from, you probably have internal reasons why it’s not working as expected.
Sorry I can’t be more helpful right now 😬
@FedericoBonel I also think that dynamic fields for slots would be very helpful for complex scenarios. To give you a better understanding of my current implementation, I have summarized the steps so that you can understand my logic.
In principle, the structure and the config (component props) are processed separately so that they fit into the existing API data structure.
1.) Drop Component inside Dropzone
2.) Structure (data) is submitted to the API
3.) A Drawer is opening with a dedicated Form (json-schema) with Input of the col-count which came earlier from the API
4.) Form is submitted to the API
5.) On the API-Side the structure and the components (with all props) are stored completely separated
6.) Finally: the structure and the components are coming back as a response to the frontend and now in the frontend those to parts are matched together
For the average user, this scenario is certainly not a use case, but it is very helpful for integrating puck into an existing structure.
For now, I will leave it in my primary implementation (which works, except for the “problem” that 12 columns are always created).
Thanks again!
Blocked by #1299