Skip to content

Commit a8ee5cf

Browse files
akoclaude
andcommitted
feat: serialize object-list child blocks to BSON (Phase 1 Layer 3 executor)
Refs: mendixlabs#538 Closes the gap between the def.json `objectLists` metadata (Layer 1+2) and end-to-end MDL → BSON for pluggable widgets that have list-of-object properties (Accordion groups, PopupMenu basicItems / customItems, AreaChart series, Maps markers / dynamicMarkers). Backend (mdl/backend/mutation.go, mdl/backend/mpr/widget_builder.go): - New ObjectListItemSpec / ObjectListItemProperty types describe one item of an object-list property, with operation-dispatched scalar fields (primitive / attribute / expression / texttemplate / datasource / action) plus a ChildWidgets map for widgets-typed sub-properties. - New WidgetObjectBuilder.SetObjectList(propertyKey, items) interface method, implemented in mpr backend by walking the parent property's NestedPropertyIDs to build each item's BSON: per sub-property, take the template default and overlay the spec value (PrimitiveValue / Expression / TextTemplate / AttributeRef / DataSource / Action / Widgets). Executor (mdl/executor/widget_engine.go): - New PluggableWidgetEngine.applyObjectLists step matches each AST child whose Type equals an objectLists[].MDLContainer (e.g. GROUP, ITEM, CUSTOMITEM, SERIES, MARKER) and builds an ObjectListItemSpec from the child's properties (via ItemProperties dispatch) and inner widgets (via ItemSlots dispatch + a "default content slot" heuristic). - applyChildSlots and the Phase 1/2 auto-widgets-discovery passes now receive a set of object-list keywords and skip those children, so the generic builder doesn't try to convert `item` / `group` AST nodes into navlist items. End-to-end verification on a Mendix 11.9 project (test5-app): - Accordion with two text-header groups: groups serialize correctly, headerText "First Section" / "Second Section" appear in the BSON, `mx check` passes (no widget errors). - All 7 test cases in 32-pluggable-widget-object-lists-v010.test.mdl execute without error and pass `mx check` (only pre-existing OData errors unrelated to this work remain). Limitations / follow-ups: - DESCRIBE doesn't yet read object-list items back from BSON. The grammar parses the syntax but the round-trip MDL → BSON → MDL still drops the list items. Reader-side parser work needed. - Object-list sub-properties of kind datasource and action are skipped at the visitor/engine boundary because widgetPropertyV3 doesn't yet allow `database from X` / action expressions in arbitrary sub-property positions. Affects AreaChart series and Maps dynamicMarkers — the fixture has TODO markers calling this out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b9c7c3 commit a8ee5cf

3 files changed

Lines changed: 424 additions & 3 deletions

File tree

mdl/backend/mpr/widget_builder.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,179 @@ func (ob *mprWidgetObjectBuilder) SetAction(propertyKey string, action pages.Cli
203203
})
204204
}
205205

206+
// SetObjectList sets a list of structured items on an object-list property
207+
// (e.g. Accordion `groups`, PopupMenu `basicItems`). Each item is built from
208+
// the template's nested PropertyTypeIDs, with spec values overlaid onto the
209+
// matching sub-properties. Sub-properties not mentioned in the spec keep the
210+
// template's default values (via createDefaultWidgetProperty).
211+
//
212+
// This is the generic implementation used by the pluggable widget engine for
213+
// .def.json `objectLists` mappings. The DataGrid columns path keeps its own
214+
// hand-coded builder (datagrid_builder.go) for backward compatibility.
215+
func (ob *mprWidgetObjectBuilder) SetObjectList(propertyKey string, items []backend.ObjectListItemSpec) {
216+
if len(items) == 0 {
217+
return
218+
}
219+
entry, ok := ob.propertyTypeIDs[propertyKey]
220+
if !ok || entry.ObjectTypeID == "" || len(entry.NestedPropertyIDs) == 0 {
221+
return
222+
}
223+
224+
objects := bson.A{int32(2)}
225+
for _, item := range items {
226+
objects = append(objects, buildObjectListItemBSON(entry, item))
227+
}
228+
229+
ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D {
230+
result := make(bson.D, 0, len(val))
231+
for _, elem := range val {
232+
if elem.Key == "Objects" {
233+
result = append(result, bson.E{Key: "Objects", Value: objects})
234+
} else {
235+
result = append(result, elem)
236+
}
237+
}
238+
return result
239+
})
240+
}
241+
242+
// buildObjectListItemBSON constructs the BSON for one item of an object-list
243+
// property. Walks the nested PropertyTypeIDs, applies spec overrides where
244+
// the spec mentions a sub-property, and falls back to template defaults
245+
// otherwise.
246+
func buildObjectListItemBSON(parentEntry pages.PropertyTypeIDEntry, item backend.ObjectListItemSpec) bson.D {
247+
// Index spec scalar properties by key for fast lookup.
248+
specByKey := make(map[string]backend.ObjectListItemProperty, len(item.Properties))
249+
for _, p := range item.Properties {
250+
specByKey[p.PropertyKey] = p
251+
}
252+
253+
propsArr := bson.A{int32(2)}
254+
// Sort keys for deterministic output.
255+
nestedKeys := make([]string, 0, len(parentEntry.NestedPropertyIDs))
256+
for k := range parentEntry.NestedPropertyIDs {
257+
nestedKeys = append(nestedKeys, k)
258+
}
259+
sort.Strings(nestedKeys)
260+
261+
for _, k := range nestedKeys {
262+
nestedEntry := parentEntry.NestedPropertyIDs[k]
263+
spec, hasSpec := specByKey[k]
264+
childWidgets := item.ChildWidgets[k]
265+
266+
var prop bson.D
267+
switch {
268+
case hasSpec:
269+
prop = buildItemSubProperty(nestedEntry, spec)
270+
case len(childWidgets) > 0:
271+
prop = buildItemChildWidgetsProperty(nestedEntry, childWidgets)
272+
default:
273+
prop = createDefaultWidgetProperty(nestedEntry)
274+
}
275+
propsArr = append(propsArr, prop)
276+
}
277+
278+
return bson.D{
279+
{Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())},
280+
{Key: "$Type", Value: "CustomWidgets$WidgetObject"},
281+
{Key: "TypePointer", Value: types.UUIDToBlob(parentEntry.ObjectTypeID)},
282+
{Key: "Properties", Value: propsArr},
283+
}
284+
}
285+
286+
// buildItemSubProperty builds one sub-property (a scalar value: primitive,
287+
// attribute, expression, etc.) of an object-list item, starting from the
288+
// template default value and overlaying the spec.
289+
func buildItemSubProperty(entry pages.PropertyTypeIDEntry, spec backend.ObjectListItemProperty) bson.D {
290+
value := createDefaultWidgetValue(entry)
291+
value = overlayItemValue(value, entry, spec)
292+
return bson.D{
293+
{Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())},
294+
{Key: "$Type", Value: "CustomWidgets$WidgetProperty"},
295+
{Key: "TypePointer", Value: types.UUIDToBlob(entry.PropertyTypeID)},
296+
{Key: "Value", Value: value},
297+
}
298+
}
299+
300+
// overlayItemValue mutates the template-default WidgetValue BSON to apply the
301+
// spec's value, dispatched by Operation. Returns the updated value.
302+
func overlayItemValue(value bson.D, entry pages.PropertyTypeIDEntry, spec backend.ObjectListItemProperty) bson.D {
303+
switch spec.Operation {
304+
case "primitive":
305+
value = setBSONField(value, "PrimitiveValue", spec.PrimitiveVal)
306+
case "attribute":
307+
if spec.AttributePath != "" {
308+
value = setAttributeRefField(value, spec.AttributePath)
309+
}
310+
case "expression":
311+
value = setBSONField(value, "Expression", spec.Expression)
312+
value = setBSONField(value, "PrimitiveValue", "")
313+
case "texttemplate":
314+
text := spec.TextTemplate
315+
if text == "" {
316+
text = " "
317+
}
318+
tmpl := createClientTemplateBSONWithParams(text, spec.EntityContext)
319+
value = setBSONField(value, "TextTemplate", tmpl)
320+
case "datasource":
321+
if spec.DataSource != nil {
322+
value = setDataSource(value, spec.DataSource)
323+
}
324+
case "action":
325+
if spec.Action != nil {
326+
actionBSON := mpr.SerializeClientAction(spec.Action)
327+
value = setBSONField(value, "Action", actionBSON)
328+
}
329+
}
330+
return value
331+
}
332+
333+
// buildItemChildWidgetsProperty builds an item sub-property whose value type is
334+
// Widgets — populates the Widgets array with serialized child widgets.
335+
func buildItemChildWidgetsProperty(entry pages.PropertyTypeIDEntry, children []pages.Widget) bson.D {
336+
value := createDefaultWidgetValue(entry)
337+
widgetsArr := bson.A{int32(2)}
338+
for _, w := range children {
339+
widgetsArr = append(widgetsArr, mpr.SerializeWidget(w))
340+
}
341+
value = setBSONField(value, "Widgets", widgetsArr)
342+
return bson.D{
343+
{Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())},
344+
{Key: "$Type", Value: "CustomWidgets$WidgetProperty"},
345+
{Key: "TypePointer", Value: types.UUIDToBlob(entry.PropertyTypeID)},
346+
{Key: "Value", Value: value},
347+
}
348+
}
349+
350+
// setBSONField returns a copy of the bson.D with the named key's value
351+
// replaced. If the key is absent, the original is returned unchanged.
352+
func setBSONField(doc bson.D, key string, val any) bson.D {
353+
result := make(bson.D, len(doc))
354+
for i, elem := range doc {
355+
if elem.Key == key {
356+
result[i] = bson.E{Key: key, Value: val}
357+
} else {
358+
result[i] = elem
359+
}
360+
}
361+
return result
362+
}
363+
364+
// setAttributeRefField sets the AttributeRef field on a WidgetValue BSON to
365+
// reference the given fully-qualified attribute path (Module.Entity.Attr).
366+
func setAttributeRefField(value bson.D, attributePath string) bson.D {
367+
parts := strings.Split(attributePath, ".")
368+
if len(parts) < 2 {
369+
return value
370+
}
371+
attrRef := bson.D{
372+
{Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())},
373+
{Key: "$Type", Value: "CustomWidgets$AttributeRef"},
374+
{Key: "Attribute", Value: parts[len(parts)-1]},
375+
}
376+
return setBSONField(value, "AttributeRef", attrRef)
377+
}
378+
206379
func (ob *mprWidgetObjectBuilder) SetAttributeObjects(propertyKey string, attributePaths []string) {
207380
if len(attributePaths) == 0 {
208381
return

mdl/backend/mutation.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,13 @@ type WidgetObjectBuilder interface {
273273
SetAction(propertyKey string, action pages.ClientAction)
274274
SetAttributeObjects(propertyKey string, attributePaths []string)
275275

276+
// SetObjectList sets a list of structured items on an object-list property
277+
// (e.g. Accordion `groups`, PopupMenu `basicItems`, DataGrid `columns` —
278+
// for widgets routed through the generic pluggable engine, not the dedicated
279+
// DataGrid builder). The backend uses the template's nested PropertyTypeIDs
280+
// to convert each spec entry into the correct BSON shape.
281+
SetObjectList(propertyKey string, items []ObjectListItemSpec)
282+
276283
// --- Template metadata ---
277284

278285
// PropertyTypeIDs returns the property type metadata for the loaded template.
@@ -295,6 +302,38 @@ type WidgetObjectBuilder interface {
295302
Finalize(id model.ID, name string, label string, editable string) *pages.CustomWidget
296303
}
297304

305+
// ObjectListItemSpec describes one item of an object-list property (e.g. one
306+
// Accordion group, one PopupMenu basicItem, one Maps marker). The backend
307+
// applies these specs to the template's nested PropertyTypeIDs to produce
308+
// item BSON.
309+
//
310+
// Each property in the item is dispatched by Operation. Only the field
311+
// matching Operation is used; others are ignored. Operations correspond to
312+
// the same names as the engine's top-level operations (primitive, attribute,
313+
// datasource, texttemplate, expression, action) — see PluggablePropertyOp.
314+
type ObjectListItemSpec struct {
315+
Properties []ObjectListItemProperty
316+
// ChildWidgets carries pre-built widgets for Widgets-typed sub-properties
317+
// of the item (e.g. Accordion group's headerContent / content). Keyed by
318+
// the sub-property's key (matching the def.json itemSlots[].propertyKey).
319+
ChildWidgets map[string][]pages.Widget
320+
}
321+
322+
// ObjectListItemProperty describes one scalar property within an
323+
// ObjectListItemSpec. Mirrors the engine's PluggablePropertyContext but
324+
// scoped to a list item's sub-properties.
325+
type ObjectListItemProperty struct {
326+
PropertyKey string
327+
Operation string // primitive | attribute | datasource | texttemplate | expression | action
328+
PrimitiveVal string
329+
AttributePath string
330+
DataSource pages.DataSource
331+
TextTemplate string
332+
Expression string
333+
Action pages.ClientAction
334+
EntityContext string // for texttemplate operations needing param resolution
335+
}
336+
298337
// DataGridColumnSpec carries pre-resolved column data for DataGrid2 construction.
299338
// All attribute paths are fully qualified. Child widgets are already built as
300339
// domain objects; the backend serializes them to storage format internally.

0 commit comments

Comments
 (0)