diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-column-filterable-names-the-filter-slot.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-column-filterable-names-the-filter-slot.json new file mode 100644 index 0000000000..db178d287f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-column-filterable-names-the-filter-slot.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-20","symptom":"`column (Attribute: Name, Filterable: true)` on a Data grid 2: the original report was a silent drop; on current main check refuses it, but with a project the message lists the column's properties without the `filter` slot, and without one it suggests `did you mean FilterType?` — a key the column does not have","cause":"The refusal (MDL-WIDGET01 / MDL-WIDGET07) built its hint from property names only: the item's widgets slots (`content`, `filter`) are not properties, so neither the property list nor nearestKey over the builtin union could name the slot the author was reaching for","file":"`mdl/executor/validate_widgets.go` (`widgetSlotHint`), `mdl/executor/validate_widget_embedded_schema.go` (`widgetSlots`)","insight":"Re-run an old silent-drop report on main before fixing it: #177 had already become a refusal, and what was left was the message. A did-you-mean over property names cannot reach a slot, so a key whose prefix is a slot key (`Filterable` → `filter`) needs its own answer naming the body form and the widget types the slot routes (itemSlotAcceptedChildTypes, already per widget). Measured on Mendix 11.13.0: the suggested `column (Attribute: X) { textfilter tf }` builds 0 errors. Control: both new assertions fail on main with `its properties are attribute, header (Caption)` and `did you mean FilterType`","refs":["mendixlabs/mxcli#177","#1230"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-container-rendermode-reads-as-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-container-rendermode-reads-as-dropped.json new file mode 100644 index 0000000000..3798d662d3 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-container-rendermode-reads-as-dropped.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-20","symptom":"`container ctnMain (RenderMode: 'Main')` in `create or modify layout` passes check and exec, then `describe layout` prints `container ctnMain (Class: 'app-main')` — reported as \"the value isn't stored\"; `alter layout … set (RenderMode: …)` refused with `property \"RenderMode\" is not a property of this built-in widget`","cause":"The write was fine: `bson dump` showed `RenderMode: \"Main\"` on the stored Forms$DivContainer. DESCRIBE's DivContainer branch never read RenderMode (only DynamicText did), the ALTER `rendermode` case routed only dynamic texts and sent containers to the pluggable setter, and buildContainerV3 cast any string straight to the enum, so `'main'` or `'Banner'` was stored verbatim","file":"`mdl/executor/cmd_pages_describe_parse.go`, `cmd_pages_describe_output.go`, `cmd_pages_builder_v3_layout.go` (`buildContainerV3`), `mdl/backend/pagemutator/mutator.go` (`setContainerRenderModeMut`), `sdk/pages/pages_widgets_container.go` (`CanonicalContainerRenderMode`)","insight":"A report that a value \"isn't stored\" is usually read off `describe`; run `bson dump` before touching the write path — here it split a suspected write drop into a read-side gap in one command, and the builder had been right all along. The sdk enum had drifted too (`Div`, `Form`; Mendix's set is Div…Address in modelsdk/gen/pages), so take the member list from the generated enum rather than the hand-written one. Control: the four new tests fail on main with `describe dropped the container's RenderMode`, `RenderMode = \"main\", want Main`, `'Banner' must be refused` and `property \"RenderMode\" not found on this widget`","refs":["mendixlabs/mxcli#1582","#1487"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-pluggable-top-level-expression-unmapped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-pluggable-top-level-expression-unmapped.json new file mode 100644 index 0000000000..f10717226e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-pluggable-top-level-expression-unmapped.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-20","symptom":"Marketplace Calendar 2.6.1 page passes check and exec, then `mx check` fails `[CE0642] \"Property 'Editable' is required.\"` and `\"Property 'Show event date range' is required.\"`; `editable: 'true'` is accepted with no warning and changes nothing, `showEventDate:` warns MDL-WIDGET06 \"will be dropped\" although it is written, and `describe page` prints neither","cause":"Four hops, each dropping the same value: (1) GenerateFromMPK's createDefaultWidgetValue had no Expression case, so `defaultValue=\"true\"` was written as an empty expression; (2) GenerateDefJSON emitted no mapping for a top-level `expression` property, leaving it in KnownProperties (MDL-WIDGET06) and written only by the explicit pass; (3) that pass skips builtin names, and `editable` is also the widget-level editability, which the Calendar's package does not declare — so the widget's own `editable` was unreachable in every spelling; (4) extractExplicitProperties had no Expression branch","file":"`modelsdk/widgets/augment.go` (`createDefaultWidgetValue`), `mdl/executor/widget_defs.go` (`GenerateDefJSON`, generator v19), `mdl/executor/widget_engine.go` (`resolveMapping`, `applyOperation`), `mdl/executor/pluggable_system_props.go` (`editableIsOwnProperty`), `mdl/executor/cmd_pages_describe_pluggable.go`","insight":"A builtin MDL key and a widget's own property of the same name collide silently: the builtin always wins, and when the widget does not declare the matching system property the builtin is a no-op too. Decide the routing in the def generator, which reads the .mpk's system properties, so check and build read one answer. The cheapest probe was the reporter's table — `showEventDate:` made the CE0642 go away while `editable:` did not, which already says the explicit pass writes expressions and something else eats `editable`. Make the template write declared defaults first: that alone makes every Calendar page build, before any MDL names the property. Measured on Mendix 11.13.0 with Calendar 245304 v2.6.1: main → 2× CE0642; this branch → 0 errors with neither, both 'true' and both false/'false', and describe → exec reports Unchanged","refs":["mendixlabs/mxcli#1578","#1483"]} diff --git a/docs-wiki/bug-patterns/silent-property-drop.md b/docs-wiki/bug-patterns/silent-property-drop.md index 5e42f8eb55..7a8e824a54 100644 --- a/docs-wiki/bug-patterns/silent-property-drop.md +++ b/docs-wiki/bug-patterns/silent-property-drop.md @@ -146,6 +146,22 @@ model holding a value that the tool reports as absent — the same lossy round t wearing the right answer. Several findings here are the follow-up half of a fix that shipped write-only. +**"Not stored" is usually read off `describe` — check the store first.** A +report that a value was dropped is almost always an inference from `describe` +printing nothing, and `describe` is a separate hop that can be the only one +missing. A container's `RenderMode` was on disk all along; only the read side +had never learnt it. One `bson dump` splits a write drop from a read gap before +any code is opened, and points the fix at the right half. + +**A builtin key can shadow a widget's own property of the same name.** The +universal names (`Editable`, `Visible`, …) win over a pluggable widget's own +key, and where the package declares no matching system property the builtin is +a no-op as well — so the widget's own property is unreachable in every +spelling, with nothing to warn about because the key is "known". Which meaning +a key has is a fact of the package (its system properties), so decide it where +the package is read — the definition generator — and let check and build both +read that one answer. + **A half-wired feature is worse than a missing one.** When the grammar already accepts a form, scripts using it look correct and produce models without it. Before assuming a spelling works, follow the whole chain: AST → model → both diff --git a/mdl-examples/bug-tests/1578-calendar-expression-properties.mdl b/mdl-examples/bug-tests/1578-calendar-expression-properties.mdl new file mode 100644 index 0000000000..79106795a1 --- /dev/null +++ b/mdl-examples/bug-tests/1578-calendar-expression-properties.mdl @@ -0,0 +1,52 @@ +-- mendixlabs/mxcli#1578: a page with the Marketplace Calendar (content 245304) +-- passed `check` and `exec`, then `mx check` failed with +-- [CE0642] "Property 'Editable' is required." at Calendar 'CalRepro_Calendar' +-- [CE0642] "Property 'Show event date range' is required." +-- Both are `type="expression" defaultValue="true"` in Calendar.xml. The +-- template wrote them empty, `editable:` only ever reached the widget-level +-- editability (which the Calendar does not declare), `showEventDate:` drew a +-- false MDL-WIDGET06, and `describe page` printed neither. +-- After the fix an unnamed one gets its declared default, `editable:` and +-- `showEventDate:` are written in every spelling, and describe prints both. +-- Needs: `mxcli marketplace install 245304 -p ` first. +mdl 1; + +create module BugTest1578; + +create persistent entity BugTest1578.LeaveRequest ( + Reason: String(200), + StartDate: DateTime, + EndDate: DateTime +); + +-- Neither property named: both get their default, `mx check` is clean. +create or modify page BugTest1578.CalDefaults (Title: 'Calendar', Layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.web.calendar.Calendar' calDefaults ( + databaseDataSource: database BugTest1578.LeaveRequest, + titleType: 'attribute', + titleAttribute: Reason, + startAttribute: StartDate, + endAttribute: EndDate, + view: 'standard', + defaultViewStandard: 'month' + ) +}; + +-- Both named with a non-default value. +create or modify page BugTest1578.CalReadOnly (Title: 'Calendar', Layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.web.calendar.Calendar' calReadOnly ( + databaseDataSource: database BugTest1578.LeaveRequest, + titleType: 'attribute', + titleAttribute: Reason, + startAttribute: StartDate, + endAttribute: EndDate, + view: 'standard', + defaultViewStandard: 'month', + editable: false, + showEventDate: 'false' + ) +}; + +-- Expect: editable: 'true', showEventDate: 'true' — and 'false' on the second. +describe page BugTest1578.CalDefaults; +describe page BugTest1578.CalReadOnly; diff --git a/mdl-examples/bug-tests/1582-container-rendermode.mdl b/mdl-examples/bug-tests/1582-container-rendermode.mdl new file mode 100644 index 0000000000..791c9bff5c --- /dev/null +++ b/mdl-examples/bug-tests/1582-container-rendermode.mdl @@ -0,0 +1,33 @@ +-- mendixlabs/mxcli#1582: `container ctnMain (RenderMode: 'Main')` in a layout or +-- page was stored, but `describe` never printed it — so it read as dropped, and +-- a describe → exec round trip did drop it. `alter layout … set (RenderMode: …)` +-- was refused with `property "RenderMode" is not a property of this built-in +-- widget`, and an unknown value (`'Banner'`, or `'main'` as typed) was written +-- verbatim into the document. +-- After the fix describe prints `RenderMode: Main`, ALTER sets it, the value is +-- stored in its canonical spelling, and an unknown member is refused. +mdl 1; + +create module BugTest1582; + +create or modify layout BugTest1582.L_Landmarks ( + layouttype: 'Responsive' +) { + scrollcontainer layoutContainer { + region center { + container ctnMain (RenderMode: 'Main', Class: 'app-main') { + placeholder Main + } + container ctnNav (RenderMode: nav) { + placeholder Nav + } + } + } +}; + +alter layout BugTest1582.L_Landmarks { + set (RenderMode: 'Header') on ctnNav; +}; + +-- Expect: ctnMain (RenderMode: Main, …) and ctnNav (RenderMode: Header). +describe layout BugTest1582.L_Landmarks; diff --git a/mdl-examples/bug-tests/177-datagrid-column-filter-slot.mdl b/mdl-examples/bug-tests/177-datagrid-column-filter-slot.mdl new file mode 100644 index 0000000000..1de22b7367 --- /dev/null +++ b/mdl-examples/bug-tests/177-datagrid-column-filter-slot.mdl @@ -0,0 +1,27 @@ +-- mendixlabs/mxcli#177: `column (Attribute: Name, Filterable: true)` was +-- accepted by check and exec and silently dropped — a Data grid 2 column has no +-- Filterable property. Its filter is the column's `filter` widgets slot. +-- check now refuses `Filterable:` (MDL-WIDGET01 with -p, MDL-WIDGET07 without) +-- and the message names the slot and the filter widgets it takes, instead of +-- listing the column's properties or suggesting `FilterType`. +-- This script is the form the message points at; it builds with 0 errors. +mdl 1; + +create module BugTest177; + +create persistent entity BugTest177.Order ( + OrderNumber: String(20), + Amount: Decimal, + PlacedOn: DateTime +); + +create or modify page BugTest177.Order_Overview (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + datagrid dgOrders (DataSource: database BugTest177.Order) { + column (Attribute: OrderNumber) { textfilter tfNumber } + column (Attribute: Amount) { numberfilter nfAmount } + column (Attribute: PlacedOn) { datefilter dfPlacedOn } + } +}; + +-- Expect: each column describes back with its filter widget in its braces. +describe page BugTest177.Order_Overview; diff --git a/mdl/backend/pagemutator/container_rendermode_test.go b/mdl/backend/pagemutator/container_rendermode_test.go new file mode 100644 index 0000000000..a0f112b8c5 --- /dev/null +++ b/mdl/backend/pagemutator/container_rendermode_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// `alter layout … set (RenderMode: 'Main') on ctnMain` was refused with +// "property \"RenderMode\" is not a property of this built-in widget", although +// CREATE writes Forms$DivContainer.RenderMode (mendixlabs/mxcli#1582). +func TestSetWidgetProperty_ContainerRenderMode(t *testing.T) { + for _, tc := range []struct{ in, want string }{{"Main", "Main"}, {"nav", "Nav"}, {"Div", "Div"}} { + rawData := makeRawPage(bson.D{ + {Key: "$Type", Value: "Forms$DivContainer"}, + {Key: "Name", Value: "ctnMain"}, + {Key: "RenderMode", Value: "Div"}, + }) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("ctnMain", "RenderMode", tc.in); err != nil { + t.Fatalf("SetWidgetProperty(RenderMode=%s): %v", tc.in, err) + } + if got := bsonnav.DGetString(findBsonWidget(rawData, "ctnMain").widget, "RenderMode"); got != tc.want { + t.Errorf("RenderMode = %q, want %q", got, tc.want) + } + } +} + +func TestSetWidgetProperty_ContainerRenderModeInvalid(t *testing.T) { + rawData := makeRawPage(bson.D{ + {Key: "$Type", Value: "Forms$DivContainer"}, + {Key: "Name", Value: "ctnMain"}, + {Key: "RenderMode", Value: "Main"}, + }) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("ctnMain", "RenderMode", "H1"); err == nil { + t.Fatal("RenderMode H1 on a container must be refused") + } + if got := bsonnav.DGetString(findBsonWidget(rawData, "ctnMain").widget, "RenderMode"); got != "Main" { + t.Errorf("a refused value changed the stored RenderMode to %q", got) + } +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 1f8d805e44..02d494c93a 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3264,6 +3264,9 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { if isDynamicTextWidget(widget) { return setDynamicTextRenderModeMut(widget, value) } + if typ := bsonnav.DGetString(widget, "$Type"); typ == "Forms$DivContainer" || typ == "Pages$DivContainer" { + return setContainerRenderModeMut(widget, value) + } return setPluggableWidgetPropertyMut(widget, propName, value) default: // Try as pluggable widget property @@ -3320,6 +3323,27 @@ func setDynamicTextRenderModeMut(widget bson.D, value any) error { fmt.Sprint(value), bsonnav.DGetString(widget, "Name"), strings.Join(names, ", ")) } +// setContainerRenderModeMut writes a container's RenderMode — the HTML element +// it renders as — in place, canonically spelled; an unknown member is refused +// before the document is touched (mendixlabs/mxcli#1582). +func setContainerRenderModeMut(widget bson.D, value any) error { + s, _ := value.(string) + mode, ok := pages.CanonicalContainerRenderMode(s) + if !ok { + names := make([]string, len(pages.ContainerRenderModes)) + for i, m := range pages.ContainerRenderModes { + names[i] = string(m) + } + return fmt.Errorf("invalid RenderMode %q for container %q: expected one of %s", + fmt.Sprint(value), bsonnav.DGetString(widget, "Name"), strings.Join(names, ", ")) + } + if !bsonnav.DSet(widget, "RenderMode", string(mode)) { + return fmt.Errorf("container %q has no stored RenderMode property to set", + bsonnav.DGetString(widget, "Name")) + } + return nil +} + // setLegacyImageRefMut writes a static image's Image or a dynamic image's // DefaultImage: the qualified name of an image-collection entry as a plain // string ("" when unset), the key and shape CREATE writes and DESCRIBE reads diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 5136154faf..bf140dff91 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -564,7 +564,7 @@ func (pb *pageBuilder) buildPluggable(def *WidgetDefinition, w *ast.WidgetV3) (p if err := applyWidgetAppearance(widget, w, pb.themeRegistry); err != nil { return nil, err } - if err := applyPluggableSystemSettings(widget, w); err != nil { + if err := applyPluggableSystemSettings(widget, w, def); err != nil { return nil, err } if err := pb.applyVisibleWhen(widget, w); err != nil { diff --git a/mdl/executor/cmd_pages_builder_v3_layout.go b/mdl/executor/cmd_pages_builder_v3_layout.go index 7f20a5023f..51261e2847 100644 --- a/mdl/executor/cmd_pages_builder_v3_layout.go +++ b/mdl/executor/cmd_pages_builder_v3_layout.go @@ -268,9 +268,19 @@ func (pb *pageBuilder) buildContainerV3(w *ast.WidgetV3) (*pages.Container, erro }, } - // Handle RenderMode + // Handle RenderMode. The codec stores it verbatim, so an unknown member is + // refused here rather than written into a document Studio Pro cannot open. if rm := w.GetRenderMode(); rm != "" { - container.RenderMode = pages.ContainerRenderMode(rm) + mode, ok := pages.CanonicalContainerRenderMode(rm) + if !ok { + names := make([]string, len(pages.ContainerRenderModes)) + for i, m := range pages.ContainerRenderModes { + names[i] = string(m) + } + return nil, mdlerrors.NewValidationf("container %q: unknown RenderMode %q — valid modes are %s", + w.Name, rm, strings.Join(names, ", ")) + } + container.RenderMode = mode } // Handle Action / OnClick property — a Container is clickable in Mendix via diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index c15d1d4243..d96df8f278 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -453,7 +453,11 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { case "Forms$DivContainer", "Pages$DivContainer": header := fmt.Sprintf("container %s", mdlIdent(w.Name)) - props := appendAppearanceProps(ctx, nil, w) + var props []string + if w.RenderMode != "" && w.RenderMode != "Div" { + props = append(props, fmt.Sprintf("RenderMode: %s", w.RenderMode)) + } + props = appendAppearanceProps(ctx, props, w) if w.Action != "" { props = append(props, actionProp("Action", w.Action)) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 3347af9194..ab6ea4375b 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -257,6 +257,10 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s if onClick := asActionMap(w["OnClickAction"]); onClick != nil { widget.Action = extractButtonAction(ctx, map[string]any{"Action": onClick}) } + // The HTML element it renders as (mendixlabs/mxcli#1582). + if rm, ok := w["RenderMode"].(string); ok { + widget.RenderMode = rm + } } // Extract GroupBox-specific properties if typeName == "Forms$GroupBox" || typeName == "Pages$GroupBox" { diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 8b6ed80e63..21264a19e6 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -1313,6 +1313,22 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit continue } + // An expression property: its text, quoted. This path had no branch for + // one, so a stored expression was described as absent — the Calendar's + // `editable` and `showEventDate` (mendixlabs/mxcli#1578). Only an + // Expression-typed property is read: a value of another type keeps + // whatever its PrimitiveValue says below. + if valueTypes[typePointerID] == "Expression" { + if expr := extractString(value["Expression"]); expr != "" { + result = append(result, rawExplicitProp{ + Key: propKey, + Value: expr, + ValueType: "Expression", + }) + } + continue + } + // Check for a PrimitiveValue. // // Booleans used to be dropped here as "common defaults". They are not: diff --git a/mdl/executor/container_rendermode_1582_test.go b/mdl/executor/container_rendermode_1582_test.go new file mode 100644 index 0000000000..315adfc65e --- /dev/null +++ b/mdl/executor/container_rendermode_1582_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +// A container's RenderMode (mendixlabs/mxcli#1582). +// +// Reported: "`create or modify layout` accepts `RenderMode` on a container, +// and `check` and `exec` both pass, but the value isn't stored. The container +// keeps the default render mode (`div`)." — read from `describe layout`, which +// printed `container ctnMain (Class: 'app-main') {`. +// +// The write was never the problem: `bson dump` shows `RenderMode: "Main"` on +// the stored Forms$DivContainer. DESCRIBE never read it, so the value looked +// dropped and a describe → exec round trip did drop it. The builder also +// wrote any string verbatim, so `RenderMode: 'main'` stored a member that is +// not in the enumeration. +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +func buildContainerFromMDL(t *testing.T, container string) (*pages.Container, error) { + t.Helper() + src := "create page Mod.P (Title: 'T') {\n " + container + "\n}" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", container, errs) + } + page := prog.Statements[0].(*ast.CreatePageStmtV3) + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + widget, err := pb.buildWidgetV3(page.Widgets[0]) + if err != nil { + return nil, err + } + return widget.(*pages.Container), nil +} + +func TestContainerRenderMode_DescribeEmitsIt(t *testing.T) { + ctx, _ := newMockCtx(t) + raw := map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "ctnMain", + "RenderMode": "Main", + "Appearance": map[string]any{"Class": "app-main"}, + } + got := parseRawWidget(ctx, raw) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + + var buf bytes.Buffer + outputWidgetMDLV3(&ExecContext{Output: &buf}, got[0], 0) + if out := buf.String(); !strings.Contains(out, "RenderMode: Main") { + t.Errorf("describe dropped the container's RenderMode:\n%s", out) + } +} + +// Control: the default render mode is not printed, so a plain container +// describes as it always has. +func TestContainerRenderMode_DescribeOmitsDefault(t *testing.T) { + ctx, _ := newMockCtx(t) + for _, rm := range []any{"Div", nil} { + raw := map[string]any{"$Type": "Forms$DivContainer", "Name": "c"} + if rm != nil { + raw["RenderMode"] = rm + } + var buf bytes.Buffer + outputWidgetMDLV3(&ExecContext{Output: &buf}, parseRawWidget(ctx, raw)[0], 0) + if out := buf.String(); strings.Contains(out, "RenderMode") { + t.Errorf("RenderMode %v: the default must not be printed:\n%s", rm, out) + } + } +} + +// The describe output re-parses into the same container: the round trip the +// report's `describe` → `exec` depends on. +func TestContainerRenderMode_RoundTrip(t *testing.T) { + for _, form := range []string{"RenderMode: Main", "RenderMode: 'Main'", "RenderMode: 'main'"} { + c, err := buildContainerFromMDL(t, "container ctnMain ("+form+") { dynamictext t (Content: 'x') }") + if err != nil { + t.Fatalf("%s: %v", form, err) + } + if c.RenderMode != "Main" { + t.Errorf("%s: RenderMode = %q, want Main", form, c.RenderMode) + } + } +} + +// An unknown member is refused before anything is written: the codec would +// store it verbatim, and a document with an unknown enum member is one Studio +// Pro cannot open. +func TestContainerRenderMode_UnknownRefused(t *testing.T) { + _, err := buildContainerFromMDL(t, "container c (RenderMode: 'Banner') { dynamictext t (Content: 'x') }") + if err == nil { + t.Fatal("RenderMode 'Banner' must be refused") + } + for _, want := range []string{"Banner", "Main", "Nav"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should name %q: %v", want, err) + } + } +} diff --git a/mdl/executor/pluggable_system_props.go b/mdl/executor/pluggable_system_props.go index e8ca68bfe3..7c8a8f6313 100644 --- a/mdl/executor/pluggable_system_props.go +++ b/mdl/executor/pluggable_system_props.go @@ -68,6 +68,41 @@ func widgetAsksEditability(w *ast.WidgetV3) bool { return ok && (expr != "" || enum != "Always") } +// editableIsOwnProperty reports whether `editable:` on a widget of def is the +// widget's own `editable` property rather than the widget-level editability. +// The generator maps it only when the package declares no Editability system +// property, so it never shadows a setting the widget has — the Marketplace +// Calendar's required `editable` expression could otherwise not be written at +// all (mendixlabs/mxcli#1578). +func editableIsOwnProperty(def *WidgetDefinition) bool { + if def == nil { + return false + } + owns := func(ms []PropertyMapping) bool { + for _, m := range ms { + if strings.EqualFold(m.PropertyKey, "editable") { + return true + } + } + return false + } + if owns(def.PropertyMappings) { + return true + } + for _, mode := range def.Modes { + if owns(mode.PropertyMappings) { + return true + } + } + return false +} + +// widgetAsksSystemEditability is widgetAsksEditability for a pluggable widget: +// false when `editable:` names the widget's own property. +func widgetAsksSystemEditability(w *ast.WidgetV3, def *WidgetDefinition) bool { + return !editableIsOwnProperty(def) && widgetAsksEditability(w) +} + // systemPropsOfType returns the system properties a CustomWidgetType declares, // and false when the Type carries no property types at all (nothing to judge). func systemPropsOfType(rawType any) (map[string]bool, bool) { @@ -86,7 +121,7 @@ func systemPropsOfType(rawType any) (map[string]bool, bool) { // undeclaredSystemProps lists the system properties the statement needs that // the widget does not declare. -func undeclaredSystemProps(w *ast.WidgetV3, declared map[string]bool) []string { +func undeclaredSystemProps(w *ast.WidgetV3, def *WidgetDefinition, declared map[string]bool) []string { var missing []string // Visibility is not gated: Studio Pro offers conditional visibility on // every pluggable widget, declared or not — TestApp's Studio Pro-authored @@ -94,7 +129,7 @@ func undeclaredSystemProps(w *ast.WidgetV3, declared map[string]bool) []string { // a Datagrid whose Type declares no Visibility system property. Refusing // it made describe → exec of that page fail. A declared Visibility only // places the setting in the widget's own property tabs. - if widgetAsksEditability(w) && !declared[sysPropEditability] { + if widgetAsksSystemEditability(w, def) && !declared[sysPropEditability] { missing = append(missing, sysPropEditability) } return missing @@ -118,15 +153,20 @@ func pluralY(n int) string { // editability, refusing either when the widget's package does not declare it. // The attribute-based `visible: Attr in (…)` form is applied by the caller, // which has the entity context it resolves against. -func applyPluggableSystemSettings(cw *pages.CustomWidget, w *ast.WidgetV3) error { +func applyPluggableSystemSettings(cw *pages.CustomWidget, w *ast.WidgetV3, def *WidgetDefinition) error { if cw == nil || w == nil { return nil } if declared, known := systemPropsOfType(cw.RawType); known { - if missing := undeclaredSystemProps(w, declared); len(missing) > 0 { + if missing := undeclaredSystemProps(w, def, declared); len(missing) > 0 { return mdlerrors.NewValidation(undeclaredSystemPropMessage(w.Name, w.Type, missing)) } } + if editableIsOwnProperty(def) { + // The engine wrote `editable:` into the widget's own property; it is + // not also the widget-level setting. + w = withoutEditability(w) + } applyConditionalSettings(cw, w) // The writer stores CustomWidget.Editable (the engine fills it with the // definition's default); the shared helper above set BaseWidget's. @@ -136,6 +176,19 @@ func applyPluggableSystemSettings(cw *pages.CustomWidget, w *ast.WidgetV3) error return nil } +// withoutEditability is a shallow copy of w without its `editable:` keys. +func withoutEditability(w *ast.WidgetV3) *ast.WidgetV3 { + c := *w + c.Properties = make(map[string]any, len(w.Properties)) + for k, v := range w.Properties { + if strings.EqualFold(k, "Editable") || strings.EqualFold(k, "EditableIf") { + continue + } + c.Properties[k] = v + } + return &c +} + // templateSystemPropsCache: projectPath + "\x00" + widgetID → declared set // (nil when the widget has no template to judge by). var templateSystemPropsCache sync.Map @@ -176,14 +229,14 @@ func validatePluggableSystemProps(w *ast.WidgetV3, def *WidgetDefinition, regist if w == nil || def == nil || def.WidgetID == "" { return nil } - if !widgetAsksVisibility(w) && !widgetAsksEditability(w) { + if !widgetAsksVisibility(w) && !widgetAsksSystemEditability(w, def) { return nil } declared, known := templateSystemProps(registryProjectPath(registry), def.WidgetID) if !known { return nil } - missing := undeclaredSystemProps(w, declared) + missing := undeclaredSystemProps(w, def, declared) if len(missing) == 0 { return nil } diff --git a/mdl/executor/pluggable_visibility_editability_test.go b/mdl/executor/pluggable_visibility_editability_test.go index 071f07385d..20a739649b 100644 --- a/mdl/executor/pluggable_visibility_editability_test.go +++ b/mdl/executor/pluggable_visibility_editability_test.go @@ -142,7 +142,7 @@ func TestApplyPluggableSystemSettings_RefusesUndeclared(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { cw := &pages.CustomWidget{RawType: typeDeclaring(c.declared...), Editable: "Always"} - err := applyPluggableSystemSettings(cw, &ast.WidgetV3{Type: "switch", Name: "w", Properties: c.props}) + err := applyPluggableSystemSettings(cw, &ast.WidgetV3{Type: "switch", Name: "w", Properties: c.props}, nil) if c.wantErr == "" { if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/mdl/executor/validate_column_filterable_177_test.go b/mdl/executor/validate_column_filterable_177_test.go new file mode 100644 index 0000000000..ba18350b22 --- /dev/null +++ b/mdl/executor/validate_column_filterable_177_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 + +// `column (Attribute: Name, Filterable: true)` (mendixlabs/mxcli#177). +// +// Reported: "there is no way to set `Filterable: true` on individual columns. +// The property is silently ignored." A column's filter is not a property: it +// is the `filter` widgets slot, written as a filter widget inside the column's +// braces. check now refuses the key, but the message listed the column's +// properties (with a project) or suggested `FilterType` (without one) — neither +// says where a column filter goes. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestColumnFilterable_PointsAtTheFilterSlot(t *testing.T) { + col := &ast.WidgetV3{Type: "column", Name: "c1", Properties: map[string]any{"Attribute": "Name", "Filterable": true}} + + mapping := &ObjectListMapping{ + MDLContainer: "COLUMN", + ItemProperties: []ItemPropertyMapping{ + {PropertyKey: "attribute", Operation: "attribute"}, + {PropertyKey: "header", Operation: "texttemplate", MdlAliases: []string{"Caption"}}, + }, + ItemSlots: []ItemSlotMapping{ + {PropertyKey: "content", Operation: "widgets"}, + {PropertyKey: "filter", Operation: "widgets", AcceptedChildTypes: []string{"textfilter", "numberfilter"}}, + }, + } + withProject := validateObjectListItemUnknownProps(col, mapping, "page X") + + grid := &ast.WidgetV3{Type: "datagrid", Name: "dg"} + schema := embeddedSchemaFor(col, grid) + if schema == nil { + t.Fatal("no embedded Data grid 2 column schema") + } + withoutProject := validateStaticWidgetUnknownPropsIn(col, "page X", schema, false) + + for name, vs := range map[string]int{"with a project": len(withProject), "without one": len(withoutProject)} { + if vs != 1 { + t.Fatalf("%s: want one violation for Filterable, got %d", name, vs) + } + } + for name, msg := range map[string]string{ + "with a project": withProject[0].Message, + "without one": withoutProject[0].Message, + } { + for _, want := range []string{"`filter`", "textfilter"} { + if !strings.Contains(msg, want) { + t.Errorf("%s: message should point at the filter slot (%s): %s", name, want, msg) + } + } + if strings.Contains(msg, "FilterType") { + t.Errorf("%s: suggests FilterType, which a column does not have: %s", name, msg) + } + } +} + +// Control: a plain typo still gets the nearest-key suggestion. +func TestColumnUnknownKey_TypoStillSuggests(t *testing.T) { + col := &ast.WidgetV3{Type: "column", Name: "c1", Properties: map[string]any{"Sortabel": true}} + schema := embeddedSchemaFor(col, &ast.WidgetV3{Type: "datagrid", Name: "dg"}) + vs := validateStaticWidgetUnknownPropsIn(col, "page X", schema, false) + if len(vs) != 1 || !strings.Contains(vs[0].Message, "did you mean `sortable`") { + t.Errorf("want a sortable suggestion, got %+v", vs) + } +} diff --git a/mdl/executor/validate_widget_embedded_schema.go b/mdl/executor/validate_widget_embedded_schema.go index 478bd8efea..1e8737a83b 100644 --- a/mdl/executor/validate_widget_embedded_schema.go +++ b/mdl/executor/validate_widget_embedded_schema.go @@ -44,6 +44,24 @@ type embeddedWidgetSchema struct { // types maps a lower-cased key to its declared type ("Expression", // "TextTemplate", "Boolean", …). types map[string]string + // slotAccepts are the widget types an item's widgets slot routes, by slot + // key (a column's `filter` → textfilter, …); empty for a widget's own schema. + slotAccepts map[string][]string +} + +// widgetSlots lists the schema's widgets-typed properties — the slots a child +// widget goes in — with the widget types each one routes. +func (s *embeddedWidgetSchema) widgetSlots() []itemSlotHint { + if s == nil { + return nil + } + var out []itemSlotHint + for _, n := range s.Names { + if s.types[strings.ToLower(n)] == "Widgets" { + out = append(out, itemSlotHint{Key: n, Accepts: s.slotAccepts[n]}) + } + } + return out } // declares reports whether the template declares key, case-insensitively — or @@ -128,6 +146,7 @@ func loadEmbeddedSchema(widgetID, listKey string) *embeddedWidgetSchema { schema = &embeddedWidgetSchema{Title: tmpl.Name, Version: tmpl.Version, types: map[string]string{}} if listKey != "" { schema.Title += " column" + schema.slotAccepts = itemSlotAcceptedChildTypes[widgetID][listKey] } for _, pt := range propTypes { key, _ := pt["PropertyKey"].(string) diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index c61db2935e..8138073c11 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -921,7 +921,13 @@ func validateObjectListItemUnknownProps(w *ast.WidgetV3, mapping *ObjectListMapp continue } hint := "" - if ext := extensionsOf(key, names); len(ext) > 0 { + var slots []itemSlotHint + for _, s := range mapping.ItemSlots { + slots = append(slots, itemSlotHint{Key: s.PropertyKey, Accepts: s.AcceptedChildTypes}) + } + if h := widgetSlotHint(key, strings.ToLower(mapping.MDLContainer), slots); h != "" { + hint = h + } else if ext := extensionsOf(key, names); len(ext) > 0 { // `attributeValue` on an HTMLElement attribute: the value lives under // attributeValueTemplate / attributeValueExpression, and edit // distance picks attributeName, the one key that is certainly wrong. @@ -943,6 +949,38 @@ func validateObjectListItemUnknownProps(w *ast.WidgetV3, mapping *ObjectListMapp return out } +// itemSlotHint is one widgets slot of a widget or object-list item: its key +// and the widget types it routes, when known. +type itemSlotHint struct { + Key string + Accepts []string +} + +// widgetSlotHint explains a key that names one of the item's widgets slots — +// `Filterable: true` on a column, whose filter is the `filter` slot. A slot +// takes child widgets, written in the item's braces, never a property value; +// the generic "did you mean" picked an unrelated key there (`FilterType`), and +// the property list did not mention the slot at all (mendixlabs/mxcli#177). +func widgetSlotHint(key, kind string, slots []itemSlotHint) string { + lower := strings.ToLower(key) + for _, s := range slots { + if s.Key == "" || !strings.HasPrefix(lower, strings.ToLower(s.Key)) { + continue + } + child := "" + if len(s.Accepts) > 0 { + child = s.Accepts[0] + } + hint := fmt.Sprintf(" — `%s` is a slot for child widgets, not a property: put the widget in the %s's braces, "+ + "e.g. `%s (…) { %s w1 (…) }`", s.Key, kind, kind, child) + if len(s.Accepts) > 1 { + hint += fmt.Sprintf(" (it takes %s)", strings.Join(s.Accepts, ", ")) + } + return hint + } + return "" +} + // extensionsOf returns the candidates that extend key (case-insensitively), // excluding `…Params` companions — the names a truncated key most likely meant. func extensionsOf(key string, candidates []string) []string { @@ -1157,7 +1195,9 @@ func validateStaticWidgetUnknownPropsIn(w *ast.WidgetV3, locationPrefix string, candidates = append(append([]string(nil), schema.Names...), staticWidgetKnownPropList...) } hint := "" - if suggestion := nearestKey(key, candidates); suggestion != "" { + if h := widgetSlotHint(key, strings.ToLower(w.Type), schema.widgetSlots()); h != "" { + hint = h + } else if suggestion := nearestKey(key, candidates); suggestion != "" { hint = fmt.Sprintf(" — did you mean `%s`?", suggestion) } message := fmt.Sprintf( diff --git a/mdl/executor/widget_defs.go b/mdl/executor/widget_defs.go index 7626ecbe75..3859ca6dc1 100644 --- a/mdl/executor/widget_defs.go +++ b/mdl/executor/widget_defs.go @@ -347,6 +347,31 @@ func GenerateDefJSON(mpkDef *mpk.WidgetDefinition, mdlName string) *WidgetDefini Operation: "action", Description: p.Description, }) + case "expression": + // A top-level expression property is authored by its own key, like a + // named action slot. It had no mapping at all, so it was a + // known-but-unmapped key: the Calendar's `showEventDate` drew + // MDL-WIDGET06 and its `editable` was unreachable (mendixlabs/mxcli#1578). + // An unset one keeps the template's default (applyOperation). + aliases := propertyAliases[mpkDef.ID][p.Key] + if strings.EqualFold(p.Key, "editable") { + if mpkDef.SystemPropertyKeys()["Editability"] { + // `editable:` is the widget-level editability here, which the + // package declares; the widget's own property of that name + // stays unmapped rather than taking the setting's spelling. + continue + } + // No Editability system property, so `editable:` can only mean + // this property. EditableIf is the key the visitor gives its + // bare-expression form; `Editable` already matches the key. + aliases = append(append([]string(nil), aliases...), "EditableIf") + } + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Operation: "expression", + Description: p.Description, + MdlAliases: aliases, + }) case "boolean", "integer", "decimal", "string", "enumeration": m := PropertyMapping{ PropertyKey: p.Key, diff --git a/mdl/executor/widget_defs_test.go b/mdl/executor/widget_defs_test.go index cee81eb268..49a97d7e24 100644 --- a/mdl/executor/widget_defs_test.go +++ b/mdl/executor/widget_defs_test.go @@ -147,15 +147,19 @@ func TestGenerateDefJSON_SkipsComplexTypes(t *testing.T) { // textTemplate always yields a mapping (so captions like Badge `value` are // authorable), and so does every action property — one whose key is not // onClick/onChange becomes a NAMED slot, mapped with no Source and addressed - // by its own key (#956). expression/icon/non-list object remain skipped. - if len(def.PropertyMappings) != 2 { - t.Fatalf("PropertyMappings count = %d, want 2 (texttemplate + the named action slot; expression/icon/object skipped): %+v", + // by its own key (#956) — and every expression, the same way + // (mendixlabs/mxcli#1578). icon/non-list object remain skipped. + if len(def.PropertyMappings) != 3 { + t.Fatalf("PropertyMappings count = %d, want 3 (texttemplate + the named action slot + the expression; icon/object skipped): %+v", len(def.PropertyMappings), def.PropertyMappings) } byKey := map[string]PropertyMapping{} for _, m := range def.PropertyMappings { byKey[m.PropertyKey] = m } + if ex := byKey["myExpr"]; ex.Operation != "expression" || ex.Source != "" { + t.Errorf("myExpr = {op=%q src=%q}, want {op=expression src=\"\"}", ex.Operation, ex.Source) + } if tt := byKey["myTemplate"]; tt.Operation != "texttemplate" { t.Errorf("myTemplate operation = %q, want texttemplate", tt.Operation) } @@ -241,7 +245,8 @@ func TestGenerateDefJSON_KnownPropertiesUnmapped(t *testing.T) { {Key: "onClick", Type: "action"}, // mapped (action → OnClick) {Key: "pageSize", Type: "integer"}, // mapped (primitive) {Key: "onSelectionChange", Type: "action"}, // mapped as a named slot (#956) - {Key: "rowClass", Type: "expression"}, // unmapped (expression not handled) + {Key: "rowClass", Type: "expression"}, // mapped by its own key (mendixlabs/mxcli#1578) + {Key: "rowIcon", Type: "icon"}, // unmapped (icon not handled) }, } def := GenerateDefJSON(mpkDef, "DATAGRID") @@ -249,14 +254,14 @@ func TestGenerateDefJSON_KnownPropertiesUnmapped(t *testing.T) { for _, k := range def.KnownProperties { known[k] = true } - for _, want := range []string{"rowClass"} { + for _, want := range []string{"rowIcon"} { if !known[want] { t.Errorf("KnownProperties missing unmapped property %q — the checker can't warn it will be dropped", want) } } // onSelectionChange is now mapped as a named slot, so listing it would make // MDL-WIDGET06 warn that a value mxcli writes is going to be dropped (#956). - for _, notWant := range []string{"onClick", "pageSize", "onSelectionChange"} { + for _, notWant := range []string{"onClick", "pageSize", "onSelectionChange", "rowClass"} { if known[notWant] { t.Errorf("KnownProperties should not list mapped property %q", notWant) } diff --git a/mdl/executor/widget_editable_quoted_expression_test.go b/mdl/executor/widget_editable_quoted_expression_test.go index 9750ed3924..b91372ed8a 100644 --- a/mdl/executor/widget_editable_quoted_expression_test.go +++ b/mdl/executor/widget_editable_quoted_expression_test.go @@ -345,7 +345,7 @@ func TestApplyPluggableSystemSettings_QuotedExpression(t *testing.T) { t.Run(c.name, func(t *testing.T) { cw := &pages.CustomWidget{RawType: typeDeclaring(c.declared...), Editable: "Always"} w := &ast.WidgetV3{Type: "switch", Name: "w", Properties: map[string]any{"Editable": lockedExpr}} - err := applyPluggableSystemSettings(cw, w) + err := applyPluggableSystemSettings(cw, w, nil) if c.wantErr != (err != nil) { t.Fatalf("err = %v, want error: %v", err, c.wantErr) } diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 94627c3c3c..be3c0d719b 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -96,7 +96,11 @@ const defaultSlotContainer = "template" // spelling of a column's `Visible:` is lowered to VisibleIf, so without it // the column was written always visible. Bump forces existing projects to // regenerate so the alias reaches their datagrid def. -const WidgetDefGeneratorVersion = 18 +// 19 — a top-level `expression` property gets a mapping, by its own key (and +// `editable:` when the package declares no Editability); before, it was +// known-but-unmapped and the Calendar's `editable` could not be written +// (mendixlabs/mxcli#1578). +const WidgetDefGeneratorVersion = 19 // WidgetDefinition describes how to construct a pluggable widget from MDL syntax. // Loaded from embedded JSON definition files (*.def.json). @@ -936,6 +940,11 @@ func (e *PluggableWidgetEngine) applyOperation(builder backend.WidgetObjectBuild builder.SetImage(propKey, ctx.PrimitiveVal) } case "expression": + // No MDL value: keep the template's default expression rather than + // writing an empty one over it — an empty required expression is CE0642. + if ctx.PrimitiveVal == "" { + return nil + } builder.SetExpression(propKey, ctx.PrimitiveVal) case "datasource": builder.SetDataSource(propKey, ctx.DataSource) @@ -1155,6 +1164,20 @@ func namedPropValue(mapping PropertyMapping, w *ast.WidgetV3) string { return "" } +// namedPropRaw is the unstringified value of a mapping's property, under its +// key or an alias, with the name it was found under. +func namedPropRaw(mapping PropertyMapping, w *ast.WidgetV3) (any, string, bool) { + if v, ok := lookupProperty(w.Properties, mapping.PropertyKey); ok { + return v, mapping.PropertyKey, true + } + for _, alias := range mapping.MdlAliases { + if v, ok := lookupProperty(w.Properties, alias); ok { + return v, alias, true + } + } + return nil, "", false +} + // namedPropValueWithKey is namedPropValue plus the NAME the value was found // under. A text-template property's parameters live beside it under that same // name + "Params", and the script may have written either the schema key or one @@ -1455,6 +1478,18 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W ctx.Action = built } } + // A top-level expression property, by its own key or an alias + // (mendixlabs/mxcli#1578). The value's shape decides nothing here: a + // quoted, a bare and a bracketed expression are all its text. + if mapping.Operation == "expression" { + if raw, key, ok := namedPropRaw(mapping, w); ok { + text, err := scalarPropertyText(w, key, raw, "expression") + if err != nil { + return nil, err + } + ctx.PrimitiveVal = text + } + } return ctx, nil } diff --git a/mdl/executor/widget_expression_property_1578_test.go b/mdl/executor/widget_expression_property_1578_test.go new file mode 100644 index 0000000000..eff468cccb --- /dev/null +++ b/mdl/executor/widget_expression_property_1578_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 + +// A pluggable widget's own top-level expression properties +// (mendixlabs/mxcli#1578, the Marketplace Calendar). +// +// Reported: "`editable` cannot be written at all. The key is accepted with no +// warning, and nothing reaches the model", `showEventDate` drew MDL-WIDGET06 +// "recognized but not yet persisted", "`describe page` prints neither +// property", and the build failed with +// `[CE0642] "Property 'Editable' is required."`. +// +// The generator gave a top-level `expression` property no mapping, so it was a +// known-but-unmapped key; and `editable:` is also the generic widget-level +// editability, which the Calendar's package does not declare — so the +// widget's own property of that name could never be reached. +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +func calendarLikeMPK(systemProps ...string) *mpk.WidgetDefinition { + def := &mpk.WidgetDefinition{ + ID: "com.mendix.widget.web.calendar.Calendar", Name: "Calendar", IsPluggable: true, + Properties: []mpk.PropertyDef{ + {Key: "editable", Type: "expression", DefaultValue: "true"}, + {Key: "showEventDate", Type: "expression", DefaultValue: "true"}, + {Key: "titleExpression", Type: "expression"}, + }, + } + for _, k := range systemProps { + def.SystemProps = append(def.SystemProps, mpk.PropertyDef{Key: k}) + } + return def +} + +func mappingFor(def *WidgetDefinition, key string) (PropertyMapping, bool) { + for _, m := range def.PropertyMappings { + if m.PropertyKey == key { + return m, true + } + } + return PropertyMapping{}, false +} + +func TestGenerateDefJSON_TopLevelExpressionIsMapped(t *testing.T) { + def := GenerateDefJSON(calendarLikeMPK(), "CALENDAR") + for _, key := range []string{"editable", "showEventDate", "titleExpression"} { + m, ok := mappingFor(def, key) + if !ok { + t.Errorf("%s: no mapping — an unmapped key is dropped on write", key) + continue + } + if m.Operation != "expression" { + t.Errorf("%s: operation %q, want expression", key, m.Operation) + } + } + for _, k := range def.KnownProperties { + if strings.EqualFold(k, "editable") || strings.EqualFold(k, "showEventDate") { + t.Errorf("%s is still listed as known-but-unmapped (MDL-WIDGET06 would warn it is dropped)", k) + } + } +} + +// With no Editability system property, `editable:` can only mean the widget's +// own property; with one, it keeps its widget-level meaning. +func TestGenerateDefJSON_EditableRoutesToOwnPropertyWithoutEditability(t *testing.T) { + own := GenerateDefJSON(calendarLikeMPK(), "CALENDAR") + if !editableIsOwnProperty(own) { + t.Error("a widget declaring `editable` and no Editability: `editable:` must reach its own property") + } + sys := GenerateDefJSON(calendarLikeMPK("Editability"), "CALENDAR") + if editableIsOwnProperty(sys) { + t.Error("a widget declaring Editability keeps `editable:` as the widget-level setting") + } +} + +func TestResolveMapping_OwnEditableExpression(t *testing.T) { + def := GenerateDefJSON(calendarLikeMPK(), "CALENDAR") + m, _ := mappingFor(def, "editable") + engine := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + for _, tc := range []struct{ prop, want string }{ + {"editable: 'true'", "true"}, + {"editable: false", "false"}, + {"editable: '$currentObject/Reason != empty'", "$currentObject/Reason != empty"}, + {"editable: $currentObject/Reason != empty", "$currentObject/Reason != empty"}, + } { + src := "create page Mod.P (Title: 'T') {\n pluggablewidget 'com.mendix.widget.web.calendar.Calendar' cal (" + tc.prop + ")\n}" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", tc.prop, errs) + } + w := prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0] + ctx, err := engine.resolveMapping(m, w) + if err != nil { + t.Fatalf("%s: %v", tc.prop, err) + } + if ctx.PrimitiveVal != tc.want { + t.Errorf("%s: expression = %q, want %q", tc.prop, ctx.PrimitiveVal, tc.want) + } + // The widget-level editability must not be asked for as well: the + // package declares none, and MDL-WIDGET41 would refuse the page. + if widgetAsksSystemEditability(w, def) { + t.Errorf("%s: also read as the widget-level editability", tc.prop) + } + } +} + +// An unset expression mapping keeps the template's default instead of writing +// an empty expression over it — the CE0642 itself. +func TestApplyOperation_UnsetExpressionKeepsTemplateDefault(t *testing.T) { + b := &recordingBuilder{} + engine := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + if err := engine.applyOperation(b, "expression", "editable", &BuildContext{}); err != nil { + t.Fatal(err) + } + if len(b.expressions) != 0 { + t.Errorf("an unset expression was written: %v", b.expressions) + } +} + +func TestDescribe_PluggableExpressionProperty(t *testing.T) { + ctx, _ := newMockCtx(t) + w := map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "Name": "cal", + "Type": map[string]any{ + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{"$ID": "pt1", "PropertyKey": "editable", + "ValueType": map[string]any{"$ID": "vt1", "Type": "Expression"}}, + }, + }, + }, + "Object": map[string]any{ + "Properties": []any{ + map[string]any{"TypePointer": "pt1", "Value": map[string]any{ + "Expression": "$currentObject/Reason != empty", "PrimitiveValue": "", + }}, + }, + }, + } + got := extractExplicitProperties(ctx, w) + var buf bytes.Buffer + out := appendExplicitProps(&ExecContext{Output: &buf}, nil, got) + if len(out) != 1 || out[0] != "editable: '$currentObject/Reason != empty'" { + t.Errorf("describe = %v, want [editable: '$currentObject/Reason != empty']", out) + } +} + +// recordingBuilder records SetExpression calls; nothing else is reached. +type recordingBuilder struct { + backend.WidgetObjectBuilder + expressions map[string]string +} + +func (b *recordingBuilder) SetExpression(key, expr string) { + if b.expressions == nil { + b.expressions = map[string]string{} + } + b.expressions[key] = expr +} diff --git a/modelsdk/widgets/augment.go b/modelsdk/widgets/augment.go index 0194c961ee..744efe86fa 100644 --- a/modelsdk/widgets/augment.go +++ b/modelsdk/widgets/augment.go @@ -933,6 +933,14 @@ func createDefaultWidgetValue(vtID string, bsonType string, p mpk.PropertyDef) m if p.DefaultValue != "" { val["PrimitiveValue"] = p.DefaultValue } + case "Expression": + // The declared default expression, as Studio Pro writes it. Left empty, + // a defaulted expression the script does not name fails the build — + // the Calendar's `editable` (defaultValue="true") was CE0642 "Property + // 'Editable' is required" on every page (mendixlabs/mxcli#1578). + if p.DefaultValue != "" { + val["Expression"] = p.DefaultValue + } case "TextTemplate": // Populate the default template with the widget's shipped caption // translations (empty when the .mpk declares none). A required textTemplate diff --git a/modelsdk/widgets/expression_default_test.go b/modelsdk/widgets/expression_default_test.go new file mode 100644 index 0000000000..57aed0260c --- /dev/null +++ b/modelsdk/widgets/expression_default_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package widgets + +import ( + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/widgets/mpk" +) + +// mendixlabs/mxcli#1578: the Marketplace Calendar declares +// ``, and a page +// built from its generated template failed `mx check` with +// `[CE0642] "Property 'Editable' is required."` — the template wrote the +// expression empty. Studio Pro writes the declared default; so must the +// template, or every widget with a defaulted expression is unbuildable until +// each one is spelled out. +func TestGenerateFromMPK_ExpressionDefault(t *testing.T) { + ResetPlaceholderCounter() + def := &mpk.WidgetDefinition{ + ID: "com.example.Cal", Name: "Cal", Version: "1.0.0", + Properties: []mpk.PropertyDef{ + {Key: "editable", Type: "expression", Caption: "Editable", DefaultValue: "true"}, + {Key: "titleExpression", Type: "expression", Caption: "Title expression"}, + }, + } + tmpl := GenerateFromMPK(def) + + got := map[string]string{} + keyByTypeID := map[string]string{} + objType := tmpl.Type["ObjectType"].(map[string]any) + for _, pt := range objType["PropertyTypes"].([]any) { + m, ok := pt.(map[string]any) + if !ok { + continue + } + keyByTypeID[m["$ID"].(string)] = m["PropertyKey"].(string) + } + for _, p := range tmpl.Object["Properties"].([]any) { + m, ok := p.(map[string]any) + if !ok { + continue + } + key := keyByTypeID[m["TypePointer"].(string)] + got[key], _ = m["Value"].(map[string]any)["Expression"].(string) + } + if got["editable"] != "true" { + t.Errorf("editable Expression = %q, want the declared default %q", got["editable"], "true") + } + // Control: an expression with no declared default stays empty. + if got["titleExpression"] != "" { + t.Errorf("titleExpression Expression = %q, want empty", got["titleExpression"]) + } +} diff --git a/sdk/pages/pages_widgets_container.go b/sdk/pages/pages_widgets_container.go index f3ee2ec154..43aa2288e9 100644 --- a/sdk/pages/pages_widgets_container.go +++ b/sdk/pages/pages_widgets_container.go @@ -3,6 +3,8 @@ package pages import ( + "strings" + "github.com/mendixlabs/mxcli/model" ) @@ -69,10 +71,27 @@ type Container struct { type ContainerRenderMode string const ( - ContainerRenderModeDiv ContainerRenderMode = "Div" - ContainerRenderModeForm ContainerRenderMode = "Form" + ContainerRenderModeDiv ContainerRenderMode = "Div" ) +// ContainerRenderModes are the members of Forms$DivContainer.RenderMode — the +// HTML element the container renders as (modelsdk/gen/pages ContainerRenderMode). +var ContainerRenderModes = []ContainerRenderMode{ + "Div", "Section", "Article", "Header", "Footer", "Main", "Nav", "Aside", "Hgroup", "Address", +} + +// CanonicalContainerRenderMode returns the stored spelling of a container's +// RenderMode, matched case-insensitively. A value outside the enumeration is +// not one Studio Pro can open, so callers refuse it rather than store it. +func CanonicalContainerRenderMode(s string) (ContainerRenderMode, bool) { + for _, m := range ContainerRenderModes { + if strings.EqualFold(s, string(m)) { + return m, true + } + } + return "", false +} + // GroupBox represents a group box container. type GroupBox struct { BaseWidget