From e29099095f641085fa48f436ebab4a5585989e38 Mon Sep 17 00:00:00 2001 From: Ron Tiso Date: Tue, 28 Jul 2026 23:23:09 +0200 Subject: [PATCH] fix: preserve stored widget property metadata on ALTER PAGE REPLACE (closes #112) Replacing a pluggable widget emitted Type metadata (per-property Caption/Category) generated from the embedded template and installed .mpk, while untouched sibling widgets in the same page unit carried metadata from whichever widget version authored them in Studio Pro. MxBuild flags the mixed vintages as CE0463 on the rebuilt widget, even for an identity rebuild that changes nothing. ReplaceWidget now grafts the stored widget's per-property display metadata (Caption, Category, Description) onto the replacement's freshly generated Type block: - matched by slash-joined PropertyKey path, not flat key, so distinct nested object types that reuse a key (e.g. markers/latitude vs dynamicMarkers/latitude) stay independent - only onto replacements of the same WidgetId, including pluggable widgets nested inside container replacements - leaf display fields only; $IDs and ValueTypes are never copied, so the new widget's Object/Type cross-references stay intact Verified against a Mendix 11.12.2 MPRv2 project with Combobox v2.8.1: identity rebuild and datasource-XPath replace both now pass docker check with zero errors (previously CE0463 on both). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 + mdl/backend/pagemutator/mutator.go | 121 +++++++++++++ mdl/backend/pagemutator/mutator_test.go | 227 ++++++++++++++++++++++++ 3 files changed, 352 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0134061..1e85a6801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`ALTER PAGE … REPLACE` of a pluggable widget no longer triggers CE0463 (#112)** — replacing a pluggable widget (e.g. a combobox) emitted `Type` metadata (per-property `Caption`/`Category`) generated from the embedded template and installed `.mpk`, while untouched sibling widgets in the same page unit still carried metadata from the (older) widget version that authored them in Studio Pro. MxBuild flagged the mixed vintages as CE0463 ("The definition of this widget has changed") on the rebuilt widget — even for an identity rebuild that changed nothing. The replacement now grafts the stored widget's per-property display metadata (`Caption`, `Category`, `Description`) onto the freshly generated `Type` block, matched by property-key *path* (so nested object types that reuse a key, like a Maps widget's `markers/latitude` vs `dynamicMarkers/latitude`, stay independent) and only when the `WidgetId` matches — including pluggable widgets nested inside container replacements. Structural fields (`$ID`s, `ValueType`s) are never copied, so the new widget's `Object`↔`Type` cross-references stay intact. Verified against a Mendix 11.12.2 project: identity rebuild and datasource-XPath replace of a Combobox v2.8.1 both now pass `docker check` with zero errors (previously CE0463). + ## [0.16.0] - 2026-07-12 Headline: **Pluggable chart authoring reaches round-trip fidelity**, plus in-place enum-caption editing, named layout placeholders, and a batch of new pre-build `check` heuristics. Charts gain widget-level datasource attributes, the `LINE`/`SCALECOLOR` object-list keywords, and a `DESCRIBE` that reconstructs them as executable MDL; workflows and widget-less pages now describe cleanly; view-entity OQL is validated before build; and several authoring mistakes are caught at `mxcli check` time instead of only by MxBuild. diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 46f20694c..3b9e8dcfc 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -324,6 +324,8 @@ func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pa return fmt.Errorf("serialize widgets: %w", err) } + preserveStoredWidgetPropertyMetadata(result.widget, newBsonWidgets) + newArr := make([]any, 0, len(result.parentArr)-1+len(newBsonWidgets)) newArr = append(newArr, result.parentArr[:result.index]...) newArr = append(newArr, newBsonWidgets...) @@ -333,6 +335,125 @@ func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pa return nil } +const widgetPropertyTypeName = "CustomWidgets$WidgetPropertyType" + +// preservedPropertyMetadataFields are the per-property display fields grafted from +// the stored widget onto its replacement. Structural fields ($ID, ValueType, Key) +// are never copied — the replacement's Object block cross-references its own +// freshly generated PropertyType IDs. +var preservedPropertyMetadataFields = []string{"Caption", "Category", "Description"} + +// preserveStoredWidgetPropertyMetadata grafts the per-property display metadata +// (Caption, Category, Description) of the stored pluggable widget being replaced +// onto any replacement of the same widget package, matched by PropertyKey. +// +// A freshly built pluggable widget carries Type metadata derived from the embedded +// template and the installed .mpk. The stored model may hold different metadata for +// the same properties — whichever widget version authored the page in Studio Pro. +// Mixing the two vintages in one unit makes mxbuild flag the rebuilt widget with +// CE0463 ("The definition of this widget has changed"), so the replacement must +// stay consistent with the stored model rather than with the toolchain's template. +func preserveStoredWidgetPropertyMetadata(oldWidget bson.D, newWidgets []any) { + oldType := bsonnav.DGetDoc(oldWidget, "Type") + if oldType == nil { + return // not a pluggable widget (or a grid column) — nothing to preserve + } + oldWidgetID := bsonnav.DGetString(oldType, "WidgetId") + if oldWidgetID == "" { + return + } + storedMeta := map[string]bson.D{} + collectWidgetPropertyMetadata(oldType, "", storedMeta) + if len(storedMeta) == 0 { + return + } + for _, w := range newWidgets { + graftOntoMatchingWidgetTypes(w, oldWidgetID, storedMeta) + } +} + +// graftOntoMatchingWidgetTypes walks a serialized replacement widget tree and +// grafts the stored metadata onto every embedded widget Type of the same widget +// package — including pluggable widgets nested inside container replacements. +func graftOntoMatchingWidgetTypes(node any, widgetID string, storedMeta map[string]bson.D) { + switch v := node.(type) { + case bson.D: + if t := bsonnav.DGetDoc(v, "Type"); t != nil && bsonnav.DGetString(t, "WidgetId") == widgetID { + applyWidgetPropertyMetadata(t, "", storedMeta) + } + for _, elem := range v { + graftOntoMatchingWidgetTypes(elem.Value, widgetID, storedMeta) + } + default: + for _, item := range bsonnav.ToBsonA(v) { + graftOntoMatchingWidgetTypes(item, widgetID, storedMeta) + } + } +} + +// collectWidgetPropertyMetadata walks a widget Type tree and indexes every +// CustomWidgets$WidgetPropertyType doc by its slash-joined PropertyKey path +// (e.g. "/markers/latitude"). Paths — not flat keys — scope the match, because +// distinct nested object types may reuse the same property key with different +// metadata. First occurrence wins for a duplicate path. +func collectWidgetPropertyMetadata(node any, path string, out map[string]bson.D) { + switch v := node.(type) { + case bson.D: + if bsonnav.DGetString(v, "$Type") == widgetPropertyTypeName { + if key := bsonnav.DGetString(v, "PropertyKey"); key != "" { + childPath := path + "/" + key + if _, seen := out[childPath]; !seen { + out[childPath] = v + } + for _, elem := range v { + collectWidgetPropertyMetadata(elem.Value, childPath, out) + } + return + } + } + for _, elem := range v { + collectWidgetPropertyMetadata(elem.Value, path, out) + } + default: + for _, item := range bsonnav.ToBsonA(v) { + collectWidgetPropertyMetadata(item, path, out) + } + } +} + +// applyWidgetPropertyMetadata overwrites the display metadata of every +// CustomWidgets$WidgetPropertyType doc in a widget Type tree with the stored +// values collected from the widget being replaced, matched by PropertyKey path. +// Properties the stored widget does not define keep their generated metadata. +func applyWidgetPropertyMetadata(node any, path string, storedMeta map[string]bson.D) { + switch v := node.(type) { + case bson.D: + if bsonnav.DGetString(v, "$Type") == widgetPropertyTypeName { + if key := bsonnav.DGetString(v, "PropertyKey"); key != "" { + childPath := path + "/" + key + if stored, ok := storedMeta[childPath]; ok { + for _, field := range preservedPropertyMetadataFields { + if s, isString := bsonnav.DGet(stored, field).(string); isString { + bsonnav.DSet(v, field, s) + } + } + } + for _, elem := range v { + applyWidgetPropertyMetadata(elem.Value, childPath, storedMeta) + } + return + } + } + for _, elem := range v { + applyWidgetPropertyMetadata(elem.Value, path, storedMeta) + } + default: + for _, item := range bsonnav.ToBsonA(v) { + applyWidgetPropertyMetadata(item, path, storedMeta) + } + } +} + // InsertColumns inserts new DataGrid2 columns before/after an existing column. // Columns are serialized as CustomWidgets$WidgetObject (not as form widgets). func (m *Mutator) InsertColumns(gridRef, afterColumnRef string, position backend.InsertPosition, columns []*backend.DataGridColumnSpec) error { diff --git a/mdl/backend/pagemutator/mutator_test.go b/mdl/backend/pagemutator/mutator_test.go index 5563a951c..2ea7e3dd7 100644 --- a/mdl/backend/pagemutator/mutator_test.go +++ b/mdl/backend/pagemutator/mutator_test.go @@ -1301,3 +1301,230 @@ func TestSetWidgetProperty_EditableIf_Unsupported(t *testing.T) { t.Fatal("expected error setting EditableIf on a container, got nil") } } + +// --- preserveStoredWidgetPropertyMetadata --- + +func makeCustomWidgetWithType(name, widgetID string, propTypes ...bson.D) bson.D { + ptArr := bson.A{int32(2)} // type marker + for _, pt := range propTypes { + ptArr = append(ptArr, pt) + } + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: name}, + {Key: "Type", Value: bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetType"}, + {Key: "WidgetId", Value: widgetID}, + {Key: "ObjectType", Value: bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetObjectType"}, + {Key: "PropertyTypes", Value: ptArr}, + }}, + }}, + } +} + +func makePropertyType(key, caption, category string) bson.D { + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "PropertyKey", Value: key}, + {Key: "Caption", Value: caption}, + {Key: "Category", Value: category}, + } +} + +func propTypeByKey(t *testing.T, w bson.D, key string) bson.D { + t.Helper() + found := bson.D{} + var walk func(node any) + walk = func(node any) { + switch v := node.(type) { + case bson.D: + if bsonnav.DGetString(v, "$Type") == widgetPropertyTypeName && + bsonnav.DGetString(v, "PropertyKey") == key { + found = v + return + } + for _, elem := range v { + walk(elem.Value) + } + default: + for _, item := range bsonnav.ToBsonA(v) { + walk(item) + } + } + } + walk(w) + if len(found) == 0 { + t.Fatalf("property type %q not found", key) + } + return found +} + +func TestPreserveStoredWidgetPropertyMetadata_SameWidget(t *testing.T) { + // Stored widget: authored by an older widget version (different Caption/Category). + oldWidget := makeCustomWidgetWithType("comboBox1", "com.mendix.widget.web.Combobox", + makePropertyType("onChangeEvent", "On selection", "Events"), + makePropertyType("filterInputDebounceInterval", "Debounce", "Advanced::Filter"), + ) + // Replacement: freshly generated from the current template/.mpk. + newWidget := makeCustomWidgetWithType("comboBox1", "com.mendix.widget.web.Combobox", + makePropertyType("onChangeEvent", "On change", "Events"), + makePropertyType("filterInputDebounceInterval", "Debounce", "Events"), + makePropertyType("newInThisVersion", "Shiny", "General"), + ) + + preserveStoredWidgetPropertyMetadata(oldWidget, []any{newWidget}) + + pt := propTypeByKey(t, newWidget, "onChangeEvent") + if got := bsonnav.DGetString(pt, "Caption"); got != "On selection" { + t.Errorf("onChangeEvent Caption = %q, want stored %q", got, "On selection") + } + pt = propTypeByKey(t, newWidget, "filterInputDebounceInterval") + if got := bsonnav.DGetString(pt, "Category"); got != "Advanced::Filter" { + t.Errorf("filterInputDebounceInterval Category = %q, want stored %q", got, "Advanced::Filter") + } + // Properties the stored widget does not define keep generated metadata. + pt = propTypeByKey(t, newWidget, "newInThisVersion") + if got := bsonnav.DGetString(pt, "Caption"); got != "Shiny" { + t.Errorf("newInThisVersion Caption = %q, want generated %q", got, "Shiny") + } +} + +func TestPreserveStoredWidgetPropertyMetadata_DifferentWidgetID(t *testing.T) { + oldWidget := makeCustomWidgetWithType("w1", "com.mendix.widget.web.Combobox", + makePropertyType("onChangeEvent", "On selection", "Events"), + ) + newWidget := makeCustomWidgetWithType("w1", "com.mendix.widget.web.Gallery", + makePropertyType("onChangeEvent", "On change", "Events"), + ) + + preserveStoredWidgetPropertyMetadata(oldWidget, []any{newWidget}) + + pt := propTypeByKey(t, newWidget, "onChangeEvent") + if got := bsonnav.DGetString(pt, "Caption"); got != "On change" { + t.Errorf("Caption = %q, want untouched %q (different widget package)", got, "On change") + } +} + +func TestPreserveStoredWidgetPropertyMetadata_NonPluggableOld(t *testing.T) { + oldWidget := makeWidget("txtName", "Pages$TextBox") // no Type block + newWidget := makeCustomWidgetWithType("txtName", "com.mendix.widget.web.Combobox", + makePropertyType("onChangeEvent", "On change", "Events"), + ) + + preserveStoredWidgetPropertyMetadata(oldWidget, []any{newWidget}) // must not panic + + pt := propTypeByKey(t, newWidget, "onChangeEvent") + if got := bsonnav.DGetString(pt, "Caption"); got != "On change" { + t.Errorf("Caption = %q, want untouched %q", got, "On change") + } +} + +func TestPreserveStoredWidgetPropertyMetadata_NestedObjectType(t *testing.T) { + // Stored metadata for a property nested inside an object-list ValueType. + nestedStored := bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "PropertyKey", Value: "optionCaption"}, + {Key: "Caption", Value: "Stored caption"}, + {Key: "Category", Value: "General"}, + } + oldWidget := makeCustomWidgetWithType("w1", "com.mendix.widget.web.Combobox", + bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "PropertyKey", Value: "options"}, + {Key: "Caption", Value: "Options"}, + {Key: "Category", Value: "General"}, + {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{int32(2), nestedStored}}, + }}, + }}, + }, + ) + nestedNew := bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "PropertyKey", Value: "optionCaption"}, + {Key: "Caption", Value: "Template caption"}, + {Key: "Category", Value: "General"}, + } + newWidget := makeCustomWidgetWithType("w1", "com.mendix.widget.web.Combobox", + bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "PropertyKey", Value: "options"}, + {Key: "Caption", Value: "Options"}, + {Key: "Category", Value: "General"}, + {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{int32(2), nestedNew}}, + }}, + }}, + }, + ) + + preserveStoredWidgetPropertyMetadata(oldWidget, []any{newWidget}) + + pt := propTypeByKey(t, newWidget, "optionCaption") + if got := bsonnav.DGetString(pt, "Caption"); got != "Stored caption" { + t.Errorf("nested Caption = %q, want stored %q", got, "Stored caption") + } +} + +func TestPreserveStoredWidgetPropertyMetadata_DuplicateKeysScopedByPath(t *testing.T) { + makeObjectListProp := func(key string, nested ...bson.D) bson.D { + ptArr := bson.A{int32(2)} + for _, pt := range nested { + ptArr = append(ptArr, pt) + } + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "PropertyKey", Value: key}, + {Key: "Caption", Value: key}, + {Key: "Category", Value: "General"}, + {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: ptArr}, + }}, + }}, + } + } + // Two nested object types both define "latitude" with different stored captions. + oldWidget := makeCustomWidgetWithType("maps1", "com.mendix.widget.custom.Maps", + makeObjectListProp("markers", makePropertyType("latitude", "Marker latitude", "Data")), + makeObjectListProp("dynamicMarkers", makePropertyType("latitude", "Dynamic latitude", "Data")), + ) + newWidget := makeCustomWidgetWithType("maps1", "com.mendix.widget.custom.Maps", + makeObjectListProp("markers", makePropertyType("latitude", "Latitude", "Data")), + makeObjectListProp("dynamicMarkers", makePropertyType("latitude", "Latitude", "Data")), + ) + + preserveStoredWidgetPropertyMetadata(oldWidget, []any{newWidget}) + + markers := propTypeByKey(t, newWidget, "markers") + lat := propTypeByKey(t, markers, "latitude") + if got := bsonnav.DGetString(lat, "Caption"); got != "Marker latitude" { + t.Errorf("markers/latitude Caption = %q, want %q", got, "Marker latitude") + } + dynamic := propTypeByKey(t, newWidget, "dynamicMarkers") + lat = propTypeByKey(t, dynamic, "latitude") + if got := bsonnav.DGetString(lat, "Caption"); got != "Dynamic latitude" { + t.Errorf("dynamicMarkers/latitude Caption = %q, want %q", got, "Dynamic latitude") + } +} + +func TestPreserveStoredWidgetPropertyMetadata_NestedInContainer(t *testing.T) { + oldWidget := makeCustomWidgetWithType("comboBox1", "com.mendix.widget.web.Combobox", + makePropertyType("onChangeEvent", "On selection", "Events"), + ) + // Replacement is a container wrapping a freshly generated combobox. + nestedCombo := makeCustomWidgetWithType("comboBox1", "com.mendix.widget.web.Combobox", + makePropertyType("onChangeEvent", "On change", "Events"), + ) + container := makeContainerWidget("wrapper", nestedCombo) + + preserveStoredWidgetPropertyMetadata(oldWidget, []any{container}) + + pt := propTypeByKey(t, nestedCombo, "onChangeEvent") + if got := bsonnav.DGetString(pt, "Caption"); got != "On selection" { + t.Errorf("nested combobox Caption = %q, want stored %q", got, "On selection") + } +}