Skip to content

Commit 41d01f0

Browse files
committed
fix: address code review issues for pluggable widget engine
- Remove dead DefaultSelection field from WidgetDefinition struct - Add warning logs for silent errors in initPluggableEngine and LoadUserDefinitions - Refactor TestOpSelection to test real opSelection function, add TestOpSelectionEmptyValue - Use return instead of break in buildWidgetV3 default case for clearer control flow - Extract association attribute correctly in ComboBox DESCRIBE (extractCustomWidgetPropertyAssociation) - Update MDL examples to reflect CE1613 fix status
1 parent 182f34e commit 41d01f0

9 files changed

Lines changed: 137 additions & 35 deletions

File tree

mdl-examples/doctype-tests/17-custom-widget-examples.mdl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
-- 1. GALLERY basic — DATABASE datasource + TEMPLATE (PASS)
99
-- 2. GALLERY with FILTER — TEXTFILTER in FILTER block (KNOWN BUG: CE0463)
1010
-- 3. COMBOBOX enum — Enum attribute (KNOWN BUG: CE1613)
11-
-- 4. COMBOBOX association — DataSource + CaptionAttribute (KNOWN BUG: CE1613)
11+
-- 4. COMBOBOX association — DataSource + CaptionAttribute (FIXED: Issue #21)
1212
--
1313
-- Known engine bugs (do not affect MDL syntax correctness):
1414
-- - CE0463 on TEXTFILTER: embedded template property count mismatch
15-
-- - CE1613 on COMBOBOX: enum/association attribute written as association pointer
15+
-- - CE1613 on COMBOBOX enum: enum attribute written as association pointer (pending)
1616
--
1717
-- ============================================================================
1818

@@ -166,12 +166,12 @@ CREATE PAGE MyFirstModule.P_ComboBox_Enum
166166

167167
-- ============================================================================
168168
-- Test 4: COMBOBOX association mode — DataSource + CaptionAttribute
169-
-- Result: CE1613 (association attribute lookup fails)
169+
-- Result: FIXED (Issue #21) — Attribute now correctly reads back as association name
170170
-- ============================================================================
171171

172172
/**
173173
* Task edit form with a COMBOBOX for selecting Category via association.
174-
* KNOWN BUG: CE1613 — association attribute incorrectly resolved.
174+
* FIXED (Issue #21): Attribute now correctly roundtrips as association name.
175175
*
176176
* @param $Task The task to edit
177177
*/

mdl/executor/cmd_pages_builder.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package executor
44

55
import (
66
"fmt"
7+
"os"
78
"strings"
89

910
"github.com/mendixlabs/mxcli/mdl/ast"
@@ -53,7 +54,7 @@ func (pb *pageBuilder) initPluggableEngine() {
5354
}
5455
registry, err := NewWidgetRegistry()
5556
if err != nil {
56-
// Non-fatal: engine won't be available, fall through to error
57+
fmt.Fprintf(os.Stderr, "warning: pluggable widget registry init failed: %v\n", err)
5758
return
5859
}
5960
if pb.reader != nil {

mdl/executor/cmd_pages_builder_v3.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,7 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) {
328328
pb.initPluggableEngine()
329329
if pb.widgetRegistry != nil {
330330
if def, ok := pb.widgetRegistry.Get(strings.ToUpper(w.Type)); ok {
331-
widget, err = pb.pluggableEngine.Build(def, w)
332-
break
331+
return pb.pluggableEngine.Build(def, w)
333332
}
334333
}
335334
return nil, fmt.Errorf("unsupported V3 widget type: %s", w.Type)

mdl/executor/cmd_pages_describe_parse.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,13 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
142142
widget.Caption = e.extractLabelText(w)
143143
widget.Content = e.extractCustomWidgetAttribute(w)
144144
widget.RenderMode = e.extractCustomWidgetType(w) // Store widget type in RenderMode
145-
// For ComboBox, extract datasource and caption attribute for association mode
145+
// For ComboBox, extract datasource and association attribute for association mode.
146+
// In association mode the Attribute binding is stored as EntityRef (not AttributeRef),
147+
// so we must use extractCustomWidgetPropertyAssociation instead of the generic scan.
146148
if widget.RenderMode == "COMBOBOX" {
147149
widget.DataSource = e.extractComboBoxDataSource(w)
148150
if widget.DataSource != nil {
151+
widget.Content = e.extractCustomWidgetPropertyAssociation(w, "attributeAssociation")
149152
widget.CaptionAttribute = e.extractCustomWidgetPropertyAttributeRef(w, "optionsSourceAssociationCaptionAttribute")
150153
}
151154
}

mdl/executor/cmd_pages_describe_pluggable.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,76 @@ func (e *Executor) extractCustomWidgetPropertyAttributeRef(w map[string]any, pro
902902
return ""
903903
}
904904

905+
// extractCustomWidgetPropertyAssociation extracts an association name from a named
906+
// CustomWidget property that was written by opAssociation (setAssociationRef).
907+
// The association is stored as EntityRef.Steps[1].Association (qualified path);
908+
// this function returns only the short name (last segment after the final dot).
909+
//
910+
// This is the symmetric counterpart of extractCustomWidgetPropertyAttributeRef,
911+
// handling the EntityRef storage format instead of AttributeRef.
912+
func (e *Executor) extractCustomWidgetPropertyAssociation(w map[string]any, propertyKey string) string {
913+
obj, ok := w["Object"].(map[string]any)
914+
if !ok {
915+
return ""
916+
}
917+
918+
// Build property key map from Type.ObjectType.PropertyTypes
919+
propTypeKeyMap := make(map[string]string)
920+
if widgetType, ok := w["Type"].(map[string]any); ok {
921+
var propTypes []any
922+
if objType, ok := widgetType["ObjectType"].(map[string]any); ok {
923+
propTypes = getBsonArrayElements(objType["PropertyTypes"])
924+
}
925+
for _, pt := range propTypes {
926+
ptMap, ok := pt.(map[string]any)
927+
if !ok {
928+
continue
929+
}
930+
key := extractString(ptMap["PropertyKey"])
931+
if key == "" {
932+
continue
933+
}
934+
id := extractBinaryID(ptMap["$ID"])
935+
if id != "" {
936+
propTypeKeyMap[id] = key
937+
}
938+
}
939+
}
940+
941+
// Find the named property and extract EntityRef.Steps[1].Association
942+
props := getBsonArrayElements(obj["Properties"])
943+
for _, prop := range props {
944+
propMap, ok := prop.(map[string]any)
945+
if !ok {
946+
continue
947+
}
948+
typePointerID := extractBinaryID(propMap["TypePointer"])
949+
if propTypeKeyMap[typePointerID] != propertyKey {
950+
continue
951+
}
952+
value, ok := propMap["Value"].(map[string]any)
953+
if !ok {
954+
continue
955+
}
956+
entityRef, ok := value["EntityRef"].(map[string]any)
957+
if !ok || entityRef == nil {
958+
return ""
959+
}
960+
steps := getBsonArrayElements(entityRef["Steps"])
961+
// Steps layout: [int32(2), step0, step1, ...] — first element is version marker
962+
for _, step := range steps {
963+
stepMap, ok := step.(map[string]any)
964+
if !ok {
965+
continue
966+
}
967+
if assoc := extractString(stepMap["Association"]); assoc != "" {
968+
return shortAttributeName(assoc)
969+
}
970+
}
971+
}
972+
return ""
973+
}
974+
905975
// extractCustomWidgetPropertyString extracts a string property value from a CustomWidget.
906976
func (e *Executor) extractCustomWidgetPropertyString(w map[string]any, propertyKey string) string {
907977
obj, ok := w["Object"].(map[string]any)

mdl/executor/widget_engine.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ type WidgetDefinition struct {
2525
MDLName string `json:"mdlName"`
2626
TemplateFile string `json:"templateFile"`
2727
DefaultEditable string `json:"defaultEditable"`
28-
DefaultSelection string `json:"defaultSelection,omitempty"`
2928
PropertyMappings []PropertyMapping `json:"propertyMappings,omitempty"`
3029
ChildSlots []ChildSlotMapping `json:"childSlots,omitempty"`
3130
Modes []WidgetMode `json:"modes,omitempty"`

mdl/executor/widget_engine_test.go

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/mendixlabs/mxcli/mdl/ast"
1010
"github.com/mendixlabs/mxcli/model"
11+
"github.com/mendixlabs/mxcli/sdk/mpr"
1112
"github.com/mendixlabs/mxcli/sdk/pages"
1213
"go.mongodb.org/mongo-driver/bson"
1314
)
@@ -17,8 +18,7 @@ func TestWidgetDefinitionJSONRoundTrip(t *testing.T) {
1718
WidgetID: "com.mendix.widget.web.combobox.Combobox",
1819
MDLName: "COMBOBOX",
1920
TemplateFile: "combobox.json",
20-
DefaultEditable: "Always",
21-
DefaultSelection: "Single",
21+
DefaultEditable: "Always",
2222
PropertyMappings: []PropertyMapping{
2323
{PropertyKey: "attributeEnumeration", Source: "Attribute", Operation: "attribute"},
2424
{PropertyKey: "optionsSourceType", Value: "enumeration", Operation: "primitive"},
@@ -62,9 +62,6 @@ func TestWidgetDefinitionJSONRoundTrip(t *testing.T) {
6262
if decoded.DefaultEditable != original.DefaultEditable {
6363
t.Errorf("DefaultEditable: got %q, want %q", decoded.DefaultEditable, original.DefaultEditable)
6464
}
65-
if decoded.DefaultSelection != original.DefaultSelection {
66-
t.Errorf("DefaultSelection: got %q, want %q", decoded.DefaultSelection, original.DefaultSelection)
67-
}
6865

6966
// Verify property mappings
7067
if len(decoded.PropertyMappings) != len(original.PropertyMappings) {
@@ -119,10 +116,6 @@ func TestWidgetDefinitionJSONOmitsEmptyOptionalFields(t *testing.T) {
119116
t.Fatalf("failed to unmarshal to map: %v", err)
120117
}
121118

122-
// defaultSelection should be omitted when empty
123-
if _, exists := raw["defaultSelection"]; exists {
124-
t.Error("defaultSelection should be omitted when empty")
125-
}
126119
}
127120

128121
func TestOperationRegistryLookupFound(t *testing.T) {
@@ -517,29 +510,49 @@ func TestSetChildWidgets(t *testing.T) {
517510
}
518511

519512
func TestOpSelection(t *testing.T) {
520-
// Test opSelection directly on a Value bson.D (same level as setChildWidgets test)
521-
// opSelection calls updateWidgetPropertyValue which needs TypePointer matching.
522-
// Instead, test the inner logic: the Selection field update in a Value document.
523-
val := bson.D{
524-
{Key: "TypePointer", Value: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}},
525-
{Key: "PrimitiveValue", Value: ""},
526-
{Key: "Selection", Value: "None"},
513+
// Call the real opSelection function with a properly structured widget BSON.
514+
typePointerBytes := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
515+
typePointerUUID := mpr.BlobToUUID(typePointerBytes)
516+
517+
widgetObj := bson.D{
518+
{Key: "Properties", Value: bson.A{
519+
int32(2), // version marker
520+
bson.D{
521+
{Key: "TypePointer", Value: typePointerBytes},
522+
{Key: "Value", Value: bson.D{
523+
{Key: "PrimitiveValue", Value: ""},
524+
{Key: "Selection", Value: "None"},
525+
}},
526+
},
527+
}},
528+
}
529+
530+
propTypeIDs := map[string]pages.PropertyTypeIDEntry{
531+
"selectionType": {PropertyTypeID: typePointerUUID},
527532
}
528533

529-
// Simulate what opSelection's inner function does
530534
ctx := &BuildContext{PrimitiveVal: "Multi"}
531-
result := make(bson.D, 0, len(val))
532-
for _, elem := range val {
533-
if elem.Key == "Selection" {
534-
result = append(result, bson.E{Key: "Selection", Value: ctx.PrimitiveVal})
535-
} else {
536-
result = append(result, elem)
535+
result := opSelection(widgetObj, propTypeIDs, "selectionType", ctx)
536+
537+
// Extract the updated Value from Properties
538+
var props bson.A
539+
for _, elem := range result {
540+
if elem.Key == "Properties" {
541+
props = elem.Value.(bson.A)
542+
}
543+
}
544+
prop := props[1].(bson.D) // skip version marker at index 0
545+
var val bson.D
546+
for _, elem := range prop {
547+
if elem.Key == "Value" {
548+
val = elem.Value.(bson.D)
537549
}
538550
}
539551

540-
// Verify Selection was updated
541-
for _, elem := range result {
552+
selectionFound := false
553+
for _, elem := range val {
542554
if elem.Key == "Selection" {
555+
selectionFound = true
543556
if elem.Value != "Multi" {
544557
t.Errorf("Selection: got %q, want %q", elem.Value, "Multi")
545558
}
@@ -550,4 +563,20 @@ func TestOpSelection(t *testing.T) {
550563
}
551564
}
552565
}
566+
if !selectionFound {
567+
t.Error("Selection field not found in result")
568+
}
569+
}
570+
571+
func TestOpSelectionEmptyValue(t *testing.T) {
572+
widgetObj := bson.D{
573+
{Key: "Properties", Value: bson.A{int32(2)}},
574+
}
575+
ctx := &BuildContext{PrimitiveVal: ""}
576+
result := opSelection(widgetObj, nil, "any", ctx)
577+
578+
// With empty PrimitiveVal, opSelection returns obj unchanged
579+
if len(result) != len(widgetObj) {
580+
t.Errorf("expected unchanged obj, got different length: %d vs %d", len(result), len(widgetObj))
581+
}
553582
}

mdl/executor/widget_registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ func (r *WidgetRegistry) LoadUserDefinitions(projectPath string) error {
8888
if err := r.loadDefinitionsFromDir(globalDir); err != nil {
8989
return fmt.Errorf("global widgets: %w", err)
9090
}
91+
} else {
92+
fmt.Fprintf(os.Stderr, "warning: cannot determine home directory for user widget definitions: %v\n", err)
9193
}
9294

9395
// 2. Project: <projectDir>/.mxcli/widgets/*.def.json (overrides global)

sdk/widgets/definitions/gallery.def.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"mdlName": "GALLERY",
44
"templateFile": "gallery.json",
55
"defaultEditable": "Always",
6-
"defaultSelection": "Single",
76
"propertyMappings": [
87
{"propertyKey": "datasource", "source": "DataSource", "operation": "datasource"},
98
{"propertyKey": "itemSelection", "source": "Selection", "operation": "selection", "default": "Single"}

0 commit comments

Comments
 (0)