Skip to content

Commit a2bc5b3

Browse files
authored
Merge pull request #271 from hjotha/fix/219-scrollcontainer-tabcontrol-parsing
fix: DESCRIBE PAGE recurses into ScrollContainer / TabControl children
2 parents e3026f1 + 93c84de commit a2bc5b3

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)