From 5027f479dddde1e7be161a355af93b542a8fa124 Mon Sep 17 00:00:00 2001 From: Ako Date: Wed, 11 Nov 2026 21:15:39 +0100 Subject: [PATCH 1/3] fix(check): accept the container names Studio Pro stores in grid rows, control bars and combo box options (#2109) describe page -> exec of four FactoryManagement pages was refused on its own output (MDL-BUTTON02, MDL-PAGEARG01), although the stored pages build: - a show page in a selectable data grid's column passing `$grid1`: the grid's selection, stored as Variable {Widget: grid1} as from its control bar (MxModelReflection.MxObjectType_Select); - `$dataView3` from the control bar (filters placeholder) of a grid inside dataView3: the bar sits inside the grid, so dataView3 is an outer container there (OIDC.OIDC_Client_NewEdit); - `$dataView2` in a combo box's option content: the option is the combo box's own context (AgentCommons.Version_NewEdit). The check walk and the show page argument context model the three, and the engine enters a data widget through the same constructor (enteringDataWidgetOf), so check and exec agree. Re-measured on 11.16: the control-bar case builds with 0 errors in the variable form written since #2095; two older tests are updated with that evidence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82ttaGzay5CwiC2V7cf --- ...1-11-own-container-name-in-item-slots.json | 1 + CHANGELOG.md | 1 + .../2109-own-container-name-in-item-slots.mdl | 64 +++++++++++++++ mdl/executor/cmd_pages_showpage_args.go | 42 +++++++++- ...d_pages_showpage_selection_arg_122_test.go | 12 ++- .../validate_own_container_slots_2109_test.go | 81 +++++++++++++++++++ mdl/executor/validate_page_button_context.go | 46 ++++++++--- .../validate_page_own_container_name_test.go | 10 ++- mdl/executor/widget_engine.go | 4 +- 9 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-own-container-name-in-item-slots.json create mode 100644 mdl-examples/bug-tests/2109-own-container-name-in-item-slots.mdl create mode 100644 mdl/executor/validate_own_container_slots_2109_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-own-container-name-in-item-slots.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-own-container-name-in-item-slots.json new file mode 100644 index 0000000000..f0b8343da1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-own-container-name-in-item-slots.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-11", "refs": ["ako/mxcli#2109", "mendixlabs/mxcli#1324", "mendixlabs/mxcli#122", "ako/mxcli#2095"], "ce": ["CE0117", "CE0115"], "symptom": "describe page -> exec of four FactoryManagement pages (Mendix 11.16) was refused by check on its own output: \"`actionButton1` reads `$grid1` …, but `grid1` is the data container the widget sits in directly\" (MDL-BUTTON02) and \"$grid1 cannot be stored — … an explicit one is rejected as CE0115\" (MDL-PAGEARG01). The stored pages build (untouched app: 4 unrelated errors).", "file": "mdl/executor/validate_page_button_context.go (checkButtonContextTree: control bar nearest \"\", nearestSelectable; isObjectContextContainer + combobox; checkOwnContainerName skips a show page in a selectable grid's rows), mdl/executor/cmd_pages_showpage_args.go (pageArgContext.rowSelectionOf, namesRowSelection, selectableDataGrid, enteringDataWidgetOf shared by argContextForChildren and both engine sites), mdl/executor/widget_engine.go. Tests: validate_own_container_slots_2109_test.go; updated validate_page_own_container_name_test.go (btnCtlBar) and cmd_pages_showpage_selection_arg_122_test.go", "cause": "Three slots open or move an object context that the walks modelled wrong. A selectable data grid's name is its selection in its rows as well as its control bar (Studio Pro stores Variable {Widget: grid1} from column content). A control bar takes its object from above the grid but sits INSIDE it, so the container above is an outer one, not the nearest. A combo box's option content is its own item context. And the pluggable engine entered a data widget's argument context with its own constructor, so a fix in argContextForChildren alone left exec refusing what check accepted.", "insight": "**Dump the stored slot before deciding which side is wrong, and find the property path, not just the widget.** A small walker mapping each CustomWidget TypePointer to its PropertyKey showed `Datagrid:grid1 > [columns] > [content]`, `[filtersPlaceholder]` (printed as controlbar) and `Combobox > [optionsSourceAssociationCustomContent]` - three different reasons in one rule. Then **re-measure an old 'measured' verdict with today's writer before trusting it**: btnCtlBar's CE0117 on 11.14 was an error in an EXPRESSION `$dvP`; since #2095 the argument is written as the variable pair, and a probe on 11.16 builds it - and even a button DIRECTLY in dvP passing $dvP - with 0 errors (positive control in the same build: CE1365), so MDL-BUTTON02's premise for action arguments may be stale beyond this fix. Controls: reverting each of the five edits fails its test; old binary refuses all four pages, new one reports Unchanged page on the three that MDL-WIDGET03 (#2110) does not block."} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f78b49c01..a688924d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check` accepts the data-container names Studio Pro stores in a grid row, a control bar and a combo box option** (ako/mxcli#2109) — `describe page` → `exec` of four FactoryManagement pages was refused on its own output (MDL-BUTTON02, MDL-PAGEARG01): a show page in a selectable data grid's column passing the grid's selection (`$grid1`), a control-bar button passing the data view around its grid, and a combo box's option content reading the data view around the combo box. All three build in Studio Pro's own pages. `check` and `exec` now accept them and write what Studio Pro stores, and the pages report `Unchanged page`. - **`describe page` → `exec` no longer rewrites a page over formatting and stored keys MDL cannot state** (ako/mxcli#2106) — on FactoryManagement, 8 pages reported `Replaced page` with nothing changed: a dynamic-classes expression's trailing newlines, an XPath constraint's line breaks and spacing, the order of an enumeration condition's values, an association source's null `SourceVariable` key, and an inherited `createdDate` binding requalified to the specialization. The stored value is now kept where only its form differs, the writer stores the key Studio Pro stores, and a system member resolves to the entity that stores it. A full sweep of the app's 229 pages turns those 8 `Unchanged` and changes no other. - **`describe page` → `exec` keeps a check box's label position, a radio button group's orientation and a show page action's close count** (ako/mxcli#2106) — none had an MDL spelling, so a round trip of a Studio Pro page reset a check box with its label after the control to the default, turned every vertical radio button group horizontal, and stopped a show page button from closing the page it came from, with `mx check` green. `checkbox` takes `LabelPosition: Default|BeforeControl|AfterControl`, `radiobuttons` takes `RenderHorizontal: false`, and `show page` takes `with (NumberOfPagesToClose: n)`; `describe` prints each when it differs from the default. - **`mxcli test --local --watch` keeps applying changes on macOS and Linux mxbuild** (mendixlabs/mxcli#1787, refs mendixlabs/mxcli#1786) — the test runner's rebuild called mxbuild's serve process directly, without the recovery `run --local --watch` has, so the second build of a serve process failed on regenerated pages and every later change reported "could not be applied". The rebuild now goes through the same recovery, which also recognises mxbuild's resource-key form of that failure (`IO_PathNotFound_Path`, as macOS and Linux report it) and skips the web-bundle check, which only matters when a browser is served. diff --git a/mdl-examples/bug-tests/2109-own-container-name-in-item-slots.mdl b/mdl-examples/bug-tests/2109-own-container-name-in-item-slots.mdl new file mode 100644 index 0000000000..1fcbdd85b6 --- /dev/null +++ b/mdl-examples/bug-tests/2109-own-container-name-in-item-slots.mdl @@ -0,0 +1,64 @@ +mdl 1; +-- ============================================================================ +-- Bug ako/mxcli#2109: check refused Studio Pro's own data-container names +-- ============================================================================ +-- +-- Symptom (before fix): describe page -> exec of four FactoryManagement pages +-- (Mendix 11.16) was refused by check on its own output: +-- "`actionButton1` reads `$grid1` …, but `grid1` is the data container the +-- widget sits in directly — its name is a variable only inside a data +-- container nested below it (CE0117)" [MDL-BUTTON02] +-- "$grid1 cannot be stored — … an explicit one is rejected as CE0115" [MDL-PAGEARG01] +-- The stored pages build. Measured shapes: +-- - a button in a SELECTABLE data grid's column passing `$grid1`: Studio Pro +-- stores Variable {Widget: grid1}, the grid's selection, as from its +-- control bar (MxModelReflection.MxObjectType_Select); +-- - `$dataView3` from the filter bar (control bar) of a grid inside dataView3 +-- (OIDC.OIDC_Client_NewEdit): the bar sits inside the grid, so dataView3 is +-- an outer container there; +-- - `$dataView2` in a combo box's option content inside dataView2 +-- (AgentCommons.Version_NewEdit): the option is the combo box's own context. +-- +-- After fix: check accepts all three; exec writes the variable forms Studio +-- Pro stores; mx check 0 errors on Mendix 11.16.0 for this script. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/2109-own-container-name-in-item-slots.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ + +create module BugTest2109; +create module role BugTest2109.User; + +create persistent entity BugTest2109.Gate (Name: String(100)); +create persistent entity BugTest2109.Item (Name: String(100)); + +create microflow BugTest2109.ACT_Open ($Gate: BugTest2109.Gate) returns Boolean +begin + return true; +end; +grant execute on microflow BugTest2109.ACT_Open to BugTest2109.User; + +create page BugTest2109.Item_Details (Params: ($Item: BugTest2109.Item), Title: 'Item', Layout: Atlas_Core.PopupLayout) { + dataview dvItem (DataSource: $Item) { + dynamictext txtName (Content: '{1}', ContentParams: ({1} = Name)) + } +}; +grant view on page BugTest2109.Item_Details to BugTest2109.User; + +create page BugTest2109.Overview (Params: ($Gate: BugTest2109.Gate), Title: 'Overview', Layout: Atlas_Core.PopupLayout) { + dataview dvGate (DataSource: $Gate) { + datagrid dgGates (DataSource: database from BugTest2109.Gate) { + controlbar { + actionbutton btnOpen (Caption: 'Open', Action: call microflow BugTest2109.ACT_Open(Gate = $dvGate)) + } + column (Attribute: Name) + } + } + datagrid dgItems (DataSource: database from BugTest2109.Item, Selection: Single) { + column (Attribute: Name, ShowContentAs: customContent) { + actionbutton btnView (Caption: 'View', Action: show page BugTest2109.Item_Details(Item = $dgItems)) + } + } +}; +grant view on page BugTest2109.Overview to BugTest2109.User; diff --git a/mdl/executor/cmd_pages_showpage_args.go b/mdl/executor/cmd_pages_showpage_args.go index 9ada8c67b5..67d7445a8e 100644 --- a/mdl/executor/cmd_pages_showpage_args.go +++ b/mdl/executor/cmd_pages_showpage_args.go @@ -62,6 +62,12 @@ type pageArgContext struct { // selection `$` passes to a page as an explicit mapping naming the // grid (mendixlabs/mxcli#122). The row object is not the control bar's. selectionOf string + // rowSelectionOf is set inside a selectable data grid's rows: the grid's + // name, whose selection `$` a show page in a column passes as the + // same explicit mapping. Studio Pro stores exactly that (Variable {Widget: + // grid1}) on FactoryManagement's MxModelReflection.MxObjectType_Select, + // and it builds (ako/mxcli#2109). The row object stays $currentObject. + rowSelectionOf string } // inDataWidget names the data widget whose context c is. @@ -77,12 +83,42 @@ func (c pageArgContext) namesSelection(value string) bool { return ok && c.selectionOf != "" && strings.EqualFold(name, c.selectionOf) } +// namesRowSelection reports whether value is `$` written in that grid's +// rows, where the grid has a selection: a show page argument only. +func (c pageArgContext) namesRowSelection(value string) bool { + name, ok := strings.CutPrefix(value, "$") + return ok && c.rowSelectionOf != "" && strings.EqualFold(name, c.rowSelectionOf) +} + +// selectableDataGrid reports whether w is a data grid with a selection, whose +// name is then a variable in its rows as well as in its control bar. +func selectableDataGrid(w *ast.WidgetV3) bool { + switch strings.ToLower(w.Type) { + case "datagrid", "datagrid2": + default: + return false + } + sel := w.GetSelection() + return sel != "" && !strings.EqualFold(sel, "None") +} + // enteringDataWidget is the context below a data-bound widget bound to ds and // yielding entity. func enteringDataWidget(ds *ast.DataSourceV3, entity string) pageArgContext { return pageArgContext{known: true, present: true, varName: contextVarFor(ds), entity: entity} } +// enteringDataWidgetOf is enteringDataWidget for the rows of data widget w, by +// name: a selectable data grid's name is its selection there too. The one +// constructor the check walk and both builders use, so the three agree. +func enteringDataWidgetOf(w *ast.WidgetV3, ds *ast.DataSourceV3, entity string) pageArgContext { + c := enteringDataWidget(ds, entity).inDataWidget(w.Name) + if selectableDataGrid(w) { + c.rowSelectionOf = w.Name + } + return c +} + // atDocumentRoot is the context at the top of a page, snippet or layout the pass // walks in full: knowable, and empty. func atDocumentRoot() pageArgContext { @@ -126,7 +162,7 @@ func argContextForChildren(w *ast.WidgetV3, parent pageArgContext) pageArgContex if ds := w.GetDataSource(); ds != nil { // The entity is only used to word the refusal; the executor's builder // overwrites this with one that carries it. - c := enteringDataWidget(ds, "").inDataWidget(w.Name) + c := enteringDataWidgetOf(w, ds, "") c.docParams = parent.docParams return c } @@ -303,7 +339,7 @@ func refuseShowPageArgument(widgetName, target, argName, argValue string, c page func (pb *pageBuilder) namedParameterArgument(value string) (variable, kind string) { variable, kind = pb.classifyFlowArgValue(value) if kind != "parameter" && kind != "snippet" { - if pb.argCtx.namesSelection(value) { + if pb.argCtx.namesSelection(value) || pb.argCtx.namesRowSelection(value) { variable, kind = value, "widget" } else { return "", "" @@ -361,7 +397,7 @@ func validateShowPageArguments(w *ast.WidgetV3, c pageArgContext, locationPrefix var out []linter.Violation for _, arg := range action.Args { strVal, ok := arg.Value.(string) - if !ok || c.binds(strVal) || c.namesDocumentParameter(strVal) || c.namesSelection(strVal) { + if !ok || c.binds(strVal) || c.namesDocumentParameter(strVal) || c.namesSelection(strVal) || c.namesRowSelection(strVal) { continue } out = append(out, linter.Violation{ diff --git a/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go b/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go index e46c9b95e8..597c03662f 100644 --- a/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go +++ b/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go @@ -75,8 +75,12 @@ func TestValidateShowPageArguments_ControlBarPassesGridSelection(t *testing.T) { } } -// The selection is the control bar's, not the row's: a column button passing -// its own grid's name is not the measured shape and stays refused. +// A column button passing its own selectable grid's name passes the grid's +// selection too: Studio Pro stores Variable {Widget: grid1} there +// (FactoryManagement MxModelReflection.MxObjectType_Select, which builds), and +// so does the builder now, at 0 errors on 11.16 (ako/mxcli#2109). This used to +// be refused as an unmeasured shape. A grid without a selection still has no +// `$grid` to pass (TestCheck_GridSelectionFromColumnContent). func TestValidateShowPageArguments_GridSelectionOnlyInItsControlBar(t *testing.T) { src := `create page Mod.Overview (title: 'O', layout: Atlas_Core.Atlas_Default) { datagrid dgOrders (datasource: database from Mod.Order, selection: single) { @@ -90,8 +94,8 @@ func TestValidateShowPageArguments_GridSelectionOnlyInItsControlBar(t *testing.T t.Fatalf("parse: %v", errs) } s := prog.Statements[0].(*ast.CreatePageStmtV3) - if got := pageArgViolations(t, s.Widgets); len(got) != 1 { - t.Errorf("row-scoped button passing $dgOrders: %d MDL-PAGEARG01, want 1", len(got)) + if got := pageArgViolations(t, s.Widgets); len(got) != 0 { + t.Errorf("row-scoped button passing its selectable grid's selection $dgOrders: %d MDL-PAGEARG01, want 0", len(got)) } } diff --git a/mdl/executor/validate_own_container_slots_2109_test.go b/mdl/executor/validate_own_container_slots_2109_test.go new file mode 100644 index 0000000000..aa19454569 --- /dev/null +++ b/mdl/executor/validate_own_container_slots_2109_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#2109: check refused its own describe output on four FactoryManagement +// pages (Mendix 11.16), which Studio Pro authored and which build clean. The +// stored shapes, measured: +// +// - MxModelReflection.MxObjectType_Select, OIDC.OIDC_Client_NewEdit, +// UserCommons.ClaimsForClaimEntityAttribute: a button in a Data grid 2 +// column's content, in a grid with `Selection: Single`, whose show page +// mapping stores Variable {Widget: grid1} — the grid's SELECTION, the shape +// a control bar button stores for `$grid` (mendixlabs/mxcli#122). +// - AgentCommons.Version_NewEdit: container5 in a combo box's +// optionsSourceAssociationCustomContent, inside dataView2, with +// `Visible: $currentObject = $dataView2`. The option content is the combo +// box's own item context, so $dataView2 is an OUTER container there. +// +// check said: "`actionButton1` reads `$grid1` …, but `grid1` is the data +// container the widget sits in directly — its name is a variable only inside +// a data container nested below it" (MDL-BUTTON02), and refused the argument +// as an explicit mapping (MDL-PAGEARG01, "an explicit one is rejected as CE0115"). +package executor + +import ( + "strings" + "testing" +) + +func ownContainerRules(t *testing.T, src string) []string { + t.Helper() + var out []string + for _, v := range ValidateProgram(parseMDL(t, src), "") { + if v.RuleID == "MDL-BUTTON02" || v.RuleID == "MDL-PAGEARG01" { + out = append(out, v.RuleID+": "+v.Message) + } + } + return out +} + +func gridColumnButtonPage(selection string) string { + return `mdl 1; +create page M.Select ( Title: 'Select', Layout: Atlas_Core.PopupLayout ) { + datagrid grid1 ( DataSource: database from M.Item` + selection + ` ) { + column ( Attribute: Name, ShowContentAs: customContent ) { + actionbutton actionButton1 ( Caption: 'View', Action: show page M.Item_Details(Item = $grid1) ) + } + } +};` +} + +func TestCheck_GridSelectionFromColumnContent(t *testing.T) { + if got := ownContainerRules(t, gridColumnButtonPage(", Selection: Single")); len(got) != 0 { + t.Errorf("a selectable grid's selection refused from its column content:\n%s", strings.Join(got, "\n")) + } + // Control: a grid with no selection has no `$grid1` to pass. + if got := ownContainerRules(t, gridColumnButtonPage("")); len(got) == 0 { + t.Error("$grid1 accepted in a grid with no selection") + } +} + +func comboBoxOptionContentPage(visible string) string { + return `mdl 1; +create page M.Edit ( Title: 'Edit', Layout: Atlas_Core.PopupLayout, Params: ( $Agent: M.Agent ) ) { + dataview dataView2 ( DataSource: $Agent ) { + combobox comboBox3 ( Attribute: Agent_Version_InUse, DataSource: database from M.Version, optionsSourceAssociationCustomContentType: 'yes' ) { + optionssourceassociationcustomcontent { + container container5 ( Visible: ` + visible + ` ) + } + } + container container9 ( Visible: $currentObject = $dataView2 ) + } +};` +} + +func TestCheck_ComboBoxOptionContentIsItsOwnContext(t *testing.T) { + got := ownContainerRules(t, comboBoxOptionContentPage("$currentObject = $dataView2")) + // container9 sits directly in dataView2 and is still refused (the control); + // container5, in the combo box's option content, is not. + if len(got) != 1 || !strings.Contains(got[0], "container9") { + t.Errorf("want only container9 refused, got:\n%s", strings.Join(got, "\n")) + } +} diff --git a/mdl/executor/validate_page_button_context.go b/mdl/executor/validate_page_button_context.go index 5a6b6cdd6e..b8915bf53c 100644 --- a/mdl/executor/validate_page_button_context.go +++ b/mdl/executor/validate_page_button_context.go @@ -25,7 +25,7 @@ func ValidatePageButtonContext(prog *ast.Program) []linter.Violation { var out []linter.Violation for _, stmt := range prog.Statements { if label, widgets, ok := documentWidgets(stmt); ok { - out = append(out, checkButtonContextTree(widgets, "", false, "", label)...) + out = append(out, checkButtonContextTree(widgets, "", false, "", false, label)...) } } return out @@ -48,9 +48,14 @@ func ValidatePageButtonContext(prog *ast.Program) []linter.Violation { // control bar inherits the context from above the grid, not from the grid. // // nearest is the name of the data container whose object the widget sits in -// ("" at the top), for MDL-BUTTON02 (mendixlabs/mxcli#1324). It moves with the -// same rule as inContext: a control bar keeps the context from above its grid. -func checkButtonContextTree(widgets []*ast.WidgetV3, controlBarOf string, inContext bool, nearest, locationPrefix string) []linter.Violation { +// ("" at the top), for MDL-BUTTON02 (mendixlabs/mxcli#1324). Inside a control +// bar it is "": the bar takes its object from above its grid but sits inside +// the grid, so no container's name is its own there (ako/mxcli#2109). +// +// nearestSelectable says the nearest container is a data grid with a +// selection: its name then is a variable in its rows too, the selection, which +// a show page argument may pass (ako/mxcli#2109). +func checkButtonContextTree(widgets []*ast.WidgetV3, controlBarOf string, inContext bool, nearest string, nearestSelectable bool, locationPrefix string) []linter.Violation { var out []linter.Violation for _, w := range widgets { if w == nil { @@ -60,19 +65,24 @@ func checkButtonContextTree(widgets []*ast.WidgetV3, controlBarOf string, inCont out = append(out, checkControlBarAction(a, w.Name, controlBarOf, locationPrefix)...) } if nearest != "" { - out = append(out, checkOwnContainerName(w, nearest, locationPrefix)...) + out = append(out, checkOwnContainerName(w, nearest, nearestSelectable, locationPrefix)...) } childContext := inContext || isObjectContextContainer(w) - childNearest := nearest + childNearest, childSelectable := nearest, nearestSelectable if isObjectContextContainer(w) && w.Name != "" { - childNearest = w.Name + childNearest, childSelectable = w.Name, selectableDataGrid(w) } for _, c := range w.Children { - childOf, ctx, near := controlBarOf, childContext, childNearest + childOf, ctx, near, sel := controlBarOf, childContext, childNearest, childSelectable if c != nil && strings.EqualFold(c.Type, "controlbar") { - childOf, ctx, near = w.Name, inContext, nearest + // The control bar's object comes from above the grid, but the + // control bar sits INSIDE the grid, so the container above is + // an outer one there: `$dataView3` from the filter bar of a + // grid in dataView3 is Studio Pro's own spelling and builds + // (FactoryManagement OIDC.OIDC_Client_NewEdit, ako/mxcli#2109). + childOf, ctx, near, sel = w.Name, inContext, "", false } - out = append(out, checkButtonContextTree([]*ast.WidgetV3{c}, childOf, ctx, near, locationPrefix)...) + out = append(out, checkButtonContextTree([]*ast.WidgetV3{c}, childOf, ctx, near, sel, locationPrefix)...) } } return out @@ -95,7 +105,12 @@ var ownNameExprProps = []struct{ key, written string }{ // arguments (and a chained THEN action's), a data source's flow arguments, // Visible, Editable and DynamicClasses are CE0117 "Error(s) in expression.", // and a data source's XPath `where` is CE0161 "Error(s) in XPath constraint.". -func checkOwnContainerName(w *ast.WidgetV3, nearest, locationPrefix string) []linter.Violation { +// +// A selectable data grid's name in its rows is the grid's selection, which a +// show page argument stores as Variable {Widget: }: Studio Pro writes it +// there and it builds (ako/mxcli#2109), so that one slot is not judged. Other +// slots are, as no stored example shows them reading the selection. +func checkOwnContainerName(w *ast.WidgetV3, nearest string, nearestSelectable bool, locationPrefix string) []linter.Violation { var out []linter.Violation flag := func(slot, expr, code string) { if !exprReadsVariable(expr, nearest) { @@ -113,6 +128,9 @@ func checkOwnContainerName(w *ast.WidgetV3, nearest, locationPrefix string) []li }) } for a := w.GetAction(); a != nil; a = a.ThenAction { + if nearestSelectable && a.Type == "showPage" { + continue + } for _, arg := range a.Args { if v, ok := arg.Value.(string); ok { flag(a.Type+" action argument", v, "CE0117") @@ -174,10 +192,12 @@ func isExprIdentByte(c byte) bool { // isObjectContextContainer reports whether w gives its (non-control-bar) // children a current object: a data view's object, a list view or gallery -// item, a grid row (column content). +// item, a grid row (column content), a combo box option (its custom content: +// AgentCommons.Version_NewEdit reads `$dataView2` there, the data view +// around the combo box, and builds; ako/mxcli#2109). func isObjectContextContainer(w *ast.WidgetV3) bool { switch strings.ToLower(w.Type) { - case "dataview", "listview", "gallery", "templategrid", "datagrid", "datagrid2": + case "dataview", "listview", "gallery", "templategrid", "datagrid", "datagrid2", "combobox": return true } return false diff --git a/mdl/executor/validate_page_own_container_name_test.go b/mdl/executor/validate_page_own_container_name_test.go index b922252017..fa2bd1c6b1 100644 --- a/mdl/executor/validate_page_own_container_name_test.go +++ b/mdl/executor/validate_page_own_container_name_test.go @@ -56,6 +56,14 @@ func TestValidatePageButtonContext_OwnDataViewName_Issue1324(t *testing.T) { // data container decides — a plain container is transparent, a control bar // takes the context from ABOVE its grid (so a grid's own selection stays // spellable there), and a row or item of a nested list is a new context. +// +// btnCtlBar is no longer flagged (ako/mxcli#2109). Its CE0117 was an error in +// an EXPRESSION `$dvP`; since #2095 the argument is written as the variable +// pair {Widget: dvP, PageParameter: Gate}, which builds with 0 errors on +// 11.16 (probe page, with a CE1365 positive control in the same build), and +// is what Studio Pro stores for a control-bar button there +// (FactoryManagement OIDC.OIDC_Client_NewEdit actionButton7, which builds). +// A control bar sits inside its grid, so the container above is an outer one. func TestValidatePageButtonContext_OwnContainerName_MeasuredShapes(t *testing.T) { src := `create page G52.Probe (Title: 'Probe', Layout: Atlas_Core.PopupLayout, Params: ( $Gate: G52.Gate )) { dataview dvP (DataSource: $Gate) { @@ -92,7 +100,7 @@ func TestValidatePageButtonContext_OwnContainerName_MeasuredShapes(t *testing.T) } };` got := strings.Join(ownContainerFlagged(t, src), ",") - want := "btnCtlBar,btnDgOwnCol,btnGaOwn,btnInContainer,btnLvOwn,btnOwnPath" + want := "btnDgOwnCol,btnGaOwn,btnInContainer,btnLvOwn,btnOwnPath" if got != want { t.Fatalf("flagged %q, want %q (mx check 11.14.0's CE0117 set)", got, want) } diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index b867974c9d..d2184b9ba2 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -527,7 +527,7 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* e.recordDataSourceEntity(propKey, entityName) if entityName != "" { e.pageBuilder.entityContext = entityName - e.pageBuilder.argCtx = enteringDataWidget(ds, entityName).inDataWidget(w.Name) + e.pageBuilder.argCtx = enteringDataWidgetOf(w, ds, entityName) } } } @@ -1776,7 +1776,7 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W e.recordDataSourceEntity(mapping.PropertyKey, entityName) if entityName != "" { e.pageBuilder.entityContext = entityName - e.pageBuilder.argCtx = enteringDataWidget(ds, entityName).inDataWidget(w.Name) + e.pageBuilder.argCtx = enteringDataWidgetOf(w, ds, entityName) if w.Name != "" { e.pageBuilder.paramEntityNames[w.Name] = entityName } From dc81d8026706acc246552a2971564fc46df1e05a Mon Sep 17 00:00:00 2001 From: Ako Date: Wed, 11 Nov 2026 21:15:39 +0100 Subject: [PATCH 2/3] fix(pages): keep a binding read through an outer data view (#2107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio Pro stores a binding to an outer data view's object with the data-view pair SourceVariable {Widget, PageParameter}, with association steps for a path. describe printed the bare qualified name, and the rewrite read it against the nearest data view: CE7247 on five ConversationalUI markdown viewers, "TraceView has no attribute HasError" on Trace_Details. `$dvOuter.Attr` and `$dvOuter/Assoc/Attr`, the spelling input widgets already had (#826, #833), now work on a pluggable widget's attribute property and in `Visible: … in (…)`: one resolver (namedObjectVariable) for check, describe and exec, MDL-WIDGET34 accepting an enclosing data view, the rooted path accepted in the form the parser produces, and WidgetObjectBuilder.SetAttributeWithSteps to store the steps. All six pages report Unchanged page; a fresh page with the three forms builds with mx check 0 errors on 11.16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82ttaGzay5CwiC2V7cf --- ...11-11-binding-through-outer-data-view.json | 1 + CHANGELOG.md | 1 + docs-site/src/reference/page/create-page.md | 17 +- .../2107-binding-through-outer-data-view.mdl | 51 +++ mdl/backend/mcp/widget.go | 11 + mdl/backend/mutation.go | 4 + mdl/backend/widgetobj/builder.go | 9 + mdl/executor/cmd_pages_builder_v3.go | 30 +- .../cmd_pages_builder_visible_when.go | 11 +- mdl/executor/cmd_pages_describe_parse.go | 6 +- mdl/executor/cmd_pages_describe_pluggable.go | 18 +- .../cmd_pages_input_binding_context.go | 12 +- ..._pages_outer_dataview_binding_2107_test.go | 307 ++++++++++++++++++ mdl/executor/cmd_pages_parameter_binding.go | 30 +- .../validate_widget_expression_value.go | 7 +- mdl/executor/widget_engine.go | 64 +++- mdl/executor/widget_expression_value.go | 5 + 17 files changed, 551 insertions(+), 33 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-binding-through-outer-data-view.json create mode 100644 mdl-examples/bug-tests/2107-binding-through-outer-data-view.mdl create mode 100644 mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-binding-through-outer-data-view.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-binding-through-outer-data-view.json new file mode 100644 index 0000000000..093b761016 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-binding-through-outer-data-view.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-11", "refs": ["ako/mxcli#2107", "ako/mxcli#826", "ako/mxcli#833", "mendixlabs/mxcli#1591"], "ce": ["CE7247"], "symptom": "describe page -> exec of six FactoryManagement pages (Mendix 11.16) failed: a markdown viewer in an inner data view was refused \"`GenAICommons.Span.Output` is an attribute of GenAICommons.Span, not of ConversationalUI.CopyHelper … CE7247\" (and Span_TextGeneration_SlideIn's markdownViewer3 failed \"failed to resolve mapping for stringAttribute\"); an attribute-based visibility condition failed \"ConversationalUI.TraceView has no attribute HasError\".", "file": "mdl/executor/cmd_pages_parameter_binding.go (namedObjectSourceName, isNamedObjectAttributeText), cmd_pages_describe_pluggable.go and cmd_pages_describe_parse.go (print $dv.Attr / $dv/Assoc/Attr), cmd_pages_builder_v3.go (namedObjectVariable, used by resolveRootedAssociationPath), widget_engine.go (parameterBinding takes a data view; rootedAttributeBinding; BuildContext.AttributeSteps), widget_expression_value.go and validate_widget_expression_value.go (rooted data-source form on an attribute property), cmd_pages_builder_visible_when.go, cmd_pages_input_binding_context.go (MDL-WIDGET34 accepts an enclosing data view), mdl/backend (WidgetObjectBuilder.SetAttributeWithSteps in widgetobj and mcp). Tests: cmd_pages_outer_dataview_binding_2107_test.go", "cause": "Studio Pro stores both bindings with the data-view pair SourceVariable {Widget: , PageParameter: …} (markdownViewer3 also with association steps). parameterSourceName returns \"\" when Widget is set, so describe fell back to the qualified name and the rewrite resolved it against the nearest container. The `$dataView1.Attr` spelling existed for built-in inputs only (#826); the pluggable engine, Visible-by-attribute and MDL-WIDGET34 accepted a parameter only, and no pluggable attribute property could store association steps at all.", "insight": "**When a describe falls back to a qualified name, look for a SourceVariable the reader ignored.** Every one of these bindings carried the pair, and the existing spelling for it just had not been wired into every reader and writer: grep the parameter-only helpers (parameterSourceName, parameterVariable) for their callers, and widen them in ONE helper (namedObjectVariable, namedObjectSourceName) so check, describe and exec agree. Also note that `$x/Assoc/Attr` parses as an association data source, not an expression: a fix tested only through the expression form passes its unit test and still fails the real page (MDL-WIDGET42) - feed the engine the parsed shape. Controls: reverting each edit fails its test; the old binary refuses all six pages, the new one reports Unchanged page on all six; a fresh page with $dvOuter.Output, $dvOuter/Span_Trace/SystemPrompt and Visible: $dvOuter.Flag in (true) builds with mx check 0 errors on 11.16."} diff --git a/CHANGELOG.md b/CHANGELOG.md index a688924d3e..74b4c8aafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **`check` accepts the data-container names Studio Pro stores in a grid row, a control bar and a combo box option** (ako/mxcli#2109) — `describe page` → `exec` of four FactoryManagement pages was refused on its own output (MDL-BUTTON02, MDL-PAGEARG01): a show page in a selectable data grid's column passing the grid's selection (`$grid1`), a control-bar button passing the data view around its grid, and a combo box's option content reading the data view around the combo box. All three build in Studio Pro's own pages. `check` and `exec` now accept them and write what Studio Pro stores, and the pages report `Unchanged page`. +- **A binding to an outer data view keeps that data view** (ako/mxcli#2107) — a markdown viewer or an attribute-based visibility condition inside a nested data view, bound to the outer one's object, was described as a bare qualified name and rewritten against the nearest data view: CE7247, or "has no attribute". `$dvOuter.Attr` and `$dvOuter/Assoc/Attr` now work on a pluggable widget's attribute property and in `Visible: … in (…)`, as on input widgets, and `describe` prints them; the six FactoryManagement pages report `Unchanged page`. - **`describe page` → `exec` no longer rewrites a page over formatting and stored keys MDL cannot state** (ako/mxcli#2106) — on FactoryManagement, 8 pages reported `Replaced page` with nothing changed: a dynamic-classes expression's trailing newlines, an XPath constraint's line breaks and spacing, the order of an enumeration condition's values, an association source's null `SourceVariable` key, and an inherited `createdDate` binding requalified to the specialization. The stored value is now kept where only its form differs, the writer stores the key Studio Pro stores, and a system member resolves to the entity that stores it. A full sweep of the app's 229 pages turns those 8 `Unchanged` and changes no other. - **`describe page` → `exec` keeps a check box's label position, a radio button group's orientation and a show page action's close count** (ako/mxcli#2106) — none had an MDL spelling, so a round trip of a Studio Pro page reset a check box with its label after the control to the default, turned every vertical radio button group horizontal, and stopped a show page button from closing the page it came from, with `mx check` green. `checkbox` takes `LabelPosition: Default|BeforeControl|AfterControl`, `radiobuttons` takes `RenderHorizontal: false`, and `show page` takes `with (NumberOfPagesToClose: n)`; `describe` prints each when it differs from the default. - **`mxcli test --local --watch` keeps applying changes on macOS and Linux mxbuild** (mendixlabs/mxcli#1787, refs mendixlabs/mxcli#1786) — the test runner's rebuild called mxbuild's serve process directly, without the recovery `run --local --watch` has, so the second build of a serve process failed on regenerated pages and every later change reported "could not be applied". The rebuild now goes through the same recovery, which also recognises mxbuild's resource-key form of that failure (`IO_PathNotFound_Path`, as macOS and Linux report it) and skips the web-bundle check, which only matters when a browser is served. diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index e62da012fc..0d445871b8 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -238,7 +238,14 @@ DATAGRID dgOrders (DataSource: DATABASE FROM Sales.Order, Selection: Single) { A `SHOW PAGE` in the control bar takes the selection the same way — `Action: SHOW PAGE Sales.Order_Detail(Order = $dgOrders)` opens the page with -the selected row, and the button is disabled until a row is selected. +the selected row, and the button is disabled until a row is selected. In a grid +with `Selection`, a `SHOW PAGE` in a column's custom content may pass the +grid's selection by name as well, which is what Studio Pro stores there; +`$currentObject` is the row. + +A control bar takes its object from above its grid, but sits inside the grid, +so the data view around the grid is an outer one there: `$dvOrder` from the +control bar of a grid in data view `dvOrder` is valid. `mxcli check -p ` reports a missing argument before mxbuild does. @@ -288,6 +295,14 @@ attribute property set by its own key too, such as a Custom chart's attribute (`Module.Other.Attr`) is refused, because stored without the parameter it is mx check CE7247. +An **enclosing data view** is named the same way: inside a data view nested in +`dvOuter`, `$dvOuter.Attr` reads the outer data view's object, and +`$dvOuter/Assoc/Attr` reads over an association from it. This works on input +widgets, on a pluggable widget's attribute property +(`markdown md (stringAttribute: $dvOuter.Output)`) and in an attribute-based +visibility condition (`Visible: $dvOuter.HasError in (true)`). It is the form +Studio Pro stores for these bindings. + ## Parameters `module.Name` diff --git a/mdl-examples/bug-tests/2107-binding-through-outer-data-view.mdl b/mdl-examples/bug-tests/2107-binding-through-outer-data-view.mdl new file mode 100644 index 0000000000..6cd2bcebe9 --- /dev/null +++ b/mdl-examples/bug-tests/2107-binding-through-outer-data-view.mdl @@ -0,0 +1,51 @@ +mdl 1; +-- ============================================================================ +-- Bug ako/mxcli#2107: a binding to an outer data view resolved against the nearest +-- ============================================================================ +-- +-- Symptom (before fix): describe page -> exec of six FactoryManagement pages +-- (Mendix 11.16) failed. Studio Pro stores the binding with the data-view pair +-- SourceVariable {Widget: , PageParameter: …}; describe printed +-- the bare qualified name and the rewrite read it against the NEAREST data view: +-- "`GenAICommons.Span.Output` is an attribute of GenAICommons.Span, not of +-- ConversationalUI.CopyHelper … CE7247" (markdown viewer) +-- "ConversationalUI.TraceView has no attribute HasError" (Visible by attribute) +-- +-- After fix: `$dvOuter.Attr`, and the rooted `$dvOuter/Assoc/Attr`, on a +-- pluggable widget's attribute property and in `Visible: … in (…)`, store the +-- data-view pair (with the association steps); describe prints them so, and +-- the six pages report `Unchanged page`. mx check 0 errors on Mendix 11.16.0 +-- for this script (needs the Markdown widget, as Conversational UI ships it). +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/2107-binding-through-outer-data-view.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ + +create module BugTest2107; +create module role BugTest2107.User; + +create persistent entity BugTest2107.Span (Output: String(unlimited), Flag: Boolean default false); +create persistent entity BugTest2107.Trace (SystemPrompt: String(unlimited)); +create association BugTest2107.Span_Trace from BugTest2107.Span to BugTest2107.Trace; +create non-persistent entity BugTest2107.Helper (Note: String(100)); + +create microflow BugTest2107.DS_Helper () returns BugTest2107.Helper +begin + $Helper = create BugTest2107.Helper (Note = 'x'); + return $Helper; +end; +grant execute on microflow BugTest2107.DS_Helper to BugTest2107.User; + +create page BugTest2107.Span_Details (Params: ($Span: BugTest2107.Span), Title: 'Span', Layout: Atlas_Core.PopupLayout) { + dataview dvOuter (DataSource: $Span) { + dataview dvInner (DataSource: microflow BugTest2107.DS_Helper()) { + markdown mdOutput (stringAttribute: $dvOuter.Output) + markdown mdPrompt (stringAttribute: $dvOuter/Span_Trace/SystemPrompt) + container cFlag (Visible: $dvOuter.Flag in (true)) { + dynamictext txtFlag (Content: 'flagged') + } + } + } +}; +grant view on page BugTest2107.Span_Details to BugTest2107.User; diff --git a/mdl/backend/mcp/widget.go b/mdl/backend/mcp/widget.go index 243970a11e..991359b651 100644 --- a/mdl/backend/mcp/widget.go +++ b/mdl/backend/mcp/widget.go @@ -165,6 +165,17 @@ func (w *mcpWidgetBuilder) SetAttribute(propertyKey, attributePath string) { } } +// SetAttributeWithSteps: the MCP payload's attribute has no association +// steps, so a path over associations is reported unsupported rather than sent +// as a direct attribute. +func (w *mcpWidgetBuilder) SetAttributeWithSteps(propertyKey, attributePath string, steps []pages.AttributeRefStep) { + if len(steps) > 0 { + w.note("SetAttributeWithSteps(" + propertyKey + ")") + return + } + w.SetAttribute(propertyKey, attributePath) +} + // SetSourceVariable: the MCP widget payload has no slot for a property's // parameter source, so a `$Param.Attr` binding is reported unsupported rather // than sent as one read from the enclosing object. diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index b4d3f5e80c..37da3f9a42 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -392,6 +392,10 @@ type WidgetObjectBuilder interface { // and deferred to Finalize, which returns the aggregate result. SetAttribute(propertyKey string, attributePath string) + // SetAttributeWithSteps is SetAttribute for an attribute reached over + // associations: the AttributeRef's EntityRef holds one step per hop, as + // a rooted path `$Name/Assoc/Attr` stores it beside its SourceVariable. + SetAttributeWithSteps(propertyKey string, attributePath string, steps []pages.AttributeRefStep) SetAssociation(propertyKey string, assocPath string, entityName string) // SetSourceVariable names the object an attribute or association property // reads, when that is not the enclosing data context: a page or snippet diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index b21885a1e5..24f60fd111 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -136,6 +136,15 @@ func (ob *Builder) SetAttribute(propertyKey string, attributePath string) { }) } +func (ob *Builder) SetAttributeWithSteps(propertyKey string, attributePath string, steps []pages.AttributeRefStep) { + if attributePath == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setAttributeRefField(val, attributePath, steps) + }) +} + func (ob *Builder) SetAssociation(propertyKey string, assocPath string, entityName string) { if assocPath == "" { return diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 7c838f82f5..331ea041cc 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -2573,13 +2573,8 @@ func (pb *pageBuilder) resolveRootedAssociationPath(attrRef string) (wv pages.Wi if !ok { return pages.WidgetVariable{}, "", nil, false } - var start string - if v, entity, isParam := pb.parameterVariable(root); isParam { - wv, start = v, entity - } else if v, isDV := pb.dataViewVariables[root]; isDV { - wv, start = v, pb.paramEntityNames[root] - } - if start == "" { + wv, start, named := pb.namedObjectVariable(root) + if !named { return pages.WidgetVariable{}, "", nil, false } finalQN, steps, ok = pb.resolveAssociationPathFrom(path, start) @@ -3017,6 +3012,27 @@ func (pb *pageBuilder) parameterVariable(name string) (pages.WidgetVariable, str return pages.WidgetVariable{Variable: name, Kind: pb.parameterSlotKind(name)}, entity, true } +// namedObjectVariable is parameterVariable widened to an enclosing data view: +// the `$name` of a `$name.Attr` binding read from a page or snippet parameter, +// or else from a data view the widget sits inside, whose SourceVariable is the +// data-view pair {Widget: name, PageParameter: …} (ako/mxcli#826, #2107). It +// returns the SourceVariable to store and the object's entity. +func (pb *pageBuilder) namedObjectVariable(name string) (pages.WidgetVariable, string, bool) { + if wv, entity, ok := pb.parameterVariable(name); ok { + return wv, entity, true + } + name = strings.TrimPrefix(name, "$") + wv, known := pb.dataViewVariables[name] + if !known || pb.isDeclaredParameter(name) { + return pages.WidgetVariable{}, "", false + } + entity := pb.paramEntityNames[name] + if entity == "" { + return pages.WidgetVariable{}, "", false + } + return wv, entity, true +} + // documentKind names what is being built, for messages. func (pb *pageBuilder) documentKind() string { if pb.isSnippet { diff --git a/mdl/executor/cmd_pages_builder_visible_when.go b/mdl/executor/cmd_pages_builder_visible_when.go index fbeabf3cba..0be6c22ed2 100644 --- a/mdl/executor/cmd_pages_builder_visible_when.go +++ b/mdl/executor/cmd_pages_builder_visible_when.go @@ -70,11 +70,16 @@ func (pb *pageBuilder) visibleWhenSettings(w *ast.WidgetV3, vw *ast.VisibleWhenV // the snippet with no data container around it. entity, attrName := pb.entityContext, vw.Attribute var source *pages.WidgetVariable + // + // `$dataView1.Attr` reads an enclosing data view's object: the data-view pair + // {Widget, PageParameter}, as on FactoryManagement's + // ConversationalUI.Trace_Details (ako/mxcli#2107). if name, rest, ok := namedObjectBinding(vw.Attribute); ok { - wv, paramEntity, isParam := pb.parameterVariable(name) + wv, paramEntity, isParam := pb.namedObjectVariable(name) if !isParam || strings.Contains(rest, ".") { - return nil, mdlerrors.NewValidationf("%s: `$%s` is not a parameter of this %s — `Visible: $Param.Attr in (…)` "+ - "reads an attribute of a page or snippet parameter; inside a data container name the attribute bare", + return nil, mdlerrors.NewValidationf("%s: `$%s` is not a parameter of this %s or an enclosing data view — "+ + "`Visible: $Param.Attr in (…)` reads an attribute of a page or snippet parameter, `$dataView1.Attr` one of "+ + "a data view the widget is inside; for the nearest data container's object name the attribute bare", where, name, pb.documentKind()) } entity, attrName, source = paramEntity, rest, &wv diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index bb94b4bb70..947b7b4646 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -39,8 +39,10 @@ func extractConditionalSettings(ctx *ExecContext, widget *rawWidget, w map[strin if attr, ok := cvs["Attribute"].(string); ok && attr != "" { widget.VisibleAttr = describeAttr(ctx, attr) // Read from a page or snippet parameter (TestApp - // Snip_UserTask_NameColumnWithIcon): `$Param.Attr in (…)`. - if param := parameterSourceName(cvs["SourceVariable"]); param != "" { + // Snip_UserTask_NameColumnWithIcon): `$Param.Attr in (…)`, or from + // an enclosing data view (FactoryManagement + // ConversationalUI.Trace_Details): `$dataView6.Attr in (…)`. + if param := namedObjectSourceName(cvs["SourceVariable"]); param != "" { widget.VisibleAttr = parameterAttributeMDL(param, attr) } for _, c := range getBsonArrayElements(cvs["Conditions"]) { diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index bedac40b17..96f27b936a 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -1345,10 +1345,20 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any, enclosingEnti if attrRef, ok := value["AttributeRef"].(map[string]any); ok && attrRef != nil { if attr := extractString(attrRef["Attribute"]); attr != "" { described := describeAttr(ctx, attr) - if param := parameterSourceName(value["SourceVariable"]); param != "" && attrRef["EntityRef"] == nil { - // Read from a page or snippet parameter: `$Param.Attr`. The - // qualified name re-executes without the SourceVariable — - // CE7247 (mendixlabs/mxcli#1591). + if root := namedObjectSourceName(value["SourceVariable"]); root != "" && attrRef["EntityRef"] != nil { + // The same object, with association steps: `$Root/Assoc/Attr` + // (ako/mxcli#833), as FactoryManagement's + // ConversationalUI.Span_TextGeneration_SlideIn stores it + // (ako/mxcli#2107). + if path := columnAttributeFromRef(ctx, attrRef); strings.Contains(path, "/") { + described = "$" + root + "/" + path + } + } else if param := namedObjectSourceName(value["SourceVariable"]); param != "" && attrRef["EntityRef"] == nil { + // Read from a page or snippet parameter, `$Param.Attr`, or an + // enclosing data view, `$dataView1.Attr`. The qualified name + // re-executes without the SourceVariable and binds against + // the nearest container — CE7247 (mendixlabs/mxcli#1591, + // ako/mxcli#2107). described = parameterAttributeMDL(param, attr) } else if schemas[typePointerID].DataSourceProperty == "" && strings.Contains(enclosing, ".") && !strings.EqualFold(attributeEntity(attr), enclosing) { diff --git a/mdl/executor/cmd_pages_input_binding_context.go b/mdl/executor/cmd_pages_input_binding_context.go index daf351ec10..016d4b5325 100644 --- a/mdl/executor/cmd_pages_input_binding_context.go +++ b/mdl/executor/cmd_pages_input_binding_context.go @@ -306,8 +306,10 @@ func validatePageVariableBindings(widgets []*ast.WidgetV3, variables []ast.PageV // validateNamedObjectBindings is MDL-WIDGET34 for `$name.Attr` on a whole page // or snippet, where the parameters are known: a name that is neither one of -// them nor — on a built-in input — an enclosing data view names nothing, and -// exec refuses it. `Visible: $name.Attr in (…)` reads a parameter only. +// them nor — on a built-in input, or in `Visible: $name.Attr in (…)` — an +// enclosing data view names nothing, and exec refuses it. The visibility +// setting stores the data-view pair as an input does (FactoryManagement +// ConversationalUI.Trace_Details, ako/mxcli#2107). func validateNamedObjectBindings(widgets []*ast.WidgetV3, params []string, locationPrefix string) []linter.Violation { declared := make(map[string]bool, len(params)) for _, p := range params { @@ -382,9 +384,9 @@ func validateNamedObjectBindings(widgets []*ast.WidgetV3, params []string, locat } } if vw, ok := w.Properties["VisibleWhen"].(*ast.VisibleWhenV3); ok && vw != nil { - if name, rest, ok := namedObjectBinding(vw.Attribute); ok && (!declared[name] || strings.Contains(rest, ".")) { - report(fmt.Sprintf("%s `%s`: `Visible: %s in (…)` — `$%s` is not a parameter of this document, "+ - "so the condition would read nothing", kind, w.Name, vw.Attribute, name)) + if name, rest, ok := namedObjectBinding(vw.Attribute); ok && (!(declared[name] || dataViews[name]) || strings.Contains(rest, ".")) { + report(fmt.Sprintf("%s `%s`: `Visible: %s in (…)` — `$%s` is not a parameter of this document "+ + "or a data view the widget is inside, so the condition would read nothing", kind, w.Name, vw.Attribute, name)) } } inner := dataViews diff --git a/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go b/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go new file mode 100644 index 0000000000..1906e5e144 --- /dev/null +++ b/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#2107: a widget bound to an attribute of an OUTER data view's object. +// Studio Pro stores the binding with the data-view pair as its source, +// SourceVariable {Widget: , PageParameter: } +// (FactoryManagement, Mendix 11.16): +// +// ConversationalUI.Span_ToolSpan_SlideIn markdownViewer1.stringAttribute +// AttributeRef GenAICommons.Span.Output, SourceVariable {Widget: dataView1, PageParameter: ToolSpan} +// ConversationalUI.Trace_Details container112 Visible +// Attribute GenAICommons.Trace.HasError, SourceVariable {Widget: dataView6, PageParameter: Trace} +// +// describe printed the bare qualified name, which the rewrite resolved against the +// NEAREST data view: "`GenAICommons.Span.Output` is an attribute of +// GenAICommons.Span, not of ConversationalUI.CopyHelper … CE7247", and +// "ConversationalUI.TraceView has no attribute HasError". The pair already has +// an MDL spelling on input widgets, `$dataView1.Attr` (ako/mxcli#826). +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func outerDataViewPair(widget, param string) map[string]any { + return map[string]any{"$Type": "Forms$PageVariable", "Widget": widget, "PageParameter": param} +} + +func markdownInNestedDataView(sourceVariable any) map[string]any { + md := map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "Name": "markdownViewer1", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.markdown.Markdown", + "ObjectType": map[string]any{"PropertyTypes": []any{ + map[string]any{"$ID": "pt-str", "PropertyKey": "stringAttribute", + "ValueType": map[string]any{"Type": "Attribute"}}, + }}, + }, + "Object": map[string]any{"Properties": []any{ + map[string]any{"TypePointer": "pt-str", "Value": map[string]any{ + "AttributeRef": map[string]any{"Attribute": "GenAICommons.Span.Output"}, + "SourceVariable": sourceVariable, + }}, + }}, + } + inner := map[string]any{ + "$Type": "Forms$DataView", "Name": "dataView3", + "DataSource": map[string]any{"$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "ConversationalUI.CopyHelper"}}, + "Widgets": []any{md}, + } + return map[string]any{ + "$Type": "Forms$DataView", "Name": "dataView1", + "DataSource": map[string]any{"$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "GenAICommons.ToolSpan"}, + "SourceVariable": map[string]any{"$Type": "Forms$PageVariable", "PageParameter": "ToolSpan"}}, + "Widgets": []any{inner}, + } +} + +func describedMarkdownAttribute(t *testing.T, sourceVariable any) string { + t.Helper() + ctx, _ := newMockCtx(t) + outer := parseRawWidget(ctx, markdownInNestedDataView(sourceVariable)) + if len(outer) != 1 || len(outer[0].Children) != 1 || len(outer[0].Children[0].Children) != 1 { + t.Fatalf("want dataView1 > dataView3 > markdown, got %+v", outer) + } + for _, p := range outer[0].Children[0].Children[0].ExplicitProperties { + if p.Key == "stringAttribute" { + return p.Value + } + } + t.Fatal("no stringAttribute described") + return "" +} + +func TestDescribePluggableAttributeThroughOuterDataView(t *testing.T) { + if got := describedMarkdownAttribute(t, outerDataViewPair("dataView1", "ToolSpan")); got != "$dataView1.Output" { + t.Errorf("stringAttribute = %q, want $dataView1.Output (the stored data-view pair)", got) + } + // Control: with no SourceVariable the qualified name stays, as before. + if got := describedMarkdownAttribute(t, nil); got != "GenAICommons.Span.Output" { + t.Errorf("stringAttribute without a source = %q, want GenAICommons.Span.Output", got) + } +} + +func describedVisibleAttr(t *testing.T, sourceVariable any) string { + t.Helper() + ctx, _ := newMockCtx(t) + var w rawWidget + extractConditionalSettings(ctx, &w, map[string]any{"ConditionalVisibilitySettings": map[string]any{ + "Attribute": "GenAICommons.Trace.HasError", + "SourceVariable": sourceVariable, + "Conditions": []any{int32(2), + map[string]any{"AttributeValue": "true", "EditableVisible": true}, + map[string]any{"AttributeValue": "false", "EditableVisible": false}}, + }}) + return w.VisibleAttr +} + +func TestDescribeVisibleAttributeThroughOuterDataView(t *testing.T) { + if got := describedVisibleAttr(t, outerDataViewPair("dataView6", "Trace")); got != "$dataView6.HasError" { + t.Errorf("Visible attribute = %q, want $dataView6.HasError", got) + } + // Control: a parameter source stays `$Param.Attr`. + if got := describedVisibleAttr(t, map[string]any{"$Type": "Forms$PageVariable", "PageParameter": "Trace"}); got != "$Trace.HasError" { + t.Errorf("Visible attribute from a parameter = %q, want $Trace.HasError", got) + } +} + +// The builder half: `$dataView1.Attr` on a Visible-by-attribute setting and on +// a pluggable widget's attribute key writes the data-view pair, as it already +// did on an input widget. + +func TestBuildVisibleWhen_ThroughOuterDataView(t *testing.T) { + pb := scopedPB(false) + pb.paramEntityNames["dataView1"] = "M.Job" + defer pb.registerDataViewVariable("dataView1", &pages.DataViewSource{ParameterName: "Task"})() + cvs, err := buildVisibleWhen(t, pb, "$dataView1.Status", "Running") + if err != nil { + t.Fatalf("build: %v", err) + } + if cvs == nil || cvs.Attribute != "M.Job.Status" { + t.Fatalf("settings = %+v, want Attribute M.Job.Status", cvs) + } + if want := (pages.WidgetVariable{Widget: "dataView1", Variable: "Task"}); cvs.SourceVariable == nil || *cvs.SourceVariable != want { + t.Errorf("SourceVariable = %+v, want %+v", cvs.SourceVariable, want) + } + // Control: a name that is neither a parameter nor an enclosing data view. + if _, err := buildVisibleWhen(t, scopedPB(false), "$dataView9.Status", "Running"); err == nil { + t.Error("Visible: $dataView9.Status built; want a refusal") + } +} + +func TestBuildPluggableAttribute_ThroughOuterDataView(t *testing.T) { + e := scopeEngine(t) + e.pageBuilder.paramEntityNames["dvOuter"] = "Sales.Customer" + defer e.pageBuilder.registerDataViewVariable("dvOuter", &pages.DataViewSource{ParameterName: "Ctx"})() + w := twoSourceWidget(map[string]any{"staticAttribute": &ast.WidgetExpressionV3{Text: "$dvOuter.Name"}}) + widget, err := e.Build(multiSourceDef(), w) + if err != nil { + t.Fatalf("Build: %v", err) + } + val := widgetValueFor(widget.RawObject, "Sales.Customer.Name") + if val == nil { + t.Fatal("no WidgetValue binds Sales.Customer.Name — the binding was dropped") + } + sv, ok := dGet(val, "SourceVariable").(bson.D) + if !ok { + t.Fatalf("SourceVariable = %#v, want the data-view pair", dGet(val, "SourceVariable")) + } + for k, want := range map[string]any{"Widget": "dvOuter", "PageParameter": "Ctx"} { + if got := dGet(sv, k); got != want { + t.Errorf("SourceVariable.%s = %#v, want %#v", k, got, want) + } + } + // Control: a name that is no data view and no parameter is still refused. + w = twoSourceWidget(map[string]any{"staticAttribute": &ast.WidgetExpressionV3{Text: "$dvNope.Name"}}) + if _, err := scopeEngine(t).Build(multiSourceDef(), w); err == nil { + t.Error("staticAttribute: $dvNope.Name built; want a refusal") + } +} + +// The check half: MDL-WIDGET34 accepted `Visible: $name.Attr in (…)` for a +// parameter only, so describe's `$dataView6.HasError` would be refused. +func TestCheck_VisibleAttributeThroughOuterDataView(t *testing.T) { + widget34 := func(src string) int { + n := 0 + for _, v := range ValidateProgram(parseMDL(t, src), "") { + if v.RuleID == "MDL-WIDGET34" { + n++ + } + } + return n + } + page := func(inner string) string { + return `mdl 1; +create page M.Trace ( Title: 'T', Layout: Atlas_Core.PopupLayout, Params: ( $Trace: M.Job ) ) { + dataview dvOuter ( DataSource: $Trace ) { + dataview dvInner ( DataSource: microflow M.DS_View() ) { + container c1 ( Visible: $dvOuter.Status in (Running) ) + } + } + ` + inner + ` +};` + } + if n := widget34(page("")); n != 0 { + t.Errorf("`$dvOuter.Status` inside dvOuter: %d MDL-WIDGET34, want 0", n) + } + // Control: the same name outside dvOuter reads nothing. + if n := widget34(page("container c2 ( Visible: $dvOuter.Status in (Running) )")); n != 1 { + t.Errorf("`$dvOuter.Status` outside dvOuter: %d MDL-WIDGET34, want 1", n) + } +} + +// The pair with association steps: ConversationalUI.Span_TextGeneration_SlideIn's +// markdownViewer3 reads GenAICommons.Trace.SystemPrompt over Span_Trace from +// dataView1's object, SourceVariable {Widget: dataView1, PageParameter: +// ModelSpan}. The rooted spelling is #833's `$Root/Assoc/Attr`. +func TestDescribePluggableAttributePathThroughOuterDataView(t *testing.T) { + ctx, _ := newMockCtx(t) + w := markdownInNestedDataView(outerDataViewPair("dataView1", "ModelSpan")) + md := w["Widgets"].([]any)[0].(map[string]any)["Widgets"].([]any)[0].(map[string]any) + prop := md["Object"].(map[string]any)["Properties"].([]any)[0].(map[string]any) + prop["Value"].(map[string]any)["AttributeRef"] = map[string]any{ + "Attribute": "GenAICommons.Trace.SystemPrompt", + "EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef", "Steps": []any{int32(2), + map[string]any{"$Type": "DomainModels$EntityRefStep", "Association": "GenAICommons.Span_Trace", "DestinationEntity": "GenAICommons.Trace"}}}, + } + outer := parseRawWidget(ctx, w) + var got string + for _, p := range outer[0].Children[0].Children[0].ExplicitProperties { + if p.Key == "stringAttribute" { + got = p.Value + } + } + if !strings.HasPrefix(got, "$dataView1/") || !strings.HasSuffix(got, "Span_Trace/SystemPrompt") { + t.Errorf("stringAttribute = %q, want $dataView1/…Span_Trace/SystemPrompt", got) + } +} + +func associationEngine(t *testing.T) *PluggableWidgetEngine { + t.Helper() + real := &modelsdkbackend.Backend{} + mod := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-sales")}, Name: "Sales"} + b := &mock.MockBackend{ + LoadWidgetTemplateFunc: real.LoadWidgetTemplate, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ + BaseElement: model.BaseElement{ID: model.ID("dm-sales")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: model.ID("e-invoice")}, Name: "Invoice", + Attributes: []*domainmodel.Attribute{{Name: "Reference"}, {Name: "Label"}}}, + {BaseElement: model.BaseElement{ID: model.ID("e-order")}, Name: "Order", + Attributes: []*domainmodel.Attribute{{Name: "Number"}, {Name: "Label"}}}, + {BaseElement: model.BaseElement{ID: model.ID("e-customer")}, Name: "Customer", + Attributes: []*domainmodel.Attribute{{Name: "Name"}, {Name: "Label"}}}, + }, + Associations: []*domainmodel.Association{{Name: "Order_Customer", ParentID: "e-order", ChildID: "e-customer", + Type: domainmodel.AssociationTypeReference}}, + }}, nil + }, + } + pb := &pageBuilder{backend: b, entityContext: "Sales.Invoice", paramEntityNames: map[string]string{}, widgetScope: map[string]model.ID{}} + e := NewPluggableWidgetEngine(b, pb) + pb.pluggableEngine = e + return e +} + +func TestBuildPluggableAttributePath_ThroughOuterDataView(t *testing.T) { + e := associationEngine(t) + e.pageBuilder.paramEntityNames["dvOuter"] = "Sales.Order" + defer e.pageBuilder.registerDataViewVariable("dvOuter", &pages.DataViewSource{ParameterName: "Ctx"})() + w := twoSourceWidget(map[string]any{"staticAttribute": &ast.WidgetExpressionV3{Text: "$dvOuter/Order_Customer/Name"}}) + widget, err := e.Build(multiSourceDef(), w) + if err != nil { + t.Fatalf("Build: %v", err) + } + val := widgetValueFor(widget.RawObject, "Sales.Customer.Name") + if val == nil { + t.Fatal("no WidgetValue binds Sales.Customer.Name — the binding was dropped") + } + ref, _ := dGet(val, "AttributeRef").(bson.D) + entityRef, _ := dGet(ref, "EntityRef").(bson.D) + steps, _ := dGet(entityRef, "Steps").(bson.A) + var step bson.D + if len(steps) == 2 { + step, _ = steps[1].(bson.D) + } + if dGet(step, "Association") != "Sales.Order_Customer" { + t.Errorf("EntityRef steps = %#v, want one hop over Sales.Order_Customer", dGet(ref, "EntityRef")) + } + sv, _ := dGet(val, "SourceVariable").(bson.D) + if dGet(sv, "Widget") != "dvOuter" || dGet(sv, "PageParameter") != "Ctx" { + t.Errorf("SourceVariable = %#v, want the data-view pair", dGet(val, "SourceVariable")) + } +} + +// As the parser hands it over: `stringAttribute: $dataView1/Span_Trace/SystemPrompt` +// parses as an association data source rooted at the data view, which the +// engine refused as "an attribute property … cannot hold the expression" +// (MDL-WIDGET42) on Span_TextGeneration_SlideIn. +func TestBuildPluggableAttributePath_ParsedForm(t *testing.T) { + e := associationEngine(t) + e.pageBuilder.paramEntityNames["dvOuter"] = "Sales.Order" + defer e.pageBuilder.registerDataViewVariable("dvOuter", &pages.DataViewSource{ParameterName: "Ctx"})() + w := twoSourceWidget(map[string]any{"staticAttribute": &ast.DataSourceV3{ + Type: "association", Reference: "Order_Customer/Name", ContextVariable: "dvOuter"}}) + widget, err := e.Build(multiSourceDef(), w) + if err != nil { + t.Fatalf("Build: %v", err) + } + if widgetValueFor(widget.RawObject, "Sales.Customer.Name") == nil { + t.Fatal("no WidgetValue binds Sales.Customer.Name") + } +} diff --git a/mdl/executor/cmd_pages_parameter_binding.go b/mdl/executor/cmd_pages_parameter_binding.go index dbf9de5ea1..679b15734c 100644 --- a/mdl/executor/cmd_pages_parameter_binding.go +++ b/mdl/executor/cmd_pages_parameter_binding.go @@ -48,7 +48,7 @@ func namedObjectBinding(s string) (name, rest string, ok bool) { // one shape is still an expression. func parameterAttributeValue(raw any) (string, bool) { e, ok := raw.(*ast.WidgetExpressionV3) - if !ok || !isParameterAttributeText(e.Text) { + if !ok || !isNamedObjectAttributeText(e.Text) { return "", false } return e.Text, true @@ -89,6 +89,21 @@ func parameterSourceName(sv any) string { return extractString(m["PageParameter"]) } +// namedObjectSourceName is the `$name` of the object a stored SourceVariable +// names: the data view of a data-view pair {Widget, PageParameter}, or else a +// page or snippet parameter — "" for none, or for a page variable. MDL spells +// the pair `$dataView1.Attr` (ako/mxcli#826, #2107). +func namedObjectSourceName(sv any) string { + m, ok := sv.(map[string]any) + if !ok || m == nil || extractString(m["LocalVariable"]) != "" { + return "" + } + if widget := extractString(m["Widget"]); widget != "" { + return widget + } + return parameterSourceName(sv) +} + // customWidgetPropertyParameter is the parameter a CustomWidget property's // WidgetValue reads (its SourceVariable), or "". func customWidgetPropertyParameter(w map[string]any, propertyKey string) string { @@ -114,6 +129,19 @@ func parameterAttributeMDL(name, attrQN string) string { return "$" + name + "." + mdlIdent(shortAttributeName(attrQN)) } +// isNamedObjectAttributeText reports whether text binds an attribute of a named +// object: `$Name.Attr`, or the rooted association path `$Name/Assoc/Attr` +// (ako/mxcli#833), which a pluggable attribute property takes as well +// (FactoryManagement ConversationalUI.Span_TextGeneration_SlideIn, +// ako/mxcli#2107). Neither is a Mendix expression to an attribute property. +func isNamedObjectAttributeText(text string) bool { + if isParameterAttributeText(text) { + return true + } + _, _, rooted := rootedAssociationPath(text) + return rooted +} + // isParameterAttributeText reports whether text is the `$Param.Attr` shape. // namedObjectBinding alone takes `$Ctx.Name + 'x'` too, which is an expression. func isParameterAttributeText(text string) bool { diff --git a/mdl/executor/validate_widget_expression_value.go b/mdl/executor/validate_widget_expression_value.go index 4a08a385fe..787d5b62b4 100644 --- a/mdl/executor/validate_widget_expression_value.go +++ b/mdl/executor/validate_widget_expression_value.go @@ -58,6 +58,11 @@ func validateWidgetExpressionValuesIn(w *ast.WidgetV3, def *WidgetDefinition, it continue } text = src + // `$Name/Assoc/Attr` parses as a data source; on an attribute + // property it is a rooted attribute path (ako/mxcli#2107). + if root, path, rooted := rootedAttributeValue(v); rooted { + text, isExpr = "$"+root+"/"+path, true + } default: continue } @@ -77,7 +82,7 @@ func validateWidgetExpressionValuesIn(w *ast.WidgetV3, def *WidgetDefinition, it switch { case op == "expression": continue - case isExpr && isParameterAttributeText(text) && (op == "attribute" || !known): + case isExpr && isNamedObjectAttributeText(text) && (op == "attribute" || !known): // `$Param.Attr` reads an attribute of a page or snippet parameter // (mendixlabs/mxcli#1591); exec resolves the parameter. It is no // Mendix expression (that is `$Param/Attr`), so a key whose kind is diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index d2184b9ba2..e5c361b9a2 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -278,6 +278,10 @@ type BuildContext struct { // SourceVariable names the parameter an attribute or association binding // reads (`$Param.Attr`); nil for one read from the enclosing object. SourceVariable *pages.WidgetVariable + // AttributeSteps are the association hops of a rooted attribute path, + // `$Name/Assoc/Attr`, stored as the AttributeRef's EntityRef beside + // SourceVariable (ako/mxcli#2107). + AttributeSteps []pages.AttributeRefStep // ImageEntity is the entity an image-typed property binds to when it was // written `$currentObject` rather than as a collection entry (#1476). ImageEntity string @@ -747,6 +751,17 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* // re-applies a property authored by its schema key AFTER every // datasource has run, so the shared context here is always the LAST // datasource's entity, which is what #647 wrote for every name. + if _, _, rooted := rootedAssociationPath(strVal); rooted { + // `$Name/Assoc/Attr`: an association path read from a named + // object (ako/mxcli#833, #2107). + wv, finalQN, steps, err := e.rootedAttributeBinding(w, propName, strVal) + if err != nil { + return nil, err + } + builder.SetAttributeWithSteps(propName, finalQN, steps) + builder.SetSourceVariable(propName, &wv) + break + } if name, rest, named := namedObjectBinding(strVal); named { // `$Param.Attr`: the attribute of a page or snippet parameter, // whatever encloses the widget — the AttributeRef plus a @@ -1059,7 +1074,11 @@ var templateAttrPlaceholderRe = regexp.MustCompile(`\{[A-Za-z][A-Za-z0-9_]*\}`) func (e *PluggableWidgetEngine) applyOperation(builder backend.WidgetObjectBuilder, opName string, propKey string, ctx *BuildContext) error { switch opName { case "attribute": - builder.SetAttribute(propKey, ctx.AttributePath) + if len(ctx.AttributeSteps) > 0 { + builder.SetAttributeWithSteps(propKey, ctx.AttributePath, ctx.AttributeSteps) + } else { + builder.SetAttribute(propKey, ctx.AttributePath) + } if ctx.SourceVariable != nil && ctx.AttributePath != "" { builder.SetSourceVariable(propKey, ctx.SourceVariable) } @@ -1497,13 +1516,15 @@ func refuseAmbiguousGenericDataSource(def *WidgetDefinition, w *ast.WidgetV3, ds } // parameterBinding resolves the `$name` of a `$name.Attr` binding on a pluggable -// widget property to the page or snippet parameter it names: the SourceVariable -// to store beside the reference, and the parameter's entity. A name that is no -// parameter is refused — the data-view pair (#826) is measured on the built-in -// inputs only, and anything else would be written as a binding that reads +// widget property to the object it names: a page or snippet parameter, or an +// enclosing data view — the data-view pair Studio Pro stores on a markdown +// viewer in an inner data view (FactoryManagement ConversationalUI +// Span_*_SlideIn, ako/mxcli#2107) as on the built-in inputs (#826). It returns +// the SourceVariable to store beside the reference and the object's entity. +// Any other name is refused: it would be written as a binding that reads // nothing. func (e *PluggableWidgetEngine) parameterBinding(w *ast.WidgetV3, propertyKey, attr, name string) (pages.WidgetVariable, string, error) { - wv, entity, ok := e.pageBuilder.parameterVariable(name) + wv, entity, ok := e.pageBuilder.namedObjectVariable(name) if !ok { return pages.WidgetVariable{}, "", notAParameterError( fmt.Sprintf("%s `%s` property `%s`", strings.ToLower(w.Type), w.Name, propertyKey), @@ -1512,12 +1533,30 @@ func (e *PluggableWidgetEngine) parameterBinding(w *ast.WidgetV3, propertyKey, a return wv, entity, nil } +// rootedAttributeBinding resolves `$Name/Assoc/Attr` on a pluggable widget's +// attribute property — an association path read from a parameter or an +// enclosing data view, the shape Studio Pro stores as the AttributeRef's +// steps beside a SourceVariable naming the object (FactoryManagement +// ConversationalUI.Span_TextGeneration_SlideIn, ako/mxcli#2107). +func (e *PluggableWidgetEngine) rootedAttributeBinding(w *ast.WidgetV3, propertyKey, attr string) (pages.WidgetVariable, string, []pages.AttributeRefStep, error) { + wv, finalQN, steps, ok := e.pageBuilder.resolveRootedAssociationPath(attr) + if !ok { + root, _, _ := rootedAssociationPath(attr) + return pages.WidgetVariable{}, "", nil, mdlerrors.NewValidationf( + "%s `%s` property `%s`: `%s` — `$%s/…` reads an association path from a parameter of this %s or an "+ + "enclosing data view, and `$%s` is neither, or the path does not resolve from it", + strings.ToLower(w.Type), w.Name, propertyKey, attr, root, e.pageBuilder.documentKind(), root) + } + return wv, finalQN, steps, nil +} + // notAParameterError refuses a `$name.Attr` binding whose name is no page or // snippet parameter. where names the widget property, for the message. func notAParameterError(where, attr, name, documentKind string) error { return mdlerrors.NewValidationf( - "%s: `%s` — `$%s` is not a parameter of this %s; `$Param.Attr` reads an attribute "+ - "of a page or snippet parameter. Inside a data container, name the attribute bare", + "%s: `%s` — `$%s` is not a parameter of this %s or an enclosing data view; `$Param.Attr` reads an "+ + "attribute of a page or snippet parameter, `$dataView1.Attr` one of a data view the widget is inside. "+ + "For the nearest data container's object, name the attribute bare", where, attr, name, documentKind) } @@ -1688,7 +1727,14 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W } } } - if name, rest, named := namedObjectBinding(attr); named { + if _, _, rooted := rootedAssociationPath(attr); rooted { + // `$Name/Assoc/Attr` (ako/mxcli#833, #2107). + wv, finalQN, steps, err := e.rootedAttributeBinding(w, mapping.PropertyKey, attr) + if err != nil { + return nil, err + } + ctx.AttributePath, ctx.AttributeSteps, ctx.SourceVariable = finalQN, steps, &wv + } else if name, rest, named := namedObjectBinding(attr); named { // `$Param.Attr`: read from a page or snippet parameter, whatever // encloses the widget (see cmd_pages_parameter_binding.go). wv, entity, err := e.parameterBinding(w, mapping.PropertyKey, attr, name) diff --git a/mdl/executor/widget_expression_value.go b/mdl/executor/widget_expression_value.go index 3a547423f6..efe752db0e 100644 --- a/mdl/executor/widget_expression_value.go +++ b/mdl/executor/widget_expression_value.go @@ -69,6 +69,11 @@ func scalarPropertyText(w *ast.WidgetV3, key string, raw any, op string) (string } return "", expressionInScalarPropertyError(w, key, op, v.Text) case *ast.DataSourceV3: + // `$Name/Assoc/Attr` parses as a data source; on an attribute property + // it is a rooted attribute path, resolved by the engine (ako/mxcli#2107). + if root, path, rooted := rootedAttributeValue(v); rooted && op == "attribute" { + return "$" + root + "/" + path, nil + } src, hasSrc := w.ExpressionSource(key) if op == "expression" && hasSrc { return src, nil From 8cbe6d691c506eeda09cced2402547628b8ddc17 Mon Sep 17 00:00:00 2001 From: Ako Date: Wed, 11 Nov 2026 22:15:44 +0100 Subject: [PATCH 3/3] style: gofmt the #2107 test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82ttaGzay5CwiC2V7cf --- mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go b/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go index 1906e5e144..ad6793caf0 100644 --- a/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go +++ b/mdl/executor/cmd_pages_outer_dataview_binding_2107_test.go @@ -22,8 +22,8 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" - modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" "github.com/mendixlabs/mxcli/mdl/backend/mock" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/pages"