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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-10-22", "symptom": "an Accordion group with widgets in headerContent and content describes them all as the group's body; regenerating the page from the description stores the header widget in content (headerContent -> [txtHead], content -> [txtBody] became content -> [txtHead, txtBody]); and `headercontent { … }` inside a group fails check with MDL-WIDGET25 \"not a widget in this project\"", "cause": "extractObjectListItem appended every Widgets-typed sub-property of an item to one Children list; the widget-kind validator resolved no parent definition for a child of an object-list ITEM, so the item's own slot keyword fell through to the unknown-widget error even though the item builder's slot pass routes it", "file": "`mdl/executor/cmd_pages_describe_objectlist.go` (Slots, promoteBodySlot, objectListItemWidgetSlotKeys), `cmd_pages_describe_output.go`, `widget_engine.go` (bodyItemSlotKey shared with defaultItemSlotKey), `validate_widgets.go` / `validate_widget_kind.go` (parentItem, itemDeclaresSlot)", "insight": "Updating the existing page reported 'Unchanged page' and hid the loss — the round trip has to be run against a FRESH app, which is the regenerate-from-describe case the bug actually hurts. Map each widget to its slot by TypePointer -> PropertyKey in the JSON dump; the ndsl view does not show which slot a widget is in. The body rule (content, else first widget slot) now lives in one function both CREATE and DESCRIBE call. The fix needed both halves: an honest describe whose output check refuses is no better than a silent one", "refs": ["mendixlabs/mxcli#1453", "ako/mxcli#1326"]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
mdl 1;
-- mendixlabs/mxcli#1453 (prerequisite of
-- PROPOSAL_alter_object_list_item_address.md): an Accordion group has two
-- widget slots, headerContent and content. DESCRIBE folded both into the
-- group's body, so a page regenerated from the description stored the header
-- widget in content (blank 11.13.0: headerContent -> [txtHead],
-- content -> [txtBody] became content -> [txtHead, txtBody]). And `check`
-- refused the block form CREATE builds from — `headercontent { … }` inside a
-- group was MDL-WIDGET25 "not a widget in this project".
--
-- Expected: the describe below prints
-- group (headerRenderMode: 'custom', …) {
-- headercontent {
-- dynamictext txtHead (Content: 'Header')
-- }
-- dynamictext txtBody (Content: 'Body')
-- }
-- and executing that description on a fresh app stores txtHead in
-- headerContent and txtBody in content (`mxcli bson dump --format json`).

create module Bug1453;

create page Bug1453.P (title: 'P', layout: Atlas_Core.Atlas_Default) {
accordion acc1 (advancedMode: false, collapsible: true, expandBehavior: 'singleExpanded', animate: true, showIcon: 'right', animateIcon: true) {
group (headerRenderMode: 'custom') {
headercontent { dynamictext txtHead (Content: 'Header') }
dynamictext txtBody (Content: 'Body')
}
}
};

describe page Bug1453.P;
71 changes: 62 additions & 9 deletions mdl/executor/cmd_pages_describe_objectlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ type rawObjectList struct {
type rawObjectListItem struct {
Props []rawExplicitProp
DataSource *rawDataSource
// Children are the widgets nested in a Widgets-typed sub-property of the
// item — an Accordion group's `content` / `headerContent` slot. Without
// these an accordion described as an empty group, and re-executing that
// description deleted whatever was inside it (#891).
// Children are the widgets in the item's BODY slot (bodyItemSlotKey — an
// Accordion group's `content`). Without these an accordion described as an
// empty group, and re-executing that description deleted whatever was
// inside it (#891).
Children []rawWidget
// Slots are the item's other non-empty widget slots — an Accordion group's
// `headerContent` — printed as `<keyword> { … }` blocks inside the item.
Slots []rawChildSlot
}

// extractObjectLists reconstructs every object-list property of a pluggable
Expand Down Expand Up @@ -72,7 +75,8 @@ func extractObjectLists(ctx *ExecContext, w map[string]any) []rawObjectList {
continue
}
item := extractObjectListItem(ctx, om, nestedMap)
if len(item.Props) > 0 || item.DataSource != nil || len(item.Children) > 0 {
item.promoteBodySlot(bodyItemSlotKey(objectListItemWidgetSlotKeys(w, listKey)))
if len(item.Props) > 0 || item.DataSource != nil || len(item.Children) > 0 || len(item.Slots) > 0 {
items = append(items, item)
}
}
Expand Down Expand Up @@ -179,17 +183,22 @@ func extractObjectListItem(ctx *ExecContext, itemObj map[string]any, nestedMap m
// change: an EMPTY map means the field is simply unset.
continue
}
// Child widgets (an Accordion group's `content` slot). A Widgets-typed
// sub-property holds a widget tree, not a scalar, so it is parsed with the
// same recursion the rest of DESCRIBE uses rather than stringified (#891).
// Child widgets. A Widgets-typed sub-property holds a widget tree, not a
// scalar, so it is parsed with the same recursion the rest of DESCRIBE
// uses rather than stringified (#891). Each slot is kept apart: folding
// them all into one body put an Accordion group's headerContent widgets
// into its content on re-exec (#1453). promoteBodySlot then moves the
// body slot's widgets into Children.
if childElems := getBsonArrayElements(value["Widgets"]); len(childElems) > 0 {
slot := rawChildSlot{Keyword: strings.ToLower(key), PropertyKey: key}
for _, ce := range childElems {
cm, ok := ce.(map[string]any)
if !ok {
continue
}
item.Children = append(item.Children, parseRawWidget(ctx, cm)...)
slot.Widgets = append(slot.Widgets, parseRawWidget(ctx, cm)...)
}
item.Slots = append(item.Slots, slot)
continue
}
// Action sub-property (a chart series' staticOnClickAction, a popupmenu
Expand Down Expand Up @@ -272,3 +281,47 @@ func extractObjectListItem(ctx *ExecContext, itemObj map[string]any, nestedMap m
func objectListMDLKey(schemaKey string) string {
return schemaKey
}

// promoteBodySlot moves the widgets of the item's body slot (bodyKey) into
// Children, leaving its other slots as named blocks. With no body key — a
// schema that cannot be read — every slot stays a block, which CREATE routes by
// its keyword just as well.
func (item *rawObjectListItem) promoteBodySlot(bodyKey string) {
if bodyKey == "" {
return
}
for i, s := range item.Slots {
if s.PropertyKey == bodyKey {
item.Children = append(item.Children, s.Widgets...)
item.Slots = append(item.Slots[:i:i], item.Slots[i+1:]...)
return
}
}
}

// objectListItemWidgetSlotKeys lists the Widgets-typed property keys of one
// object-list's item type, in schema order — the input bodyItemSlotKey needs.
func objectListItemWidgetSlotKeys(w map[string]any, listKey string) []string {
widgetType, _ := w["Type"].(map[string]any)
objType, _ := widgetType["ObjectType"].(map[string]any)
for _, pt := range getBsonArrayElements(objType["PropertyTypes"]) {
ptMap, ok := pt.(map[string]any)
if !ok || extractString(ptMap["PropertyKey"]) != listKey {
continue
}
valueType, _ := ptMap["ValueType"].(map[string]any)
itemObjType, _ := valueType["ObjectType"].(map[string]any)
var keys []string
for _, ipt := range getBsonArrayElements(itemObjType["PropertyTypes"]) {
iptMap, ok := ipt.(map[string]any)
if !ok {
continue
}
if vt, _ := iptMap["ValueType"].(map[string]any); extractString(vt["Type"]) == "Widgets" {
keys = append(keys, extractString(iptMap["PropertyKey"]))
}
}
return keys
}
return nil
}
5 changes: 4 additions & 1 deletion mdl/executor/cmd_pages_describe_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -960,8 +960,11 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) {
// An item holding child widgets (an Accordion group's `content`
// slot) needs a body, or the children have nowhere to go and the
// description silently drops them on re-exec (#891).
if len(item.Children) > 0 {
if len(item.Children) > 0 || len(item.Slots) > 0 {
formatWidgetProps(ctx.Output, childPrefix, itemHeader, itemProps, " {\n")
// The item's other slots as blocks (headercontent { … }),
// then its body — CREATE routes each by keyword (#1453).
outputChildSlots(ctx, item.Slots, childPrefix+" ", indent+2)
for _, child := range item.Children {
outputWidgetMDLV3(ctx, child, indent+2)
}
Expand Down
156 changes: 156 additions & 0 deletions mdl/executor/describe_item_slots_1453_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: Apache-2.0

// An object-list item can hold widgets in more than one slot — an Accordion
// group has `headerContent` and `content`. DESCRIBE folded every slot into the
// item's body, so a header widget was printed beside the body widgets, and a
// page regenerated from that description stored it in `content` (measured on a
// blank 11.13.0 app: headerContent -> [txtHead], content -> [txtBody] became
// content -> [txtHead, txtBody]). And `check` refused the block form CREATE
// builds from — `headercontent { … }` inside a group was MDL-WIDGET25 "not a
// widget in this project" — so the honest description could not run either.
// (ako/mxcli#1326, mendixlabs/mxcli#1453; prerequisite of
// PROPOSAL_alter_object_list_item_address.md.)
package executor

import (
"bytes"
"strings"
"testing"

"github.com/mendixlabs/mxcli/mdl/linter"
"github.com/mendixlabs/mxcli/mdl/visitor"
)

// accordionWithBothSlots is a group holding txtHead in headerContent and
// txtBody in content, with the schema declaring headerContent FIRST — so a
// "first widget slot is the body" rule would pick the wrong one.
func accordionWithBothSlots() map[string]any {
const (
idGroups = "type-id-groups"
idHead = "type-id-headerContent"
idBody = "type-id-content"
)
text := func(name, s string) map[string]any {
return map[string]any{"$Type": "Forms$StaticText", "Name": name,
"Text": map[string]any{"Items": []any{map[string]any{"Text": s}}}}
}
return map[string]any{
"Name": "acc1",
"Type": map[string]any{
"WidgetId": "com.mendix.widget.web.accordion.Accordion",
"ObjectType": map[string]any{"PropertyTypes": []any{
map[string]any{"$ID": idGroups, "PropertyKey": "groups",
"ValueType": map[string]any{"ObjectType": map[string]any{"PropertyTypes": []any{
map[string]any{"$ID": idHead, "PropertyKey": "headerContent",
"ValueType": map[string]any{"Type": "Widgets"}},
map[string]any{"$ID": idBody, "PropertyKey": "content",
"ValueType": map[string]any{"Type": "Widgets"}},
}}}},
}},
},
"Object": map[string]any{"Properties": []any{
map[string]any{"TypePointer": idGroups, "Value": map[string]any{"Objects": []any{
map[string]any{"Properties": []any{
map[string]any{"TypePointer": idHead, "Value": map[string]any{"Widgets": []any{text("txtHead", "Header")}}},
map[string]any{"TypePointer": idBody, "Value": map[string]any{"Widgets": []any{text("txtBody", "Body")}}},
}},
}}},
}},
}
}

func TestObjectListItem_BodyAndOtherSlotsKeptApart_1453(t *testing.T) {
lists := extractObjectLists(nil, accordionWithBothSlots())
if len(lists) != 1 || len(lists[0].Items) != 1 {
t.Fatalf("want one group, got %+v", lists)
}
item := lists[0].Items[0]
if len(item.Children) != 1 || item.Children[0].Name != "txtBody" {
t.Fatalf("body = %+v, want only txtBody — headerContent was folded into the body", item.Children)
}
if len(item.Slots) != 1 || item.Slots[0].Keyword != "headercontent" ||
len(item.Slots[0].Widgets) != 1 || item.Slots[0].Widgets[0].Name != "txtHead" {
t.Fatalf("slots = %+v, want headercontent: [txtHead]", item.Slots)
}
}

func TestObjectListItem_EmitsHeaderContentBlock_1453(t *testing.T) {
var buf bytes.Buffer
ctx := &ExecContext{Output: &buf}
outputWidgetMDLV3(ctx, rawWidget{
Type: "CustomWidgets$CustomWidget",
RenderMode: "accordion",
Name: "acc1",
WidgetID: "com.mendix.widget.web.accordion.Accordion",
ObjectLists: extractObjectLists(nil, accordionWithBothSlots()),
}, 0)
out := buf.String()
block := strings.Index(out, "headercontent {")
head := strings.Index(out, "txtHead")
body := strings.Index(out, "txtBody")
if block < 0 || head < block || body < 0 {
t.Fatalf("want txtHead inside a `headercontent { … }` block of the group:\n%s", out)
}
if end := strings.Index(out[block:], "}"); block+end > body {
t.Fatalf("txtBody must not be inside the headercontent block:\n%s", out)
}
}

// With no `content` slot the body is the item's first widget slot, as CREATE's
// defaultItemSlotKey routes loose children.
func TestBodyItemSlotKey_1453(t *testing.T) {
for _, c := range []struct {
keys []string
want string
}{
{[]string{"headerContent", "content"}, "content"},
{[]string{"children", "extra"}, "children"},
{nil, ""},
} {
if got := bodyItemSlotKey(c.keys); got != c.want {
t.Errorf("bodyItemSlotKey(%v) = %q, want %q", c.keys, got, c.want)
}
}
}

// The block describe now prints must pass check: an item's own slot keyword is
// a container of that item, not an unknown widget.
func TestCheckAcceptsItemSlotBlock_1453(t *testing.T) {
prog, errs := visitor.Build(`create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {
pluggablewidget 'com.mendix.widget.web.accordion.Accordion' acc1 {
group (headerRenderMode: 'custom') {
headercontent { dynamictext txtHead (content: 'Header') }
dynamictext txtBody (content: 'Body')
}
}
}`)
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
for _, v := range ValidateWidgetPropertiesForStatement(prog.Statements[0], accordionRegistry(t)) {
if v.Severity == linter.SeverityError {
t.Errorf("an item slot block was refused: %s: %s", v.RuleID, v.Message)
}
}
}

// The control for the test above: a keyword that is NOT one of the item's
// slots is still refused, so the fix did not just switch the rule off.
func TestCheckStillRefusesUnknownBlockInItem_1453(t *testing.T) {
prog, errs := visitor.Build(`create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {
pluggablewidget 'com.mendix.widget.web.accordion.Accordion' acc1 {
group (headerRenderMode: 'custom') {
headercontnt { dynamictext txtHead (content: 'Header') }
}
}
}`)
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
for _, v := range ValidateWidgetPropertiesForStatement(prog.Statements[0], accordionRegistry(t)) {
if v.RuleID == "MDL-WIDGET25" || v.RuleID == "MDL-WIDGET26" {
return
}
}
t.Fatal("a misspelt slot block inside a group was not refused")
}
23 changes: 22 additions & 1 deletion mdl/executor/validate_widget_kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func explicitWidgetID(w *ast.WidgetV3) string {
// validateWidgetKind reports a widget whose kind mxcli cannot resolve, and a
// container keyword the parent's definition does not declare.
func validateWidgetKind(w *ast.WidgetV3, registry *WidgetRegistry, parentDef *WidgetDefinition,
parentObjectLists map[string]*ObjectListMapping, locationPrefix string) []linter.Violation {
parentObjectLists map[string]*ObjectListMapping, parentItem *ObjectListMapping, locationPrefix string) []linter.Violation {
if w == nil || registry == nil {
return nil
}
Expand Down Expand Up @@ -90,6 +90,13 @@ func validateWidgetKind(w *ast.WidgetV3, registry *WidgetRegistry, parentDef *Wi
if _, known := registry.Get(strings.ToUpper(w.Type)); known {
return nil
}
// A slot of the enclosing object-list item — `headercontent { … }` in an
// Accordion group — is a container the item declares, routed by the
// item builder's slot pass. Refusing it as "not a widget" made the very
// block DESCRIBE prints for that slot impossible to run (#1453).
if itemDeclaresSlot(parentItem, w.Type) {
return nil
}
// A container the parent declares is not a widget and never resolves in
// the registry — that is what makes it a container. Accept it here and
// let the object-list engine validate its properties.
Expand Down Expand Up @@ -287,3 +294,17 @@ func nearestWidgetNames(registry *WidgetRegistry, name string) string {
}
return ""
}

// itemDeclaresSlot reports whether an object-list item mapping has a widget
// slot whose container keyword is kw (case-insensitive).
func itemDeclaresSlot(item *ObjectListMapping, kw string) bool {
if item == nil {
return false
}
for _, s := range item.ItemSlots {
if strings.EqualFold(s.MDLContainer, kw) || strings.EqualFold(s.PropertyKey, kw) {
return true
}
}
return false
}
8 changes: 4 additions & 4 deletions mdl/executor/validate_widget_kind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func TestValidateWidgetKind_ContainerNotDeclaredByTheParentIsReported(t *testing
},
}
got := validateWidgetKind(&ast.WidgetV3{Type: "group", Name: "g1"},
registry, parent, objectListMappingSet(parent), "page X")
registry, parent, objectListMappingSet(parent), nil, "page X")

if len(got) == 0 || got[0].RuleID != "MDL-WIDGET26" {
t.Fatalf("an undeclared container was not reported; got %v", got)
Expand All @@ -102,7 +102,7 @@ func TestValidateWidgetKind_ContainerDeclaredByTheParentIsSilent(t *testing.T) {
},
}
if got := validateWidgetKind(&ast.WidgetV3{Type: "group", Name: "g1"},
registry, parent, objectListMappingSet(parent), "page X"); len(got) != 0 {
registry, parent, objectListMappingSet(parent), nil, "page X"); len(got) != 0 {
t.Errorf("the parent declares `group`, so it must not be reported: %v", got)
}
}
Expand All @@ -116,7 +116,7 @@ func TestValidateWidgetKind_DeclaredChildSlotIsSilent(t *testing.T) {
ChildSlots: []ChildSlotMapping{{MDLContainer: "GROUP", PropertyKey: "groups"}},
}
if got := validateWidgetKind(&ast.WidgetV3{Type: "group", Name: "g1"},
registry, parent, nil, "page X"); len(got) != 0 {
registry, parent, nil, nil, "page X"); len(got) != 0 {
t.Errorf("a declared child slot must not be reported: %v", got)
}
}
Expand All @@ -127,7 +127,7 @@ func TestValidateWidgetKind_DeclaredChildSlotIsSilent(t *testing.T) {
func TestValidateWidgetKind_UnresolvableParentSilencesTheContainerRule(t *testing.T) {
registry := LoadWidgetRegistry("")
if got := validateWidgetKind(&ast.WidgetV3{Type: "group", Name: "g1"},
registry, nil, nil, "page X"); len(got) != 0 {
registry, nil, nil, nil, "page X"); len(got) != 0 {
t.Errorf("with no resolvable parent the rule must stay silent: %v", got)
}
}
Expand Down
Loading
Loading