Skip to content

Commit 93c84de

Browse files
committed
fix: DESCRIBE PAGE recurses into ScrollContainer and TabControl children
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`.
1 parent cf6860c commit 93c84de

5 files changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- ============================================================================
2+
-- Bug #219: DESCRIBE PAGE misses widgets inside ScrollContainer / TabControl
3+
-- ============================================================================
4+
--
5+
-- Symptom (before fix):
6+
-- `DESCRIBE PAGE` walks the widget tree via Widgets[] on every container.
7+
-- ScrollContainer stores children under CenterRegion.Widgets and TabControl
8+
-- under TabPages[].Widgets — neither path was traversed, so every widget
9+
-- nested inside those two container types was invisible in the describe
10+
-- output. Any external tool that relied on DESCRIBE output to map widget
11+
-- IDs to pages (e.g. test impact analysis linkage maps) had silent gaps.
12+
--
13+
-- After fix:
14+
-- parseRawWidget now recurses into CenterRegion.Widgets for ScrollContainer
15+
-- (with a fallback to Widgets for older BSON) and iterates TabPages[] for
16+
-- TabControl, emitting a synthetic Pages$TabPage intermediate so DESCRIBE
17+
-- output shows which tab each widget belongs to.
18+
--
19+
-- Usage:
20+
-- mxcli describe page BugTest219.PageWithTabs -p app.mpr
21+
-- The describe output must include the inner widget names on each tab;
22+
-- before the fix these were dropped.
23+
-- ============================================================================
24+
25+
create module BugTest219;
26+
27+
create page BugTest219.PageWithTabs
28+
(
29+
title: 'Page with tabs',
30+
layout: Atlas_Core.Atlas_Default,
31+
url: 'bugtest219/tabs',
32+
folder: 'Pages'
33+
)
34+
{
35+
tabcontainer tabs {
36+
tabpage tab1 (caption: 'General') {
37+
dynamictext generalField (content: 'Hi from tab 1', rendermode: paragraph)
38+
}
39+
tabpage tab2 (caption: 'Details') {
40+
dynamictext detailsField (content: 'Hi from tab 2', rendermode: paragraph)
41+
}
42+
}
43+
};
44+
/

mdl/executor/cmd_pages_describe.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,10 @@ type rawWidget struct {
529529
// GroupBox properties
530530
Collapsible string // "No", "YesInitiallyExpanded", "YesInitiallyCollapsed"
531531
HeaderMode string // "Div", "H1"-"H6"
532+
// TabPage property (only set on synthetic rawWidget wrappers emitted by
533+
// TabControl parsing — preserves the original tab page name/caption so
534+
// DESCRIBE output shows which tab each nested widget belongs to).
535+
TabCaption string
532536
// Conditional visibility/editability
533537
VisibleIf string // Expression from ConditionalVisibilitySettings
534538
EditableIf string // Expression from ConditionalEditabilitySettings
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Tests for issue #219: parseRawWidget missed ScrollContainer / TabControl
4+
// children because they live under CenterRegion.Widgets and TabPages[].Widgets
5+
// respectively, not under the top-level Widgets array that every other
6+
// container uses.
7+
8+
package executor
9+
10+
import (
11+
"bytes"
12+
"strings"
13+
"testing"
14+
)
15+
16+
func TestParseRawWidget_ScrollContainerRecursesIntoCenterRegion(t *testing.T) {
17+
ctx, _ := newMockCtx(t)
18+
19+
raw := map[string]any{
20+
"$Type": "Pages$ScrollContainer",
21+
"Name": "Scroll1",
22+
"CenterRegion": map[string]any{
23+
"Widgets": []any{
24+
map[string]any{"$Type": "Pages$TextBox", "Name": "InnerText"},
25+
},
26+
},
27+
}
28+
29+
got := parseRawWidget(ctx, raw)
30+
if len(got) != 1 {
31+
t.Fatalf("expected 1 widget, got %d", len(got))
32+
}
33+
sc := got[0]
34+
if sc.Type != "Pages$ScrollContainer" || sc.Name != "Scroll1" {
35+
t.Errorf("outer widget: type=%q name=%q", sc.Type, sc.Name)
36+
}
37+
if len(sc.Children) != 1 {
38+
t.Fatalf("expected 1 child under ScrollContainer, got %d", len(sc.Children))
39+
}
40+
if sc.Children[0].Name != "InnerText" {
41+
t.Errorf("child name: got %q, want InnerText", sc.Children[0].Name)
42+
}
43+
}
44+
45+
func TestParseRawWidget_ScrollContainerFallsBackToWidgets(t *testing.T) {
46+
// Older/legacy BSON shape where children lived directly under Widgets.
47+
// parseRawWidget must still recurse so existing projects don't regress.
48+
ctx, _ := newMockCtx(t)
49+
50+
raw := map[string]any{
51+
"$Type": "Forms$ScrollContainer",
52+
"Name": "LegacyScroll",
53+
"Widgets": []any{
54+
map[string]any{"$Type": "Forms$TextBox", "Name": "LegacyText"},
55+
},
56+
}
57+
58+
got := parseRawWidget(ctx, raw)
59+
if len(got) != 1 || len(got[0].Children) != 1 {
60+
t.Fatalf("expected 1 widget with 1 child, got %+v", got)
61+
}
62+
if got[0].Children[0].Name != "LegacyText" {
63+
t.Errorf("child name: got %q, want LegacyText", got[0].Children[0].Name)
64+
}
65+
}
66+
67+
func TestParseRawWidget_TabControlPreservesTabPages(t *testing.T) {
68+
ctx, _ := newMockCtx(t)
69+
70+
raw := map[string]any{
71+
"$Type": "Pages$TabControl",
72+
"Name": "Tabs1",
73+
"TabPages": []any{
74+
map[string]any{
75+
"Name": "GeneralTab",
76+
"Widgets": []any{
77+
map[string]any{"$Type": "Pages$TextBox", "Name": "GeneralField"},
78+
},
79+
},
80+
map[string]any{
81+
"Name": "DetailsTab",
82+
"Widgets": []any{
83+
map[string]any{"$Type": "Pages$TextBox", "Name": "DetailsField"},
84+
map[string]any{"$Type": "Pages$TextBox", "Name": "DetailsNote"},
85+
},
86+
},
87+
},
88+
}
89+
90+
got := parseRawWidget(ctx, raw)
91+
if len(got) != 1 {
92+
t.Fatalf("expected 1 widget, got %d", len(got))
93+
}
94+
tc := got[0]
95+
if tc.Type != "Pages$TabControl" || tc.Name != "Tabs1" {
96+
t.Errorf("outer widget: type=%q name=%q", tc.Type, tc.Name)
97+
}
98+
if len(tc.Children) != 2 {
99+
t.Fatalf("expected 2 TabPage children, got %d", len(tc.Children))
100+
}
101+
102+
for i, expectedName := range []string{"GeneralTab", "DetailsTab"} {
103+
if tc.Children[i].Type != "Pages$TabPage" {
104+
t.Errorf("tab %d type: got %q, want Pages$TabPage", i, tc.Children[i].Type)
105+
}
106+
if tc.Children[i].Name != expectedName {
107+
t.Errorf("tab %d name: got %q, want %q", i, tc.Children[i].Name, expectedName)
108+
}
109+
}
110+
111+
if len(tc.Children[0].Children) != 1 || tc.Children[0].Children[0].Name != "GeneralField" {
112+
t.Errorf("GeneralTab children: %+v", tc.Children[0].Children)
113+
}
114+
if len(tc.Children[1].Children) != 2 {
115+
t.Fatalf("DetailsTab expected 2 children, got %d", len(tc.Children[1].Children))
116+
}
117+
}
118+
119+
func TestOutputWidgetMDLV3_TabControlEmitsTabPageStructure(t *testing.T) {
120+
var buf bytes.Buffer
121+
ctx := &ExecContext{Output: &buf}
122+
123+
tab := rawWidget{
124+
Type: "Pages$TabControl",
125+
Name: "Tabs1",
126+
Children: []rawWidget{
127+
{
128+
Type: "Pages$TabPage",
129+
Name: "GeneralTab",
130+
TabCaption: "General",
131+
Children: []rawWidget{
132+
{Type: "Pages$TextBox", Name: "GeneralField"},
133+
},
134+
},
135+
},
136+
}
137+
outputWidgetMDLV3(ctx, tab, 0)
138+
139+
out := buf.String()
140+
for _, want := range []string{
141+
"tabcontainer Tabs1",
142+
"tabpage GeneralTab",
143+
"Caption: 'General'",
144+
} {
145+
if !strings.Contains(out, want) {
146+
t.Errorf("output missing %q\nfull output:\n%s", want, out)
147+
}
148+
}
149+
}
150+
151+
func TestOutputWidgetMDLV3_ScrollContainerEmitsHeader(t *testing.T) {
152+
var buf bytes.Buffer
153+
ctx := &ExecContext{Output: &buf}
154+
155+
sc := rawWidget{
156+
Type: "Pages$ScrollContainer",
157+
Name: "Scroll1",
158+
Children: []rawWidget{
159+
{Type: "Pages$TextBox", Name: "InnerText"},
160+
},
161+
}
162+
outputWidgetMDLV3(ctx, sc, 0)
163+
164+
out := buf.String()
165+
if !strings.Contains(out, "scrollcontainer Scroll1") {
166+
t.Errorf("expected 'scrollcontainer Scroll1' in output, got:\n%s", out)
167+
}
168+
}

mdl/executor/cmd_pages_describe_output.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,48 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) {
129129
prefix := strings.Repeat(" ", indent)
130130

131131
switch w.Type {
132+
case "Forms$ScrollContainer", "Pages$ScrollContainer":
133+
header := fmt.Sprintf("scrollcontainer %s", w.Name)
134+
props := appendAppearanceProps(nil, w)
135+
if len(w.Children) > 0 {
136+
formatWidgetProps(ctx.Output, prefix, header, props, " {\n")
137+
for _, child := range w.Children {
138+
outputWidgetMDLV3(ctx, child, indent+1)
139+
}
140+
fmt.Fprintf(ctx.Output, "%s}\n", prefix)
141+
} else {
142+
formatWidgetProps(ctx.Output, prefix, header, props, "\n")
143+
}
144+
145+
case "Forms$TabControl", "Pages$TabControl":
146+
header := fmt.Sprintf("tabcontainer %s", w.Name)
147+
props := appendAppearanceProps(nil, w)
148+
if len(w.Children) > 0 {
149+
formatWidgetProps(ctx.Output, prefix, header, props, " {\n")
150+
for _, child := range w.Children {
151+
outputWidgetMDLV3(ctx, child, indent+1)
152+
}
153+
fmt.Fprintf(ctx.Output, "%s}\n", prefix)
154+
} else {
155+
formatWidgetProps(ctx.Output, prefix, header, props, "\n")
156+
}
157+
158+
case "Pages$TabPage":
159+
header := fmt.Sprintf("tabpage %s", w.Name)
160+
var props []string
161+
if w.TabCaption != "" {
162+
props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(w.TabCaption)))
163+
}
164+
if len(w.Children) > 0 {
165+
formatWidgetProps(ctx.Output, prefix, header, props, " {\n")
166+
for _, child := range w.Children {
167+
outputWidgetMDLV3(ctx, child, indent+1)
168+
}
169+
fmt.Fprintf(ctx.Output, "%s}\n", prefix)
170+
} else {
171+
formatWidgetProps(ctx.Output, prefix, header, props, "\n")
172+
}
173+
132174
case "Forms$DivContainer", "Pages$DivContainer":
133175
header := fmt.Sprintf("container %s", w.Name)
134176
props := appendAppearanceProps(nil, w)

mdl/executor/cmd_pages_describe_parse.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,86 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s
3131
typeName, _ := w["$Type"].(string)
3232
name, _ := w["Name"].(string)
3333

34+
// ScrollContainer: children are nested inside CenterRegion.Widgets
35+
// rather than Widgets directly, so recurse into CenterRegion so nested
36+
// widget IDs are visible in DESCRIBE PAGE output.
37+
if typeName == "Forms$ScrollContainer" || typeName == "Pages$ScrollContainer" {
38+
widget := rawWidget{
39+
Type: typeName,
40+
Name: name,
41+
}
42+
if appearance, ok := w["Appearance"].(map[string]any); ok {
43+
if class, ok := appearance["Class"].(string); ok && class != "" {
44+
widget.Class = class
45+
}
46+
if style, ok := appearance["Style"].(string); ok && style != "" {
47+
widget.Style = style
48+
}
49+
widget.DesignProperties = extractDesignProperties(appearance)
50+
}
51+
extractConditionalSettings(&widget, w)
52+
// Primary location: CenterRegion.Widgets (Mendix 9+)
53+
var children []any
54+
if centerRegion, ok := w["CenterRegion"].(map[string]any); ok {
55+
children = getBsonArrayElements(centerRegion["Widgets"])
56+
}
57+
// Fallback for older BSON layouts that stored children directly.
58+
if len(children) == 0 {
59+
children = getBsonArrayElements(w["Widgets"])
60+
}
61+
for _, c := range children {
62+
if cMap, ok := c.(map[string]any); ok {
63+
widget.Children = append(widget.Children, parseRawWidget(ctx, cMap, inheritedCtx)...)
64+
}
65+
}
66+
return []rawWidget{widget}
67+
}
68+
69+
// TabControl: children are grouped under TabPages[]. Preserve each tab
70+
// page as a synthetic intermediate widget so the output distinguishes
71+
// which tab each nested widget belongs to.
72+
if typeName == "Forms$TabControl" || typeName == "Pages$TabControl" {
73+
widget := rawWidget{
74+
Type: typeName,
75+
Name: name,
76+
}
77+
if appearance, ok := w["Appearance"].(map[string]any); ok {
78+
if class, ok := appearance["Class"].(string); ok && class != "" {
79+
widget.Class = class
80+
}
81+
if style, ok := appearance["Style"].(string); ok && style != "" {
82+
widget.Style = style
83+
}
84+
widget.DesignProperties = extractDesignProperties(appearance)
85+
}
86+
extractConditionalSettings(&widget, w)
87+
for _, tp := range getBsonArrayElements(w["TabPages"]) {
88+
tpMap, ok := tp.(map[string]any)
89+
if !ok {
90+
continue
91+
}
92+
tabPage := rawWidget{
93+
Type: "Pages$TabPage",
94+
}
95+
if n, ok := tpMap["Name"].(string); ok {
96+
tabPage.Name = n
97+
}
98+
if ct, ok := tpMap["CaptionTemplate"].(map[string]any); ok {
99+
tabPage.TabCaption = extractTextFromTemplate(ctx, ct)
100+
}
101+
if tabPage.TabCaption == "" {
102+
tabPage.TabCaption = extractTextCaption(ctx, tpMap)
103+
}
104+
for _, tw := range getBsonArrayElements(tpMap["Widgets"]) {
105+
if twMap, ok := tw.(map[string]any); ok {
106+
tabPage.Children = append(tabPage.Children, parseRawWidget(ctx, twMap, inheritedCtx)...)
107+
}
108+
}
109+
widget.Children = append(widget.Children, tabPage)
110+
}
111+
return []rawWidget{widget}
112+
}
113+
34114
// Parse DivContainer as a proper CONTAINER widget with children
35115
if typeName == "Forms$DivContainer" || typeName == "Pages$DivContainer" ||
36116
typeName == "Forms$GroupBox" || typeName == "Pages$GroupBox" {

0 commit comments

Comments
 (0)