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" {