Skip to content

Commit 212042f

Browse files
akoclaude
andcommitted
feat: add full roundtrip for pluggable Image widget (com.mendix.widget.web.image.Image)
Implement DESCRIBE → CREATE roundtrip for the pluggable React-based Image widget, which is the most-used pluggable widget (784 instances across test projects). The IMAGE keyword now routes to the pluggable engine instead of the built-in Forms$StaticImageViewer (use STATICIMAGE for the built-in). - Add image.json template with all 21 properties from the widget XML definition - Add image.def.json with property mappings for the pluggable engine - Add opTextTemplate and opAction operations to the widget engine - Add OnClick source resolver for AST action → serialized BSON - Export SerializeClientAction for use by the pluggable engine - Add DESCRIBE extraction: extractImageProperties, extractCustomWidgetPropertyTextTemplate, extractCustomWidgetPropertyAction helpers - Add IMAGE output formatting in DESCRIBE with non-default property suppression - Re-route IMAGE keyword to pluggable engine; STATICIMAGE/DYNAMICIMAGE unchanged - Update docs: custom-widgets.md skill and engine design doc Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3c83de commit 212042f

15 files changed

Lines changed: 2416 additions & 7 deletions

.claude/skills/mendix/custom-widgets.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ Modes are evaluated in definition order -- first match wins. A mode with no `con
221221
| `datasource` | Sets `Value.DataSource` (serialized BSON) | `DataSource` |
222222
| `selection` | Sets `Value.Selection` (mode string) | `Selection` |
223223
| `widgets` | Replaces `Value.Widgets` array with child widget BSON | child slot |
224+
| `texttemplate` | Sets text in `Value.TextTemplate` (Forms$ClientTemplate) | property name (resolved as string) |
225+
| `action` | Sets `Value.Action` with serialized client action BSON | `OnClick` (resolved from AST Action) |
224226

225227
### Mapping Order Constraints
226228

docs/plans/2026-03-25-pluggable-widget-engine-design.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ All existing pluggable widget builders use combinations of these 6 operations:
143143
| `datasource` | `setDataSource()` | `source` → MDL DataSource prop | Builds and sets `DataSource` object |
144144
| `selection` | `setSelectionMode()` | `value` or `source` | Set widget selection mode (Single/Multi) |
145145
| `widgets` | inline child BSON | `childSlots` config | Embeds serialized child widgets into `Widgets` array |
146+
| `texttemplate` | `setTextTemplateValue()` | `source` → string prop | Sets text in `TextTemplate` (Forms$ClientTemplate) |
147+
| `action` | `SerializeClientAction()` | `OnClick` → AST Action | Sets `Action` with serialized client action BSON |
146148

147149
Operations are registered in an `OperationRegistry` and new types can be added without modifying the engine.
148150

@@ -198,7 +200,7 @@ Therefore, in any mode that uses both, **`datasource` must come before `associat
198200

199201
### Operation Validation
200202

201-
Operation names in `.def.json` files are validated at load time against the 6 known operations: `attribute`, `association`, `primitive`, `selection`, `datasource`, `widgets`. Invalid operation names produce an error when `NewWidgetRegistry()` or `LoadUserDefinitions()` runs, rather than failing silently at build time.
203+
Operation names in `.def.json` files are validated at load time against the 8 known operations: `attribute`, `association`, `primitive`, `selection`, `datasource`, `widgets`, `texttemplate`, `action`. Invalid operation names produce an error when `NewWidgetRegistry()` or `LoadUserDefinitions()` runs, rather than failing silently at build time.
202204

203205
### Mode Selection Conditions
204206

mdl/executor/cmd_pages_builder_v3.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,20 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) {
319319
widget, err = pb.buildDropdownFilterV3(w)
320320
case "DATEFILTER":
321321
widget, err = pb.buildDateFilterV3(w)
322-
case "IMAGE", "STATICIMAGE":
322+
case "STATICIMAGE":
323323
widget, err = pb.buildStaticImageV3(w)
324324
case "DYNAMICIMAGE":
325325
widget, err = pb.buildDynamicImageV3(w)
326+
case "IMAGE":
327+
// IMAGE routes to the pluggable React widget (com.mendix.widget.web.image.Image)
328+
pb.initPluggableEngine()
329+
if pb.widgetRegistry != nil {
330+
if def, ok := pb.widgetRegistry.Get("IMAGE"); ok {
331+
return pb.pluggableEngine.Build(def, w)
332+
}
333+
}
334+
// Fallback to static image if pluggable engine unavailable
335+
widget, err = pb.buildStaticImageV3(w)
326336
default:
327337
// Try pluggable widget engine for registered widget types
328338
pb.initPluggableEngine()

mdl/executor/cmd_pages_describe.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,17 @@ type rawWidget struct {
531531
EditableIf string // Expression from ConditionalEditabilitySettings
532532
// Design properties from Appearance
533533
DesignProperties []rawDesignProp
534+
// Pluggable Image widget properties
535+
ImageUrl string // Image URL (from textTemplate)
536+
AlternativeText string // Alt text (from textTemplate)
537+
ImageWidth string // Width in pixels/percentage
538+
ImageHeight string // Height in pixels/percentage
539+
WidthUnit string // "auto", "pixels", "percentage"
540+
HeightUnit string // "auto", "pixels", "percentage", "viewport"
541+
DisplayAs string // "fullImage", "thumbnail"
542+
Responsive string // "true", "false"
543+
ImageType string // "image", "imageUrl", "icon"
544+
OnClickType string // "action", "enlarge"
534545
}
535546

536547
// rawDesignProp represents a parsed design property from BSON.

mdl/executor/cmd_pages_describe_output.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,45 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
461461
} else {
462462
formatWidgetProps(e.output, prefix, header, props, "\n")
463463
}
464+
} else if widgetType == "IMAGE" {
465+
header := fmt.Sprintf("IMAGE %s", w.Name)
466+
props := []string{}
467+
if w.ImageType != "" && w.ImageType != "image" {
468+
props = append(props, fmt.Sprintf("ImageType: %s", w.ImageType))
469+
}
470+
if w.ImageUrl != "" {
471+
props = append(props, fmt.Sprintf("ImageUrl: %s", mdlQuote(w.ImageUrl)))
472+
}
473+
if w.AlternativeText != "" {
474+
props = append(props, fmt.Sprintf("AlternativeText: %s", mdlQuote(w.AlternativeText)))
475+
}
476+
if w.WidthUnit != "" && w.WidthUnit != "auto" {
477+
props = append(props, fmt.Sprintf("WidthUnit: %s", w.WidthUnit))
478+
}
479+
if w.ImageWidth != "" && w.ImageWidth != "100" {
480+
props = append(props, fmt.Sprintf("Width: %s", w.ImageWidth))
481+
}
482+
if w.HeightUnit != "" && w.HeightUnit != "auto" {
483+
props = append(props, fmt.Sprintf("HeightUnit: %s", w.HeightUnit))
484+
}
485+
if w.ImageHeight != "" && w.ImageHeight != "100" {
486+
props = append(props, fmt.Sprintf("Height: %s", w.ImageHeight))
487+
}
488+
if w.DisplayAs != "" && w.DisplayAs != "fullImage" {
489+
props = append(props, fmt.Sprintf("DisplayAs: %s", w.DisplayAs))
490+
}
491+
if w.Responsive != "" && w.Responsive != "true" {
492+
props = append(props, fmt.Sprintf("Responsive: %s", w.Responsive))
493+
}
494+
if w.OnClickType == "enlarge" {
495+
props = append(props, "OnClickType: enlarge")
496+
}
497+
if w.Action != "" {
498+
props = append(props, fmt.Sprintf("OnClick: %s", w.Action))
499+
}
500+
props = appendConditionalProps(props, w)
501+
props = appendAppearanceProps(props, w)
502+
formatWidgetProps(e.output, prefix, header, props, "\n")
464503
} else {
465504
header := fmt.Sprintf("%s %s", widgetType, w.Name)
466505
props := []string{}

mdl/executor/cmd_pages_describe_parse.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
195195
widget.FilterAttributes = e.extractFilterAttributes(w)
196196
widget.FilterExpression = e.extractFilterExpression(w)
197197
}
198+
// For pluggable Image widget, extract image-specific properties
199+
if widget.RenderMode == "IMAGE" {
200+
e.extractImageProperties(w, &widget)
201+
}
198202
return []rawWidget{widget}
199203

200204
case "Forms$Label", "Pages$Label":

mdl/executor/cmd_pages_describe_pluggable.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ func (e *Executor) extractCustomWidgetType(w map[string]any) string {
7676
return "DROPDOWNFILTER"
7777
case "com.mendix.widget.web.datagriddatefilter.DatagridDateFilter":
7878
return "DATEFILTER"
79+
case "com.mendix.widget.web.image.Image":
80+
return "IMAGE"
7981
default:
8082
// Extract last part of widget ID and uppercase it
8183
parts := strings.Split(widgetID, ".")
@@ -996,3 +998,117 @@ func (e *Executor) extractCustomWidgetPropertyAttributes(w map[string]any, prope
996998
}
997999
return nil
9981000
}
1001+
1002+
// extractImageProperties extracts properties from a pluggable Image CustomWidget.
1003+
func (e *Executor) extractImageProperties(w map[string]any, widget *rawWidget) {
1004+
widget.ImageType = e.extractCustomWidgetPropertyString(w, "datasource")
1005+
widget.ImageUrl = e.extractCustomWidgetPropertyTextTemplate(w, "imageUrl")
1006+
widget.AlternativeText = e.extractCustomWidgetPropertyTextTemplate(w, "alternativeText")
1007+
widget.ImageWidth = e.extractCustomWidgetPropertyString(w, "width")
1008+
widget.ImageHeight = e.extractCustomWidgetPropertyString(w, "height")
1009+
widget.WidthUnit = e.extractCustomWidgetPropertyString(w, "widthUnit")
1010+
widget.HeightUnit = e.extractCustomWidgetPropertyString(w, "heightUnit")
1011+
widget.DisplayAs = e.extractCustomWidgetPropertyString(w, "displayAs")
1012+
widget.Responsive = e.extractCustomWidgetPropertyString(w, "responsive")
1013+
widget.OnClickType = e.extractCustomWidgetPropertyString(w, "onClickType")
1014+
widget.Action = e.extractCustomWidgetPropertyAction(w, "onClick")
1015+
}
1016+
1017+
// extractCustomWidgetPropertyTextTemplate extracts text from a TextTemplate property of a CustomWidget.
1018+
func (e *Executor) extractCustomWidgetPropertyTextTemplate(w map[string]any, propertyKey string) string {
1019+
obj, ok := w["Object"].(map[string]any)
1020+
if !ok {
1021+
return ""
1022+
}
1023+
1024+
propTypeKeyMap := buildPropertyTypeKeyMap(w, false)
1025+
1026+
props := getBsonArrayElements(obj["Properties"])
1027+
for _, prop := range props {
1028+
propMap, ok := prop.(map[string]any)
1029+
if !ok {
1030+
continue
1031+
}
1032+
typePointerID := extractBinaryID(propMap["TypePointer"])
1033+
propKey := propTypeKeyMap[typePointerID]
1034+
if propKey != propertyKey {
1035+
continue
1036+
}
1037+
value, ok := propMap["Value"].(map[string]any)
1038+
if !ok {
1039+
continue
1040+
}
1041+
// Extract text from TextTemplate
1042+
if textTemplate, ok := value["TextTemplate"].(map[string]any); ok && textTemplate != nil {
1043+
if template, ok := textTemplate["Template"].(map[string]any); ok && template != nil {
1044+
items := getBsonArrayElements(template["Items"])
1045+
for _, item := range items {
1046+
itemMap, ok := item.(map[string]any)
1047+
if !ok {
1048+
continue
1049+
}
1050+
if text := extractString(itemMap["Text"]); text != "" {
1051+
return text
1052+
}
1053+
}
1054+
}
1055+
}
1056+
}
1057+
return ""
1058+
}
1059+
1060+
// extractCustomWidgetPropertyAction extracts an action description from a CustomWidget property.
1061+
// Returns a formatted string like "CALL_MICROFLOW Module.Flow" or "SHOW_PAGE Module.Page".
1062+
func (e *Executor) extractCustomWidgetPropertyAction(w map[string]any, propertyKey string) string {
1063+
obj, ok := w["Object"].(map[string]any)
1064+
if !ok {
1065+
return ""
1066+
}
1067+
1068+
propTypeKeyMap := buildPropertyTypeKeyMap(w, false)
1069+
1070+
props := getBsonArrayElements(obj["Properties"])
1071+
for _, prop := range props {
1072+
propMap, ok := prop.(map[string]any)
1073+
if !ok {
1074+
continue
1075+
}
1076+
typePointerID := extractBinaryID(propMap["TypePointer"])
1077+
propKey := propTypeKeyMap[typePointerID]
1078+
if propKey != propertyKey {
1079+
continue
1080+
}
1081+
value, ok := propMap["Value"].(map[string]any)
1082+
if !ok {
1083+
continue
1084+
}
1085+
action, ok := value["Action"].(map[string]any)
1086+
if !ok || action == nil {
1087+
continue
1088+
}
1089+
actionType := extractString(action["$Type"])
1090+
switch actionType {
1091+
case "Forms$MicroflowAction", "Pages$MicroflowClientAction":
1092+
if settings, ok := action["MicroflowSettings"].(map[string]any); ok {
1093+
if mf := extractString(settings["Microflow"]); mf != "" {
1094+
return "CALL_MICROFLOW " + mf
1095+
}
1096+
}
1097+
case "Forms$CallNanoflowClientAction", "Pages$CallNanoflowClientAction":
1098+
if settings, ok := action["NanoflowSettings"].(map[string]any); ok {
1099+
if nf := extractString(settings["Nanoflow"]); nf != "" {
1100+
return "CALL_NANOFLOW " + nf
1101+
}
1102+
}
1103+
case "Forms$FormAction", "Pages$FormAction":
1104+
if settings, ok := action["PageSettings"].(map[string]any); ok {
1105+
if page := extractString(settings["Page"]); page != "" {
1106+
return "SHOW_PAGE " + page
1107+
}
1108+
}
1109+
case "Forms$NoAction", "Pages$NoAction":
1110+
return ""
1111+
}
1112+
}
1113+
return ""
1114+
}

mdl/executor/theme_reader.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ var mdlKeywordToDesignPropsKey = map[string]string{
120120
"LAYOUTGRID": "LayoutGrid",
121121
"DYNAMICTEXT": "DynamicText",
122122
"STATICTEXT": "Label",
123-
"IMAGE": "StaticImageViewer",
123+
"IMAGE": "Image",
124124
"STATICIMAGE": "StaticImageViewer",
125125
"DYNAMICIMAGE": "DynamicImageViewer",
126126
"NAVIGATIONLIST": "NavigationList",

0 commit comments

Comments
 (0)