From a54e8b40f0812d06e4646c2534e5b5af215d158d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 08:20:17 +0000 Subject: [PATCH 1/3] fix(pages): write SIGN_OUT instead of dropping it (both engines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by the default engine: client action *pages.SignOutClientAction not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy The refusal was honest. The advice was not. The legacy writer had no case for the action either, and its default branch is QUIET — it returns Forms$NoAction for anything unmatched — so the recommended escape hatch produced a button that rendered, said "Sign out", and did nothing, with `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13, `describe page` came back `actionbutton btnOut (Caption: 'Sign out')` with no action at all, and the stored BSON held Forms$NoAction. Both engines now write the same document, and DESCRIBE renders `sign_out` so it round-trips: { "$Type": "Forms$SignOutClientAction", "DisabledDuringExecution": true } Two keys and no more, pinned against a Studio Pro-authored sign-out button in ako/TestApp. That reference is provably Studio Pro's rather than mxcli's, because until this change NEITHER engine could emit the type — which is also why the shape could not have been guessed from the writers. Verified with the fix reverted, one engine at a time: modelsdk fails with the refusal verbatim, legacy fails by writing Forms$NoAction. mx check is 0 errors on both engines' output, before and after — this was never a build error, which is exactly what made it dangerous. The control pins the fallback separately. A test asserting "SIGN_OUT is no longer NoAction" would also pass if someone had merely softened the default, so OPEN_LINK — still unimplemented — is asserted to STILL hit Forms$NoAction on legacy and still be refused on modelsdk. OPEN_LINK is left unimplemented deliberately: gen calls it OpenLinkClientAction and its Address is an element rather than a string, so it is a separate job. `mxcli syntax page.action` listed it as available with no caveat; it now says it is written by neither engine and points at a nanoflow instead. Reported as CapTrackV2 FINDINGS §10. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- .../fix-issue/findings/mdl-executor.jsonl | 1 + cmd/mxcli/syntax/features_page.go | 2 +- .../bug-tests/captrack-10-sign-out-action.mdl | 43 ++++++++++ mdl/backend/modelsdk/widget_write.go | 17 ++++ .../modelsdk/widget_write_signout_test.go | 78 +++++++++++++++++++ mdl/executor/cmd_pages_describe_output.go | 2 + sdk/mpr/writer_widgets_action.go | 15 ++++ sdk/mpr/writer_widgets_action_test.go | 65 ++++++++++++++++ 8 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 mdl-examples/bug-tests/captrack-10-sign-out-action.mdl create mode 100644 mdl/backend/modelsdk/widget_write_signout_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 34eb9618fa..47f8b44fc9 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -510,3 +510,4 @@ {"area": "mdl/executor", "date": "2026-09-04", "symptom": "`return` inside a `loop` passes `mxcli check` AND `exec`, then mxbuild fails **CE0068** \"End events cannot be placed inside a loop.\" \u2014 even though MDL062 exists precisely to catch that. It fires only when the microflow header has NO `returns T as $Var` clause.", "cause": "MDL062 stood down for the whole microflow whenever the AS clause was present, on two claims: that buildFlowGraph synthesizes the End event from the variable so none lands in the loop, and that the shape builds CE0109 instead. `describe microflow` shows the in-loop `return` written either way, so the first was never true. The second was a measurement artefact: mxbuild reports ONE error per microflow, and in the shape that was measured the AS variable was never assigned, so CE0109 \"Undefined variable\" won the race and hid CE0068 underneath. Adding `declare $Done Boolean = false` and changing nothing else turns the same microflow from CE0109 into CE0068. The exemption was deleted, not narrowed.", "file": "`mdl/executor/validate_microflow_ce_gaps.go` (checkReturnInLoop \u2014 the `v.returnType.Variable != \"\"` early return); test inverted in `validate_microflow_ce_gaps_test.go` (TestMDL062_ExemptsReturnsAsClause -> TestMDL062_FiresWithReturnsAsClause); examples `mdl-examples/bug-tests/captrack-19-return-in-loop-as-clause{,.fail}.mdl`", "insight": "A second error in the same document can HIDE the one you are measuring, because mxbuild reports one error per microflow. An exemption justified by \"measured: builds X instead\" is only sound if the reproduction was otherwise valid \u2014 here the repro was broken in a second way, and the error that surfaced was the one nobody was asking about. When a measurement says a construct is clean, add the minimum that removes every OTHER error from that document and measure again; the differential (CE0109 -> CE0068 on one added `declare`) is what settles it. Reported as CapTrackV2 FINDINGS \u00a719."} {"area": "mdl/executor", "date": "2026-09-04", "symptom": "`dataview dv (\u2026, OnClick: SHOW_PAGE \u2026)` parses, `mxcli check` is clean, `exec` writes the page without a word \u2014 and the rendered element has no handler and no role=\"button\". The same silence on `dynamictext`, `listview` and every other widget except a container or a button.", "cause": "`OnClick:` is an ALIAS for `Action:` (both stored as Properties[\"Action\"], #603), and mxcli writes that property for three widget kinds only: container/customcontainer, the buttons, and a navigationlist item. Every other widget drops it, and the property allow-lists behind MDL-WIDGET01/07 could not see that because they are widget-type AGNOSTIC \u2014 the same blind spot as #928's `editable:`. Added MDL-WIDGET23 (warning), with two messages: Mendix models no click action at all (dataview, dynamictext, inputs, groupbox\u2026) vs Mendix models one that mxcli cannot write (listview, staticimage, dynamicimage \u2014 measured against generated/metamodel).", "file": "`mdl/executor/validate_widget_onclick.go` (new), wired in `validate_widgets.go` beside validateWidgetEditability; `.claude/skills/mendix/create-page/reference/widgets.md`; example `mdl-examples/bug-tests/captrack-21-dataview-onclick-dropped.mdl`", "insight": "The first draft reported everything OUTSIDE an allow-list of the three writers, and running it over the shipped examples flagged three of them. Cause: `mxcli check` without `-p` has no widget registry, so `lookupWidgetDef` returns nil for a PLUGGABLE widget too and the caller's \"static widgets only\" branch silently does not hold \u2014 `datagrid` is DataGrid 2, a pluggable widget whose onClick the engine does write. For any rule keyed on widget type, an allow-list makes the unknown case an ERROR and a deny-list makes it silence; pick the deny-list, because a missed warning costs nothing and a false one tells an author their working page is broken. Running a new rule across mdl-examples/ before wiring it up is what caught it \u2014 the same exercise #893 describes. Reported as CapTrackV2 FINDINGS \u00a721."} {"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ALTER SETTINGS MODEL AfterStartupMicroflow = 'Mod.MF_Seed'` accepts a microflow with no return type, `mxcli check` passes, and the build fails **CE0142** \"After startup microflow should return a boolean\". `ALTER MICROFLOW \u2026 RETURNS \u2026` does not parse either, so the remedy is DROP + CREATE.", "cause": "#274 made ALTER SETTINGS resolve the qualified names it writes, which catches a MISSPELLED microflow. Here the name resolves perfectly \u2014 the constraint is on the thing the setting names, not on the reference, and nothing looked at the return type. Added MDL073: a project-less pass (ValidateAfterStartupReturnType) for a microflow the script itself creates, which is the usual shape, plus the stored return type on the project path. Both call one function so they cannot drift. flowSignature gained ReturnKind because its existing Returns field is the entity name and cannot tell Boolean from void.", "file": "`mdl/executor/validate_settings_refs.go` (checkAfterStartupReturnsBoolean + ValidateAfterStartupReturnType), `validate_program.go`, `helpers.go` (buildMicroflowReturnTypes), `validate_datasource_args.go` (flowSignature.ReturnKind); examples `mdl-examples/bug-tests/captrack-6-after-startup-must-return-boolean{,.fail}.mdl`", "insight": "\"The reference resolves\" and \"the reference is usable\" are different questions, and a resolver answers only the first. Whenever a setting stores a NAME, ask what the platform requires of the named thing \u2014 an existence check will pass and the build will still fail, and the error arrives with no connection to the statement that caused it. The scope discipline that goes with it: only AfterStartup is type-checked, because only its rule was measured; BeforeShutdown and HealthCheck are left alone rather than constrained on a guess, and there is a control test asserting that. Reported as CapTrackV2 FINDINGS \u00a76."} +{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ACTIONBUTTON \u2026 (Action: SIGN_OUT)` is refused by the default engine \u2014 \"client action *pages.SignOutClientAction not yet supported by the modelsdk engine \u2014 rerun with MXCLI_ENGINE=legacy\" \u2014 and the suggested workaround SILENTLY produces a dead button: on legacy the action is written as `Forms$NoAction`, so it renders, says \"Sign out\", and does nothing, with `mxcli check`, `exec` and `mx check` all clean.", "cause": "Neither engine had a case for the action. modelsdk's clientActionToGen ended in a loud default; sdk/mpr's serializeClientAction ended in a QUIET one that returns Forms$NoAction for anything unmatched. Added the case to both. The document is two keys \u2014 `Forms$SignOutClientAction` + `DisabledDuringExecution: true` \u2014 pinned against a Studio Pro-authored button in ako/TestApp, plus `sign_out` in the DESCRIBE renderer so it round-trips. Both engines now emit byte-identical documents; mx check 0 errors on each. OPEN_LINK is still unwritten by both (gen calls it OpenLinkClientAction and its Address is an element, not a string) \u2014 the syntax topic now says so instead of listing it as available.", "file": "`mdl/backend/modelsdk/widget_write.go` (clientActionToGen), `sdk/mpr/writer_widgets_action.go` (serializeClientAction), `mdl/executor/cmd_pages_describe_output.go` (renderClientActionMDL), `cmd/mxcli/syntax/features_page.go`; example `mdl-examples/bug-tests/captrack-10-sign-out-action.mdl`", "insight": "When one engine refuses something and points at the other, CHECK THE OTHER before repeating the advice \u2014 the refusal is visible and the fallback is not, so the recommended escape hatch can be the strictly worse path. The structural tell is the shape of the default branch: modelsdk's raises, legacy's returns Forms$NoAction, and a silent default in a serializer converts every unimplemented type into data loss rather than an error. Grep for the fallthrough before trusting a switch. Note the control this needs: a test that SIGN_OUT is no longer NoAction can pass because someone softened the default, so pin the fallback separately with a type that is still unimplemented (OPEN_LINK). Reported as CapTrackV2 FINDINGS \u00a710."} diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 35cdd470de..0eab96e28f 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -140,7 +140,7 @@ func init() { "button style", "primary", "danger", "success", "icon", "linkbutton", "link button", }, - Syntax: "Action: SAVE_CHANGES\nAction: SAVE_CHANGES CLOSE_PAGE -- save, then close the pop-up\nAction: CANCEL_CHANGES\nAction: CANCEL_CHANGES CLOSE_PAGE\nAction: CLOSE_PAGE\nAction: DELETE\nAction: DELETE CLOSE_PAGE\nAction: DELETE_OBJECT\nAction: NANOFLOW Module.NF\nAction: OPEN_LINK 'https://example.com'\nAction: SIGN_OUT\nAction: COMPLETE_TASK 'OutcomeName'\nAction: SHOW_PAGE Module.Page\nAction: SHOW_PAGE Module.Page(Param: $currentObject)\nAction: MICROFLOW Module.MF\nAction: MICROFLOW Module.MF(Param: $val)\nAction: CREATE_OBJECT Module.Entity THEN SHOW_PAGE Module.Page\n\nA SHOW_PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\nIcon: 'Module.IconCollection.IconName' -- e.g. 'Atlas_Core.Atlas_Filled.pencil'\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", + Syntax: "Action: SAVE_CHANGES\nAction: SAVE_CHANGES CLOSE_PAGE -- save, then close the pop-up\nAction: CANCEL_CHANGES\nAction: CANCEL_CHANGES CLOSE_PAGE\nAction: CLOSE_PAGE\nAction: DELETE\nAction: DELETE CLOSE_PAGE\nAction: DELETE_OBJECT\nAction: NANOFLOW Module.NF\nAction: OPEN_LINK 'https://example.com'\nAction: SIGN_OUT\nAction: COMPLETE_TASK 'OutcomeName'\nAction: SHOW_PAGE Module.Page\nAction: SHOW_PAGE Module.Page(Param: $currentObject)\nAction: MICROFLOW Module.MF\nAction: MICROFLOW Module.MF(Param: $val)\nAction: CREATE_OBJECT Module.Entity THEN SHOW_PAGE Module.Page\n\nA SHOW_PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\n\nOPEN_LINK parses but is written by NEITHER engine: the modelsdk engine\nrefuses it, and the legacy engine writes Forms$NoAction, so the button\nrenders and does nothing (measured on 11.13). Call a nanoflow that opens\nthe URL instead. SIGN_OUT is written by both.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\nIcon: 'Module.IconCollection.IconName' -- e.g. 'Atlas_Core.Atlas_Filled.pencil'\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", Example: "ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\nACTIONBUTTON btnEdit (Caption: 'Edit',\n Action: SHOW_PAGE Module.EditPage(Item: $currentObject))\nLINKBUTTON btnDelete (Caption: 'Delete', Action: DELETE,\n Icon: 'Atlas_Core.Atlas_Filled.pencil')", SeeAlso: []string{"page.widgets"}, }) diff --git a/mdl-examples/bug-tests/captrack-10-sign-out-action.mdl b/mdl-examples/bug-tests/captrack-10-sign-out-action.mdl new file mode 100644 index 0000000000..6432a9052a --- /dev/null +++ b/mdl-examples/bug-tests/captrack-10-sign-out-action.mdl @@ -0,0 +1,43 @@ +-- CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by +-- the default engine: +-- +-- client action *pages.SignOutClientAction not yet supported by the +-- modelsdk engine — rerun with MXCLI_ENGINE=legacy +-- +-- The refusal was honest. The advice was not: the legacy writer had no case for +-- the action either, so it fell through to the default and wrote +-- `Forms$NoAction`. The button rendered, said "Sign out", and did nothing — +-- with `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13: +-- +-- before, either engine: describe -> actionbutton btnOut (Caption: 'Sign out') +-- stored -> Forms$NoAction +-- after, either engine: describe -> ... Action: sign_out +-- stored -> Forms$SignOutClientAction, +-- DisabledDuringExecution: true +-- +-- That two-key shape is pinned against a Studio Pro-authored sign-out button +-- (ako/TestApp). The reference is provably Studio Pro's rather than mxcli's, +-- because until this fix NEITHER engine could emit the type. +-- +-- Verify: +-- mxcli exec captrack-10-sign-out-action.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page SignOut.P_Account" +-- -- must emit: Action: sign_out +-- MXCLI_ENGINE=legacy mxcli exec … -p other.mpr -- same document +-- mx check -p app.mpr -- 0 errors, before and after (this never was a +-- build error, which is what made it dangerous) + +create module SignOut; +/ + +create or replace page SignOut.P_Account + (Title: 'Account', Layout: Atlas_Core.Atlas_Default) +{ + container cActions { + actionbutton btnSignOut (Caption: 'Sign out', Action: sign_out, ButtonStyle: Default) + + -- CONTROL: the actions that already worked are untouched by the new case. + actionbutton btnClose (Caption: 'Close', Action: close_page) + } +} +/ diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 4ee902bebd..c7814838bc 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -1483,6 +1483,23 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) { g.SetNumberOfPagesToClose2("") g.SetPageSettings(formSettingsToGen(x.PageName)) return g, nil + case *pages.SignOutClientAction: + // sign_out → Forms$SignOutClientAction. One property, and the reference + // pins its value: a Studio Pro-authored sign-out button (ako/TestApp, + // Mendix 11) stores exactly + // + // { "$Type": "Forms$SignOutClientAction", "DisabledDuringExecution": true } + // + // That document is provably Studio Pro's rather than mxcli's, because + // until now NEITHER engine could emit the type — modelsdk refused it and + // legacy wrote Forms$NoAction (CapTrackV2 FINDINGS §10). + g := genPg.NewSignOutClientAction() + if x.ID != "" { + g.SetID(element.ID(x.ID)) + } + assignID(g) + g.SetDisabledDuringExecution(true) + return g, nil case *pages.SetTaskOutcomeClientAction: g := genPg.NewSetTaskOutcomeClientAction() if x.ID != "" { diff --git a/mdl/backend/modelsdk/widget_write_signout_test.go b/mdl/backend/modelsdk/widget_write_signout_test.go new file mode 100644 index 0000000000..62dcc8cf58 --- /dev/null +++ b/mdl/backend/modelsdk/widget_write_signout_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "strings" + "testing" + + genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused +// outright by the default engine: +// +// client action *pages.SignOutClientAction not yet supported by the +// modelsdk engine — rerun with MXCLI_ENGINE=legacy +// +// The refusal was honest; the advice was not. The legacy writer had no case for +// the action either and fell through to Forms$NoAction, so the recommended +// escape hatch produced a button that rendered, said "Sign out", and did +// nothing — with check, exec and mx check all clean. +// +// The document is one property, pinned against a Studio Pro-authored button +// (ako/TestApp, Mendix 11): +// +// { "$Type": "Forms$SignOutClientAction", "DisabledDuringExecution": true } +// +// That reference is provably Studio Pro's rather than mxcli's, because until +// this change NEITHER engine could emit the type. +func TestClientActionToGen_SignOut(t *testing.T) { + el, err := clientActionToGen(&pages.SignOutClientAction{ + BaseElement: model.BaseElement{ID: "action-id"}, + }) + if err != nil { + t.Fatalf("SIGN_OUT is still refused by the modelsdk engine: %v", err) + } + g, ok := el.(*genPg.SignOutClientAction) + if !ok { + t.Fatalf("got %T, want *pages.SignOutClientAction", el) + } + if g.TypeName() != "Forms$SignOutClientAction" { + t.Errorf("$Type = %q, want Forms$SignOutClientAction", g.TypeName()) + } + if !g.DisabledDuringExecution() { + t.Error("DisabledDuringExecution is false; the Studio Pro reference stores true") + } +} + +// CONTROL 1: an action that is still unimplemented must still be REFUSED, not +// quietly written. Without this the test above could pass because the default +// branch had been softened, which is the exact failure the legacy engine had. +func TestClientActionToGen_StillRefusesWhatItCannotWrite(t *testing.T) { + _, err := clientActionToGen(&pages.LinkClientAction{ + BaseElement: model.BaseElement{ID: "link-id"}, + Address: "https://example.com", + }) + if err == nil { + t.Fatal("OPEN_LINK was accepted; it has no writer, so accepting it means dropping it") + } + if !strings.Contains(err.Error(), "not yet supported") { + t.Errorf("unexpected message: %v", err) + } +} + +// CONTROL 2: the actions that already worked are untouched. +func TestClientActionToGen_ExistingActionsUnchanged(t *testing.T) { + for _, a := range []pages.ClientAction{ + &pages.SaveChangesClientAction{BaseElement: model.BaseElement{ID: "a"}}, + &pages.ClosePageClientAction{BaseElement: model.BaseElement{ID: "b"}}, + &pages.DeleteClientAction{BaseElement: model.BaseElement{ID: "c"}}, + } { + if _, err := clientActionToGen(a); err != nil { + t.Errorf("%T was refused: %v", a, err) + } + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index f96cab77b3..d758689c59 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1168,6 +1168,8 @@ func renderClientActionMDL(ctx *ExecContext, action map[string]any) string { case "Forms$SetTaskOutcomeClientAction", "Pages$SetTaskOutcomeClientAction": outcomeValue, _ := action["OutcomeValue"].(string) return "complete_task '" + strings.ReplaceAll(outcomeValue, "'", "''") + "'" + case "Forms$SignOutClientAction", "Pages$SignOutClientAction": + return "sign_out" case "Forms$NoClientAction", "Pages$NoClientAction": return "" default: diff --git a/sdk/mpr/writer_widgets_action.go b/sdk/mpr/writer_widgets_action.go index c7ec8942db..cdb79b6dd4 100644 --- a/sdk/mpr/writer_widgets_action.go +++ b/sdk/mpr/writer_widgets_action.go @@ -53,6 +53,21 @@ func serializeClientAction(action pages.ClientAction) bson.D { {Key: "$Type", Value: "Forms$DeleteClientAction"}, {Key: "ClosePage", Value: a.ClosePage}, } + case *pages.SignOutClientAction: + // Until this case existed, SIGN_OUT fell through to the default below + // and was written as Forms$NoAction — so the button rendered, said + // "Sign out", and did nothing, with `mxcli check`, `exec` and `mx check` + // all clean. That made the documented workaround for the modelsdk + // engine's refusal ("rerun with MXCLI_ENGINE=legacy") the more dangerous + // of the two paths (CapTrackV2 FINDINGS §10). + // + // One property, pinned against a Studio Pro-authored button (ako/TestApp, + // Mendix 11): DisabledDuringExecution, true. + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, + {Key: "$Type", Value: "Forms$SignOutClientAction"}, + {Key: "DisabledDuringExecution", Value: true}, + } case *pages.CreateObjectClientAction: // Build EntityRef if entity is specified var entityRef any diff --git a/sdk/mpr/writer_widgets_action_test.go b/sdk/mpr/writer_widgets_action_test.go index 6b6b5de46e..d534e2c5f9 100644 --- a/sdk/mpr/writer_widgets_action_test.go +++ b/sdk/mpr/writer_widgets_action_test.go @@ -174,3 +174,68 @@ func TestPageClientAction_RequiredFields(t *testing.T) { t.Error("TitleOverride key missing entirely; Studio Pro writes it as an explicit null") } } + +// CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by +// the default modelsdk engine with "client action *pages.SignOutClientAction +// not yet supported … rerun with MXCLI_ENGINE=legacy". +// +// That advice was the more dangerous of the two paths. The legacy writer had no +// case for the action either, so it fell through to the default below and wrote +// Forms$NoAction: the button rendered, said "Sign out", and did nothing, with +// `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13 — +// `describe page` came back `actionbutton btnOut (Caption: 'Sign out')`, no +// action at all, and the stored BSON held Forms$NoAction. +// +// The shape is pinned against a Studio Pro-authored sign-out button +// (ako/TestApp), which is provably Studio Pro's rather than mxcli's: until this +// change NEITHER engine could emit the type. +func TestSignOutClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { + doc := serializeClientAction(&pages.SignOutClientAction{ + BaseElement: model.BaseElement{ID: "action-id"}, + }) + if doc == nil { + t.Fatal("serializeClientAction returned nil") + } + + got := map[string]any{} + for _, e := range doc { + got[e.Key] = e.Value + } + + if got["$Type"] == "Forms$NoAction" { + t.Fatal("SIGN_OUT was written as Forms$NoAction — the button renders and does nothing, " + + "which check, exec and mx check all report as fine") + } + if got["$Type"] != "Forms$SignOutClientAction" { + t.Errorf("$Type = %v, want Forms$SignOutClientAction", got["$Type"]) + } + if got["DisabledDuringExecution"] != true { + t.Errorf("DisabledDuringExecution = %v, want true (the Studio Pro reference's only property)", + got["DisabledDuringExecution"]) + } + // The reference carries exactly these three keys and no more. An extra + // property is what Studio Pro refuses to open even when mxbuild accepts it. + if len(doc) != 3 { + t.Errorf("the action has %d keys, want 3 ($ID, $Type, DisabledDuringExecution): %v", len(doc), doc) + } +} + +// CONTROL: the default branch still exists and still yields Forms$NoAction, so +// this test proves something about SIGN_OUT rather than about the fallback being +// removed. OPEN_LINK is the action that still lands there — see FINDINGS §10. +func TestUnhandledClientActionStillFallsBackToNoAction(t *testing.T) { + doc := serializeClientAction(&pages.LinkClientAction{ + BaseElement: model.BaseElement{ID: "link-id"}, + Address: "https://example.com", + }) + var typeName string + for _, e := range doc { + if e.Key == "$Type" { + typeName, _ = e.Value.(string) + } + } + if typeName != "Forms$NoAction" { + t.Errorf("$Type = %q; this control pins the fallback SIGN_OUT used to hit, "+ + "so that the test above cannot pass for the wrong reason", typeName) + } +} From 3bd242c410c520b16d1bd96f1ac114713a536f86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 08:49:26 +0000 Subject: [PATCH 2/3] feat(pages): write OPEN_LINK instead of dropping it to Forms$NoAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ACTIONBUTTON … (Action: OPEN_LINK 'https://…')` reached storage on neither engine. modelsdk refused it; legacy fell through to its QUIET default and wrote Forms$NoAction, so the button rendered, said "Docs", and did nothing — with `mxcli check`, `exec` and `mx check` all clean. Same defect as the SIGN_OUT case in the previous commit, and the syntax help listed the action as available either way. Two traps here that a Studio Pro reference settled and reasoning would not. The STORAGE NAME is Forms$OpenLinkClientAction. The semantic type is LinkClientAction and the executor stamped "Forms$LinkClientAction", which is not what Mendix stores — a wrong $Type that never reached disk only because nothing could write the action at all. And the address is not a string property: it is a nested Forms$StaticOrDynamicString. Pinned against 31 Studio Pro-authored link buttons (ako/TestApp, FeedbackModule) — exactly five keys, LinkType "Web" in all 31: { "$Type": "Forms$OpenLinkClientAction", "Address": { "$Type": "Forms$StaticOrDynamicString", "AttributeRef": null, "IsDynamic": false, "Value": "https://www.mendix.com/" }, "DisabledDuringExecution": true, "LinkType": "Web" } 6 of those 31 are DYNAMIC — the address is read from an attribute at runtime. MDL cannot author that, so DESCRIBE flags such a button rather than printing its address as a literal, which would round-trip into a different link. gen declares a fourth property on Forms$StaticOrDynamicString, `Attribute`, that not one of the 31 documents carries. It is deliberately left unset: writing a key Mendix does not store is what produces a document mxbuild accepts and Studio Pro cannot open. The previous commit's "still unimplemented" controls named LinkClientAction, which stops being a valid control the moment this lands — they now name ShowHomePageClientAction, which has no gen type, no metamodel counterpart and no MDL statement that builds one, so it is structurally unwritable rather than merely not yet written. Both engines emit the same document; mx check 0 errors on each. Reported as CapTrackV2 FINDINGS §10. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- cmd/mxcli/syntax/features_page.go | 7 +- .../captrack-10-open-link-action.mdl | 53 ++++++++++++++ mdl/backend/modelsdk/widget_write.go | 44 ++++++++++++ .../modelsdk/widget_write_signout_test.go | 72 +++++++++++++++++-- mdl/executor/cmd_pages_builder_v3.go | 8 ++- mdl/executor/cmd_pages_describe_output.go | 19 +++++ sdk/mpr/writer_widgets_action.go | 27 +++++++ sdk/mpr/writer_widgets_action_test.go | 61 +++++++++++++--- 8 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 mdl-examples/bug-tests/captrack-10-open-link-action.mdl diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 0eab96e28f..9522aea0c3 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -140,7 +140,7 @@ func init() { "button style", "primary", "danger", "success", "icon", "linkbutton", "link button", }, - Syntax: "Action: SAVE_CHANGES\nAction: SAVE_CHANGES CLOSE_PAGE -- save, then close the pop-up\nAction: CANCEL_CHANGES\nAction: CANCEL_CHANGES CLOSE_PAGE\nAction: CLOSE_PAGE\nAction: DELETE\nAction: DELETE CLOSE_PAGE\nAction: DELETE_OBJECT\nAction: NANOFLOW Module.NF\nAction: OPEN_LINK 'https://example.com'\nAction: SIGN_OUT\nAction: COMPLETE_TASK 'OutcomeName'\nAction: SHOW_PAGE Module.Page\nAction: SHOW_PAGE Module.Page(Param: $currentObject)\nAction: MICROFLOW Module.MF\nAction: MICROFLOW Module.MF(Param: $val)\nAction: CREATE_OBJECT Module.Entity THEN SHOW_PAGE Module.Page\n\nA SHOW_PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\n\nOPEN_LINK parses but is written by NEITHER engine: the modelsdk engine\nrefuses it, and the legacy engine writes Forms$NoAction, so the button\nrenders and does nothing (measured on 11.13). Call a nanoflow that opens\nthe URL instead. SIGN_OUT is written by both.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\nIcon: 'Module.IconCollection.IconName' -- e.g. 'Atlas_Core.Atlas_Filled.pencil'\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", + Syntax: "Action: SAVE_CHANGES\nAction: SAVE_CHANGES CLOSE_PAGE -- save, then close the pop-up\nAction: CANCEL_CHANGES\nAction: CANCEL_CHANGES CLOSE_PAGE\nAction: CLOSE_PAGE\nAction: DELETE\nAction: DELETE CLOSE_PAGE\nAction: DELETE_OBJECT\nAction: NANOFLOW Module.NF\nAction: OPEN_LINK 'https://example.com'\nAction: SIGN_OUT\nAction: COMPLETE_TASK 'OutcomeName'\nAction: SHOW_PAGE Module.Page\nAction: SHOW_PAGE Module.Page(Param: $currentObject)\nAction: MICROFLOW Module.MF\nAction: MICROFLOW Module.MF(Param: $val)\nAction: CREATE_OBJECT Module.Entity THEN SHOW_PAGE Module.Page\n\nA SHOW_PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\n\nOPEN_LINK takes a static web address and stores it as a\nForms$StaticOrDynamicString. Mendix also supports a DYNAMIC address, read\nfrom an attribute at runtime; MDL cannot author that one, and DESCRIBE\nflags such a button rather than printing its address as a literal.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\nIcon: 'Module.IconCollection.IconName' -- e.g. 'Atlas_Core.Atlas_Filled.pencil'\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", Example: "ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\nACTIONBUTTON btnEdit (Caption: 'Edit',\n Action: SHOW_PAGE Module.EditPage(Item: $currentObject))\nLINKBUTTON btnDelete (Caption: 'Delete', Action: DELETE,\n Icon: 'Atlas_Core.Atlas_Filled.pencil')", SeeAlso: []string{"page.widgets"}, }) @@ -375,7 +375,7 @@ func init() { "menu", "menus", "menu document", "menu item", }, Syntax: "CREATE [OR MODIFY] MENU Module.Name [FOLDER 'path'] (\n" + - " MENU ITEM '' [PAGE Module.Page | MICROFLOW Module.Flow] [ICON Module.Collection.name];\n" + + " MENU ITEM '' [PAGE Module.Page | MICROFLOW Module.Flow | SIGN_OUT] [ICON Module.Collection.name];\n" + " MENU '' [ICON Module.Collection.name] ( );\n" + ");\n" + "DESCRIBE MENU Module.Name;\n" + @@ -394,6 +394,9 @@ func init() { "-- SHOW NAVIGATION MENU and ALTER NAVIGATION. Both use these same items.\n" + "-- * OR MODIFY replaces the item list wholesale; an omitted item is removed.\n" + "-- The document's identity and export level are preserved.\n" + + "-- * SIGN_OUT is the log-out menu item. It needs no target and stores the\n" + + "-- same Forms$SignOutClientAction a sign-out BUTTON carries. Works both\n" + + "-- here and in a navigation profile's menu.\n" + "-- * ICON names an icon collection entry. A glyph or image icon cannot be\n" + "-- expressed in MDL; DESCRIBE flags those rather than dropping them silently.\n" + "-- * A page with required parameters cannot be opened from a menu item\n" + diff --git a/mdl-examples/bug-tests/captrack-10-open-link-action.mdl b/mdl-examples/bug-tests/captrack-10-open-link-action.mdl new file mode 100644 index 0000000000..3f736ec467 --- /dev/null +++ b/mdl-examples/bug-tests/captrack-10-open-link-action.mdl @@ -0,0 +1,53 @@ +-- The other half of the same defect as SIGN_OUT (CapTrackV2 FINDINGS §10): +-- `ACTIONBUTTON … (Action: OPEN_LINK '…')` was written by NEITHER engine. +-- +-- modelsdk: refused — "client action *pages.LinkClientAction not yet +-- supported by the modelsdk engine" +-- legacy: fell through to the quiet default and wrote Forms$NoAction, so +-- the button rendered and did nothing, with `mxcli check`, `exec` +-- and `mx check` all clean. +-- +-- Two things a reference settles that reasoning would not. The STORAGE NAME is +-- Forms$OpenLinkClientAction, not the "Forms$LinkClientAction" the semantic type +-- carried — a wrong $Type that never reached disk only because nothing could +-- write the action. And the address is not a string field: it is a nested +-- Forms$StaticOrDynamicString. +-- +-- Pinned against 31 Studio Pro-authored link buttons (ako/TestApp, +-- FeedbackModule): exactly five keys, LinkType "Web" in all 31. +-- +-- { "$Type": "Forms$OpenLinkClientAction", +-- "Address": { "$Type": "Forms$StaticOrDynamicString", +-- "AttributeRef": null, "IsDynamic": false, +-- "Value": "https://www.mendix.com/" }, +-- "DisabledDuringExecution": true, +-- "LinkType": "Web" } +-- +-- 6 of those 31 are DYNAMIC (IsDynamic true, an AttributeRef, an empty Value) — +-- the address is read from an attribute at runtime. MDL cannot author that, so +-- DESCRIBE flags such a button instead of printing its address as a literal, +-- which would round-trip into a different link. +-- +-- Verify: +-- mxcli exec captrack-10-open-link-action.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page OpenLink.P_Links" +-- -- must emit: Action: open_link 'https://example.com' +-- mx check -p app.mpr -- 0 errors + +create module OpenLink; +/ + +create or replace page OpenLink.P_Links + (Title: 'Links', Layout: Atlas_Core.Atlas_Default) +{ + container cLinks { + actionbutton btnDocs (Caption: 'Docs', Action: open_link 'https://example.com') + + -- A link button is the render mode a URL usually wants. + linkbutton lnkHome (Caption: 'Mendix', Action: open_link 'https://www.mendix.com/') + + -- CONTROL: the actions that already worked are untouched. + actionbutton btnClose (Caption: 'Close', Action: close_page) + } +} +/ diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index c7814838bc..773e157a6b 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -1445,6 +1445,27 @@ func formSettingsToGen(pageName string) element.Element { return ps } +// staticAddressToGen builds the Forms$StaticOrDynamicString an open-link action +// nests as its Address. +// +// Only the STATIC form is authored: MDL spells `OPEN_LINK 'https://…'` and has +// no syntax for the dynamic one. 6 of the 31 Studio Pro references are dynamic +// (IsDynamic true, an AttributeRef, an empty Value) — DESCRIBE flags those +// rather than rendering them as a literal, because a dynamic address printed as +// a static one round-trips into a different link. +// +// AttributeRef is deliberately left unset. gen declares a fourth property here, +// `Attribute`, that not one of the 31 documents carries; writing a key Mendix +// does not store is what makes a document mxbuild accepts and Studio Pro cannot +// open (CLAUDE.md, "Overlay Writes: Never Invent a Key"). +func staticAddressToGen(address string) element.Element { + s := genPg.NewStaticOrDynamicString() + assignID(s) + s.SetIsDynamic(false) + s.SetValue(address) + return s +} + // clientActionToGen converts a widget client action. Simple actions are supported; // the page/microflow/nanoflow/create-object actions (which carry settings sub- // objects) are refused loudly for now. @@ -1483,6 +1504,29 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) { g.SetNumberOfPagesToClose2("") g.SetPageSettings(formSettingsToGen(x.PageName)) return g, nil + case *pages.LinkClientAction: + // open_link → Forms$OpenLinkClientAction. Note the storage name: the + // semantic type is LinkClientAction and the executor stamped it + // "Forms$LinkClientAction", which is not what Mendix stores — a wrong + // $Type that never reached disk only because neither engine could write + // the action at all. + // + // Pinned against 31 Studio Pro-authored link buttons (ako/TestApp, + // FeedbackModule): exactly five keys, LinkType "Web" in all 31, and the + // address nested as a Forms$StaticOrDynamicString. + g := genPg.NewOpenLinkClientAction() + if x.ID != "" { + g.SetID(element.ID(x.ID)) + } + assignID(g) + g.SetDisabledDuringExecution(true) + linkType := string(x.LinkType) + if linkType == "" { + linkType = "Web" + } + g.SetLinkType(linkType) + g.SetAddress(staticAddressToGen(x.Address)) + return g, nil case *pages.SignOutClientAction: // sign_out → Forms$SignOutClientAction. One property, and the reference // pins its value: a Studio Pro-authored sign-out button (ako/TestApp, diff --git a/mdl/backend/modelsdk/widget_write_signout_test.go b/mdl/backend/modelsdk/widget_write_signout_test.go index 62dcc8cf58..a454018d11 100644 --- a/mdl/backend/modelsdk/widget_write_signout_test.go +++ b/mdl/backend/modelsdk/widget_write_signout_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages" "github.com/mendixlabs/mxcli/model" + genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages" "github.com/mendixlabs/mxcli/sdk/pages" ) @@ -49,21 +49,81 @@ func TestClientActionToGen_SignOut(t *testing.T) { } // CONTROL 1: an action that is still unimplemented must still be REFUSED, not -// quietly written. Without this the test above could pass because the default +// quietly written. Without this the tests above could pass because the default // branch had been softened, which is the exact failure the legacy engine had. +// +// ShowHomePage is the stand-in: gen has no type for it, generated/metamodel has +// no Pages counterpart, and no MDL statement builds one — so it is a semantic +// type nothing can write, which is precisely what this control needs. (The +// earlier draft used LinkClientAction, which stopped being a valid control the +// moment OPEN_LINK was implemented.) func TestClientActionToGen_StillRefusesWhatItCannotWrite(t *testing.T) { - _, err := clientActionToGen(&pages.LinkClientAction{ - BaseElement: model.BaseElement{ID: "link-id"}, - Address: "https://example.com", + _, err := clientActionToGen(&pages.ShowHomePageClientAction{ + BaseElement: model.BaseElement{ID: "home-id"}, }) if err == nil { - t.Fatal("OPEN_LINK was accepted; it has no writer, so accepting it means dropping it") + t.Fatal("an action with no writer was accepted, which means dropping it") } if !strings.Contains(err.Error(), "not yet supported") { t.Errorf("unexpected message: %v", err) } } +// OPEN_LINK, the other action that used to fall through. gen calls it +// OpenLinkClientAction — the storage name differs from the semantic type's +// "Forms$LinkClientAction", a wrong $Type that never reached disk only because +// nothing could write the action at all. +// +// Pinned against 31 Studio Pro-authored link buttons (ako/TestApp, +// FeedbackModule): five keys, LinkType "Web" in all 31, address nested as a +// Forms$StaticOrDynamicString. +func TestClientActionToGen_OpenLink(t *testing.T) { + el, err := clientActionToGen(&pages.LinkClientAction{ + BaseElement: model.BaseElement{ID: "link-id"}, + LinkType: pages.LinkTypeWeb, + Address: "https://example.com", + }) + if err != nil { + t.Fatalf("OPEN_LINK is still refused: %v", err) + } + g, ok := el.(*genPg.OpenLinkClientAction) + if !ok { + t.Fatalf("got %T, want *pages.OpenLinkClientAction", el) + } + if g.TypeName() != "Forms$OpenLinkClientAction" { + t.Errorf("$Type = %q — Mendix stores OpenLink, not Link", g.TypeName()) + } + if g.LinkType() != "Web" { + t.Errorf("LinkType = %q, want Web", g.LinkType()) + } + addr, ok := g.Address().(*genPg.StaticOrDynamicString) + if !ok { + t.Fatalf("Address is %T, want *pages.StaticOrDynamicString", g.Address()) + } + if addr.IsDynamic() { + t.Error("IsDynamic is true; MDL authors the static form only") + } + if addr.Value() != "https://example.com" { + t.Errorf("Value = %q, want the authored URL", addr.Value()) + } +} + +// An empty LinkType must not reach storage: Mendix's enum is Call/Email/Text/Web +// and every one of the 31 references is Web, so that is the default rather than +// writing a blank a build would reject. +func TestClientActionToGen_OpenLinkDefaultsLinkType(t *testing.T) { + el, err := clientActionToGen(&pages.LinkClientAction{ + BaseElement: model.BaseElement{ID: "link-id"}, + Address: "https://example.com", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lt := el.(*genPg.OpenLinkClientAction).LinkType(); lt != "Web" { + t.Errorf("LinkType = %q, want Web", lt) + } +} + // CONTROL 2: the actions that already worked are untouched. func TestClientActionToGen_ExistingActionsUnchanged(t *testing.T) { for _, a := range []pages.ClientAction{ diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 555fdee1cd..d448c0587c 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -1449,8 +1449,12 @@ func (pb *pageBuilder) buildClientActionV3(action *ast.ActionV3) (pages.ClientAc case "openLink": return &pages.LinkClientAction{ BaseElement: model.BaseElement{ - ID: model.ID(types.GenerateID()), - TypeName: "Forms$LinkClientAction", + ID: model.ID(types.GenerateID()), + // Mendix stores this as Forms$OpenLinkClientAction — the + // storage name differs from the SDK type name, the split + // CLAUDE.md documents. The wrong value here never reached disk + // only because neither engine could write the action at all. + TypeName: "Forms$OpenLinkClientAction", }, LinkType: pages.LinkTypeWeb, Address: action.LinkURL, diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index d758689c59..64d1d55d9c 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1170,6 +1170,25 @@ func renderClientActionMDL(ctx *ExecContext, action map[string]any) string { return "complete_task '" + strings.ReplaceAll(outcomeValue, "'", "''") + "'" case "Forms$SignOutClientAction", "Pages$SignOutClientAction": return "sign_out" + case "Forms$OpenLinkClientAction", "Pages$OpenLinkClientAction": + // The address is a nested Forms$StaticOrDynamicString. MDL can spell the + // static form only; a DYNAMIC address (6 of the 31 Studio Pro references + // use one) reads its value from an attribute at runtime, so rendering it + // as a literal would round-trip into a different link. Say so instead. + addr := actionMapForKey(action, "Address") + if addr == nil { + return "open_link ''" + } + if isDynamic, _ := addr["IsDynamic"].(bool); isDynamic { + attr := "" + if ref := actionMapForKey(addr, "AttributeRef"); ref != nil { + attr, _ = ref["Attribute"].(string) + } + return "-- open_link with a dynamic address (" + attr + ") — MDL cannot author this; " + + "the button is left as-is" + } + value, _ := addr["Value"].(string) + return "open_link '" + strings.ReplaceAll(value, "'", "''") + "'" case "Forms$NoClientAction", "Pages$NoClientAction": return "" default: diff --git a/sdk/mpr/writer_widgets_action.go b/sdk/mpr/writer_widgets_action.go index cdb79b6dd4..1274b331ba 100644 --- a/sdk/mpr/writer_widgets_action.go +++ b/sdk/mpr/writer_widgets_action.go @@ -53,6 +53,33 @@ func serializeClientAction(action pages.ClientAction) bson.D { {Key: "$Type", Value: "Forms$DeleteClientAction"}, {Key: "ClosePage", Value: a.ClosePage}, } + case *pages.LinkClientAction: + // OPEN_LINK fell through to the default below and was written as + // Forms$NoAction, exactly as SIGN_OUT was — the button rendered and did + // nothing (CapTrackV2 FINDINGS §10). + // + // The storage name is Forms$OpenLinkClientAction, NOT the + // "Forms$LinkClientAction" the semantic type carries. Pinned against 31 + // Studio Pro-authored link buttons: five keys, LinkType "Web" in all 31, + // address nested as a Forms$StaticOrDynamicString whose AttributeRef is + // null for the static form MDL authors. + linkType := string(a.LinkType) + if linkType == "" { + linkType = "Web" + } + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, + {Key: "$Type", Value: "Forms$OpenLinkClientAction"}, + {Key: "Address", Value: bson.D{ + {Key: "$ID", Value: idToBsonBinary(generateUUID())}, + {Key: "$Type", Value: "Forms$StaticOrDynamicString"}, + {Key: "AttributeRef", Value: nil}, + {Key: "IsDynamic", Value: false}, + {Key: "Value", Value: a.Address}, + }}, + {Key: "DisabledDuringExecution", Value: true}, + {Key: "LinkType", Value: linkType}, + } case *pages.SignOutClientAction: // Until this case existed, SIGN_OUT fell through to the default below // and was written as Forms$NoAction — so the button rendered, said diff --git a/sdk/mpr/writer_widgets_action_test.go b/sdk/mpr/writer_widgets_action_test.go index d534e2c5f9..aaafcc660d 100644 --- a/sdk/mpr/writer_widgets_action_test.go +++ b/sdk/mpr/writer_widgets_action_test.go @@ -220,13 +220,17 @@ func TestSignOutClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { } } -// CONTROL: the default branch still exists and still yields Forms$NoAction, so -// this test proves something about SIGN_OUT rather than about the fallback being -// removed. OPEN_LINK is the action that still lands there — see FINDINGS §10. +// CONTROL: the quiet default still exists and still yields Forms$NoAction, so +// the tests here prove something about the actions they name rather than about +// the fallback having been removed. +// +// ShowHomePage is the stand-in: no MDL statement builds one, so it is a +// semantic type nothing writes. (An earlier draft used LinkClientAction, which +// stopped being a valid control the moment OPEN_LINK was implemented — a +// control has to name something still genuinely unhandled.) func TestUnhandledClientActionStillFallsBackToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.LinkClientAction{ - BaseElement: model.BaseElement{ID: "link-id"}, - Address: "https://example.com", + doc := serializeClientAction(&pages.ShowHomePageClientAction{ + BaseElement: model.BaseElement{ID: "home-id"}, }) var typeName string for _, e := range doc { @@ -235,7 +239,48 @@ func TestUnhandledClientActionStillFallsBackToNoAction(t *testing.T) { } } if typeName != "Forms$NoAction" { - t.Errorf("$Type = %q; this control pins the fallback SIGN_OUT used to hit, "+ - "so that the test above cannot pass for the wrong reason", typeName) + t.Errorf("$Type = %q; this control pins the fallback SIGN_OUT and OPEN_LINK used to hit, "+ + "so the tests above cannot pass for the wrong reason", typeName) + } +} + +// OPEN_LINK on the legacy engine, which fell to that same NoAction default. +// Pinned against the 31 Studio Pro references: five keys, and the address a +// nested Forms$StaticOrDynamicString whose AttributeRef is null for the static +// form MDL authors. +func TestOpenLinkClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { + doc := serializeClientAction(&pages.LinkClientAction{ + BaseElement: model.BaseElement{ID: "link-id"}, + LinkType: pages.LinkTypeWeb, + Address: "https://example.com", + }) + got := map[string]any{} + for _, e := range doc { + got[e.Key] = e.Value + } + if got["$Type"] != "Forms$OpenLinkClientAction" { + t.Fatalf("$Type = %v, want Forms$OpenLinkClientAction (NOT Forms$LinkClientAction, "+ + "which is the SDK name and not what Mendix stores)", got["$Type"]) + } + if got["LinkType"] != "Web" { + t.Errorf("LinkType = %v, want Web", got["LinkType"]) + } + if len(doc) != 5 { + t.Errorf("the action has %d keys, want 5: %v", len(doc), doc) + } + addr, ok := got["Address"].(bson.D) + if !ok { + t.Fatalf("Address is %T, want a nested document", got["Address"]) + } + a := map[string]any{} + for _, e := range addr { + a[e.Key] = e.Value + } + if a["$Type"] != "Forms$StaticOrDynamicString" || a["IsDynamic"] != false || + a["Value"] != "https://example.com" { + t.Errorf("Address = %v", addr) + } + if _, present := a["AttributeRef"]; !present { + t.Error("AttributeRef is absent; all 31 references carry it as null") } } From d4d562712637c47afdebb0e0fc68551d000d262e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 08:49:39 +0000 Subject: [PATCH 3/3] feat(navigation): author and read a sign-out MENU ITEM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A navigation menu can carry a log-out item. mxcli could neither author one nor read one back: authoring MDL's `menu item` took PAGE or MICROFLOW only, so there was no spelling for it at all. reading ako/TestApp's sign-out menu item came back as a plain `menu item 'Item 5';`, so DESCRIBE -> exec turned a working log-out entry into a dead one — silently, with mx check clean. Fixing the sign-out BUTTON proved nothing about this, because a menu item's action reaches storage through four switches that share no code with the button path — two writers (a standalone menu document and the menu inside a navigation profile) and two readers. All four had to be wired: menuActionToGen NoAction default modelsdk, menu document navMenuAction NoAction default raw BSON, navigation profile resolveMenuAction raw type name modelsdk read parseNavMenuItem raw type name legacy read The readers are the subtler half. Both had a fallback that stored the unmapped $Type, which LOOKS like it preserves information — ActionType became "Forms$SignOutClientAction" — while breaking the round trip, because DESCRIBE and both writers key on "SignOutAction". A round trip closes only when the reader produces the exact string the writer consumes. Studio Pro stores the same element a button carries — Forms$SignOutClientAction, DisabledDuringExecution true, nothing else — which is why SIGN_OUT sits beside PAGE and MICROFLOW rather than getting a syntax of its own. It names no target, so the visitor reads it separately from the PAGE/MICROFLOW switch; folding it in would consume a qualifiedName and mis-assign a trailing ICON. That case is in the example. Measured on ako/TestApp (Mendix 11.14), and controlled by neutralising both readers and re-reading it: before show navigation menu Responsive -> Item 5 after show navigation menu Responsive -> Item 5 -> sign out describe -> exec now puts an identical Forms$SignOutClientAction back on disk, and mx check reports 0 errors on the result. Reported as CapTrackV2 FINDINGS §10. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- .../fix-issue/findings/mdl-executor.jsonl | 2 + .../captrack-10-sign-out-menu-item.mdl | 54 ++++++++ mdl/ast/ast_navigation.go | 1 + mdl/backend/modelsdk/menu_signout_test.go | 94 +++++++++++++ mdl/backend/modelsdk/menu_write.go | 10 ++ mdl/backend/modelsdk/navigation_read.go | 6 + mdl/backend/modelsdk/navigation_write.go | 9 ++ mdl/executor/cmd_menus.go | 5 + mdl/executor/cmd_navigation.go | 6 + mdl/executor/menu_signout_test.go | 127 ++++++++++++++++++ mdl/grammar/MDLParser.g4 | 6 +- mdl/types/navigation.go | 4 + mdl/visitor/visitor_navigation.go | 5 + sdk/mpr/parser_menu_signout_test.go | 56 ++++++++ sdk/mpr/parser_misc.go | 5 + 15 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl create mode 100644 mdl/backend/modelsdk/menu_signout_test.go create mode 100644 mdl/executor/menu_signout_test.go create mode 100644 sdk/mpr/parser_menu_signout_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 47f8b44fc9..6c5d2ad1f3 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -511,3 +511,5 @@ {"area": "mdl/executor", "date": "2026-09-04", "symptom": "`dataview dv (\u2026, OnClick: SHOW_PAGE \u2026)` parses, `mxcli check` is clean, `exec` writes the page without a word \u2014 and the rendered element has no handler and no role=\"button\". The same silence on `dynamictext`, `listview` and every other widget except a container or a button.", "cause": "`OnClick:` is an ALIAS for `Action:` (both stored as Properties[\"Action\"], #603), and mxcli writes that property for three widget kinds only: container/customcontainer, the buttons, and a navigationlist item. Every other widget drops it, and the property allow-lists behind MDL-WIDGET01/07 could not see that because they are widget-type AGNOSTIC \u2014 the same blind spot as #928's `editable:`. Added MDL-WIDGET23 (warning), with two messages: Mendix models no click action at all (dataview, dynamictext, inputs, groupbox\u2026) vs Mendix models one that mxcli cannot write (listview, staticimage, dynamicimage \u2014 measured against generated/metamodel).", "file": "`mdl/executor/validate_widget_onclick.go` (new), wired in `validate_widgets.go` beside validateWidgetEditability; `.claude/skills/mendix/create-page/reference/widgets.md`; example `mdl-examples/bug-tests/captrack-21-dataview-onclick-dropped.mdl`", "insight": "The first draft reported everything OUTSIDE an allow-list of the three writers, and running it over the shipped examples flagged three of them. Cause: `mxcli check` without `-p` has no widget registry, so `lookupWidgetDef` returns nil for a PLUGGABLE widget too and the caller's \"static widgets only\" branch silently does not hold \u2014 `datagrid` is DataGrid 2, a pluggable widget whose onClick the engine does write. For any rule keyed on widget type, an allow-list makes the unknown case an ERROR and a deny-list makes it silence; pick the deny-list, because a missed warning costs nothing and a false one tells an author their working page is broken. Running a new rule across mdl-examples/ before wiring it up is what caught it \u2014 the same exercise #893 describes. Reported as CapTrackV2 FINDINGS \u00a721."} {"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ALTER SETTINGS MODEL AfterStartupMicroflow = 'Mod.MF_Seed'` accepts a microflow with no return type, `mxcli check` passes, and the build fails **CE0142** \"After startup microflow should return a boolean\". `ALTER MICROFLOW \u2026 RETURNS \u2026` does not parse either, so the remedy is DROP + CREATE.", "cause": "#274 made ALTER SETTINGS resolve the qualified names it writes, which catches a MISSPELLED microflow. Here the name resolves perfectly \u2014 the constraint is on the thing the setting names, not on the reference, and nothing looked at the return type. Added MDL073: a project-less pass (ValidateAfterStartupReturnType) for a microflow the script itself creates, which is the usual shape, plus the stored return type on the project path. Both call one function so they cannot drift. flowSignature gained ReturnKind because its existing Returns field is the entity name and cannot tell Boolean from void.", "file": "`mdl/executor/validate_settings_refs.go` (checkAfterStartupReturnsBoolean + ValidateAfterStartupReturnType), `validate_program.go`, `helpers.go` (buildMicroflowReturnTypes), `validate_datasource_args.go` (flowSignature.ReturnKind); examples `mdl-examples/bug-tests/captrack-6-after-startup-must-return-boolean{,.fail}.mdl`", "insight": "\"The reference resolves\" and \"the reference is usable\" are different questions, and a resolver answers only the first. Whenever a setting stores a NAME, ask what the platform requires of the named thing \u2014 an existence check will pass and the build will still fail, and the error arrives with no connection to the statement that caused it. The scope discipline that goes with it: only AfterStartup is type-checked, because only its rule was measured; BeforeShutdown and HealthCheck are left alone rather than constrained on a guess, and there is a control test asserting that. Reported as CapTrackV2 FINDINGS \u00a76."} {"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ACTIONBUTTON \u2026 (Action: SIGN_OUT)` is refused by the default engine \u2014 \"client action *pages.SignOutClientAction not yet supported by the modelsdk engine \u2014 rerun with MXCLI_ENGINE=legacy\" \u2014 and the suggested workaround SILENTLY produces a dead button: on legacy the action is written as `Forms$NoAction`, so it renders, says \"Sign out\", and does nothing, with `mxcli check`, `exec` and `mx check` all clean.", "cause": "Neither engine had a case for the action. modelsdk's clientActionToGen ended in a loud default; sdk/mpr's serializeClientAction ended in a QUIET one that returns Forms$NoAction for anything unmatched. Added the case to both. The document is two keys \u2014 `Forms$SignOutClientAction` + `DisabledDuringExecution: true` \u2014 pinned against a Studio Pro-authored button in ako/TestApp, plus `sign_out` in the DESCRIBE renderer so it round-trips. Both engines now emit byte-identical documents; mx check 0 errors on each. OPEN_LINK is still unwritten by both (gen calls it OpenLinkClientAction and its Address is an element, not a string) \u2014 the syntax topic now says so instead of listing it as available.", "file": "`mdl/backend/modelsdk/widget_write.go` (clientActionToGen), `sdk/mpr/writer_widgets_action.go` (serializeClientAction), `mdl/executor/cmd_pages_describe_output.go` (renderClientActionMDL), `cmd/mxcli/syntax/features_page.go`; example `mdl-examples/bug-tests/captrack-10-sign-out-action.mdl`", "insight": "When one engine refuses something and points at the other, CHECK THE OTHER before repeating the advice \u2014 the refusal is visible and the fallback is not, so the recommended escape hatch can be the strictly worse path. The structural tell is the shape of the default branch: modelsdk's raises, legacy's returns Forms$NoAction, and a silent default in a serializer converts every unimplemented type into data loss rather than an error. Grep for the fallthrough before trusting a switch. Note the control this needs: a test that SIGN_OUT is no longer NoAction can pass because someone softened the default, so pin the fallback separately with a type that is still unimplemented (OPEN_LINK). Reported as CapTrackV2 FINDINGS \u00a710."} +{"area": "mdl/executor", "date": "2026-09-04", "symptom": "A navigation menu's LOG-OUT item could not be authored and did not survive a round trip. MDL's `menu item` took PAGE or MICROFLOW only, so there was no spelling for it; and ako/TestApp's sign-out menu item read back as a plain `menu item 'Item 5';`, so DESCRIBE -> exec turned a working log-out entry into a dead one \u2014 silently, with `mx check` clean.", "cause": "A menu item's action goes through FOUR places that share no code with the button path: menuActionToGen (menu document, modelsdk), navMenuAction (navigation profile, raw BSON), resolveMenuAction (modelsdk read) and parseNavMenuItem (legacy read). Both writers ended in a NoAction default and both readers left the type name unmapped. Added SIGN_OUT to navMenuItemDef in the grammar (it consumes no qualifiedName, so it is read separately from the PAGE/MICROFLOW switch or an ICON after it is mis-assigned), carried it as ActionType \"SignOutAction\" / NavMenuItemSpec.SignOut, and wired all four. Studio Pro stores the same Forms$SignOutClientAction a button carries: DisabledDuringExecution true, nothing else.", "file": "`mdl/grammar/MDLParser.g4` (navMenuItemDef), `mdl/ast/ast_navigation.go`, `mdl/visitor/visitor_navigation.go`, `mdl/executor/cmd_menus.go` + `cmd_navigation.go` (conversion + printMenuMDL + the show summary), `mdl/types/navigation.go`, `mdl/backend/modelsdk/menu_write.go` + `navigation_write.go` + `navigation_read.go`, `sdk/mpr/parser_misc.go`; example `mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl`", "insight": "A round trip closes only if the READER produces the exact string the WRITER consumes \u2014 here both readers had a raw-type-name fallback that looked like it preserved information (ActionType became \"Forms$SignOutClientAction\") while breaking the round trip, because DESCRIBE and the writers key on \"SignOutAction\". A fallback that stores the raw name is not the same as handling the case, and it hides the gap better than a NoAction default would. Also: the same logical action reaches storage through four unrelated switches (two writers x two constructs, two readers), so fixing the button path proved nothing about the menu path \u2014 grep for every switch on the action before calling such a fix complete. Controlled by neutralising both readers and re-reading TestApp: `Item 5 -> sign out` goes back to `Item 5`."} +{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ACTIONBUTTON \u2026 (Action: OPEN_LINK 'https://\u2026')` was written by neither engine: modelsdk refused it, legacy fell through to its quiet default and wrote Forms$NoAction, so the button rendered and did nothing with check, exec and mx check all clean.", "cause": "Same missing-case defect as SIGN_OUT, but with two traps a reference settled and reasoning would not. (1) The STORAGE NAME is Forms$OpenLinkClientAction, while the semantic type is LinkClientAction and the executor stamped `Forms$LinkClientAction` \u2014 a wrong $Type that never reached disk only because nothing could write the action. (2) The address is not a string field but a nested Forms$StaticOrDynamicString. Pinned against 31 Studio Pro link buttons (ako/TestApp, FeedbackModule): exactly five keys, LinkType \"Web\" in all 31, and 6 of 31 DYNAMIC (IsDynamic true + AttributeRef + empty Value). MDL authors the static form only, so DESCRIBE flags a dynamic one instead of printing its address as a literal.", "file": "`mdl/backend/modelsdk/widget_write.go` (clientActionToGen + staticAddressToGen), `sdk/mpr/writer_widgets_action.go`, `mdl/executor/cmd_pages_builder_v3.go` ($Type), `mdl/executor/cmd_pages_describe_output.go`, `cmd/mxcli/syntax/features_page.go`; example `mdl-examples/bug-tests/captrack-10-open-link-action.mdl`", "insight": "gen declares a fourth property on Forms$StaticOrDynamicString \u2014 `Attribute` \u2014 that not one of the 31 stored documents carries. Writing it would be the 'never invent a key' failure: a document mxbuild accepts and Studio Pro cannot open. When gen offers more properties than the references show, the references win. Second lesson, about controls: the SIGN_OUT commit used LinkClientAction as its 'still unimplemented' control, and implementing OPEN_LINK silently invalidated it \u2014 the test then failed for a good reason, but a control naming a specific unimplemented feature has a shelf life. Point it at something structurally unwritable instead (ShowHomePageClientAction: no gen type, no metamodel counterpart, no MDL statement that builds one)."} diff --git a/mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl b/mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl new file mode 100644 index 0000000000..5a12bbb900 --- /dev/null +++ b/mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl @@ -0,0 +1,54 @@ +-- A navigation menu can carry a LOG-OUT item, and mxcli could neither author +-- one nor read one back. This is the menu half of CapTrackV2 FINDINGS §10; the +-- button half is captrack-10-sign-out-action.mdl. +-- +-- MDL's menu item took PAGE or MICROFLOW only, so: +-- +-- authoring: there was no spelling for it at all. +-- reading: ako/TestApp's sign-out menu item came back as a plain +-- `menu item 'Item 5';` — so DESCRIBE -> exec turned a working +-- log-out entry into a dead one, silently, `mx check` clean. +-- +-- Measured on ako/TestApp (Mendix 11.14), before and after: +-- +-- before: show navigation menu Responsive -> Item 5 +-- after: show navigation menu Responsive -> Item 5 -> sign out +-- +-- Studio Pro stores it as the same element a sign-out BUTTON carries — +-- Forms$SignOutClientAction, DisabledDuringExecution true, nothing else — which +-- is why SIGN_OUT sits beside PAGE and MICROFLOW rather than having a syntax of +-- its own. It needs no target. +-- +-- Both menu constructs are covered: a standalone menu document and the menu +-- inside a navigation profile. They have separate writers and separate readers, +-- and BOTH ended in a NoAction default. +-- +-- Verify: +-- mxcli exec captrack-10-sign-out-menu-item.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe menu SignOutMenu.Account_Menu" +-- -- must emit: menu item 'Sign out' sign_out; +-- mx check -p app.mpr -- 0 errors + +create module SignOutMenu; +/ +create or replace page SignOutMenu.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default) +{ + dynamictext t (Content: 'home') +} +/ + +create or modify menu SignOutMenu.Account_Menu ( + menu item 'Home' page SignOutMenu.Home; + + -- The item that could not be spelled. + menu item 'Sign out' sign_out; + + -- SIGN_OUT names no target, so it must not disturb the qualified-name list it + -- shares with PAGE/MICROFLOW and ICON — an icon after it still lands as one. + menu item 'Log out' sign_out icon Atlas_Core.Atlas.home; + + -- CONTROL: an item with no action must stay actionless. A conversion that + -- stamped every actionless item as sign-out would look correct above. + menu item 'Plain'; +); +/ diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go index 8dd26a300a..af81e8f317 100644 --- a/mdl/ast/ast_navigation.go +++ b/mdl/ast/ast_navigation.go @@ -28,6 +28,7 @@ type NavMenuItemDef struct { Caption string // from STRING_LITERAL Page *QualifiedName // PAGE target Microflow *QualifiedName // MICROFLOW target + SignOut bool // SIGN_OUT — the third action a menu item can carry Icon string // ICON 'Module.Collection.name', empty for none Items []NavMenuItemDef // Sub-items (for MENU 'caption' (...)) } diff --git a/mdl/backend/modelsdk/menu_signout_test.go b/mdl/backend/modelsdk/menu_signout_test.go new file mode 100644 index 0000000000..74b2eba42a --- /dev/null +++ b/mdl/backend/modelsdk/menu_signout_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" + genPages "github.com/mendixlabs/mxcli/modelsdk/gen/pages" +) + +// A sign-out MENU ITEM reaches storage through two writers that do not share +// code with the button path: menuActionToGen for a standalone menu document, +// and navMenuAction for the menu inside a navigation profile. Both ended in a +// NoAction default, so both silently produced a dead entry. +// +// Studio Pro stores the same element a button carries — measured on +// ako/TestApp's sign-out menu item: Forms$SignOutClientAction with +// DisabledDuringExecution true and nothing else. + +func TestMenuActionToGen_SignOut(t *testing.T) { + el := menuActionToGen(&types.NavMenuItem{Caption: "Sign out", ActionType: "SignOutAction"}) + g, ok := el.(*genPages.SignOutClientAction) + if !ok { + t.Fatalf("got %T, want *pages.SignOutClientAction — a menu item's sign-out fell to NoAction", el) + } + if !g.DisabledDuringExecution() { + t.Error("DisabledDuringExecution is false; the Studio Pro reference stores true") + } +} + +// CONTROL: the two actions that already worked, plus the actionless item, are +// unchanged. A writer that answered SignOut for everything would pass the test +// above. +func TestMenuActionToGen_OtherItemsUnchanged(t *testing.T) { + cases := []struct { + name string + item *types.NavMenuItem + want string + }{ + {"page", &types.NavMenuItem{Page: "M.P"}, "Forms$FormAction"}, + {"microflow", &types.NavMenuItem{Microflow: "M.MF"}, "Forms$MicroflowAction"}, + {"plain", &types.NavMenuItem{Caption: "Plain"}, "Forms$NoAction"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := menuActionToGen(c.item).TypeName(); got != c.want { + t.Errorf("$Type = %q, want %q", got, c.want) + } + }) + } +} + +// The navigation-profile writer is the other half, and it builds raw BSON. +func TestNavMenuAction_SignOut(t *testing.T) { + doc := navMenuAction(types.NavMenuItemSpec{Caption: "Sign out", SignOut: true}) + got := map[string]any{} + for _, e := range doc { + got[e.Key] = e.Value + } + if got["$Type"] != "Forms$SignOutClientAction" { + t.Fatalf("$Type = %v, want Forms$SignOutClientAction", got["$Type"]) + } + if got["DisabledDuringExecution"] != true { + t.Errorf("DisabledDuringExecution = %v, want true", got["DisabledDuringExecution"]) + } + if len(doc) != 3 { + t.Errorf("the action has %d keys, want 3 ($ID, $Type, DisabledDuringExecution): %v", len(doc), doc) + } +} + +// CONTROL: an item with no action still writes Forms$NoAction, so the test +// above proves something about SIGN_OUT rather than about the default going +// away. +func TestNavMenuAction_PlainItemStillNoAction(t *testing.T) { + doc := navMenuAction(types.NavMenuItemSpec{Caption: "Plain"}) + for _, e := range doc { + if e.Key == "$Type" && e.Value != "Forms$NoAction" { + t.Errorf("$Type = %v, want Forms$NoAction", e.Value) + } + } +} + +// The reader has to produce the name the writers consume, or the round trip +// does not close. That pairing is the actual fix: reading TestApp's item as a +// raw type name would have described it as a plain item. +func TestResolveMenuAction_SignOutUsesTheWriterSName(t *testing.T) { + item := &types.NavMenuItem{} + resolveMenuAction(item, genPages.NewSignOutClientAction()) + if item.ActionType != "SignOutAction" { + t.Errorf("ActionType = %q, want SignOutAction — the writers key on that exact string", + item.ActionType) + } +} diff --git a/mdl/backend/modelsdk/menu_write.go b/mdl/backend/modelsdk/menu_write.go index 80b5a9d5e3..0f6e4869e1 100644 --- a/mdl/backend/modelsdk/menu_write.go +++ b/mdl/backend/modelsdk/menu_write.go @@ -156,6 +156,16 @@ func menuActionToGen(item *types.NavMenuItem) element.Element { ms.SetMicroflowQualifiedName(item.Microflow) a.SetMicroflowSettings(ms) return a + case item.ActionType == "SignOutAction": + // A menu item's sign-out is the same Forms$SignOutClientAction a button + // carries — measured on ako/TestApp's own sign-out menu item, which + // stores exactly DisabledDuringExecution: true and nothing else. Before + // this case it fell to NoAction below, so describe -> exec turned a + // working sign-out item into a dead one. + a := genPages.NewSignOutClientAction() + a.SetID(element.ID(mmpr.GenerateID())) + a.SetDisabledDuringExecution(true) + return a default: a := genPages.NewNoClientAction() a.SetID(element.ID(mmpr.GenerateID())) diff --git a/mdl/backend/modelsdk/navigation_read.go b/mdl/backend/modelsdk/navigation_read.go index 916978a6e2..7b98df53d5 100644 --- a/mdl/backend/modelsdk/navigation_read.go +++ b/mdl/backend/modelsdk/navigation_read.go @@ -296,6 +296,12 @@ func resolveMenuAction(item *types.NavMenuItem, action element.Element) { if ms, ok := a.MicroflowSettings().(*genPages.MicroflowSettings); ok && ms != nil { item.Microflow = ms.MicroflowQualifiedName() } + case *genPages.SignOutClientAction: + // Named explicitly rather than falling to the raw-type-name default + // below: DESCRIBE and the writer both key on "SignOutAction", and a + // round trip only closes if the reader produces the name the writer + // consumes. + item.ActionType = "SignOutAction" default: t := action.TypeName() switch { diff --git a/mdl/backend/modelsdk/navigation_write.go b/mdl/backend/modelsdk/navigation_write.go index c200d97549..1fd184d02e 100644 --- a/mdl/backend/modelsdk/navigation_write.go +++ b/mdl/backend/modelsdk/navigation_write.go @@ -341,6 +341,15 @@ func navMenuAction(mi types.NavMenuItemSpec) bson.D { }}, } } + if mi.SignOut { + // Same element a sign-out BUTTON carries, pinned against the sign-out + // menu item in ako/TestApp: two properties and nothing else. + return bson.D{ + {Key: "$ID", Value: navID()}, + {Key: "$Type", Value: "Forms$SignOutClientAction"}, + {Key: "DisabledDuringExecution", Value: true}, + } + } return bson.D{ {Key: "$ID", Value: navID()}, {Key: "$Type", Value: "Forms$NoAction"}, diff --git a/mdl/executor/cmd_menus.go b/mdl/executor/cmd_menus.go index 21fc9dcb02..d985ab06cf 100644 --- a/mdl/executor/cmd_menus.go +++ b/mdl/executor/cmd_menus.go @@ -148,6 +148,11 @@ func menuItemsFromAST(defs []ast.NavMenuItemDef) []*types.NavMenuItem { } else if d.Microflow != nil { item.Microflow = d.Microflow.String() item.ActionType = "MicroflowAction" + } else if d.SignOut { + // Studio Pro stores a sign-out menu item as the same + // Forms$SignOutClientAction a button carries (measured on + // ako/TestApp), so it is an ActionType rather than a target. + item.ActionType = "SignOutAction" } else { item.ActionType = "NoAction" } diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 9d9a32f358..5c6a36c201 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -126,6 +126,7 @@ func convertMenuItemDef(def ast.NavMenuItemDef) types.NavMenuItemSpec { if def.Microflow != nil { spec.Microflow = def.Microflow.String() } + spec.SignOut = def.SignOut for _, sub := range def.Items { spec.Items = append(spec.Items, convertMenuItemDef(sub)) } @@ -383,6 +384,9 @@ func menuItemTarget(item *types.NavMenuItem) string { if item.Microflow != "" { return " -> MF:" + item.Microflow } + if item.ActionType == "SignOutAction" { + return " -> sign out" + } return "" } @@ -402,6 +406,8 @@ func printMenuMDL(w io.Writer, items []*types.NavMenuItem, depth int, reproducer fmt.Fprintf(w, "%smenu item '%s' page %s%s;\n", indent, item.Caption, item.Page, icon) } else if item.Microflow != "" { fmt.Fprintf(w, "%smenu item '%s' microflow %s%s;\n", indent, item.Caption, item.Microflow, icon) + } else if item.ActionType == "SignOutAction" { + fmt.Fprintf(w, "%smenu item '%s' sign_out%s;\n", indent, item.Caption, icon) } else { fmt.Fprintf(w, "%smenu item '%s'%s;\n", indent, item.Caption, icon) } diff --git a/mdl/executor/menu_signout_test.go b/mdl/executor/menu_signout_test.go new file mode 100644 index 0000000000..06fc80c948 --- /dev/null +++ b/mdl/executor/menu_signout_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// A navigation menu can carry a log-out item, and mxcli could neither author one +// nor read one back. MDL's menu item took PAGE or MICROFLOW only, so: +// +// - authoring: there was no spelling for it at all; +// - reading: ako/TestApp's sign-out menu item ("Item 5") came back as a +// plain `menu item 'Item 5';`, so DESCRIBE -> exec turned a working +// sign-out into a dead menu entry — silently, with `mx check` clean. +// +// Studio Pro stores it as the same Forms$SignOutClientAction a BUTTON carries +// (measured on that item): DisabledDuringExecution true, and nothing else. + +func signOutMenuStmt(t *testing.T, src string) *ast.CreateMenuStmt { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors for %q: %v", src, errs) + } + stmt, ok := prog.Statements[0].(*ast.CreateMenuStmt) + if !ok { + t.Fatalf("got %T, want *ast.CreateMenuStmt", prog.Statements[0]) + } + return stmt +} + +// The spelling that did not exist. +func TestMenuItem_SignOutParses(t *testing.T) { + stmt := signOutMenuStmt(t, `create or modify menu M.Main ( + menu item 'Sign out' sign_out; +);`) + if len(stmt.Items) != 1 { + t.Fatalf("got %d items, want 1", len(stmt.Items)) + } + if !stmt.Items[0].SignOut { + t.Error("SIGN_OUT did not reach the AST") + } +} + +// SIGN_OUT names no target, so it must not disturb the qualifiedName list that +// PAGE/MICROFLOW and ICON share — an icon after it still has to land as an icon. +func TestMenuItem_SignOutWithAnIcon(t *testing.T) { + stmt := signOutMenuStmt(t, `create or modify menu M.Main ( + menu item 'Sign out' sign_out icon Atlas_Core.Atlas.home; +);`) + item := stmt.Items[0] + if !item.SignOut { + t.Error("SignOut lost when an icon follows") + } + if item.Icon != "Atlas_Core.Atlas.home" { + t.Errorf("Icon = %q, want the collection entry — SIGN_OUT must consume no qualifiedName", item.Icon) + } +} + +// The menu-document path: AST -> semantic model. +func TestMenuItemsFromAST_SignOutBecomesAnActionType(t *testing.T) { + items := menuItemsFromAST([]ast.NavMenuItemDef{ + {Caption: "Sign out", SignOut: true}, + {Caption: "Plain"}, + }) + if items[0].ActionType != "SignOutAction" { + t.Errorf("ActionType = %q, want SignOutAction", items[0].ActionType) + } + // CONTROL: an item with no action is still NoAction. A conversion that + // stamped every actionless item as sign-out would satisfy the line above. + if items[1].ActionType != "NoAction" { + t.Errorf("a plain item became %q", items[1].ActionType) + } +} + +// The navigation-profile path uses a different spec type and its own converter. +func TestConvertMenuItemDef_CarriesSignOut(t *testing.T) { + spec := convertMenuItemDef(ast.NavMenuItemDef{Caption: "Sign out", SignOut: true}) + if !spec.SignOut { + t.Error("SignOut did not reach NavMenuItemSpec") + } + // CONTROL: the two existing targets are untouched. + page := ast.QualifiedName{Module: "M", Name: "P"} + if got := convertMenuItemDef(ast.NavMenuItemDef{Caption: "Home", Page: &page}); got.Page != "M.P" || got.SignOut { + t.Errorf("a page item came back as %+v", got) + } +} + +// DESCRIBE must emit the spelling exec accepts, or the round trip does not +// close — which is the half that lost TestApp's item. +func TestPrintMenuMDL_RendersSignOut(t *testing.T) { + var b bytes.Buffer + printMenuMDL(&b, []*types.NavMenuItem{ + {Caption: "Sign out", ActionType: "SignOutAction"}, + {Caption: "Plain", ActionType: "NoAction"}, + }, 0, "CREATE NAVIGATION") + + out := b.String() + if !strings.Contains(out, "menu item 'Sign out' sign_out;") { + t.Errorf("describe output does not round-trip the sign-out item:\n%s", out) + } + // CONTROL: a plain item must not gain an action. + if strings.Contains(out, "'Plain' sign_out") { + t.Errorf("a plain item was rendered as sign-out:\n%s", out) + } +} + +// The whole point, end to end in one test: describe output must parse back to +// the same thing. A renderer and a parser can each be individually right and +// still not agree. +func TestMenuItem_SignOutRoundTripsThroughDescribe(t *testing.T) { + var b bytes.Buffer + printMenuMDL(&b, []*types.NavMenuItem{{Caption: "Sign out", ActionType: "SignOutAction"}}, + 0, "CREATE NAVIGATION") + + stmt := signOutMenuStmt(t, "create or modify menu M.Main (\n"+b.String()+");") + if !stmt.Items[0].SignOut { + t.Errorf("describe emitted %q, which does not parse back as a sign-out item", b.String()) + } +} diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index b6d1bb06c6..2725087072 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -337,8 +337,12 @@ navigationClause // those segments are double-quoted the same way a keyword-colliding name is: // ICON Atlas_Core.Atlas.home // ICON Atlas_Core.Atlas."align-center" +// SIGN_OUT is the third action a menu item can carry. Studio Pro writes it as +// the same Forms$SignOutClientAction a button uses (measured on ako/TestApp), +// which is why it sits beside PAGE and MICROFLOW rather than in a syntax of its +// own. navMenuItemDef - : MENU_KW ITEM STRING_LITERAL ((PAGE qualifiedName) | (MICROFLOW qualifiedName))? (ICON qualifiedName)? SEMICOLON? + : MENU_KW ITEM STRING_LITERAL ((PAGE qualifiedName) | (MICROFLOW qualifiedName) | SIGN_OUT)? (ICON qualifiedName)? SEMICOLON? | MENU_KW STRING_LITERAL (ICON qualifiedName)? LPAREN navMenuItemDef* RPAREN SEMICOLON? ; diff --git a/mdl/types/navigation.go b/mdl/types/navigation.go index 8fd7041171..4b55c2d277 100644 --- a/mdl/types/navigation.go +++ b/mdl/types/navigation.go @@ -107,6 +107,10 @@ type NavMenuItemSpec struct { Caption string Page string Microflow string + // SignOut is the third action a menu item can carry. Studio Pro stores it + // as the same Forms$SignOutClientAction a button uses, so it needs no + // target — which is why it is a flag rather than another name field. + SignOut bool // Icon is a qualified icon-collection name (Atlas_Core.Atlas.home). Empty // means no icon, which serializes as a null Icon. Icon string diff --git a/mdl/visitor/visitor_navigation.go b/mdl/visitor/visitor_navigation.go index 10de013c7d..460a529ed4 100644 --- a/mdl/visitor/visitor_navigation.go +++ b/mdl/visitor/visitor_navigation.go @@ -108,6 +108,11 @@ func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef { item.Microflow = &built next++ } + // SIGN_OUT names no target, so it consumes none of the qualifiedName list — + // which is why it is read separately rather than as a third switch arm. + if c.SIGN_OUT() != nil { + item.SignOut = true + } if c.ICON() != nil && len(names) > next { item.Icon = buildQualifiedName(names[next]).String() } diff --git a/sdk/mpr/parser_menu_signout_test.go b/sdk/mpr/parser_menu_signout_test.go new file mode 100644 index 0000000000..ed85a207dc --- /dev/null +++ b/sdk/mpr/parser_menu_signout_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import "testing" + +// menuItemRaw builds the minimum a Menus$MenuItem needs to parse: a caption with +// a real translation (parseNavMenuItem deliberately returns nil for an item with +// no caption, no page and no children) plus the action under test. +func menuItemRaw(actionType string) map[string]any { + return map[string]any{ + "Caption": map[string]any{ + "$Type": "Texts$Text", + "Items": []any{ + int32(3), + map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": "Sign out"}, + }, + }, + "Action": map[string]any{"$Type": actionType}, + } +} + +// The legacy reader is the other half of reading a sign-out MENU ITEM back. +// Before this case it fell to the raw-type-name default, so the item was +// described as a plain `menu item 'x';` and DESCRIBE -> exec turned ako/TestApp's +// working sign-out entry into a dead one — silently, with mx check clean. +func TestParseNavMenuItem_SignOut(t *testing.T) { + mi := parseNavMenuItem(menuItemRaw("Forms$SignOutClientAction")) + if mi == nil { + t.Fatal("parseNavMenuItem returned nil") + } + if mi.ActionType != "SignOutAction" { + t.Errorf("ActionType = %q, want SignOutAction — the writers and DESCRIBE key on that string", + mi.ActionType) + } +} + +// CONTROL: the action types already read must be unchanged, and an unknown one +// must still fall through to its raw name rather than being absorbed. +func TestParseNavMenuItem_OtherActionsUnchanged(t *testing.T) { + cases := []struct { + typeName string + want string + }{ + {"Forms$FormAction", "PageAction"}, + {"Forms$MicroflowAction", "MicroflowAction"}, + {"Forms$NoAction", "NoAction"}, + {"Forms$SomethingElseAction", "Forms$SomethingElseAction"}, + } + for _, c := range cases { + mi := parseNavMenuItem(menuItemRaw(c.typeName)) + if mi.ActionType != c.want { + t.Errorf("%s -> %q, want %q", c.typeName, mi.ActionType, c.want) + } + } +} diff --git a/sdk/mpr/parser_misc.go b/sdk/mpr/parser_misc.go index bf687a6215..6eeb70b12a 100644 --- a/sdk/mpr/parser_misc.go +++ b/sdk/mpr/parser_misc.go @@ -594,6 +594,11 @@ func parseNavMenuItem(raw map[string]any) *NavMenuItem { if ms, ok := action["MicroflowSettings"].(map[string]any); ok { mi.Microflow = extractString(ms["Microflow"]) } + case strings.HasSuffix(actionType, "SignOutClientAction"): + // Named rather than left to the raw-type-name default: DESCRIBE and + // both writers key on "SignOutAction", so a round trip only closes + // if the reader produces the name the writer consumes. + mi.ActionType = "SignOutAction" case strings.HasSuffix(actionType, "OpenLinkAction") || strings.HasSuffix(actionType, "OpenLinkClientAction"): mi.ActionType = "OpenLinkAction" case strings.HasSuffix(actionType, "NoAction") || strings.HasSuffix(actionType, "NoClientAction"):