diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-controlbar-show-page-grid-selection.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-controlbar-show-page-grid-selection.json new file mode 100644 index 0000000000..af2ac3ceaf --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-controlbar-show-page-grid-selection.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "A Data grid 2 control-bar button opening a page with the grid's selection had no spelling that built: `show page M.Detail(OrderParam = $currentObject)` is MDL-BUTTON01 (CE1571, not row-scoped), `OrderParam = $dgOrders` — what MDL-BUTTON01's own hint suggested — was MDL-PAGEARG01 \"a widget's page argument is always the enclosing context object\", and no argument is mx check `[CE1571] \"No argument has been selected for parameter 'OrderParam'\"`", "cause": "mxcli wrote show page arguments only as the inferred empty mapping (#296) or, since #1403, a PageVariable naming a page/snippet parameter; it had no third form, so the guard (correctly, for what could be written) refused `$grid`. The control bar also had no context of its own: the engine builds a widgets slot's children directly (applyChildSlots), never through the `controlbar` node, so a rule hung on that node in buildWidgetV3 is never reached for Data grid 2", "file": "`mdl/executor/cmd_pages_showpage_args.go` (`pageArgContext.selectionOf`, `namesSelection`, `argContextForChildren`, `namedParameterArgument`), `mdl/executor/widget_engine.go` (`applyChildSlots`, `inDataWidget` at the datasource passes), `mdl/backend/modelsdk/widget_write.go` (`pageSettingsWithMappings`, kind \"widget\"), `mdl/executor/cmd_pages_describe_output.go` (`extractPageParameters`)", "insight": "No Studio Pro reference for the shape existed in the corpus (12 PageParameterMappings, none with Variable.Widget — the scanner's control was counting all 12 first, since bson v2 decodes nested docs as bson.D and a first scan silently matched nothing). Three mxbuild 11.12.5 builds of the same script settled the storage: Variable{Widget: grid} with Argument \"\" → 0 errors, Argument \"$grid\" → CE0115, no mapping → CE1571; the microflow path's Expression form does NOT carry over to a page mapping. A runtime check then showed the binding means what it says: the button is disabled until a row is selected and opens the detail with the selected (second) row. Wrong turn to skip: a builder unit test that sets argCtx and calls buildClientActionV3 passed while exec still refused — test the engine's applyChildSlots, where Data grid 2's control bar is actually built", "refs": ["mendixlabs/mxcli#122", "ako/mxcli#1224"], "ce": ["CE1571", "CE0115"], "rules": ["MDL-PAGEARG01", "MDL-BUTTON01"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-pluggable-attribute-outside-data-view-entity.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-pluggable-attribute-outside-data-view-entity.json new file mode 100644 index 0000000000..f269ed9fcb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-pluggable-attribute-outside-data-view-entity.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "Custom chart `eventDataAttribute: EventData` inside a data view over page parameter $Entry (ChartEntry has no EventData) passes `check --references` and `exec`, then mx check reports `[CE1613] \"The selected attribute 'MyFirstModule.ChartEntry.EventData' no longer exists.\"`; written qualified as MyFirstModule.ChartContext.EventData it is stored as such, but `describe page` prints `eventDataAttribute: EventData` and re-running that rebinds it to ChartEntry", "cause": "(1) misboundAttributeError refused a bare name only when ANOTHER entity in scope declared it, and returned nil for a name no entity had (\"a plain typo, which other checks own\") — no check owned it for pluggable properties; (2) extractExplicitProperties shortened every AttributeRef with describeAttr and was given no scope, and dataSourceEntityContext returned a parameter source's NAME (`Entry`) as the context entity, so no consumer could compare it to the attribute's entity", "file": "`mdl/executor/widget_attribute_scope.go` (`misboundAttributeError`), `mdl/executor/cmd_pages_describe_pluggable.go` (`extractExplicitProperties`), `mdl/executor/cmd_pages_describe_datasource.go` (`parseContextSource`, `parameterSourceEntity`), `mdl/executor/cmd_pages_describe_flowcontext.go` (`dataSourceEntityContext`)", "insight": "A guard that stays quiet on the case it cannot place should say which check owns that case — this one named none, and grep found none. The describe half was the same mistake dataSourceEntityContext had already fixed twice (a flow's name, an association's name standing in for the entity): a parameter source stores its entity beside the name (`EntityRef.Entity` on Forms$DataViewSource), so it is read, not looked up from the page's parameters. To force the fault into a build, use a pre-fix binary: exec shares the refusal with check, so `--no-check` no longer writes the broken binding. Remaining: the stored qualified binding without a SourceVariable gets CE7247 from mx check in a fresh app; Studio Pro's page-parameter binding carries a Forms$PageVariable, which MDL still cannot spell for a pluggable attribute property", "refs": ["mendixlabs/mxcli#1591", "ako/mxcli#1502"], "ce": ["CE1613", "CE7247"]} diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index 3b1c0d20f0..2e30cfda60 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -184,6 +184,10 @@ 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. + `mxcli check -p ` reports a missing argument before mxbuild does. ### ButtonStyle Values diff --git a/mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl b/mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl new file mode 100644 index 0000000000..cc6e2c3ac9 --- /dev/null +++ b/mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- ============================================================================ +-- Control-bar SHOW PAGE passing the grid's selection (mendixlabs/mxcli#122) +-- ============================================================================ +-- +-- Symptom (before fix), Mendix 11.12.5: +-- - `Order = $currentObject` in the control bar: MDL-BUTTON01 (CE1571 — +-- a control bar is not row-scoped), whose hint said to pass `$dgOrders`; +-- - `Order = $dgOrders`: MDL-PAGEARG01 "a widget's page argument is always +-- the enclosing context object"; +-- - no argument: mx check CE1571 "No argument has been selected for +-- parameter 'OrderParam'". +-- There was no spelling that built. +-- +-- Fix: `$dgOrders` in dgOrders' control bar is the grid's selection, stored +-- as an explicit Forms$PageParameterMapping {Argument "", Variable: +-- Forms$PageVariable{Widget "dgOrders"}}. Measured on mxbuild 11.12.5: +-- that shape → 0 errors +-- Argument "$dgOrders" instead → CE0115 "arguments … do not match" +-- no mapping → CE1571 +-- In the running app the button is disabled until a row is selected, and +-- opens the detail page with the selected row (checked against the 2nd row). +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl -p app.mpr +-- mxcli docker check -p app.mpr → 0 errors +-- mxcli -p app.mpr -c "describe page BugTest122.Order_Overview" +-- → Action: show page BugTest122.Order_Detail(OrderParam = $dgOrders) +-- ============================================================================ + +create module BugTest122; + +create persistent entity BugTest122.Order (OrderNumber: String(50)); + +create page BugTest122.Order_Detail ( + Title: 'Order', + Layout: Atlas_Core.Atlas_Default, + Params: ( $OrderParam: BugTest122.Order ) +) { + dataview dv (DataSource: $OrderParam) { + textbox tbNumber (Label: 'Number', Attribute: OrderNumber) + } +}; + +create page BugTest122.Order_Overview ( + Title: 'Orders', + Layout: Atlas_Core.Atlas_Default +) { + datagrid dgOrders (DataSource: database from BugTest122.Order, Selection: Single) { + controlbar { + actionbutton btnView (Caption: 'View', Action: show page BugTest122.Order_Detail(OrderParam = $dgOrders), ButtonStyle: Primary) + } + column (Attribute: OrderNumber, Caption: 'Order') + } +}; diff --git a/mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl b/mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl new file mode 100644 index 0000000000..c898a68508 --- /dev/null +++ b/mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl @@ -0,0 +1,51 @@ +mdl 1; +-- ============================================================================ +-- Pluggable attribute property bound outside the data view entity +-- (mendixlabs/mxcli#1591) +-- ============================================================================ +-- +-- Symptom (before fix), Mendix 11.12.5: +-- +-- 1. `eventDataAttribute: EventData` inside a data view over $Entry +-- (ChartEntry, which has no EventData) passed `check --references` and +-- `exec`; mx check then reported +-- [CE1613] "The selected attribute 'MyFirstModule.ChartEntry.EventData' +-- no longer exists." at Custom chart 'chart1' +-- 2. Written qualified (MyFirstModule.ChartContext.EventData) it was stored +-- as such, but DESCRIBE printed `eventDataAttribute: EventData`, and +-- re-running the description rebound it to ChartEntry — case 1 again. +-- +-- Root cause: +-- 1. misboundAttributeError refused a name only when ANOTHER entity in scope +-- had it, and let a name no entity has through as "a typo other checks +-- own" — none did. +-- 2. The generic pluggable describer shortened every attribute, and a data +-- view over a page parameter put the parameter's NAME in scope instead of +-- its entity, so the two could not be compared. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page BugTest1591.ChartPage" +-- → eventDataAttribute: BugTest1591.ChartContext.EventData +-- Changing that line to `eventDataAttribute: EventData` is now refused by +-- check: "`EventData` is not an attribute of BugTest1591.ChartEntry …". +-- ============================================================================ + +create module BugTest1591; + +create non-persistent entity BugTest1591.ChartContext (EventData: String(unlimited)); +create non-persistent entity BugTest1591.ChartEntry (JsonData: String(unlimited), JsonLayout: String(unlimited)); + +create page BugTest1591.ChartPage ( + Title: 'Chart', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Ctx: BugTest1591.ChartContext, $Entry: BugTest1591.ChartEntry ) +) { + dataview dvEntry (DataSource: $Entry) { + customchart chart1 ( + dataAttribute: JsonData, + layoutAttribute: JsonLayout, + eventDataAttribute: BugTest1591.ChartContext.EventData + ) + } +}; diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index c209843f8f..c6cc45510f 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -2086,7 +2086,14 @@ func pageSettingsWithMappings(pageName string, mappings []*pages.PageClientParam assignID(m) m.SetParameterQualifiedName(pageName + "." + pm.ParameterName) m.SetArgument("") - m.SetVariable(sourceVariableToGen(strings.TrimPrefix(pm.Variable, "$"), pm.VariableKind)) + if pm.VariableKind == "widget" { + // A control-bar button passing its grid's selection names the grid + // in the Widget slot (mendixlabs/mxcli#122). mxbuild 11.12.5: 0 + // errors; the empty array is CE1571, Argument "$grid" CE0115. + m.SetVariable(pageVariableToGen(strings.TrimPrefix(pm.Variable, "$"), "", "")) + } else { + m.SetVariable(sourceVariableToGen(strings.TrimPrefix(pm.Variable, "$"), pm.VariableKind)) + } ps.AddParameterMappings(m) } return ps diff --git a/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go b/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go index 32635eebaa..50e2b950b7 100644 --- a/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go +++ b/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go @@ -73,3 +73,37 @@ func TestClientActionToGen_ShowPageWritesNamedParameterMapping(t *testing.T) { t.Errorf("inferred argument written as %d explicit mappings, want 0 (#296: CE0115)", len(got)) } } + +// mendixlabs/mxcli#122: a control-bar button passing its grid's selection +// (`$dgOrders`) is the same explicit mapping with the grid in the PageVariable's +// Widget slot. Measured on mxbuild 11.12.5: this shape is 0 errors; the empty +// array is CE1571 "No argument has been selected", and the selection written as +// Argument "$dgOrders" is CE0115 "do not match the expected parameters". +func TestClientActionToGen_ShowPageWritesSelectionMapping(t *testing.T) { + el, err := clientActionToGen(&pages.PageClientAction{ + BaseElement: model.BaseElement{ID: "a"}, + PageName: "M.Detail", + ParameterMappings: []*pages.PageClientParameterMapping{ + {ParameterName: "Order", Variable: "$dgOrders", VariableKind: "widget"}, + }, + }) + if err != nil { + t.Fatalf("clientActionToGen: %v", err) + } + items := el.(*genPg.PageClientAction).PageSettings().(*genPg.PageSettings).ParameterMappingsItems() + if len(items) != 1 { + t.Fatalf("%d mappings written, want 1", len(items)) + } + m := items[0].(*genPg.PageParameterMapping) + if m.TypeName() != "Forms$PageParameterMapping" || m.ParameterQualifiedName() != "M.Detail.Order" || m.Argument() != "" { + t.Errorf("mapping $Type=%q Parameter=%q Argument=%q, want Forms$PageParameterMapping, M.Detail.Order, \"\"", + m.TypeName(), m.ParameterQualifiedName(), m.Argument()) + } + pv, ok := m.Variable().(*genPg.PageVariable) + if !ok { + t.Fatalf("Variable = %T, want *PageVariable", m.Variable()) + } + if pv.WidgetQualifiedName() != "dgOrders" || pv.PageParameterQualifiedName() != "" { + t.Errorf("PageVariable widget=%q page=%q, want dgOrders and \"\"", pv.WidgetQualifiedName(), pv.PageParameterQualifiedName()) + } +} diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index a55ab32140..c900af47c1 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -665,8 +665,10 @@ type rawDataSource struct { ContextVariable string // association source: context variable name (empty → $currentObject) // Destination is the entity an association source's last hop arrives at, as // stored (EntityRef.Steps[last].DestinationEntity). Reference is the - // association there, so this is the entity the source puts in scope. Empty - // when the model does not store it. + // association there, so this is the entity the source puts in scope. A + // parameter source sets it to the parameter's entity (EntityRef.Entity), + // Reference being the parameter's name. Empty when the model does not + // store it. Destination string // EntitySteps is set on a database source reached over associations from a // context object: a Forms$ListViewXPathSource whose EntityRef is an diff --git a/mdl/executor/cmd_pages_describe_datasource.go b/mdl/executor/cmd_pages_describe_datasource.go index 567d93030b..f69554d92f 100644 --- a/mdl/executor/cmd_pages_describe_datasource.go +++ b/mdl/executor/cmd_pages_describe_datasource.go @@ -325,6 +325,16 @@ func entityPathExpr(ds *rawDataSource) string { return strings.Join(parts, "/") } +// parameterSourceEntity is the entity a parameter source stores beside the +// parameter's name (a DirectEntityRef), "" when it stores none. +func parameterSourceEntity(ds map[string]any) string { + entityRef, ok := ds["EntityRef"].(map[string]any) + if !ok || extractString(entityRef["$Type"]) != "DomainModels$DirectEntityRef" { + return "" + } + return extractString(entityRef["Entity"]) +} + // parseContextSource reads the "data from context" forms: over an association, // or straight from a page or snippet parameter. func parseContextSource(ds map[string]any) *rawDataSource { @@ -333,13 +343,13 @@ func parseContextSource(ds map[string]any) *rawDataSource { } if sv, ok := ds["SourceVariable"].(map[string]any); ok && sv != nil { if param := extractString(sv["PageParameter"]); param != "" { - return &rawDataSource{Type: "parameter", Reference: param} + return &rawDataSource{Type: "parameter", Reference: param, Destination: parameterSourceEntity(ds)} } // Inside a snippet the parameter has its own slot, PageParameter left // empty; reading PageParameter alone described the data view with no // DataSource, and re-executing that dropped the binding. if param := extractString(sv["SnippetParameter"]); param != "" { - return &rawDataSource{Type: "parameter", Reference: param} + return &rawDataSource{Type: "parameter", Reference: param, Destination: parameterSourceEntity(ds)} } } if entityPath := extractString(ds["EntityPath"]); entityPath != "" { diff --git a/mdl/executor/cmd_pages_describe_flowcontext.go b/mdl/executor/cmd_pages_describe_flowcontext.go index 6ddd7a6ca3..46ae4b4db7 100644 --- a/mdl/executor/cmd_pages_describe_flowcontext.go +++ b/mdl/executor/cmd_pages_describe_flowcontext.go @@ -26,6 +26,12 @@ import ( // accounts for the direction travelled (owner to child or back, reference or // reference set) and for a specialization of the association's end; it is read // rather than re-derived. A source that stores none keeps the reference. +// +// For a page or snippet parameter the reference is the parameter's name +// (`Entry`), which no consumer can compare with an entity: a pluggable +// property bound to an attribute of another entity described back bare, and +// re-exec bound it to the parameter's entity — CE1613 (mendixlabs/mxcli#1591). +// The parameter's entity, stored beside the name, is used instead. func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { if ds == nil || ds.Reference == "" { return "" @@ -35,7 +41,7 @@ func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { if entity := flowReturnEntity(ctx, ds.Type, ds.Reference); entity != "" { return entity } - case "association": + case "association", "parameter": if ds.Destination != "" { return ds.Destination } diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index b6c58fcabd..3e2978e0c5 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1813,6 +1813,15 @@ func extractPageParameters(ctx *ExecContext, settings map[string]any) string { if value == "" { value = pageVariableArgValue(mappingMap["Variable"]) } + // A PageVariable naming only a widget is a control-bar button passing + // its grid's selection (mendixlabs/mxcli#122), written `$grid`. + if value == "" { + if v, ok := mappingMap["Variable"].(map[string]any); ok { + if w := extractString(v["Widget"]); w != "" { + value = "$" + w + } + } + } if value != "" { params = append(params, visitor.ParameterNameSpelling(paramName)+" = "+value) diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 0502004d44..4f2f502ba9 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -614,7 +614,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s // For generic pluggable widgets (not handled by dedicated extractors above), // extract all non-default properties as explicit key-value pairs. if !isKnownCustomWidgetType(widget.RenderMode) { - widget.ExplicitProperties = extractExplicitProperties(ctx, w) + widget.ExplicitProperties = extractExplicitProperties(ctx, w, inheritedCtx) widget.ObjectLists = extractObjectLists(ctx, w) widget.ChildSlots = extractChildSlots(ctx, w, widget.EntityContext) widget.OmittedContainers = unreconstructedContainers(w, widget.ObjectLists, widget.ChildSlots) diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 21264a19e6..e788056184 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -1259,7 +1259,12 @@ func isKnownCustomWidgetType(widgetType string) bool { // extractExplicitProperties extracts non-default property values from a CustomWidget BSON. // Returns attribute references and primitive values for properties that differ from defaults. -func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicitProp { +// +// enclosingEntity is the entity of the data container the widget sits in. An +// attribute property linked to no datasource binds to that object, so one of +// another entity keeps its qualified name: shortened, re-exec bound it to the +// container's entity (mendixlabs/mxcli#1591, CE1613). +func extractExplicitProperties(ctx *ExecContext, w map[string]any, enclosingEntity ...string) []rawExplicitProp { obj, ok := w["Object"].(map[string]any) if !ok { return nil @@ -1270,6 +1275,11 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit return nil } valueTypes := buildPropertyValueTypeMap(w) + schemas := widgetPropertySchemas(w) + enclosing := "" + if len(enclosingEntity) > 0 { + enclosing = enclosingEntity[0] + } var result []rawExplicitProp props := getBsonArrayElements(obj["Properties"]) @@ -1291,9 +1301,14 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit // Check for AttributeRef (attribute binding) if attrRef, ok := value["AttributeRef"].(map[string]any); ok && attrRef != nil { if attr := extractString(attrRef["Attribute"]); attr != "" { + value := describeAttr(ctx, attr) + if schemas[typePointerID].DataSourceProperty == "" && strings.Contains(enclosing, ".") && + !strings.EqualFold(attributeEntity(attr), enclosing) { + value = attr + } result = append(result, rawExplicitProp{ Key: propKey, - Value: describeAttr(ctx, attr), + Value: value, IsRef: true, }) continue diff --git a/mdl/executor/cmd_pages_describe_pluggable_attr_scope_1591_test.go b/mdl/executor/cmd_pages_describe_pluggable_attr_scope_1591_test.go new file mode 100644 index 0000000000..433edb161e --- /dev/null +++ b/mdl/executor/cmd_pages_describe_pluggable_attr_scope_1591_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" +) + +// mendixlabs/mxcli#1591: a Custom chart inside a data view over the page +// parameter $Entry (MyFirstModule.ChartEntry), its eventDataAttribute bound to +// an attribute of ANOTHER entity, MyFirstModule.ChartContext.EventData. DESCRIBE +// printed `eventDataAttribute: EventData`, and re-running that bound the +// property to ChartEntry.EventData — CE1613 "The selected attribute +// 'MyFirstModule.ChartEntry.EventData' no longer exists". +// +// Two halves: the generic pluggable extractor shortened every attribute +// unconditionally, and a data view over a page parameter put the parameter's +// NAME in scope rather than its entity, so nothing could have compared them. +func chartInParameterDataView(eventAttr string) map[string]any { + chart := map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "Name": "chart1", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.customchart.CustomChart", + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{"$ID": "pt-data", "PropertyKey": "dataAttribute", + "ValueType": map[string]any{"Type": "Attribute"}}, + map[string]any{"$ID": "pt-event", "PropertyKey": "eventDataAttribute", + "ValueType": map[string]any{"Type": "Attribute"}}, + }, + }, + }, + "Object": map[string]any{ + "Properties": []any{ + map[string]any{"TypePointer": "pt-data", "Value": map[string]any{ + "AttributeRef": map[string]any{"Attribute": "MyFirstModule.ChartEntry.JsonData"}}}, + map[string]any{"TypePointer": "pt-event", "Value": map[string]any{ + "AttributeRef": map[string]any{"Attribute": eventAttr}}}, + }, + }, + } + return map[string]any{ + "$Type": "Forms$DataView", + "Name": "dvEntry", + "DataSource": map[string]any{ + "$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{ + "$Type": "DomainModels$DirectEntityRef", + "Entity": "MyFirstModule.ChartEntry", + }, + "SourceVariable": map[string]any{ + "$Type": "Forms$PageVariable", + "PageParameter": "Entry", + }, + }, + "Widgets": []any{chart}, + } +} + +func describedChartProps(t *testing.T, eventAttr string) map[string]string { + t.Helper() + ctx, _ := newMockCtx(t) + dvs := parseRawWidget(ctx, chartInParameterDataView(eventAttr)) + if len(dvs) != 1 || len(dvs[0].Children) != 1 { + t.Fatalf("want one data view with one chart, got %+v", dvs) + } + got := map[string]string{} + for _, p := range dvs[0].Children[0].ExplicitProperties { + got[p.Key] = p.Value + } + return got +} + +func TestDescribePluggableAttributeOfAnotherEntityStaysQualified(t *testing.T) { + got := describedChartProps(t, "MyFirstModule.ChartContext.EventData") + if got["eventDataAttribute"] != "MyFirstModule.ChartContext.EventData" { + t.Errorf("eventDataAttribute = %q, want the qualified MyFirstModule.ChartContext.EventData "+ + "(bare, re-exec binds it to ChartEntry: CE1613)", got["eventDataAttribute"]) + } + // The control: an attribute of the data view's own entity stays bare, as + // the rest of describe writes one. + if got["dataAttribute"] != "JsonData" { + t.Errorf("dataAttribute = %q, want the bare JsonData", got["dataAttribute"]) + } +} + +func TestDescribeParameterDataViewPutsTheEntityInScope(t *testing.T) { + ctx, _ := newMockCtx(t) + dvs := parseRawWidget(ctx, chartInParameterDataView("MyFirstModule.ChartEntry.JsonLayout")) + if len(dvs) != 1 || dvs[0].EntityContext != "MyFirstModule.ChartEntry" { + t.Fatalf("data view context = %q, want the parameter's entity MyFirstModule.ChartEntry", dvs[0].EntityContext) + } + if got := describedChartProps(t, "MyFirstModule.ChartEntry.JsonLayout"); got["eventDataAttribute"] != "JsonLayout" { + t.Errorf("eventDataAttribute = %q, want the bare JsonLayout", got["eventDataAttribute"]) + } +} diff --git a/mdl/executor/cmd_pages_showpage_args.go b/mdl/executor/cmd_pages_showpage_args.go index 6860d8f040..9ada8c67b5 100644 --- a/mdl/executor/cmd_pages_showpage_args.go +++ b/mdl/executor/cmd_pages_showpage_args.go @@ -55,6 +55,26 @@ type pageArgContext struct { // argument naming one is bound by name and needs no context object // (mendixlabs/mxcli#1403); the executor asks classifyFlowArgValue instead. docParams *documentParams + // dataWidget is the name of the data widget whose context this is, so a + // control bar below it knows whose selection it holds. + dataWidget string + // selectionOf is set inside a control bar: the name of its grid, whose + // 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 +} + +// inDataWidget names the data widget whose context c is. +func (c pageArgContext) inDataWidget(name string) pageArgContext { + c.dataWidget = name + return c +} + +// namesSelection reports whether value is `$` written in that grid's +// control bar: the grid's selection, bound by name like a page parameter. +func (c pageArgContext) namesSelection(value string) bool { + name, ok := strings.CutPrefix(value, "$") + return ok && c.selectionOf != "" && strings.EqualFold(name, c.selectionOf) } // enteringDataWidget is the context below a data-bound widget bound to ds and @@ -106,10 +126,15 @@ 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, "") + c := enteringDataWidget(ds, "").inDataWidget(w.Name) c.docParams = parent.docParams return c } + if strings.EqualFold(w.Type, "controlbar") && parent.dataWidget != "" { + c := parent + c.selectionOf = parent.dataWidget + return c + } if parent.known && !parent.present && !contextFreeContainers[strings.ToLower(w.Type)] { return pageArgContext{} } @@ -135,7 +160,7 @@ func argContextForOwnAction(w *ast.WidgetV3, parent pageArgContext) pageArgConte if ds := w.GetDataSource(); ds != nil { // The entity is only used to word a refusal; the executor's builder // overwrites this with one that carries it. - c := enteringDataWidget(ds, "") + c := enteringDataWidget(ds, "").inDataWidget(w.Name) c.docParams = parent.docParams return c } @@ -252,6 +277,12 @@ func refuseShowPageArgument(widgetName, target, argName, argValue string, c page "parameter of this page or snippet (bound by name), or call a microflow that shows the page with it", target, argName, argValue, where, argName) } + if c.selectionOf != "" { + return fmt.Sprintf( + "show page %s: argument %s: %s cannot be stored — in the control bar of `%s` a page argument is the "+ + "grid's selection, $%s, or a parameter of this page or snippet; %s names neither", + target, argName, argValue, c.selectionOf, c.selectionOf, argValue) + } return fmt.Sprintf( "show page %s: argument %s: %s cannot be stored — a widget's page argument is always the enclosing context "+ "object, which mxcli records by leaving the mapping empty (an explicit one is rejected as CE0115). "+ @@ -262,15 +293,21 @@ func refuseShowPageArgument(widgetName, target, argName, argValue string, c page // namedParameterArgument reports whether a show page argument is a page or // snippet parameter of the document being built, which Mendix binds by name -// rather than from a context object (mendixlabs/mxcli#1403). It returns the -// variable and its PageVariable kind, or "" when the argument is anything else +// rather than from a context object (mendixlabs/mxcli#1403) — or, in a grid's +// control bar, the grid's selection, kind "widget" (mendixlabs/mxcli#122). It +// returns the variable and its PageVariable kind, or "" when the argument is +// anything else // — including a page variable, which is never an object and so never a page // argument, and any project older than 9.7, whose page settings have no // ParameterMappings to hold the binding. func (pb *pageBuilder) namedParameterArgument(value string) (variable, kind string) { variable, kind = pb.classifyFlowArgValue(value) if kind != "parameter" && kind != "snippet" { - return "", "" + if pb.argCtx.namesSelection(value) { + variable, kind = value, "widget" + } else { + return "", "" + } } if pb.backend != nil { if pv := pb.backend.ProjectVersion(); pv != nil && pv.MajorVersion > 0 && !pv.IsAtLeast(9, 7) { @@ -324,7 +361,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) { + if !ok || c.binds(strVal) || c.namesDocumentParameter(strVal) || c.namesSelection(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 new file mode 100644 index 0000000000..e46c9b95e8 --- /dev/null +++ b/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#122: a control-bar button opening a page with the grid's +// selection had no spelling. `$currentObject` is unbound there (MDL-BUTTON01, +// CE1571), no argument is CE1571, and `$dgOrders` — what MDL-BUTTON01's own hint +// suggests — was refused as MDL-PAGEARG01 "a widget's page argument is always +// the enclosing context object". Studio Pro binds it as an explicit mapping +// whose PageVariable names the grid in its Widget slot; mxbuild 11.12.5 builds +// that at 0 errors. + +func controlBarPage(t *testing.T, arg string) []*ast.WidgetV3 { + t.Helper() + src := `create page Mod.Overview (title: 'O', layout: Atlas_Core.Atlas_Default) { + datagrid dgOrders (datasource: database from Mod.Order, selection: single) { + controlbar cb { + actionbutton btnView (caption: 'View', action: show page Mod.Detail(OrderParam = ` + arg + `)) + } + column colNum (attribute: OrderNumber, caption: 'Order') + } +}` + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + s, ok := prog.Statements[0].(*ast.CreatePageStmtV3) + if !ok { + t.Fatalf("got %T", prog.Statements[0]) + } + return s.Widgets +} + +func pageArgViolations(t *testing.T, tree []*ast.WidgetV3) []string { + t.Helper() + registry := LoadWidgetRegistry("") + if registry == nil { + t.Fatal("LoadWidgetRegistry returned nil") + } + var out []string + for _, v := range validateDocumentWidgetTree(tree, registry, "page Mod.Overview", nil) { + if v.RuleID == "MDL-PAGEARG01" { + out = append(out, v.Message) + } + } + return out +} + +func TestValidateShowPageArguments_ControlBarPassesGridSelection(t *testing.T) { + if got := pageArgViolations(t, controlBarPage(t, "$dgOrders")); len(got) != 0 { + t.Errorf("control-bar show page passing its grid's selection refused: %q", got) + } + if got := pageArgViolations(t, controlBarPage(t, "$DGORDERS")); len(got) != 0 { + t.Errorf("widget names are case-insensitive: %q", got) + } + // Control: another widget's name is still a variable that binds nothing. + got := pageArgViolations(t, controlBarPage(t, "$dgOther")) + if len(got) != 1 { + t.Fatalf("control-bar show page passing $dgOther: %d MDL-PAGEARG01, want 1", len(got)) + } + // And the refusal in a control bar names the spelling that works. + if !strings.Contains(got[0], "$dgOrders") { + t.Errorf("refusal should offer the grid's selection $dgOrders, got: %s", got[0]) + } +} + +// 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. +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) { + column colNum (attribute: OrderNumber, caption: 'Order') { + actionbutton btnView (caption: 'View', action: show page Mod.Detail(OrderParam = $dgOrders)) + } + } +}` + prog, errs := visitor.Build(src) + if len(errs) > 0 { + 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)) + } +} + +// The exec half: the builder writes the selection as a "widget" mapping. +func TestBuildShowPage_ControlBarSelectionIsAWidgetMapping(t *testing.T) { + build := func(ctx pageArgContext, arg string) (*pages.PageClientAction, error) { + pb := &pageBuilder{ + execCache: &executorCache{createdPages: map[string]*createdPageInfo{"Mod.Detail": {ID: "detail-id"}}}, + argCtx: ctx, + } + built, err := pb.buildClientActionV3(showPageButton(arg).GetAction()) + if err != nil { + return nil, err + } + return built.(*pages.PageClientAction), nil + } + grid := enteringDataWidget(&ast.DataSourceV3{Type: "database", Reference: "Mod.Order"}, "Mod.Order").inDataWidget("dgOrders") + bar := argContextForChildren(&ast.WidgetV3{Type: "controlbar", Name: "cb"}, grid) + + act, err := build(bar, "$dgOrders") + if err != nil { + t.Fatalf("control-bar show page passing $dgOrders refused: %v", err) + } + if len(act.ParameterMappings) != 1 { + t.Fatalf("mappings = %d, want 1", len(act.ParameterMappings)) + } + if m := act.ParameterMappings[0]; m.Variable != "$dgOrders" || m.VariableKind != "widget" { + t.Errorf("mapping = %+v, want Car <- $dgOrders as widget", m) + } + + // Controls: outside the control bar, and another name inside it. + if _, err := build(grid, "$dgOrders"); err == nil { + t.Error("$dgOrders from a grid row was accepted; only the control bar has the selection") + } + if _, err := build(bar, "$dgOther"); err == nil { + t.Error("$dgOther in dgOrders' control bar was accepted; it binds nothing") + } +} + +// The read half: DESCRIBE prints the selection mapping back as `$dgOrders`. It +// read only the PageParameter / SnippetParameter / LocalVariable slots, so the +// mapping described as absent. +func TestDescribeShowPage_SelectionMapping(t *testing.T) { + ctx, _ := newMockCtx(t) + settings := map[string]any{ + "ParameterMappings": []any{int32(2), map[string]any{ + "$Type": "Forms$PageParameterMapping", + "Parameter": "Mod.Detail.OrderParam", + "Argument": "", + "Variable": map[string]any{ + "$Type": "Forms$PageVariable", "Widget": "dgOrders", + "PageParameter": "", "SnippetParameter": "", "LocalVariable": "", + }, + }}, + } + if got := extractPageParameters(ctx, settings); got != "OrderParam = $dgOrders" { + t.Errorf("extractPageParameters = %q, want %q", got, "OrderParam = $dgOrders") + } +} + +// Through the engine: a Data grid 2's control bar is a widgets slot whose +// children the engine builds directly, never through the `controlbar` node, so +// the selection has to be put in scope there. The builder test above passed +// while exec still refused `$dgOrders` for exactly that reason. +type slotRecorder struct { + backend.WidgetObjectBuilder + children map[string][]pages.Widget +} + +func (r *slotRecorder) SetChildWidgets(key string, ws []pages.Widget) { r.children[key] = ws } + +func TestApplyChildSlots_ControlBarHoldsTheGridSelection(t *testing.T) { + def := datagridLikeDef() + build := func(arg string) (*slotRecorder, error) { + pb := &pageBuilder{ + execCache: &executorCache{createdPages: map[string]*createdPageInfo{"Mod.Detail": {ID: "detail-id"}}}, + widgetScope: map[string]model.ID{}, + // The grid's own context, as the engine's datasource pass sets it. + argCtx: enteringDataWidget(&ast.DataSourceV3{Type: "database", Reference: "Mod.Order"}, "Mod.Order").inDataWidget("dgOrders"), + } + e := &PluggableWidgetEngine{pageBuilder: pb} + rec := &slotRecorder{children: map[string][]pages.Widget{}} + grid := controlBarPage(t, arg)[0] + var bar []*ast.WidgetV3 + for _, c := range grid.Children { + if strings.EqualFold(c.Type, "controlbar") { + bar = append(bar, c) + } + } + grid.Children = bar + return rec, e.applyChildSlots(rec, def.ChildSlots, grid, nil, nil) + } + rec, err := build("$dgOrders") + if err != nil { + t.Fatalf("control-bar show page passing $dgOrders refused by exec: %v", err) + } + if len(rec.children["filtersPlaceholder"]) != 1 { + t.Fatalf("control bar slot = %v, want the one button", rec.children) + } + if _, err := build("$dgOther"); err == nil || !strings.Contains(err.Error(), "MDL-PAGEARG01") { + t.Errorf("control-bar show page passing $dgOther: err = %v, want MDL-PAGEARG01", err) + } +} diff --git a/mdl/executor/validate_page_button_context.go b/mdl/executor/validate_page_button_context.go index 2ec23c6bd5..5a6b6cdd6e 100644 --- a/mdl/executor/validate_page_button_context.go +++ b/mdl/executor/validate_page_button_context.go @@ -215,7 +215,7 @@ func controlBarSuggestion(controlBarOf string) string { return "Move the button into a grid column (row-scoped) so it has a current row, or pass a page parameter instead of $currentObject." } return fmt.Sprintf( - "Pass the selection of `%s` instead — `Action: microflow M.F(Param = $%s)`, with `Selection:` set on the widget. "+ + "Pass the selection of `%s` instead — `Param = $%s` in a microflow call or a show page, with `Selection:` set on the widget. "+ "Or move the button into a grid column (row-scoped) so it has a current row, or pass a page parameter.", controlBarOf, controlBarOf) } diff --git a/mdl/executor/validate_widget_attribute_scope_test.go b/mdl/executor/validate_widget_attribute_scope_test.go index fead61bbf1..d61efebd88 100644 --- a/mdl/executor/validate_widget_attribute_scope_test.go +++ b/mdl/executor/validate_widget_attribute_scope_test.go @@ -137,3 +137,53 @@ func TestCheck_AttributeScopeIsPartOfThePageReferencePass(t *testing.T) { t.Fatalf("want the page refused for its attribute binding, got %v", err) } } + +// A name NO entity in the widget's scope has was let through as "a plain typo, +// which other checks own" — and none did, so exec wrote Entity.Missing and only +// mx check said so, as CE1613 "The selected attribute … no longer exists" +// (mendixlabs/mxcli#1591: a Custom chart's eventDataAttribute inside a data view +// over a page parameter whose entity lacks it). When the bound entity is known +// and does not declare the name, the write is CE1613 whatever else is in scope. +func TestCheck_AttributeNoScopeHasIsRefused(t *testing.T) { + ctx := scopeCheckCtx(t) + s := scopeCheckPage(t, `staticAttribute: EventData`) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()) + if len(errs) != 1 { + t.Fatalf("want one binding error, got %q", errs) + } + for _, want := range []string{"viewer", "staticAttribute", "`EventData`", "Sales.Invoice", "CE1613"} { + if !strings.Contains(errs[0], want) { + t.Errorf("error should mention %q, got: %s", want, errs[0]) + } + } + + e := scopeEngine(t) + _, err := e.Build(multiSourceDef(), twoSourceWidget(map[string]any{"staticAttribute": "EventData"})) + if err == nil || !strings.Contains(err.Error(), errs[0]) { + t.Errorf("check and exec disagree:\n check: %s\n exec: %v", errs[0], err) + } +} + +// The same refusal when the data view's object is a page parameter, the shape +// mendixlabs/mxcli#1591 was reported on. +func TestCheck_AttributeThePageParameterEntityLacksIsRefused(t *testing.T) { + ctx := scopeCheckCtx(t) + src := `create page Sales.P (title: 'P', layout: Atlas_Core.Atlas_Default, params: ($Ctx: Sales.Customer, $Entry: Sales.Invoice)) { + dataview dv (datasource: $Entry) { + pluggablewidget 'com.mendix.widget.web.combobox.Combobox' viewer ( + optionsSourceAssociationDataSource: database from Sales.Order, + DatabaseSource: database from Sales.Customer, + staticAttribute: EventData + ) + } +}` + prog, perrs := visitor.Build(src) + if len(perrs) > 0 { + t.Fatalf("parse: %v", perrs) + } + s := prog.Statements[0].(*ast.CreatePageStmtV3) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()) + if len(errs) != 1 || !strings.Contains(errs[0], "Sales.Invoice") { + t.Fatalf("want one refusal naming Sales.Invoice, got %q", errs) + } +} diff --git a/mdl/executor/widget_attribute_scope.go b/mdl/executor/widget_attribute_scope.go index f047681d97..418b06d91a 100644 --- a/mdl/executor/widget_attribute_scope.go +++ b/mdl/executor/widget_attribute_scope.go @@ -127,9 +127,14 @@ func (ix attributeIndex) declares(entityQN, attr string) (found, known bool) { // author either picks an attribute of the bound entity, or binds the value to // the property the widget links to the other datasource. // +// A name no entity in scope has is refused too, without candidates. It was let +// through as "a plain typo, which other checks own", and no check owned it: exec +// wrote Entity.Missing and the first word of it was mx check's CE1613 +// (mendixlabs/mxcli#1591). +// // Nothing is refused where the question cannot be answered: a qualified name or -// path, a scope the rule did not decide (scopeShared), a model the index cannot -// read, or a name no candidate has (a plain typo, which other checks own). +// path, a scope the rule did not decide (scopeShared), or a model the index +// cannot read. func misboundAttributeError(where, attr string, scope bindingScope, outer string, dsEntities map[string]string, ix attributeIndex) error { if attr == "" || strings.ContainsAny(attr, "./$") || scope.Kind == scopeShared || scope.Entity == "" { return nil @@ -158,7 +163,10 @@ func misboundAttributeError(where, attr string, scope bindingScope, outer string add(dsEntities[k], fmt.Sprintf("datasource `%s`", k)) } if len(candidates) == 0 { - return nil + return mdlerrors.NewValidationf( + "%s: `%s` is not an attribute of %s, the entity this property binds to (%s), "+ + "and writing it is CE1613 \"The selected attribute no longer exists\". Bind an attribute of %s here", + where, attr, scope.Entity, scope.describe(), scope.Entity) } return mdlerrors.NewValidationf( "%s: `%s` is not an attribute of %s, the entity this property binds to (%s); "+ diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index be3c0d719b..495a5344fb 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -480,7 +480,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) + e.pageBuilder.argCtx = enteringDataWidget(ds, entityName).inDataWidget(w.Name) } } } @@ -1615,7 +1615,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) + e.pageBuilder.argCtx = enteringDataWidget(ds, entityName).inDataWidget(w.Name) if w.Name != "" { e.pageBuilder.paramEntityNames[w.Name] = entityName } @@ -2462,15 +2462,25 @@ func (e *PluggableWidgetEngine) applyChildSlots(builder backend.WidgetObjectBuil slot, ok = slotContainers[strings.ToLower(child.Name)] } if ok { + // The slot's children are built here, not through the slot's own + // node, so the context it gives them is applied here: a control + // bar holds its grid's selection (mendixlabs/mxcli#122), the same + // rule the check-time walk applies. + outerArgCtx := e.pageBuilder.argCtx + if lowerType == "controlbar" { + e.pageBuilder.argCtx = argContextForChildren(child, outerArgCtx) + } for _, slotChild := range child.Children { widget, err := e.pageBuilder.buildWidgetV3(slotChild) if err != nil { + e.pageBuilder.argCtx = outerArgCtx return err } if widget != nil { slotWidgets[slot.PropertyKey] = append(slotWidgets[slot.PropertyKey], widget) } } + e.pageBuilder.argCtx = outerArgCtx } else { widget, err := e.pageBuilder.buildWidgetV3(child) if err != nil { diff --git a/sdk/pages/pages_widgets_action.go b/sdk/pages/pages_widgets_action.go index 60371e4cc1..518372782c 100644 --- a/sdk/pages/pages_widgets_action.go +++ b/sdk/pages/pages_widgets_action.go @@ -230,6 +230,8 @@ type PageClientParameterMapping struct { // explicit mapping whose Variable is a Forms$PageVariable, as Studio Pro // does. Empty means the argument is the context object, which Mendix infers // from an empty ParameterMappings array (#296). See mendixlabs/mxcli#1403. + // "widget" is a control-bar button passing its grid's selection, Variable + // then naming the grid (mendixlabs/mxcli#122). VariableKind string `json:"variableKind,omitempty"` }