Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions mdl/backend/pagemutator/mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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 {
Expand Down
227 changes: 227 additions & 0 deletions mdl/backend/pagemutator/mutator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading