From 93c84de2870951d47bcccadb802646881ac07b7b Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 23 Apr 2026 08:05:53 +0200 Subject: [PATCH] fix: DESCRIBE PAGE recurses into ScrollContainer and TabControl children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseRawWidget walked children via the Widgets[] array on every container, but two widget types store their children elsewhere: - ScrollContainer holds children under CenterRegion.Widgets (Mendix 9+). A fallback to the top-level Widgets remains for older BSON shapes. - TabControl holds children under TabPages[].Widgets. The parser now emits a synthetic Pages$TabPage intermediate widget per tab, with the tab name and caption preserved, so DESCRIBE output distinguishes which tab each nested widget belongs to. Before this fix every widget nested inside those two containers was invisible in DESCRIBE PAGE output, creating silent gaps for any downstream tooling (e.g. Python-based widget ID extractors used by test-impact analysis). Closes hjotha's review of #219 (same idea, rewritten against current main after the `parseRawWidget`/`outputWidgetMDLV3` signature refactor; `tabcontainer`/`tabpage` already exist in the grammar, so the describe output round-trips back through `mxcli exec`). `scrollcontainer` has no grammar keyword yet — it is output-only for now; grammar addition is a follow-up. Credit to @NikolaSimsic for identifying the original bug in #219 and for the CenterRegion.Widgets / TabPages[].Widgets field insight. Tests - parseRawWidget: ScrollContainer with CenterRegion, ScrollContainer legacy-Widgets fallback, TabControl with multiple TabPages (name and child widgets preserved per tab). - outputWidgetMDLV3: ScrollContainer header, TabControl + TabPage + Caption emission. - Bug-test MDL script at `mdl-examples/bug-tests/219-scrollcontainer-tabcontrol-describe.mdl` round-trips through `mxcli check`. --- ...19-scrollcontainer-tabcontrol-describe.mdl | 44 +++++ mdl/executor/cmd_pages_describe.go | 4 + .../cmd_pages_describe_container_test.go | 168 ++++++++++++++++++ mdl/executor/cmd_pages_describe_output.go | 42 +++++ mdl/executor/cmd_pages_describe_parse.go | 80 +++++++++ 5 files changed, 338 insertions(+) create mode 100644 mdl-examples/bug-tests/219-scrollcontainer-tabcontrol-describe.mdl create mode 100644 mdl/executor/cmd_pages_describe_container_test.go diff --git a/mdl-examples/bug-tests/219-scrollcontainer-tabcontrol-describe.mdl b/mdl-examples/bug-tests/219-scrollcontainer-tabcontrol-describe.mdl new file mode 100644 index 00000000..b2322b0c --- /dev/null +++ b/mdl-examples/bug-tests/219-scrollcontainer-tabcontrol-describe.mdl @@ -0,0 +1,44 @@ +-- ============================================================================ +-- Bug #219: DESCRIBE PAGE misses widgets inside ScrollContainer / TabControl +-- ============================================================================ +-- +-- Symptom (before fix): +-- `DESCRIBE PAGE` walks the widget tree via Widgets[] on every container. +-- ScrollContainer stores children under CenterRegion.Widgets and TabControl +-- under TabPages[].Widgets — neither path was traversed, so every widget +-- nested inside those two container types was invisible in the describe +-- output. Any external tool that relied on DESCRIBE output to map widget +-- IDs to pages (e.g. test impact analysis linkage maps) had silent gaps. +-- +-- After fix: +-- parseRawWidget now recurses into CenterRegion.Widgets for ScrollContainer +-- (with a fallback to Widgets for older BSON) and iterates TabPages[] for +-- TabControl, emitting a synthetic Pages$TabPage intermediate so DESCRIBE +-- output shows which tab each widget belongs to. +-- +-- Usage: +-- mxcli describe page BugTest219.PageWithTabs -p app.mpr +-- The describe output must include the inner widget names on each tab; +-- before the fix these were dropped. +-- ============================================================================ + +create module BugTest219; + +create page BugTest219.PageWithTabs +( + title: 'Page with tabs', + layout: Atlas_Core.Atlas_Default, + url: 'bugtest219/tabs', + folder: 'Pages' +) +{ + tabcontainer tabs { + tabpage tab1 (caption: 'General') { + dynamictext generalField (content: 'Hi from tab 1', rendermode: paragraph) + } + tabpage tab2 (caption: 'Details') { + dynamictext detailsField (content: 'Hi from tab 2', rendermode: paragraph) + } + } +}; +/ diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 4b957911..6934d004 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -529,6 +529,10 @@ type rawWidget struct { // GroupBox properties Collapsible string // "No", "YesInitiallyExpanded", "YesInitiallyCollapsed" HeaderMode string // "Div", "H1"-"H6" + // TabPage property (only set on synthetic rawWidget wrappers emitted by + // TabControl parsing — preserves the original tab page name/caption so + // DESCRIBE output shows which tab each nested widget belongs to). + TabCaption string // Conditional visibility/editability VisibleIf string // Expression from ConditionalVisibilitySettings EditableIf string // Expression from ConditionalEditabilitySettings diff --git a/mdl/executor/cmd_pages_describe_container_test.go b/mdl/executor/cmd_pages_describe_container_test.go new file mode 100644 index 00000000..d016ca67 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_container_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Tests for issue #219: parseRawWidget missed ScrollContainer / TabControl +// children because they live under CenterRegion.Widgets and TabPages[].Widgets +// respectively, not under the top-level Widgets array that every other +// container uses. + +package executor + +import ( + "bytes" + "strings" + "testing" +) + +func TestParseRawWidget_ScrollContainerRecursesIntoCenterRegion(t *testing.T) { + ctx, _ := newMockCtx(t) + + raw := map[string]any{ + "$Type": "Pages$ScrollContainer", + "Name": "Scroll1", + "CenterRegion": map[string]any{ + "Widgets": []any{ + map[string]any{"$Type": "Pages$TextBox", "Name": "InnerText"}, + }, + }, + } + + got := parseRawWidget(ctx, raw) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + sc := got[0] + if sc.Type != "Pages$ScrollContainer" || sc.Name != "Scroll1" { + t.Errorf("outer widget: type=%q name=%q", sc.Type, sc.Name) + } + if len(sc.Children) != 1 { + t.Fatalf("expected 1 child under ScrollContainer, got %d", len(sc.Children)) + } + if sc.Children[0].Name != "InnerText" { + t.Errorf("child name: got %q, want InnerText", sc.Children[0].Name) + } +} + +func TestParseRawWidget_ScrollContainerFallsBackToWidgets(t *testing.T) { + // Older/legacy BSON shape where children lived directly under Widgets. + // parseRawWidget must still recurse so existing projects don't regress. + ctx, _ := newMockCtx(t) + + raw := map[string]any{ + "$Type": "Forms$ScrollContainer", + "Name": "LegacyScroll", + "Widgets": []any{ + map[string]any{"$Type": "Forms$TextBox", "Name": "LegacyText"}, + }, + } + + got := parseRawWidget(ctx, raw) + if len(got) != 1 || len(got[0].Children) != 1 { + t.Fatalf("expected 1 widget with 1 child, got %+v", got) + } + if got[0].Children[0].Name != "LegacyText" { + t.Errorf("child name: got %q, want LegacyText", got[0].Children[0].Name) + } +} + +func TestParseRawWidget_TabControlPreservesTabPages(t *testing.T) { + ctx, _ := newMockCtx(t) + + raw := map[string]any{ + "$Type": "Pages$TabControl", + "Name": "Tabs1", + "TabPages": []any{ + map[string]any{ + "Name": "GeneralTab", + "Widgets": []any{ + map[string]any{"$Type": "Pages$TextBox", "Name": "GeneralField"}, + }, + }, + map[string]any{ + "Name": "DetailsTab", + "Widgets": []any{ + map[string]any{"$Type": "Pages$TextBox", "Name": "DetailsField"}, + map[string]any{"$Type": "Pages$TextBox", "Name": "DetailsNote"}, + }, + }, + }, + } + + got := parseRawWidget(ctx, raw) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + tc := got[0] + if tc.Type != "Pages$TabControl" || tc.Name != "Tabs1" { + t.Errorf("outer widget: type=%q name=%q", tc.Type, tc.Name) + } + if len(tc.Children) != 2 { + t.Fatalf("expected 2 TabPage children, got %d", len(tc.Children)) + } + + for i, expectedName := range []string{"GeneralTab", "DetailsTab"} { + if tc.Children[i].Type != "Pages$TabPage" { + t.Errorf("tab %d type: got %q, want Pages$TabPage", i, tc.Children[i].Type) + } + if tc.Children[i].Name != expectedName { + t.Errorf("tab %d name: got %q, want %q", i, tc.Children[i].Name, expectedName) + } + } + + if len(tc.Children[0].Children) != 1 || tc.Children[0].Children[0].Name != "GeneralField" { + t.Errorf("GeneralTab children: %+v", tc.Children[0].Children) + } + if len(tc.Children[1].Children) != 2 { + t.Fatalf("DetailsTab expected 2 children, got %d", len(tc.Children[1].Children)) + } +} + +func TestOutputWidgetMDLV3_TabControlEmitsTabPageStructure(t *testing.T) { + var buf bytes.Buffer + ctx := &ExecContext{Output: &buf} + + tab := rawWidget{ + Type: "Pages$TabControl", + Name: "Tabs1", + Children: []rawWidget{ + { + Type: "Pages$TabPage", + Name: "GeneralTab", + TabCaption: "General", + Children: []rawWidget{ + {Type: "Pages$TextBox", Name: "GeneralField"}, + }, + }, + }, + } + outputWidgetMDLV3(ctx, tab, 0) + + out := buf.String() + for _, want := range []string{ + "tabcontainer Tabs1", + "tabpage GeneralTab", + "Caption: 'General'", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\nfull output:\n%s", want, out) + } + } +} + +func TestOutputWidgetMDLV3_ScrollContainerEmitsHeader(t *testing.T) { + var buf bytes.Buffer + ctx := &ExecContext{Output: &buf} + + sc := rawWidget{ + Type: "Pages$ScrollContainer", + Name: "Scroll1", + Children: []rawWidget{ + {Type: "Pages$TextBox", Name: "InnerText"}, + }, + } + outputWidgetMDLV3(ctx, sc, 0) + + out := buf.String() + if !strings.Contains(out, "scrollcontainer Scroll1") { + t.Errorf("expected 'scrollcontainer Scroll1' in output, got:\n%s", out) + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index d14926ab..c2da0d44 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -129,6 +129,48 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { prefix := strings.Repeat(" ", indent) switch w.Type { + case "Forms$ScrollContainer", "Pages$ScrollContainer": + header := fmt.Sprintf("scrollcontainer %s", w.Name) + props := appendAppearanceProps(nil, w) + if len(w.Children) > 0 { + formatWidgetProps(ctx.Output, prefix, header, props, " {\n") + for _, child := range w.Children { + outputWidgetMDLV3(ctx, child, indent+1) + } + fmt.Fprintf(ctx.Output, "%s}\n", prefix) + } else { + formatWidgetProps(ctx.Output, prefix, header, props, "\n") + } + + case "Forms$TabControl", "Pages$TabControl": + header := fmt.Sprintf("tabcontainer %s", w.Name) + props := appendAppearanceProps(nil, w) + if len(w.Children) > 0 { + formatWidgetProps(ctx.Output, prefix, header, props, " {\n") + for _, child := range w.Children { + outputWidgetMDLV3(ctx, child, indent+1) + } + fmt.Fprintf(ctx.Output, "%s}\n", prefix) + } else { + formatWidgetProps(ctx.Output, prefix, header, props, "\n") + } + + case "Pages$TabPage": + header := fmt.Sprintf("tabpage %s", w.Name) + var props []string + if w.TabCaption != "" { + props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(w.TabCaption))) + } + if len(w.Children) > 0 { + formatWidgetProps(ctx.Output, prefix, header, props, " {\n") + for _, child := range w.Children { + outputWidgetMDLV3(ctx, child, indent+1) + } + fmt.Fprintf(ctx.Output, "%s}\n", prefix) + } else { + formatWidgetProps(ctx.Output, prefix, header, props, "\n") + } + case "Forms$DivContainer", "Pages$DivContainer": header := fmt.Sprintf("container %s", w.Name) props := appendAppearanceProps(nil, w) diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 304a638a..f71a9155 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -31,6 +31,86 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s typeName, _ := w["$Type"].(string) name, _ := w["Name"].(string) + // ScrollContainer: children are nested inside CenterRegion.Widgets + // rather than Widgets directly, so recurse into CenterRegion so nested + // widget IDs are visible in DESCRIBE PAGE output. + if typeName == "Forms$ScrollContainer" || typeName == "Pages$ScrollContainer" { + widget := rawWidget{ + Type: typeName, + Name: name, + } + if appearance, ok := w["Appearance"].(map[string]any); ok { + if class, ok := appearance["Class"].(string); ok && class != "" { + widget.Class = class + } + if style, ok := appearance["Style"].(string); ok && style != "" { + widget.Style = style + } + widget.DesignProperties = extractDesignProperties(appearance) + } + extractConditionalSettings(&widget, w) + // Primary location: CenterRegion.Widgets (Mendix 9+) + var children []any + if centerRegion, ok := w["CenterRegion"].(map[string]any); ok { + children = getBsonArrayElements(centerRegion["Widgets"]) + } + // Fallback for older BSON layouts that stored children directly. + if len(children) == 0 { + children = getBsonArrayElements(w["Widgets"]) + } + for _, c := range children { + if cMap, ok := c.(map[string]any); ok { + widget.Children = append(widget.Children, parseRawWidget(ctx, cMap, inheritedCtx)...) + } + } + return []rawWidget{widget} + } + + // TabControl: children are grouped under TabPages[]. Preserve each tab + // page as a synthetic intermediate widget so the output distinguishes + // which tab each nested widget belongs to. + if typeName == "Forms$TabControl" || typeName == "Pages$TabControl" { + widget := rawWidget{ + Type: typeName, + Name: name, + } + if appearance, ok := w["Appearance"].(map[string]any); ok { + if class, ok := appearance["Class"].(string); ok && class != "" { + widget.Class = class + } + if style, ok := appearance["Style"].(string); ok && style != "" { + widget.Style = style + } + widget.DesignProperties = extractDesignProperties(appearance) + } + extractConditionalSettings(&widget, w) + for _, tp := range getBsonArrayElements(w["TabPages"]) { + tpMap, ok := tp.(map[string]any) + if !ok { + continue + } + tabPage := rawWidget{ + Type: "Pages$TabPage", + } + if n, ok := tpMap["Name"].(string); ok { + tabPage.Name = n + } + if ct, ok := tpMap["CaptionTemplate"].(map[string]any); ok { + tabPage.TabCaption = extractTextFromTemplate(ctx, ct) + } + if tabPage.TabCaption == "" { + tabPage.TabCaption = extractTextCaption(ctx, tpMap) + } + for _, tw := range getBsonArrayElements(tpMap["Widgets"]) { + if twMap, ok := tw.(map[string]any); ok { + tabPage.Children = append(tabPage.Children, parseRawWidget(ctx, twMap, inheritedCtx)...) + } + } + widget.Children = append(widget.Children, tabPage) + } + return []rawWidget{widget} + } + // Parse DivContainer as a proper CONTAINER widget with children if typeName == "Forms$DivContainer" || typeName == "Pages$DivContainer" || typeName == "Forms$GroupBox" || typeName == "Pages$GroupBox" {