diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-folds-item-slots-into-body.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-folds-item-slots-into-body.json new file mode 100644 index 000000000..0682fd033 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-folds-item-slots-into-body.json @@ -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"]} diff --git a/mdl-examples/bug-tests/1453-describe-accordion-headercontent-slot.mdl b/mdl-examples/bug-tests/1453-describe-accordion-headercontent-slot.mdl new file mode 100644 index 000000000..6ebc11e66 --- /dev/null +++ b/mdl-examples/bug-tests/1453-describe-accordion-headercontent-slot.mdl @@ -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; diff --git a/mdl/executor/cmd_pages_describe_objectlist.go b/mdl/executor/cmd_pages_describe_objectlist.go index ee212bac7..044e49f0b 100644 --- a/mdl/executor/cmd_pages_describe_objectlist.go +++ b/mdl/executor/cmd_pages_describe_objectlist.go @@ -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 ` { … }` blocks inside the item. + Slots []rawChildSlot } // extractObjectLists reconstructs every object-list property of a pluggable @@ -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) } } @@ -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 @@ -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 +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 828db1468..443e90d4a 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -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) } diff --git a/mdl/executor/describe_item_slots_1453_test.go b/mdl/executor/describe_item_slots_1453_test.go new file mode 100644 index 000000000..7906bebdd --- /dev/null +++ b/mdl/executor/describe_item_slots_1453_test.go @@ -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") +} diff --git a/mdl/executor/validate_widget_kind.go b/mdl/executor/validate_widget_kind.go index 483a4d4a6..652961f34 100644 --- a/mdl/executor/validate_widget_kind.go +++ b/mdl/executor/validate_widget_kind.go @@ -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 } @@ -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. @@ -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 +} diff --git a/mdl/executor/validate_widget_kind_test.go b/mdl/executor/validate_widget_kind_test.go index b8a227df2..2ef38e3dd 100644 --- a/mdl/executor/validate_widget_kind_test.go +++ b/mdl/executor/validate_widget_kind_test.go @@ -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) @@ -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) } } @@ -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) } } @@ -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) } } diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 22d963823..0693d99bd 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -195,7 +195,7 @@ func validateWidgetTree(widgets []*ast.WidgetV3, registry *WidgetRegistry, locat // validateDocumentWidgetTree is validateWidgetTree for a document whose // entity-typed parameters are known (a CREATE PAGE / SNIPPET's `Params:`). func validateDocumentWidgetTree(widgets []*ast.WidgetV3, registry *WidgetRegistry, locationPrefix string, params []string) []linter.Violation { - out := validateWidgetTreeIn(widgets, registry, locationPrefix, nil, nil, atDocumentRoot().withDocumentParameters(params)) + out := validateWidgetTreeIn(widgets, registry, locationPrefix, nil, nil, nil, atDocumentRoot().withDocumentParameters(params)) // Only a whole document: an ALTER fragment's parent is out of sight, and // whether a row or column stores a name depends on it (#749). for _, msg := range checkMissingWidgetNames(widgets) { @@ -214,7 +214,7 @@ func validateDocumentWidgetTree(widgets []*ast.WidgetV3, registry *WidgetRegistr // encloses them is unknown, so rules that depend on the enclosing context stand // down rather than guess. func validateWidgetSubtree(widgets []*ast.WidgetV3, registry *WidgetRegistry, locationPrefix string) []linter.Violation { - return validateWidgetTreeIn(widgets, registry, locationPrefix, nil, nil, pageArgContext{}) + return validateWidgetTreeIn(widgets, registry, locationPrefix, nil, nil, nil, pageArgContext{}) } // validateWidgetTreeIn is validateWidgetTree with the *parent* widget's @@ -225,7 +225,11 @@ func validateWidgetSubtree(widgets []*ast.WidgetV3, registry *WidgetRegistry, lo // must be exempt from the MDL-WIDGET07 "unrecognized property, silently dropped" // warning. When the parent mapping is known, the child's enumeration // sub-properties are validated against their member keys (MDL-WIDGET08). (9a) -func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, locationPrefix string, parentObjectLists map[string]*ObjectListMapping, parent *ast.WidgetV3, argCtx pageArgContext) []linter.Violation { +// +// parentItem is the object-list mapping of the parent when the parent is an +// object-list ITEM (an Accordion group): its widget slots (`headercontent`) +// are containers of that item, not widgets (#1453). +func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, locationPrefix string, parentObjectLists map[string]*ObjectListMapping, parent *ast.WidgetV3, parentItem *ObjectListMapping, argCtx pageArgContext) []linter.Violation { var out []linter.Violation for _, w := range widgets { if w == nil { @@ -235,7 +239,7 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc isObjectListItem := mapping != nil || isUniversalObjectListKeyword(w.Type) // Slice 0: is this a widget at all, and does the parent declare this // container? Both were previously left to `exec`. - out = append(out, validateWidgetKind(w, registry, lookupWidgetDef(parent, registry), parentObjectLists, locationPrefix)...) + out = append(out, validateWidgetKind(w, registry, lookupWidgetDef(parent, registry), parentObjectLists, parentItem, locationPrefix)...) // A keyword whose stored $Type Mendix no longer has. Unlike MDL-WIDGET25 // this needs no project: the type is unknown to every Mendix version. out = append(out, validateRetiredWidgetKind(w, locationPrefix)...) @@ -370,7 +374,7 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validateWidgetItemVisibility(parent, w, mapping, registry, locationPrefix)...) } if len(w.Children) > 0 { - out = append(out, validateWidgetTreeIn(w.Children, registry, locationPrefix, objectListMappingSet(def), w, argContextForChildren(w, argCtx))...) + out = append(out, validateWidgetTreeIn(w.Children, registry, locationPrefix, objectListMappingSet(def), w, mapping, argContextForChildren(w, argCtx))...) } } out = append(out, validateConsecutiveDynamicText(widgets, locationPrefix)...) diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 495a5344f..23007401b 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -2386,15 +2386,30 @@ func applyColumnSortableFallback(spec *backend.ObjectListItemSpec, sortableExpli } func defaultItemSlotKey(mapping *ObjectListMapping) string { - if len(mapping.ItemSlots) == 0 { - return "" - } + keys := make([]string, 0, len(mapping.ItemSlots)) for _, s := range mapping.ItemSlots { - if s.PropertyKey == "content" { - return s.PropertyKey + keys = append(keys, s.PropertyKey) + } + return bodyItemSlotKey(keys) +} + +// bodyItemSlotKey picks an object-list item's BODY among its widget slots +// (property keys, in schema order): `content` when the item has one, else the +// first. CREATE routes an item's loose children there; DESCRIBE prints that +// slot as the item's children and every other slot as a ` { … }` +// block. One function, so the two cannot disagree about which slot loose +// widgets belong to — they did, and an Accordion group's headerContent +// round-tripped into its content (mendixlabs/mxcli#1453). +func bodyItemSlotKey(keys []string) string { + for _, k := range keys { + if k == "content" { + return k } } - return mapping.ItemSlots[0].PropertyKey + if len(keys) == 0 { + return "" + } + return keys[0] } // lookupProperty does a case-insensitive lookup against an AST property map.