Skip to content
Merged
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
44 changes: 44 additions & 0 deletions mdl-examples/bug-tests/219-scrollcontainer-tabcontrol-describe.mdl
Original file line number Diff line number Diff line change
@@ -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)
}
}
};
/
4 changes: 4 additions & 0 deletions mdl/executor/cmd_pages_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions mdl/executor/cmd_pages_describe_container_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
42 changes: 42 additions & 0 deletions mdl/executor/cmd_pages_describe_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions mdl/executor/cmd_pages_describe_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down