From deb8d19473ebdf7bd9095879b1e701da0f8ae2ac Mon Sep 17 00:00:00 2001 From: Kasper de Graaff Date: Thu, 9 Jul 2026 10:40:50 +0200 Subject: [PATCH 01/28] feat(new): add --container-runtime flag (parity with mxcli init) mxcli new always generated a Docker-based devcontainer because it called initCmd.Run() without forwarding the container runtime preference. This adds --container-runtime (docker|podman, default docker) to newCmd and sets initContainerRuntime before the init call so the generated .devcontainer/ reflects the user's choice. Closes #739 Co-Authored-By: Claude Sonnet 4.6 --- cmd/mxcli/cmd_new.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index 2e9c2bbc0b..d93406d917 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -35,6 +35,7 @@ Examples: mendixVersion, _ := cmd.Flags().GetString("version") outputDir, _ := cmd.Flags().GetString("output-dir") skipInit, _ := cmd.Flags().GetBool("skip-init") + containerRuntime, _ := cmd.Flags().GetString("container-runtime") if mendixVersion == "" { fmt.Fprintln(os.Stderr, "Error: --version is required (e.g., --version 11.8.0)") @@ -118,6 +119,7 @@ Examples: // Step 3: Initialize tooling if !skipInit { fmt.Printf("\nStep 3/4: Initializing AI tooling...\n") + initContainerRuntime = containerRuntime initCmd.Run(initCmd, []string{absDir}) } else { fmt.Printf("\nStep 3/4: Skipped (--skip-init)\n") @@ -211,6 +213,7 @@ func init() { newCmd.Flags().String("version", "", "Mendix version (e.g., 11.8.0) — required") newCmd.Flags().String("output-dir", "", "Output directory (default: ./)") newCmd.Flags().Bool("skip-init", false, "Skip AI tooling initialization (mxcli init)") + newCmd.Flags().String("container-runtime", "docker", "Container runtime for devcontainer (docker or podman)") rootCmd.AddCommand(newCmd) } From b0330c9fcd7d11105c85277e30c36881df81cc05 Mon Sep 17 00:00:00 2001 From: Kasper de Graaff Date: Thu, 9 Jul 2026 14:32:58 +0200 Subject: [PATCH 02/28] fix(init): make devcontainer postCreateCommand robust on Podman-on-Windows The devcontainer template generated by `mxcli init`/`new` had two bugs that failed `postCreateCommand` (red error in VS Code) when the workspace is a 9p bind-mount from a Windows drive under Podman: 1. `file` was never installed in the image, so `file ./mxcli | grep -q Linux` emitted `command not found` and the Linux-binary fast-path was never taken (always re-downloaded). Add `file` to the Dockerfile apt list. 2. The download fallback ran an unguarded `chmod +x ./mxcli`, which fails with `Operation not permitted` on 9p (Unix perm changes disallowed) and failed the whole postCreate even though the binary is already executable. Guard it with `2>/dev/null || true`. Fixes #742 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/mxcli/tool_templates.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/mxcli/tool_templates.go b/cmd/mxcli/tool_templates.go index ea845d67df..67dd6ec456 100644 --- a/cmd/mxcli/tool_templates.go +++ b/cmd/mxcli/tool_templates.go @@ -312,7 +312,7 @@ func generateDevcontainerJSON(projectName, mprPath, containerRuntime string) str "containerEnv": { %s }, - "postCreateCommand": "curl -fsSL https://claude.ai/install.sh | bash && if [ -f ./mxcli ] && file ./mxcli | grep -q Linux; then echo 'mxcli binary OK'; else ./mxcli setup mxcli --output ./mxcli 2>/dev/null || { ARCH=$(uname -m); [ \"$ARCH\" = x86_64 ] && ARCH=amd64; [ \"$ARCH\" = aarch64 ] && ARCH=arm64; curl -fsSL https://github.com/mendixlabs/mxcli/releases/latest/download/mxcli-linux-${ARCH} -o ./mxcli && chmod +x ./mxcli; }; fi", + "postCreateCommand": "curl -fsSL https://claude.ai/install.sh | bash && if [ -f ./mxcli ] && file ./mxcli | grep -q Linux; then echo 'mxcli binary OK'; else ./mxcli setup mxcli --output ./mxcli 2>/dev/null || { ARCH=$(uname -m); [ \"$ARCH\" = x86_64 ] && ARCH=amd64; [ \"$ARCH\" = aarch64 ] && ARCH=arm64; curl -fsSL https://github.com/mendixlabs/mxcli/releases/latest/download/mxcli-linux-${ARCH} -o ./mxcli && { chmod +x ./mxcli 2>/dev/null || true; }; }; fi", "customizations": { "vscode": { "extensions": [ @@ -362,6 +362,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends wget apt-transp temurin-21-jdk \ nodejs \ postgresql-client \ + file \ kafkacat \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* From c8c40403bf329d0db25be810151a67a4d0d65214 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 17 Sep 2026 08:11:57 +0000 Subject: [PATCH 03/28] docs(lint): correct field value formats in write-lint-rules skill The reference documented three values that do not match what the runtime returns. Each mismatch makes a rule match nothing and report a clean pass, which is indistinguishable from the project being healthy. - entity_type is normalised to Persistent/NonPersistent/View in LintContext.Entities (context.go CASE), not lower case. The SEC001 example rule in this document used "persistent" and so matched no entity at all. - permission.access_type is written upper case by builder_permissions.go (CREATE/READ/WRITE/DELETE/EXECUTE/VIEW/ACCESS/MEMBER_READ/MEMBER_WRITE). - permission.module_role_name is qualified (Sales.Admin), which matters because rules compare it against user_role.module_roles. --- .claude/skills/mendix/write-lint-rules/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 99eccf45bb..e4082452a7 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -127,7 +127,7 @@ def check(): | `qualified_name` | string | `"Sales.Customer"` | | `module_name` | string | `"Sales"` | | `folder` | string | `"DomainModel"` — folder path within module | -| `entity_type` | string | `"persistent"`, `"NonPersistent"`, `"view"` | +| `entity_type` | string | `"Persistent"`, `"NonPersistent"`, `"View"` — normalised from the catalog's `PERSISTENT`/`NON_PERSISTENT`/`VIEW` | | `description` | string | Documentation text | | `generalization` | string | Parent entity qualified name | | `attribute_count` | int | Number of attributes | @@ -327,12 +327,12 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | Property | Type | Example | |----------|------|---------| -| `module_role_name` | string | `"Admin"` | +| `module_role_name` | string | `"Sales.Admin"` — qualified, so it compares directly against `user_role.module_roles` | | `element_type` | string | `"entity"`, `"microflow"`, `"page"`, `"ODATA_SERVICE"` (from `permissions()` only) | | `element_name` | string | `"Sales.Customer"` | | `module_name` | string | `"Sales"` | | `entity_name` | string | `"Sales.Customer"` (from `permissions_for()` only) | -| `access_type` | string | `"create"`, `"read"`, `"write"`, `"delete"`, `"execute"`, `"view"`, `"access"`, `"MEMBER_READ"`, `"MEMBER_WRITE"` | +| `access_type` | string | `"CREATE"`, `"READ"`, `"WRITE"`, `"DELETE"`, `"EXECUTE"`, `"VIEW"`, `"ACCESS"`, `"MEMBER_READ"`, `"MEMBER_WRITE"` — always upper case | | `member_name` | string | Attribute name (for MEMBER_READ/MEMBER_WRITE) | | `xpath_constraint` | string | XPath constraint or empty | | `is_constrained` | bool | True if XPath constraint is set | @@ -416,7 +416,7 @@ SEVERITY = "warning" def check(): violations = [] for e in entities(): - if e.entity_type == "persistent" and not e.is_external and e.access_rule_count == 0: + if e.entity_type == "Persistent" and not e.is_external and e.access_rule_count == 0: violations.append(violation( message="persistent entity '{}' has no access rules".format(e.qualified_name), location=location(module=e.module_name, document_type="entity", document_name=e.name), From db566efe068d06047ea3b8b8a34eb076230d65de Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 29 Sep 2026 07:50:46 +0000 Subject: [PATCH 04/28] feat(lint): expose the user role's security flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user role carries four security-governance settings that no rule could read: Studio Pro's per-role "Check security" flag, and the three user-management grants (ManageAllRoles, ManageUsersWithoutRoles, ManageableRoles). All four are already modelled on sdk/security.UserRole and already read from BSON by security_read.go — UserRoleInfo simply did not carry them, so they stopped at the linter boundary. This adds the fields and exposes them as check_security, manage_all_roles, manage_users_without_roles and manageable_roles. Motivating rules, neither expressible today: - "User roles with a certain amount of module roles should be checked for security" — asserts check_security is on for any role above a module-role threshold. Without the field a rule can only count module roles, which is a different and much weaker claim. - "Only 'admin' roles should manage other users" — needs the user-management grants. No catalog change, so no CatalogSchemaVersion bump: these come from the MPR reader via GetProjectSecurity, not from a catalog table. --- .../skills/mendix/write-lint-rules/SKILL.md | 4 ++++ mdl/linter/context.go | 20 ++++++++++++++++--- mdl/linter/starlark.go | 14 ++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 4f2457759b..2f8d51d4b2 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -497,6 +497,10 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | `name` | string | `"Administrator"` | | `is_anonymous` | bool | True if this is the anonymous/guest role | | `module_roles` | list of string | `["Sales.Admin", "HR.Viewer"]` | +| `check_security` | bool | Studio Pro's per-role "Check security" flag | +| `manage_all_roles` | bool | The role may hand out every user role | +| `manage_users_without_roles` | bool | The role may manage users that have no role | +| `manageable_roles` | list of string | The user roles this role may hand out, when not all | ### module_role | Property | Type | Example | diff --git a/mdl/linter/context.go b/mdl/linter/context.go index e27ef4fbf1..41b8f986e9 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -485,6 +485,16 @@ type UserRoleInfo struct { Name string IsAnonymous bool ModuleRoles []string + // CheckSecurity is Studio Pro's per-role "Check security" flag. With it on, + // the Modeler verifies that the role's access rules actually grant what its + // pages and microflows need — the setting a rule of the form "security + // should be checked for each project role" asserts. + CheckSecurity bool + // ManageAllRoles, ManageUsersWithoutRoles and ManageableRoles are the + // user-management grants: which other roles this role may hand out. + ManageAllRoles bool + ManageUsersWithoutRoles bool + ManageableRoles []string } // UserRoles returns the user roles from project security. @@ -501,9 +511,13 @@ func (ctx *LintContext) UserRoles() []UserRoleInfo { var roles []UserRoleInfo for _, ur := range ps.UserRoles { roles = append(roles, UserRoleInfo{ - Name: ur.Name, - IsAnonymous: ur.Name == ps.GuestUserRole, - ModuleRoles: ur.ModuleRoles, + Name: ur.Name, + IsAnonymous: ur.Name == ps.GuestUserRole, + ModuleRoles: ur.ModuleRoles, + CheckSecurity: ur.CheckSecurity, + ManageAllRoles: ur.ManageAllRoles, + ManageUsersWithoutRoles: ur.ManageUsersWithoutRoles, + ManageableRoles: ur.ManageableRoles, }) } return roles diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index a854bfa0c0..50e8275129 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -1045,10 +1045,18 @@ func userRoleToStarlark(ur UserRoleInfo) starlark.Value { for _, mr := range ur.ModuleRoles { moduleRoles = append(moduleRoles, starlark.String(mr)) } + var manageableRoles []starlark.Value + for _, mr := range ur.ManageableRoles { + manageableRoles = append(manageableRoles, starlark.String(mr)) + } return starlarkstruct.FromStringDict(starlark.String("user_role"), starlark.StringDict{ - "name": starlark.String(ur.Name), - "is_anonymous": starlark.Bool(ur.IsAnonymous), - "module_roles": starlark.NewList(moduleRoles), + "name": starlark.String(ur.Name), + "is_anonymous": starlark.Bool(ur.IsAnonymous), + "module_roles": starlark.NewList(moduleRoles), + "check_security": starlark.Bool(ur.CheckSecurity), + "manage_all_roles": starlark.Bool(ur.ManageAllRoles), + "manage_users_without_roles": starlark.Bool(ur.ManageUsersWithoutRoles), + "manageable_roles": starlark.NewList(manageableRoles), }) } From 31825ee1f52f89dbe6c665a9bd3b75cbb9e9f44c Mon Sep 17 00:00:00 2001 From: Wengao Liu Date: Fri, 16 Oct 2026 15:06:53 +0800 Subject: [PATCH 05/28] =?UTF-8?q?docs:=20bug=20report=20=E2=80=94=20change?= =?UTF-8?q?=20member=20through=20an=20association=20(CE1613)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...10-16-change-member-through-association.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/12-bug-reports/2026-10-16-change-member-through-association.md diff --git a/docs/12-bug-reports/2026-10-16-change-member-through-association.md b/docs/12-bug-reports/2026-10-16-change-member-through-association.md new file mode 100644 index 0000000000..04c9ba38c7 --- /dev/null +++ b/docs/12-bug-reports/2026-10-16-change-member-through-association.md @@ -0,0 +1,141 @@ +# Change member through an association — check passes it, the writer miscategorizes it, mxbuild rejects it (CE1613) + +- **Investigated**: 2026-10-16, against `e0467c692`, mxbuild 11.13.0, blank project +- **Upstream**: none yet — draft +- **Related**: FINDINGS #51 (mdl-executor, the one-qualifier refusal this bug sneaks past), #830 (same CE1613, widget `Attribute` slot) + +## Verdict + +`change $a (A_B.Title = 'x')` — setting an attribute of the object reached +through association `A_B` — is **not representable** in Mendix's +`Microflows$ChangeActionItem`: the member slot holds either an attribute of the +changed entity itself or an association reference, and no spelling encodes a +path. Yet the statement survives the whole mxcli pipeline: + +1. `mxcli check` **skips** dotted members on the assumption exec refuses them + (`mdl/executor/validate_member_refs.go:432-436`) — the assumption is false + for the `Assoc.Attr` shape. +2. `exec` writes it as an **association set** with the invalid name `A_B.Title` + (`mdl/executor/cmd_microflows_builder_actions.go`, `resolveMemberChange` → + `resolveMemberChangeFallback`, the one-dot branch at `:2416`). +3. `mx check` then fails the project: `CE1613 "The selected association + 'A_B.Title' no longer exists." at Change object activity 'Change 'a' + (Title)'` — after `mxcli check` and `exec` both reported success. + +## Reproduction + +```mdl +mdl 1; +create module Hop; +create persistent entity Hop.A (Code: string(50)); +create persistent entity Hop.B (Title: string(100)); +create association Hop.A_B from Hop.A to Hop.B type reference; + +create microflow Hop.CHG_OneHop ($a: Hop.A) returns string as $r +begin + declare $r string = ''; + change $a (A_B.Title = 'one-hop'); + set $r = 'ok'; + return $r; +end; +``` + +```bash +mxcli exec repro.mdl -p app.mpr # reports success, writes the flow +mx check app.mpr # CE1613 +``` + +Positive control: `change $a (Code = 'own')` (the entity's own attribute) is +accepted by mxbuild — the defect is specific to the dotted associated-member +form. + +## Evidence + +**What mxcli wrote** (strings of the stored `.mxunit`): + +``` +$Type Microflows$ChangeActionItem +Association A_B.Title ← the member landed in the ASSOCIATION slot +Type Association +Value 'one-hop' +``` + +**Probe matrix** — the decisive question is whether ANY encoding of a +through-association member exists. Each candidate was written into the stored +BSON by hand (BSON round-trip via `modelsdk/mpr`) and the project checked with +mxbuild 11.13.0: + +| Stored member encoding | Result | +|---|---| +| `Association: A_B.Title` (what mxcli writes) | CE1613 "selected association 'A_B.Title' no longer exists" | +| `Attribute: Hop.A_B/Title` | StorageLoadException — `'Hop.A_B/Title' is not a valid AttributeIdentifier` (project **unloadable**) | +| `Attribute: Hop.A_B.Title` | CE1613 "The selected attribute 'Hop.A_B.Title' no longer exists" | +| `Attribute: Hop.B.Title` | CE0226 "Entity 'Hop.A' does not contain attribute 'Hop.B.Title'" | +| `Attribute: Hop.A.A_B.Title` | StorageLoadException — invalid AttributeIdentifier (4-part identifiers do not parse) | +| `Attribute: Hop.A.Code` (own attribute, control) | clean | + +The identifier grammar admits only `Module.Entity.Attribute` **of the changed +entity** (CE0226 proves mxbuild checks exactly that). There is no slot for the +association step. Studio Pro's idiom is `retrieve $b from $a/Hop.A_B` then +`change $b (Title = …)`. + +## Root cause, layer by layer + +**Check** — `mdl/executor/validate_member_refs.go:432-436` (`checkMembers`): + +```go +// A qualified member is a different question with a different answer, +// and exec already refuses the one-qualifier form that cannot be an +// attribute (FINDINGS #51). Left alone here rather than second-guessed. +if strings.Contains(name, ".") { + continue +} +``` + +The trust is misplaced. FINDINGS #51's refusal fires only when the pre-dot +segment **is a module** whose domain model lacks the association +(`resolveMemberChange`, the `strings.Count(memberName, ".") == 1` branch at +`cmd_microflows_builder_actions.go:2275`). For `A_B.Title` the pre-dot segment +is an **association name, not a module**, so `GetModuleByName("A_B")` returns +nil, the whole guarded block is skipped, and the member falls through to +`resolveMemberChangeFallback` — whose one-dot heuristic +(`:2416`, "1 dot => association qualified by module") writes +`AssociationQualifiedName = "A_B.Title"` unchecked. + +**Writer** — `resolveMemberChangeFallback`'s one-dot branch has no +existence validation when reached via the module-not-found path. It is the +only leak: when the pre-dot segment *is* a real module, #51's refusal fires; +when it is not, the invalid name is written verbatim. + +**Platform** — `Microflows$ChangeActionItem` carries `Attribute` +(`Module.Entity.Attribute` of the changed entity) and `Association` (set the +reference itself). Nothing else. Through-association member changes are not a +Mendix feature of this activity. + +## Fix direction + +Both layers must move, and the writer is the one that must refuse: + +1. **Writer** (`resolveMemberChangeFallback` one-dot branch, and/or + `resolveMemberChange` before the fallback): when the pre-dot segment is not + a module of the project, refuse with the working idiom — + `a member reached through an association is not a change item: retrieve through the association and change the retrieved object` + plus the exact association to retrieve through. This is the same shape as + #51's refusal, one branch earlier. +2. **Check** (`checkMembers`): stop blanket-skipping dotted members. A member + with exactly one dot whose pre-dot segment is not a module is the same + unrepresentable form and should be reported at check time, so `mxcli check` + and `mx check` agree again. (Two dots — `Module.Entity.Attribute` — stays + legal and keeps the skip.) + +`mxcli check` refusing while exec also refuses is the target state; leaving +check silent makes the gap invisible until a whole build later, which is the +failure mode #562 documented for member drops. + +## Repro assets + +`/tmp/opencode/hopverify/` (session-local, not committed): `script1.mdl` +(entities + the failing flow + multi-hop expression/sort flows that all pass +mxbuild), `script3.mdl` (own-attribute positive control), `hopverify.mpr.bak`. +The probe tooling was a throwaway `bson.Marshal` round-trip over +`Reader.GetRawUnitByName` + `WriteTransaction.WriteUnit`; it is not preserved. From a3ce7eeb62a45f78d89964c89a9dd0142e4308c6 Mon Sep 17 00:00:00 2001 From: Wengao Liu Date: Fri, 16 Oct 2026 15:06:53 +0800 Subject: [PATCH 06/28] fix: refuse change members reached through an association MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit change $a (A_B.Title = ...) — a member whose dotted qualifier is an association, not a module — passed check, exec reported success, and the next mx check failed the build with CE1613: Mendix's ChangeActionItem cannot represent setting an attribute through an association at all (probe matrix in the bug report). The writer's one-dot heuristic wrote the name verbatim into the Association slot; check's blanket skip for dotted members trusted a FINDINGS #51 refusal that only fires when the qualifier is a real module. check -p now reports the form and exec refuses it, both naming the retrieve-then-change replacement; Module.Assoc association-sets and Module.Entity.Attribute qualified attributes are untouched. --- ...nge-member-through-association-ce1613.json | 1 + CHANGELOG.md | 4 ++ .../change-member-through-association.mdl | 47 +++++++++++++++++++ .../cmd_microflows_builder_actions.go | 37 +++++++++++++++ .../cmd_microflows_member_identifier_test.go | 33 +++++++++++++ mdl/executor/validate_member_refs.go | 43 +++++++++++++++-- mdl/executor/validate_member_refs_test.go | 22 +++++++++ 7 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-change-member-through-association-ce1613.json create mode 100644 mdl-examples/bug-tests/change-member-through-association.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-change-member-through-association-ce1613.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-change-member-through-association-ce1613.json new file mode 100644 index 0000000000..60ce654178 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-change-member-through-association-ce1613.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`change $a (A_B.Title = 'x')` — setting an attribute of the object reached through association `A_B` — passed `mxcli check` and `exec` reported \"Created microflow\", then failed the build with CE1613 \"The selected association 'A_B.Title' no longer exists.\" at the change activity. Hand-patched-BSON probes proved the deeper fact: NO encoding of a through-association member is accepted by ChangeActionItem — `Attribute: Hop.A_B/Title` and `Hop.A.A_B.Title` are invalid AttributeIdentifiers (unloadable .mpr), `Attribute: Hop.A_B.Title` is CE1613, `Attribute: Hop.B.Title` is CE0226 (mxbuild checks the identifier against the CHANGED entity), so the form is unrepresentable, not mis-spelled", "cause": "Three layers disagreed. check: `checkMembers` (validate_member_refs.go) blanket-skipped every dotted member, trusting exec's FINDINGS #51 refusal to catch bad one-dot names. Writer: that refusal lives inside the `mod != nil` branch of `resolveMemberChange` — it fires only when the pre-dot segment IS a module whose domain model lacks the association. For `A_B.Title` the pre-dot segment is an ASSOCIATION name, `GetModuleByName(\"A_B\")` misses, the guarded block is skipped, and `resolveMemberChangeFallback`'s one-dot heuristic (\"1 dot => Module.Assoc\") wrote `AssociationQualifiedName = \"A_B.Title\"` verbatim. The reader answers (nil, \"module not found\") for an absent module, so the real backend always took the skip path", "file": "mdl/executor/cmd_microflows_builder_actions.go (moduleExists + the one-dot refusal before the domain-model lookup), mdl/executor/validate_member_refs.go (backendRefusesModule; checkMembers reports the same shape), tests cmd_microflows_member_identifier_test.go + validate_member_refs_test.go; example mdl-examples/bug-tests/change-member-through-association.mdl", "insight": "**The trust boundary was drawn one branch too deep.** #51's refusal was real but conditional on the qualifier being a plausible module; check skipped dotted names citing a guard that silently did not apply to the Assoc.Attr shape. The decisive evidence was the probe matrix, not more code reading: six encodings hand-written into the stored BSON, every one rejected with a DIFFERENT error — that spread is what proves 'unrepresentable' rather than 'wrong spelling', and it kills the tempting fix of rewriting the member into some canonical form. To make the refusal fire on the real backend the existence question must go through the module ROSTER (ListModules), not GetModuleByName: the reader's not-found comes back as an error, so `err == nil && mod == nil` never fires in production — the first cut of this fix passed every unit test (mocks answered nil,nil) and still shipped CE1613 end to end. Existing writer mocks only answer GetModuleByName, so the two-tier lookup (name first, roster on error) keeps them honest without editing each one", "ce": ["CE1613", "CE0226"], "refs": ["docs/12-bug-reports/2026-10-16-change-member-through-association.md", "FINDINGS #51", "#830"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0798c1db7b..4165a1ad3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`change` through an association is refused with the working idiom instead of shipping a project mxbuild rejects** — `change $a (A_B.Title = 'x')` (a member whose dotted qualifier is an association, not a module) passed `mxcli check`, `exec` reported success, and the next build failed with CE1613 "The selected association 'A_B.Title' no longer exists": Mendix's Change object activity cannot set an attribute through an association at all (probe matrix in [the bug report](docs/12-bug-reports/2026-10-16-change-member-through-association.md) — every encoding, including the ones an older mxcli wrote, is rejected or unloadable), and the writer's one-dot heuristic categorized the name as an association-set verbatim. `check -p` now reports the form and `exec` refuses it, both naming the retrieve-then-change replacement (`retrieve $obj from $a/A_B;` then `change $obj (Title = …)`); the legal shapes — `Module.Assoc` association-sets and `Module.Entity.Attribute` qualified attributes — are untouched. Example: `mdl-examples/bug-tests/change-member-through-association.mdl`. + ## [0.27.0] - 2026-10-15 Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories. diff --git a/mdl-examples/bug-tests/change-member-through-association.mdl b/mdl-examples/bug-tests/change-member-through-association.mdl new file mode 100644 index 0000000000..2e62563c28 --- /dev/null +++ b/mdl-examples/bug-tests/change-member-through-association.mdl @@ -0,0 +1,47 @@ +mdl 1; +-- ============================================================================ +-- A member reached through an association is not a change item +-- ============================================================================ +-- +-- Symptom: `change $a (A_B.Title = 'x')` passed `mxcli check`, `exec` reported +-- success, and the next `mx check` failed the build: +-- +-- [error] [CE1613] "The selected association 'A_B.Title' no longer exists." +-- at Change object activity 'Change 'a' (Title)' +-- +-- Mendix's Change object activity cannot set an attribute through an +-- association at all — the member slot holds an attribute of the changed +-- entity or an association reference, nothing else (probe matrix: +-- docs/12-bug-reports/2026-10-16-change-member-through-association.md). +-- mxcli used to write the name verbatim into the Association slot. +-- +-- Now: `check -p` reports the form and `exec` refuses it, both naming the +-- retrieve-then-change replacement. ACT_Bad below is the repro (uncomment to +-- see the rejection). ACT_Good is the working idiom. + +create module Hop; +create or modify persistent entity "Hop"."A" (Code: string(50)); +create or modify persistent entity "Hop"."B" (Title: string(100)); +create or modify association "Hop"."A_B" +from "Hop"."A" to "Hop"."B" +type reference; + +-- Working idiom: retrieve through the association, change the retrieved object. +create or modify microflow "Hop"."ACT_Good" ($a: Hop.A) returns string as $r +begin + declare $r string = ''; + retrieve $b from $a/A_B; + change $b (Title = 'via retrieve'); + commit $b; + set $r = 'ok'; + return $r; +end; + +-- Repro (refused at check and exec — uncomment to verify): +-- create or modify microflow "Hop"."ACT_Bad" ($a: Hop.A) returns string as $r +-- begin +-- declare $r string = ''; +-- change $a (A_B.Title = 'x'); +-- set $r = 'ok'; +-- return $r; +-- end; diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 693cfdc1ff..a5587ba433 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -2243,6 +2243,28 @@ func (fb *flowBuilder) isEntity(moduleName, entityName string) bool { return false } +// moduleExists reports whether a module of this name is in the project. The +// common path is GetModuleByName; when that errors — the error could mean +// "not found" or "cannot read", and this decision treats those differently — +// the module roster decides. A roster that cannot be read counts as "exists": +// the member keeps the old silent fallback rather than this refusal, because +// silence over a guess is this file's rule for an unreadable model. +func (fb *flowBuilder) moduleExists(name string) bool { + if mod, err := fb.backend.GetModuleByName(name); err == nil { + return mod != nil + } + mods, err := fb.backend.ListModules() + if err != nil { + return true + } + for _, m := range mods { + if m != nil && m.Name == name { + return true + } + } + return false +} + // resolveMemberChange determines whether a member name is an association or attribute // and sets the appropriate field on the MemberChange. It queries the domain model // to check if the name matches an association on the entity; if no metadata is @@ -2333,6 +2355,21 @@ func (fb *flowBuilder) resolveMemberChange(mc *microflows.MemberChange, memberNa qualifiedName = moduleName + "." + memberName } + // A one-dot member whose qualifier is NOT a module (`Assoc.Attr`) is a + // member reached through an association. Mendix's ChangeActionItem cannot + // represent that at all — the slot holds either an attribute of the changed + // entity or an association reference — and writing the name verbatim (the + // fallback below) ships a project mxbuild rejects with CE1613 a whole build + // later. Refuse with the working idiom instead, but only when the module + // list answers confidently: an unreadable model keeps the old silent + // fallback rather than guessing. + // (docs/12-bug-reports/2026-10-16-change-member-through-association.md) + if fb.backend != nil && strings.Count(memberName, ".") == 1 && !fb.moduleExists(lookupModule) { + fb.addError("member %q on entity %s: %q is not a module, so this is a member reached through an association — a change item cannot set it. Retrieve the associated object and change that instead: `retrieve $obj from $var/%s;` then `change $obj (%s = ...)`", + memberName, entityQN, lookupModule, lookupModule, bareName) + return + } + // Query the authored (or target) module's domain model first. When the // association actually lives in a different module — the common case for // cross-module associations like `OtherModule.Assoc_Name` on a diff --git a/mdl/executor/cmd_microflows_member_identifier_test.go b/mdl/executor/cmd_microflows_member_identifier_test.go index 1ea71bba50..196c75d861 100644 --- a/mdl/executor/cmd_microflows_member_identifier_test.go +++ b/mdl/executor/cmd_microflows_member_identifier_test.go @@ -129,3 +129,36 @@ func TestResolveMemberChange_RejectsUnknownAssociation(t *testing.T) { t.Errorf("unexpected error: %q", fb.errors[0]) } } + +// TestResolveMemberChange_RejectsAssociationMemberPath guards the through- +// association change member (`Assoc.Attr` — the pre-dot segment is an +// association name, not a module): it is neither `Module.Assoc` (that shape +// needs a real module qualifier) nor an attribute of the changed entity, and +// Mendix's ChangeActionItem cannot represent it at all — mxbuild rejects the +// written form with CE1613, after check and exec both reported success +// (docs/12-bug-reports/2026-10-16-change-member-through-association.md). +func TestResolveMemberChange_RejectsAssociationMemberPath(t *testing.T) { + backend := &mock.MockBackend{ + // The module list answers the qualifier question: "A_B" is not in it, + // so the pre-dot segment is confidently not a module. + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{{BaseElement: model.BaseElement{ID: model.ID("hop")}, Name: "Hop"}}, nil + }, + } + fb := &flowBuilder{backend: backend} + mc := µflows.MemberChange{} + fb.resolveMemberChange(mc, "A_B.Title", "Hop.A") + + if mc.AttributeQualifiedName != "" || mc.AssociationQualifiedName != "" { + t.Errorf("association member path must not serialize: attr=%q assoc=%q", + mc.AttributeQualifiedName, mc.AssociationQualifiedName) + } + if len(fb.errors) == 0 { + t.Fatal("expected a validation error for the association member path, got none") + } + for _, want := range []string{"A_B.Title", "retrieve"} { + if !strings.Contains(fb.errors[0], want) { + t.Errorf("error should mention %q: %s", want, fb.errors[0]) + } + } +} diff --git a/mdl/executor/validate_member_refs.go b/mdl/executor/validate_member_refs.go index b05bae21b0..5aebe7f247 100644 --- a/mdl/executor/validate_member_refs.go +++ b/mdl/executor/validate_member_refs.go @@ -124,6 +124,27 @@ func resolveMemberOnEntity(ctx *ExecContext, entityQN, memberName string) member // associationNamedInModule reports whether the domain model has an association // of this bare name. A qualified member is handled by the caller; this covers // the unqualified spelling, which resolves in the entity's own module. +// backendRefusesModule reports whether the project's module list answers, +// confidently, that no module of this name exists. A list that cannot be read +// is NOT absence — this file's rule is silence over a guess +// (TestResolveMemberOnEntity_SilentWhenTheModelCannotBeRead). +func backendRefusesModule(ctx *ExecContext, qualifier string) bool { + b, ok := ctx.Backend.(entityLookupBackend) + if !ok { + return false + } + mods, err := b.ListModules() + if err != nil { + return false + } + for _, m := range mods { + if m != nil && m.Name == qualifier { + return false + } + } + return true +} + func associationNamedInModule(dm *domainmodel.DomainModel, name string) bool { for _, a := range dm.Associations { if a != nil && a.Name == name { @@ -426,10 +447,24 @@ func checkMembers(ctx *ExecContext, sc *scriptContext, authored map[string]bool, if name == "" { continue } - // A qualified member is a different question with a different answer, - // and exec already refuses the one-qualifier form that cannot be an - // attribute (FINDINGS #51). Left alone here rather than second-guessed. - if strings.Contains(name, ".") { + // Qualified members are a different question with a different answer. + // `Module.Assoc` (one dot over a real module) is a legal association-set + // — exec's #51 refusal covers the association-less case — and + // `Module.Entity.Attribute` (two dots) a legal qualified attribute. But + // one dot over a NON-module qualifier (`Assoc.Attr`) is a member reached + // through an association, which Mendix's ChangeActionItem cannot + // represent: exec writes the name verbatim and mxbuild rejects the + // project with CE1613. Report it here so check and build agree; the + // writer refuses the same shape. + // (docs/12-bug-reports/2026-10-16-change-member-through-association.md) + if dot := strings.Index(name, "."); dot >= 0 { + if strings.Count(name, ".") == 1 && backendRefusesModule(ctx, name[:dot]) { + out = append(out, fmt.Sprintf( + "%s: %q on %s is a member reached through an association — a change item cannot set it. "+ + "Retrieve the associated object and change that instead (`retrieve $obj from $var/%s;` then `change $obj (%s = ...)`); "+ + "mxbuild rejects this form with CE1613", + flowQN, name, entityQN, name[:dot], name[dot+1:])) + } continue } if authored[strings.ToLower(entityQN+"."+name)] { diff --git a/mdl/executor/validate_member_refs_test.go b/mdl/executor/validate_member_refs_test.go index d9438c03da..7a06419b16 100644 --- a/mdl/executor/validate_member_refs_test.go +++ b/mdl/executor/validate_member_refs_test.go @@ -175,6 +175,28 @@ func TestValidateMemberReferences_ReportsAMissingAttribute(t *testing.T) { } } +// A member reached THROUGH an association (`Assoc.Attr` — the pre-dot segment +// is an association name, not a module) is not representable in Mendix's +// ChangeActionItem: mxbuild rejects every encoding of it (CE1613, or an +// unloadable .mpr). The blanket skip for dotted names trusted exec to refuse +// the form; it does not — it writes AssociationQualifiedName = "Assoc.Attr" +// verbatim and the project fails a whole build later. See +// docs/12-bug-reports/2026-10-16-change-member-through-association.md. +func TestValidateMemberReferences_RefusesAMemberThroughAnAssociation(t *testing.T) { + ctx := memberFixture(t) + msgs := validateMemberReferences(ctx, + flowProgram(orderParam(), changeStmt("Order", "Order_Customer.Name")), newScriptContext()) + + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1: %v", len(msgs), msgs) + } + for _, want := range []string{"Order_Customer.Name", "retrieve"} { + if !strings.Contains(msgs[0], want) { + t.Errorf("message should mention %q: %s", want, msgs[0]) + } + } +} + // CONTROL: everything that legitimately resolves must stay silent. Each of // these is a shape a real script uses, and each would be a false error that // blocks a script which builds cleanly. From da56a3b994b0deec0a58e8386159e87c3044273a Mon Sep 17 00:00:00 2001 From: Wengao Liu Date: Fri, 16 Oct 2026 17:23:41 +0800 Subject: [PATCH 07/28] docs: make the example's working idiom exec-clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An association retrieve binds a single object for a reference, which the current executor mis-types as a list and refuses to change — the example rolled back under exec. Use the database retrieve + `first` form with a qualified member, which check, exec and mxbuild all accept. --- .../bug-tests/change-member-through-association.mdl | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mdl-examples/bug-tests/change-member-through-association.mdl b/mdl-examples/bug-tests/change-member-through-association.mdl index 2e62563c28..2d02719c78 100644 --- a/mdl-examples/bug-tests/change-member-through-association.mdl +++ b/mdl-examples/bug-tests/change-member-through-association.mdl @@ -26,12 +26,14 @@ create or modify association "Hop"."A_B" from "Hop"."A" to "Hop"."B" type reference; --- Working idiom: retrieve through the association, change the retrieved object. +-- Working idiom: retrieve the associated object, change the retrieved object. +-- (database retrieve + `first`; an association retrieve binds a single object +-- for a reference, which mxcli currently mis-types as a list — see HANDOFF) create or modify microflow "Hop"."ACT_Good" ($a: Hop.A) returns string as $r begin declare $r string = ''; - retrieve $b from $a/A_B; - change $b (Title = 'via retrieve'); + retrieve $b from Hop.B where Hop.A_B = $a first; + change $b (Hop.B.Title = 'via retrieve'); commit $b; set $r = 'ok'; return $r; From e0339043e6eccf14471930f2903db3f41cec9037 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:38:15 +0000 Subject: [PATCH 08/28] docs(proposal): accept ALTER NAVIGATION / ALTER MENU item-level edits Records the maintainer's decisions on the draft and sets it to accepted: path addressing `'Admin' > 'Users'` (suffix match, one direct step per `>`, @n per segment, shortest-unique-address listings), the set/drop page clauses, `alter menu` for menu documents, captions read and written with the page rule (default language for describe and the resolver, authoring language for writes), and no `move` for `alter page`. The README generator listed only partial/proposed/draft, so an `accepted` proposal - this one and the three already marked so - never appeared in the index. It now has an Accepted group, and the status is in the lint vocabulary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .claude/skills/lint-proposals.md | 1 + .../PROPOSAL_alter_navigation_menu_items.md | 622 ++++++++++++++++++ docs/11-proposals/README.md | 12 +- scripts/gen_proposals_readme.py | 4 +- 4 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 docs/11-proposals/PROPOSAL_alter_navigation_menu_items.md diff --git a/.claude/skills/lint-proposals.md b/.claude/skills/lint-proposals.md index 9f396115f7..8e164c8663 100644 --- a/.claude/skills/lint-proposals.md +++ b/.claude/skills/lint-proposals.md @@ -37,6 +37,7 @@ Exactly one of these lowercase values: |--------|---------|--------| | `draft` | Early design, not finalised | active | | `proposed` | Design complete, not yet started | active | +| `accepted` | Design approved by the maintainer; implementation pending or under way | active | | `partial` | Some phases implemented, more to come | active | | `done` | Fully implemented | `archive/` | | `superseded` | Replaced by another proposal | `archive/` | diff --git a/docs/11-proposals/PROPOSAL_alter_navigation_menu_items.md b/docs/11-proposals/PROPOSAL_alter_navigation_menu_items.md new file mode 100644 index 0000000000..46dee007ca --- /dev/null +++ b/docs/11-proposals/PROPOSAL_alter_navigation_menu_items.md @@ -0,0 +1,622 @@ +--- +title: ALTER NAVIGATION / ALTER MENU — item-level edits to a navigation menu or menu document +status: accepted +date: 2026-10-20 +related: + - navigation-support.md + - show-describe-menu-documents.md + - PROPOSAL_offline_sync_configuration.md + - docs/13-decisions/0010-mdl-canonical-syntax-rules.md + - docs/13-decisions/0012-mdl-first-and-data-first-editing.md + - mendixlabs/mxcli#1522 + - ako/mxcli#1404 + - ako/mxcli#1420 +--- + +# Proposal: ALTER NAVIGATION / ALTER MENU — item-level edits to a navigation menu or menu document + +**Status:** Accepted (2026-10-20) +**Date:** 2026-10-17 (draft), 2026-10-20 (accepted) + +> The maintainer's decisions are recorded in [Decisions](#decisions); the +> sections below are written to them. + +## Problem Statement + +A navigation profile's menu can only be written whole. `create or modify +navigation { … }` replaces the entire menu with the block, so a +statement that means "add one item" deletes every item it does not restate. That +is what the reporter of mendixlabs/mxcli#1522 (ako/mxcli#1404) did: they added +one item and lost the others. PR ako/mxcli#1420 documents the rule ("a `{ }` +menu block replaces the whole menu"), but the only way to change one item is +still the round trip `mxcli syntax navigation.alter` describes: `describe +navigation`, edit the output, then run all of it as `create or modify`. + +That round trip is the declarative-only workflow ADR-0012 rejected, and it costs +more here than the length of the menu: + +- **The script re-sends, and `check` re-validates, items it never meant to + touch.** Measured on ako/TestApp (Mendix 11.14.0, Studio Pro-authored). Running + `describe navigation Responsive` with one caption changed (`'Home'` → + `'Start'`) is **refused**: + + ``` + ✗ menu item 'Item 4' uses glyph 9993, which the Mendix glyph font does not define … [MDL078] + Refusing to execute: 1 error(s) above. Nothing was written. + ``` + + The user changed `'Home'`, and the error is on `'Item 4'`. Nobody can edit any + item in that menu without first repairing one they did not touch, or passing + `--no-check`. +- **Concurrent edits are lost.** The rewrite writes back whatever the script + holds. Any item someone added in Studio Pro after the `describe` is deleted. +- **There is no way to remove a page clause.** After ako/mxcli#1420, an omitted + clause keeps its stored value. That fixes the CE0527 data loss, but MDL now has + no spelling that clears a login page, a not-found page or a role's home page. + Leaving the clause out keeps it. + +Who benefits: agents and developers editing existing Studio Pro apps (the +data-first mode of ADR-0012), which is where most navigation edits happen. + +This proposal adds the data-first half for navigation: +`alter navigation { insert / drop / replace / move / set }`. It edits +menu items at any depth, and it sets or clears the profile's page clauses. The +same item operations apply to menu documents (`alter menu M.Menu { … }`), +because their items are the same BSON (see below). + +`create or replace` is a deprecated alias of `create or modify` (ADR-0010, +MDL-DEPR001). It keeps that meaning and gains no new one. + +## Decisions + +Taken by the maintainer on 2026-10-20, resolving the draft's open questions: + +1. **Addressing is the path form `'Admin' > 'Users'`** (outer > inner), the form + mxcli's messages already print item paths in, instead of the draft's + `'Users' in 'Admin'`. The rest of the scheme stands: a bare caption matches + anywhere in the tree, a path matches as a **suffix** of an item's path, each + `>` is a **direct** parent/child step, `@n` belongs to the segment it follows, + a missing or ambiguous target is refused with the shortest unique address of + every candidate (in the same `>` form), and there are no positional-only + addresses. `list navigation menu`'s Address column and every message use the + same form. +2. **Page clauses**: `set home (page | microflow) M.X [for Role]`, `drop home + page for Role`, `set login page M.P` / `drop login page`, `set not found page + M.P` / `drop not found page`. A bare `drop home page` is refused (CE0527). +3. **Menu documents**: yes, `alter menu Module.Menu { … }` with the same item + operations and no page clauses. +4. **Caption language**: handled **the same way as pages**. A caption is read + with the rule page `describe` uses and written with the rule `alter page` + uses (see [Caption language](#caption-language)). Navigation `describe` and + the address resolver both switch to it; the resulting change to `describe` + output on multi-language projects is intended. +5. **No `move` for `alter page`** (out of scope). `move` stays inside `alter + navigation` / `alter menu`. + +## BSON Structure + +All measured, not assumed. Sources: + +- `testdata/expr-checker`, `testdata/pedapp` and an 11.15.0 blank-app fixture + (Fixture1115), each a Studio Pro-created project; +- ako/TestApp (11.14.0), which has a Studio Pro-authored **nested** menu and every + action and icon kind; +- each dumped from its `mprcontents/b6/29/b62986ab-….mxunit` (the navigation + document) and its two `Menus$MenuDocument` units. + +`mxcli bson dump` has no `--type` for navigation or menus, so the units were +decoded directly. The field names agree with `modelsdk/gen/menus/types.go` and +`modelsdk/gen/navigation`, and with the reader and writer in +`mdl/backend/modelsdk/navigation_{read,write}.go` and `menu_{read,write}.go`. + +### Where the items live + +``` +Navigation$NavigationDocument (one unit per project) + Profiles: [2, …] + Navigation$NavigationProfile web profile (Responsive, Phone, …Offline) + Name, Kind, AppTitle (Texts$Text), AppIcon + HomePage: Navigation$HomePage { Page, Microflow } + HomeItems: [2, Navigation$RoleBasedHomePage { UserRole, Page, Microflow } …] + LoginPageSettings: Forms$FormSettings { Form, ParameterMappings [2], TitleOverride } + NotFoundHomepage: Navigation$NotFoundHomePage { Page, Microflow } | null + Menu: Menus$MenuItemCollection { Items: [3, Menus$MenuItem …] } + OfflineEntityConfigs, ProgressiveWebAppSettings, ThrowPartialSyncError + Navigation$NativeNavigationProfile native: BottomBarItems, NOT a Menus$ tree + +Menus$MenuDocument (one unit per menu document) + Name, Documentation, Excluded, ExportLevel + ItemCollection: Menus$MenuItemCollection { Items: [3, Menus$MenuItem …] } +``` + +The item tree is **the same in both documents**. A `Menus$MenuItemCollection` +with `Items` marker 3 holds `Menus$MenuItem` elements. The only difference is the +key that holds the collection: `Menu` on a profile, `ItemCollection` on a menu +document. This is why `alter menu` shares every item operation (Decision 3). + +### `Menus$MenuItem` + +| Key | Value | Notes | +|---|---|---| +| `$ID` | binary(16) | Unique. **Not referenced from anywhere** (measured below) | +| `$Type` | `Menus$MenuItem` | Storage name equals the qualified name. No mismatch from the CLAUDE.md table applies | +| `Action` | a client action | `Forms$FormAction` (show page), `Forms$MicroflowAction`, `Forms$CallNanoflowClientAction`, `Forms$OpenLinkClientAction`, `Forms$CreateObjectClientAction`, `Forms$SignOutClientAction`, `Forms$NoAction`. These are a button's `Forms$*` names: "Form" is the old word for "Page" | +| `AlternativeText` | `Texts$Text` \| null | null in every observed item. Introduced in 8.12.0 (`gen/menus/version.go`) | +| `Caption` | `Texts$Text { Items: [3, Texts$Translation { LanguageCode, Text } …] }` | **Translatable**: one string per language | +| `Icon` | `Forms$IconCollectionIcon { Image }` \| `Forms$GlyphIcon { Code }` \| `Forms$ImageIcon { Image }` \| null | All three kinds occur in TestApp | +| `Items` | `[3, Menus$MenuItem …]` | Sub-items. Marker 3 even when empty | + +Observed key set, identical on every item in all five units: `$ID, $Type, +Action, AlternativeText, Caption, Icon, Items`. That is the property set an +inserted item must carry (CLAUDE.md rule 4, `canon.CompletePropertySets`). +`navMenuItemBson` already writes exactly this set. + +### Identity: what an item has and what it lacks + +- **No name.** Studio Pro stores none, so an item can only be addressed by + content (its caption) or by position. Captions are translatable, need not be + unique, and may be empty (an icon-only item). +- **No `GUID`.** No `GUID`, `PersistentId` or other identity property exists on + `Menus$MenuItem`, `Menus$MenuItemCollection` or the profile. The + database-identity rule in CLAUDE.md does not apply, and `canon.identityFields` + needs no row. +- **No pointers to an item.** The 10 `Menus$MenuItem` `$ID`s of TestApp's + profile were searched as raw bytes across all of its `mprcontents` units. Each + occurs exactly once, as its own `$ID`, in the navigation unit. So no other + element points at a menu item, and an item's `$ID` matters only for diffs in + version control (ADR-0008). It is not a reference integrity issue. +- **A sub-menu has no action.** Studio Pro stores `Forms$NoAction` on an item + with sub-items (TestApp `'Item 0'`). `mx check` reports CE0548 ("Items with + subitems cannot have an action themselves") otherwise. See the comment on + `keepStoredMenuAction`. + +### The page clauses (for `set` / `drop` on the profile) + +| Clause | Stored as | "Not set" is | +|---|---|---| +| `home page` / `home microflow` | `HomePage: Navigation$HomePage { Page, Microflow }` | required on a web profile (CE0527 when empty) | +| `home … for ` | one `Navigation$RoleBasedHomePage { UserRole, Page, Microflow }` in `HomeItems` (marker 2) | the element is absent | +| `login page` | `LoginPageSettings: Forms$FormSettings { Form, … }` | **`Form: ""`**, and the element stays (Fixture1115, TestApp) | +| `not found page` | `NotFoundHomepage: Navigation$NotFoundHomePage { Page, Microflow }` | **`null`** (Fixture1115) | + +Note the two different spellings of "none": an empty `Form` string, and a null +element. Each clear must write what Studio Pro writes for that field. + +### How today's whole-menu rewrite treats identity (measured) + +`UpdateNavigationProfile` rebuilds the menu with fresh `$ID`s and writes through +`UpdateRawUnit`, so `canon.Reconcile` (`TransplantIDs`, `CarryTranslations`) +runs. On TestApp, a caption change, and separately an insert at the top plus a +drop in the middle, both kept every untouched item's stored `$ID`, including +the `$ID`s of its caption and action. So identity churn is **not** what this +proposal fixes. What it fixes is the cost, the refusal and the lost concurrent +edits above. The splice below must still be at least as good: untouched items +are passed through byte-identical, not re-paired. + +## Proposed MDL Syntax + +### Shape + +The generic `alter` of ADR-0012 decision 2, `alter { operations }`, +with the operations that type supports. Fragments are written **exactly as +`create or modify navigation` writes items** (`menu item '…' ( OnClick: …, +Icon: … )`, `menu '…' { … }`), so `describe` output can be pasted into a +fragment. + +``` +alter navigation { + insert (before | after | into) { } + insert { } -- append to the top level + replace with { } + move (before | after | into) + drop [, …] + set ( Caption: '…', OnClick: , Icon: | none ) on + + set home (page | microflow) Module.Name [for ] + drop home page for + set login page Module.Page + drop login page + set not found page Module.Page + drop not found page +} + +alter menu Module.Menu { + -- the item operations above; no page clauses +} +``` + +A semicolon after an operation is optional, as in `alter page`. The operations +are read up to the next operation keyword, so `drop 'A', 'B'` needs no +terminator either. + +### Addressing an item + +An item has no name, so it is addressed by **what `describe` prints for it**, its +caption (ADR-0010 R12, ADR-0012 decision 2), as a path written outer to inner: + +| Address | Means | +|---|---| +| `'Users'` | the item captioned `Users`, anywhere in the tree | +| `'Admin' > 'Users'` | the item `Users` whose **parent** is captioned `Admin` | +| `'Settings' > 'Admin' > 'Users'` | the same, one level further out | +| `'Users'@2` | the 2nd item captioned `Users`, in describe order | +| `'Admin'@2 > 'Users'` | `Users` inside the 2nd item captioned `Admin` | +| `'Admin' > 'Users'@2` | the 2nd `Users` among the sub-items of the items captioned `Admin` | +| `''` | an item with an empty caption (icon-only), usually with `@n` | + +Rules: + +1. **Captions match exactly.** The comparison is case-sensitive, after MDL + unquoting (`'it''s'`), against the caption string `describe` prints. The + resolver reads a caption with the **same function** `describe` does (see + [Caption language](#caption-language)), so "copy what describe printed" is + true by construction. +2. **The search covers the whole tree, in describe order.** That order is + pre-order: an item, then its sub-items, then its next sibling. This matches + how `alter workflow` and `alter microflow` resolve a caption anywhere in the + document. +3. **A path matches as a suffix, one direct step per `>`.** The first segment + matches anywhere in the tree; each following segment matches only the + **direct** sub-items of what the segment before it matched. So + `'Admin' > 'Users'` does not reach a `Users` two levels below `Admin`, and an + address never changes meaning when an unrelated level is added elsewhere. The + chain need not start at the top level. +4. **`@n` belongs to the segment it follows.** It picks the n-th match of that + segment, in describe order, among the matches the segments before it allow. + This keeps every address compositional, with no rule about which segment + `@n` applies to. A segment without `@n` keeps all its matches, so an + intermediate segment may be ambiguous as long as the whole path is not + (`'Admin' > 'Users'` resolves when only one of two `Admin` menus has a + `Users`). +5. **mxcli never guesses** (ADR-0012). A target that matches nothing is an + error, and so is one that matches several items. Each ambiguity lists every + candidate with the **shortest unique address** that reaches it, in the same + `>` form: + + ``` + alter navigation Responsive: menu item 'Users' is ambiguous: it matches 2 items — write one of these: + 'Admin' > 'Users' + 'Reports' > 'Users' + ``` + + The shortest unique address is the shortest suffix of the item's caption path + that resolves to it alone; when no suffix does (siblings sharing a caption), + it is its parent's shortest address followed by `> 'Caption'@n`, n counting + only the parent's sub-items with that caption (`'Caption'@n` over the whole + tree for a top-level item). Every address printed is checked to resolve back + to its item. + + A miss lists the captions at the level searched and any item whose caption + differs only in letter case. +6. **Positional-only addresses are not offered** (`item@3`, index paths `1.2`). + ADR-0012 rejected them as brittle under any unrelated edit. `@n` only chooses + among items that share a caption. + +`describe navigation` prints no addresses: R12 forbids printing positions or +labels Mendix does not store, and the captions it already prints are the +addresses. What a user needs when captions repeat is a listing that shows them. +`list navigation menu []` becomes a table with an **Address** column +printing the shortest unique address of every item. The ambiguity error prints +the same strings, and so do the existing messages that name a menu item by its +path (`menu item 'Admin' > 'Users'`). + +### Caption language + +A caption is a `Texts$Text`: one `Texts$Translation { LanguageCode, Text }` per +language. Before this proposal navigation read it differently from every other +text: + +| | Rule | Where | +|---|---|---| +| Page / widget describe | the **project's default language**; a default-language entry that is present but empty is printed as `''`; else `en_US`; else the first non-empty entry | `selectTranslationText(items, describeDefaultLanguage(ctx))`, `mdl/executor/describe_language.go` (#702, ako/mxcli#705) | +| `alter page … set Caption` / Title / Label | update the **authoring language's** entry (= the default language), or add one; every other language's entry is kept | `pagemutator.setTextsTextTranslation` (CE4899 case) | +| `create page` caption | one translation, in the authoring language | `model.AuthoringLanguage()` | +| Navigation / menu describe (before) | the **first non-empty** translation, in document order | `textOf`, `navigation_read.go` | + +So on a project whose default is `nl_NL` with an item stored `en_US 'Home'`, +`nl_NL 'Start'`, page-style reading prints `'Start'` and navigation printed +`'Home'`. + +Decision 4 makes navigation follow pages, with **one** reading function shared by +the three consumers: + +- **Read** (describe, `list navigation menu`, the address resolver, `check`): + the page `describe` rule above. It moves from the executor to the `model` + package (`model.SelectTranslation`) so the backend's navigation reader, the + menu splice and the page describer call the same code; `selectTranslationText` + delegates to it unchanged. +- **Write** `set ( Caption: '…' ) on `: the `alter page` rule, through the + same helper (it moves from `pagemutator` to `bsonnav`): the authoring + language's translation is updated or added, every other language is kept. +- **Write** an inserted or replacing item: one translation in the authoring + language, as `create navigation` already writes. + +The executor resolves the project's default language before reading or writing +a navigation document, as `alter page` does (`authoringLanguage(ctx)`), since +the backend reads it through `model.AuthoringLanguage()`. + +**Effect on describe (intended).** On a single-language project nothing +changes. On a multi-language project, navigation and menu `describe` now print +the default-language caption instead of the first stored one, and an item whose +default-language caption is empty prints `''` — exactly what `describe page` +prints for a button with the same texts. The reader also stops dropping an item +whose caption is empty and whose action is not a page: such an item (an +icon-only microflow item, say) was invisible to `describe` and so deleted by a +`describe` → `create or modify` round trip; it is now printed as +`menu item '' ( … )` and addressable as `''`. + +Out of scope: two page **address** forms still compare other texts — a grid +column's `column('Caption')` matches the first non-empty translation, and a +navigation-list widget's `item('Text')` matches any language. They are page +targeting rules, not navigation, and are left as they are. + +### Item operations + +- **`insert before | after { … }`** places the fragment's items as + siblings of the target. +- **`insert into { … }`** appends them as the target's last sub-items. To + place one first, use `insert before` on the first sub-item. +- **`insert { … }`** with no position appends to the top level. It works like + `set ( … )` without `on`, which addresses the document itself in `alter page`. + It is also the only way to add to an empty menu. +- **`replace with { … }`** puts the fragment's items (one or more) where + the target was. The target's sub-items go with it, unless the fragment states + them again. +- **`move before | after | into `** relocates the stored item + **with its bytes**: its `$ID`, its sub-items, every caption translation, and an + action MDL cannot spell (a page title override, a non-Web link type). Moving is + not `drop` + `insert`, which would lose all of that. A move into the item's own + subtree, or relative to itself, is refused. +- **`drop , …`** removes each item and its sub-items. +- **`set ( Key: value, … ) on `** changes properties in place, keeping the + item's `$ID` and everything not named. The keys are the item property names + `create` already uses, plus `Caption`, which `create` writes positionally: + - `Caption: '…'` writes the authoring language's translation; other languages + stay as they are, as when the caption is edited in Studio Pro. + - `OnClick: ` takes the same action expression `create` takes. `OnClick: + nothing` stores `Forms$NoAction`. + - `Icon: Module.Collection.name | glyph | image Module.Images.name | none`. + `none` follows the `Authentication: none` precedent and stores a null icon. + +### Profile operations (`alter navigation` only) + +The profile's settings are **clauses** in `create`, not a property list (ako/mxcli#754, recorded under R2 of +[PROPOSAL_mdl_beta_syntax_freeze.md](PROPOSAL_mdl_beta_syntax_freeze.md): "the profile's +clauses … stay clauses for now"). So each +operation is that clause written exactly as in `create`, preceded by `set` or +`drop`. A clause line `describe` printed becomes an operation by prefixing +`set`. + +| Operation | Writes | +|---|---| +| `set home page M.P` / `set home microflow M.F` | `HomePage`'s `Page` / `Microflow`, in the stored element (its `$ID` stays) | +| `set home page M.P for Role` | that role's `RoleBasedHomePage`: the stored one is updated in place, or one is appended; other roles are untouched | +| `drop home page for Role` | removes that role's element from `HomeItems`. The kind word does not have to match: this removes a microflow home too. A role with no home page is refused | +| `set login page M.P` / `drop login page` | `LoginPageSettings.Form` = `M.P` / `""` (the element and its `$ID` stay) | +| `set not found page M.P` / `drop not found page` | `NotFoundHomepage` = element / `null` | + +There is **no `drop home page`** without `for`. The default home page is required +on a web profile (CE0527), so the statement refuses it. Use `set home …` to +change it. + +### One complete example + +Given what `describe navigation Responsive` prints: + +```mdl +create or modify navigation Responsive + home page Shop.Home_Web + home page Shop.AdminHome for Administrator + login page Administration.Login + not found page Shop.NotFound +{ + menu item 'Home' ( OnClick: show page Shop.Home_Web, Icon: Atlas_Core.Atlas.home ) + menu 'Admin' ( Icon: Atlas_Core.Atlas.cog ) { + menu item 'Users' ( OnClick: show page Administration.Account_Overview ) + menu item 'Old import' ( OnClick: call microflow Shop.ACT_LegacyImport ) + } + menu 'Reports' { + menu item 'Users' ( OnClick: show page Shop.Report_Users ) + } + menu item 'Help' ( OnClick: open link 'https://example.com/help' ) + menu item 'Log out' ( OnClick: sign out ) +}; +``` + +one statement makes several unrelated edits and touches nothing else: + +```mdl +alter navigation Responsive { + insert after 'Home' { + menu item 'Orders' ( OnClick: show page Shop.Order_Overview, Icon: Atlas_Core.Atlas."list-bullets" ) + } + insert into 'Admin' { + menu item 'Audit log' ( OnClick: show page Shop.AuditLog_Overview ) + } + set ( Caption: 'User accounts' ) on 'Admin' > 'Users'; + set ( OnClick: show page Shop.Report_UserActivity ) on 'Reports' > 'Users'; + replace 'Help' with { + menu 'Help' { + menu item 'Documentation' ( OnClick: open link 'https://example.com/docs' ) + menu item 'Contact' ( OnClick: show page Shop.Contact ) + } + } + move 'Log out' after 'Help'; + drop 'Old import'; + set login page Shop.CustomLogin; + drop not found page; +} +``` + +A bare `'Users'` in that statement is refused, because it matches two items. The +error offers `'Admin' > 'Users'` and `'Reports' > 'Users'`. + +## Semantics + +- **Atomic per statement.** The operations apply in order to an in-memory copy + of the stored profile (or menu document). Any refusal discards the copy, and + nothing is written. The statement makes one write. +- **Sequential resolution.** Each target resolves against the menu as the + earlier operations of the same statement left it. This matches what `alter + page` does (`resolveAlterPageTargets` runs per operation), so an item inserted + by one operation can be the target of the next. +- **Splice, not rebuild (ADR-0012 decision 3).** Untouched items, and every + untouched key of a `set` target, are carried as the stored bytes. Only new + nodes are placed. New nodes are fragment items and their caption, translation, + action and icon elements, the translation a `set Caption` adds, and the + role-home, login or not-found element a `set` creates where none is stored. + They get fresh `$ID`s (CLAUDE.md rule 3), and an `Items` marker of 3. No + existing `$ID` is ever rewritten (CLAUDE.md rule 1). +- **The write.** It goes through `canon.Reconcile` via the patch entry point + `UpdateRawUnitPatch` (CLAUDE.md rule 2), as `alter microflow` does. Element + `$ID` re-pairing and translation carry are off, because the splice already owns + both. Re-pairing after a `drop` could move a surviving item's `$ID` onto a + different item, and carrying translations would restore a caption on purpose + removed. Elision and the storage-GUID guard apply as for every write, so an + `alter` that changes nothing (a `set` to the stored value, say) writes nothing + and reports so. +- **`replace` is new content.** The replacement items get fresh `$ID`s. For each + replacement item whose caption text equals a replaced item's (paired by + caption at each level, as `create or modify` pairs items), the splice carries + that caption's other-language translations. The global carry is off for a + patch, so without this a translated app would lose its translations through a + `replace` that did not change the caption. +- **Refusals.** Each one names the statement, the operation and the target: + - a target that is missing or ambiguous, or an `@n` out of range (the listing + above); + - `insert into` / `move into` an item whose action is not `Forms$NoAction`, and + `set ( OnClick: … )` other than `nothing` on an item with sub-items. These + are CE0548. The refusal says how to make the change: `set ( OnClick: nothing + ) on …` first; + - `move` into the item's own subtree, or relative to itself; + - `drop home page` without `for`, which would cause CE0527, and `drop home + page for` a role that has none; + - a **native** profile. Its menu is a bottom bar of a different `$Type` + (`BottomBarItems`) that mxcli does not write, and its home pages are + different elements; `alter navigation` refuses the profile and points at + `create or modify navigation home …`, which writes a native + profile's home pages and keeps everything else; + - a profile the project does not have. `alter` does not create one. The + message points at `create or modify navigation`, which does; + - an action a menu item cannot carry (save, delete, close page, complete + task). This is the same refusal `create` makes, from the same visitor rule + and `menuActionBuilder`. +- **Only what the statement says is validated.** The fragment items and `set` + values go through the checks `create` applies: references (CE1571), icons + (MDL077/MDL078) and roles (the user-role check of mendixlabs/mxcli#1001). + Untouched stored items are not re-validated. That is the point of the + TestApp measurement above: a stored glyph 9993 must not block an edit to + `'Home'`. +- **`check` and `exec` agree.** `mxcli check` without a project checks syntax and + the fragments. `check -p --references` runs the **same function `exec` + runs** over the stored document, opened for mutation and never saved, the + way `validate_alter_page_refs.go` does for pages: every target `exec` would + refuse, `check` reports with the same message. Successive `alter`s of one + document in a script are applied to the same unsaved copy, so a later + statement sees what an earlier one inserted. A document the script creates or + rewrites with `create or modify` is "could not establish" and stays silent. +- **A duplicate caption is allowed.** Studio Pro allows two siblings with one + caption, so an insert that creates a duplicate is written; the item is then + addressed with `@n`, which `list navigation menu` prints. + +## Implementation Plan + +Order. Each step is a separate commit (CLAUDE.md, scope and atomicity): + +1. **Refactor:** rename the AST/executor's `AlterNavigationStmt` / + `execAlterNavigation`, which today implement **`create or modify`**, to + `CreateNavigationStmt` / `execCreateNavigation`. This frees the name. No + behaviour change. +2. **Refactor:** one caption rule. `model.SelectTranslation` (from + `selectTranslationText`) and `bsonnav.SetTextsTextTranslation` (from + `pagemutator.setTextsTextTranslation`). No behaviour change. +3. **Caption language for navigation** (Decision 4): `textOf` reads with + `model.SelectTranslation`; describe/list prime the language; the reader keeps + empty-caption items. +4. **Address and resolver** (`mdl/types`, `mdl/backend`), with unit tests. +5. **Menu splice** (`mdl/backend/menumutator`) and the backend mutators, with + unit tests on encoded documents. +6. **Grammar, AST, visitor** for `alter navigation` / `alter menu`. +7. **Executor**: `exec`, `check -p`, `list navigation menu` Address column. +8. Docs, skill and syntax topics; doctype tests and the #1522 bug test. + +### Files to modify/create + +| File | Change | +|---|---| +| `mdl/grammar/MDLParser.g4` | `alterStatement`: `ALTER NAVIGATION (qualifiedName \| IDENTIFIER) LBRACE alterNavigationOperation+ RBRACE` and `ALTER MENU_KW qualifiedName LBRACE alterMenuOperation+ RBRACE`. New `alterMenuOperation` (insert/replace/move/drop/set over `navMenuChildren` fragments) and `alterNavigationOperation` (`alterMenuOperation` \| the page clauses after `SET`/`DROP`). New `alterMenuTarget : alterMenuTargetSegment (GREATER_THAN alterMenuTargetSegment)*`, a segment being `STRING_LITERAL (AT NUMBER_LITERAL)?`. `MOVE`, `INSERT`, `REPLACE`, `ITEM`, `CAPTION` and `>` are existing tokens, so the lexer is unchanged | +| `mdl/ast/ast_navigation.go` | `AlterNavigationStmt { ProfileName; Operations }`, `AlterMenuStmt { Name; Operations }` and op nodes `InsertMenuItemsOp`, `ReplaceMenuItemOp`, `MoveMenuItemOp`, `DropMenuItemsOp`, `SetMenuItemOp`, `SetNavPageClauseOp`, `DropNavPageClauseOp` | +| `mdl/types/menu_address.go` (new) | `MenuItemAddress { Segments []{Caption; Ordinal} }` with `String()` in the `>` form | +| `mdl/visitor/visitor_alter_navigation.go` (new) | Builds the AST. `set ( … )` key table (`Caption`, `OnClick`, `Icon`) with repeated/unknown-key errors. Fragments reuse `buildNavMenuItemDef` | +| `mdl/backend/menu_address.go` (new) | `MenuTreeNode`, `ResolveMenuItemAddress(addr, tree)` and `ShortestMenuItemAddress`, returning a `MenuAddressError` listing the shortest unique addresses | +| `mdl/backend/navigation.go` | `MenuMutator` (`MenuTree`, `InsertItems`, `ReplaceItem`, `MoveItem`, `DropItem`, `SetItemCaption`, `SetItemAction`, `SetItemIcon`, `Save`) and `NavigationProfileMutator` (embeds it, adds `SetHomePage`, `DropRoleHomePage`, `SetLoginPage`, `SetNotFoundPage`). `OpenNavigationProfileForMutation(profile)` and `OpenMenuDocumentForMutation(id)` on `NavigationBackend`. The mutators take resolved positions, not addresses: resolution and refusals live in one executor function both `exec` and `check` call | +| `mdl/backend/menumutator/` (new) | The bson.D splice over a `Menus$MenuItemCollection`, parameterised by where the collection sits (`Profiles[i].Menu` or `ItemCollection`), so both documents share it; the page-clause edits; local caption-translation carry for `replace` | +| `mdl/backend/modelsdk/navigation_mutator.go` (new) | Opens the raw unit, builds new items with the existing `navMenuItemBson`, and saves via `writer.UpdateRawUnitPatch` | +| `mdl/backend/mock/`, `mdl/backend/mcp/` | Mock mutator hooks for executor tests. The MCP backend reports the operation as unsupported (regenerate `unsupported_gen.go` / `unimplemented_gen.go`) | +| `mdl/executor/cmd_alter_navigation.go` (new) | Resolve, refuse, apply and save in one pass. Fragments converted with `convertMenuItemDefs` + `menuActionBuilder`. Role resolution reuses the #1001 check. Write report via `ctx.reportWrite` | +| `mdl/executor/validate_alter_navigation_refs.go` (new) | `check -p --references`: the exec function over unsaved mutators | +| `mdl/executor/cmd_navigation.go` | `list navigation menu`: a table with the Address column | +| `cmd/mxcli/syntax/features_misc.go` | Rewrite `navigation.alter` (today it documents the round trip). Fix `navigation.menu-document`. Add `menu.alter` | +| `docs-site/src/reference/navigation/alter-navigation.md` | The page titled ALTER NAVIGATION documents `CREATE OR REPLACE NAVIGATION`. Move that to `create-navigation.md` and document `alter` here | +| `.claude/skills/mendix/manage-navigation/SKILL.md` | "Change one item" section: `alter`, not describe/edit/replace. Then `make sync-skills` | +| `docs/01-project/MDL_QUICK_REFERENCE.md` | Rows for `alter navigation` / `alter menu` | +| `mdl-examples/doctype-tests/` | An `alter navigation` / `alter menu` script, one block per operation (Test Plan) | + +## Version Compatibility + +`Menus$MenuItem`, `Menus$MenuItemCollection` and every key used above exist in +every Mendix version mxcli supports. `AlternativeText` is the only versioned item +property (8.12.0), and this proposal neither reads nor writes it. The +not-found page (`Navigation$NotFoundHomePage`) and `ThrowPartialSyncError` +already exist in the `create` path, which has its own gating. **No new version +gate.** The tests run on 11.6.6 and 11.15.0 because those are the two mxbuild +versions the `create` path was measured on (ako/mxcli#1420), not because either +introduced anything. + +## Test Plan + +- **Grammar and visitor** (`mdl/visitor/`): every address form (`'X'`, + `'Y' > 'X'`, chains, `@n` on each segment, `''`), every operation, and the + `set ( … )` key errors (unknown, repeated, wrong shape). +- **Resolver** (`mdl/backend/`): unique, missing and ambiguous targets, suffix + and direct-step matching, `@n` per segment and out of range. The ambiguity + listing is the shortest unique addresses, and each listed address resolves to + exactly the item it names (checked over every item of generated trees). +- **Caption language**: a multi-language item reads the default language, an + empty default-language entry reads `''`, `en_US` is the fallback; describe of + such a menu changes accordingly (a test that fails against `textOf`'s + first-non-empty rule). +- **Mutator, on encoded documents** (`mdl/backend/menumutator/`): + - each operation produces the expected tree. Untouched items are + **byte-identical** to the input, including their caption translations; + - `move` keeps the moved item's bytes (`$ID`, translations, a kept action); + - `set Caption` changes one translation and keeps the others; + - `replace` carries the other-language translations of a kept caption. +- **Identity carry, with a control** (CLAUDE.md: "any test asserting nothing + changed must include a control"). After `drop`, every surviving item keeps its + stored `$ID` and bytes. The **control** rebuilds the same post-drop tree with + `navMenuItemBson`, which must fail the same assertion, proving the assertion + can detect a rebuild. +- **Elision.** An `alter` whose `set` restates stored values writes nothing. +- **Executor** (`mdl/executor/`, `MockBackend`): the refusals in Semantics leave + the document unwritten. The statement is atomic (a refusal in a later + operation means the earlier ones are not written). `check -p --references` and + `exec` produce the same message for each refusal. +- **The reported case.** Adding one item to a menu with `alter navigation … + { insert … }` keeps every other item, without restating the menu + (`mdl-examples/bug-tests/1522-…`). +- **`mx check` on 11.6.6 and 11.15.0** for each operation on fixture copies: + insert (before/after/into/top level), replace, move, drop, set, nested items, + each profile `set`/`drop`, and `alter menu` on a menu document. Expected: 0 + errors; and a `describe` → `create or modify` after the alter is "unchanged". + +## Open Questions + +1. **`AlternativeText`.** It exists (8.12+) but is null in every observed item, + and `create` has no key for it. Leave it out (recommended) until a Studio + Pro-authored example with a value is available. +2. **A listing for menu-document items.** `list navigation menu` gains the + Address column; there is no `list` statement for a menu document's items, so + a menu document's addresses are only shown by the ambiguity error. A `list + menu items Module.Menu` (or an Address column on some existing listing) is a + small follow-up if wanted. diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index 7ebc226a8b..ac4edf60d3 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -17,6 +17,7 @@ for display in this README): |--------|---------|--------| | Draft | Early design, not finalised | active | | Proposed | Design complete, not yet started | active | +| Accepted | Design approved by the maintainer, implementation pending or under way | active | | Partial | Some phases/features implemented | active | | Done | Fully implemented | `archive/` | | Superseded | Replaced by a newer proposal | `archive/` | @@ -26,7 +27,7 @@ for display in this README): -## Active Proposals (115) +## Active Proposals (116) ### In Progress (partial) (15) @@ -49,6 +50,15 @@ for display in this README): | [Structured description of irreducible microflow graphs](PROPOSAL_structured_microflow_description.md) | Partial | Phase 0 (detector, MDL-FLOW01), Phase E (merge/join for error-path | | [Translations — preserve, describe, author, and auto-translate](PROPOSAL_translations.md) | Partial | A Mendix app ships its user-visible strings in every language it supports. | +### Accepted (4) + +| Proposal | Status | Summary | +|----------|--------|---------| +| [ALTER NAVIGATION / ALTER MENU — item-level edits to a navigation menu or menu document](PROPOSAL_alter_navigation_menu_items.md) | Accepted | A navigation profile's menu can only be written whole. | +| [Constant values — one precedence chain, and a slot for secrets](PROPOSAL_constant_values.md) | Accepted | A Mendix constant has a value in four possible places, mxcli can write two of | +| [MDL Language Critique and Beta Syntax Freeze](PROPOSAL_mdl_beta_syntax_freeze.md) | Accepted | Related: ADR-0003; resulting decisions ADR-0010, ADR-0011, ADR-0012; design-mdl-syntax skill, PROPOSAL_workflow_microflow_syntax_alignment.m | +| [Size containers from their contents, not from a statement count](PROPOSAL_container_autosize.md) | Accepted | Closes the last open item of upstream #884 — | + ### Proposed (40) | Proposal | Status | Summary | diff --git a/scripts/gen_proposals_readme.py b/scripts/gen_proposals_readme.py index 250f187d98..9e86da64a2 100644 --- a/scripts/gen_proposals_readme.py +++ b/scripts/gen_proposals_readme.py @@ -31,6 +31,7 @@ |--------|---------|--------| | Draft | Early design, not finalised | active | | Proposed | Design complete, not yet started | active | +| Accepted | Design approved by the maintainer, implementation pending or under way | active | | Partial | Some phases/features implemented | active | | Done | Fully implemented | `archive/` | | Superseded | Replaced by a newer proposal | `archive/` | @@ -103,13 +104,14 @@ def table(items, link_prefix=""): def main(): active = collect(P) archived = collect(ARCHIVE) - by_status = {"partial": [], "proposed": [], "draft": []} + by_status = {"partial": [], "accepted": [], "proposed": [], "draft": []} for it in active: by_status.setdefault(it[2], []).append(it) parts = [HEADER] parts.append(f"\n## Active Proposals ({len(active)})\n") for st, label in (("partial", "In Progress (partial)"), + ("accepted", "Accepted"), ("proposed", "Proposed"), ("draft", "Draft")): group = sorted(by_status.get(st, []), key=lambda x: x[1].lower()) From 76000121c9f34b1a07eaae7587070490e407aa5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:48:22 +0000 Subject: [PATCH 09/28] fix(deps): raise klauspost/compress and go-mssqldb to their fixed versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second dependency scan of the published .exe, run after the mongo-driver upgrade (#1372), still blocked the customer's release. github.com/klauspost/compress v1.19.2 -> v1.20.3 github.com/microsoft/go-mssqldb v1.10.0 -> v1.11.2 klauspost/compress v1.19.2 predates GHSA-fpmh-frq8-7rj8, a zstd DecodeAll crash on crafted input fixed in v1.20.2. #1372 had only raised it to whatever mongo-driver/v2@v2.9.2 declared, which is not a security floor; it is now an explicit requirement so the floor is ours. As in #1372, the module is compiled into no mxcli build — no go.sum entry and no `go version -m bin/mxcli` line — so this moves only the requirement graph the scanner reads, not the binary. go-mssqldb is a blank import in sql/drivers.go for driver registration and mxcli calls none of its API, so the bump carries no API surface. That registration had no test, and a bump could drop a driver name without breaking the build — surfacing only as "unknown driver" at connect time in front of a user with a real database. TestExternalDriversRegistered now pins it; with the import removed it fails with `driver "sqlserver" is not registered`. Not fixed here: go.mongodb.org/mongo-driver v1 stays flagged for CVE-2026-107325, which has no fix on the v1 line — v1.17.10 is its last release and the module is deprecated. mxcli is not exposed. Measured on v1.17.10, `bsoncore.Array.Validate([]byte{0,0,0,0})` panics with `index out of range [-1]`, but mxcli imports v1 x/bsonx/bsoncore in zero files, every .Validate() call site in the tree resolves to the v2 driver, and v1's bson.Unmarshal — the API mxcli does use, 112 times on .mpr bytes — does not panic on the malformed payloads tried. The same measurement on v1.17.9 is identical, so #1372's v1 bump bought nothing for this CVE and was only CVE-2026-88031 (GridFS). Note also that CVE-2026-107324 does not reproduce on v1 at all, so that code path is v2-only and the v1 report overstates by one CVE. Clearing the finding at source means moving the remaining v1 bson imports to v2, which touches the BSON core and belongs in its own change. Verified: go build ./... clean, 104 test packages pass, go vet and gofmt clean, sqlserver/oracle/pgx all register at runtime, and `go version -m bin/mxcli` shows go-mssqldb v1.11.2 with klauspost/compress still absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rer1mzP69XxbXKxQqheipM --- ...econd-scan-klauspost-mssqldb-mongo-v1.json | 1 + CHANGELOG.md | 5 ++++ go.mod | 3 +- go.sum | 10 +++++++ sql/drivers_test.go | 29 +++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/other/2026-10-20-second-scan-klauspost-mssqldb-mongo-v1.json create mode 100644 sql/drivers_test.go diff --git a/.claude/skills/fix-issue/findings/other/2026-10-20-second-scan-klauspost-mssqldb-mongo-v1.json b/.claude/skills/fix-issue/findings/other/2026-10-20-second-scan-klauspost-mssqldb-mongo-v1.json new file mode 100644 index 0000000000..2b2020aaa4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-20-second-scan-klauspost-mssqldb-mongo-v1.json @@ -0,0 +1 @@ +{"area": "go.mod", "date": "2026-10-20", "symptom": "A second Sonatype Lifecycle scan, run after the v2.9.2 upgrade (ako/mxcli#1372), still blocked the release: github.com/klauspost/compress v1.19.2 Critical:1, go.mongodb.org/mongo-driver v1.17.10 Critical:1, and github.com/microsoft/go-mssqldb v1.10.0. mongo-driver/v2 v2.9.2 had cleared and was gone from the report", "cause": "Three unrelated causes behind one report. (1) klauspost/compress v1.19.2 predates GHSA-fpmh-frq8-7rj8 (zstd DecodeAll crash on crafted input), fixed in v1.20.2 — the previous PR had only raised it to whatever mongo-driver/v2@v2.9.2 declared, which was not a security floor. (2) The v1 mongo-driver line has NO fix for CVE-2026-107325 and never will: v1.17.10 is the last v1 release and the module is marked deprecated. (3) go-mssqldb v1.10.0 was simply behind v1.11.2", "file": "`go.mod` — klauspost/compress v1.19.2 -> v1.20.3 (explicit indirect), go-mssqldb v1.10.0 -> v1.11.2; `sql/drivers_test.go` (new)", "insight": "Measure the vulnerable SYMBOL in the exact version, do not reason from the module's advisory range — v1 and v2 of mongo-driver are separate implementations and behave differently on the same payload. Measured on v1.17.10: `bsoncore.Array.Validate([]byte{0,0,0,0})` panics with `index out of range [-1]` (CVE-2026-107325, unfixed and unfixable on v1), while `bson.Raw.Validate` on the CVE-2026-107324 MaxInt32 payload does NOT panic — that CVE's code path is v2-only, so the v1 report overstates by one. Identical result on v1.17.9, proving the v1.17.10 bump bought nothing for these two and was only CVE-2026-88031 (GridFS). mxcli is not exposed: every `.Validate()` call site in the tree imports v2 (checked by pairing each call site against its file's imports), mxcli imports v1 `x/bsonx/bsoncore` in ZERO files, and `bson.Unmarshal` — the v1 API it actually uses, 112 times on .mpr bytes — does not panic on any malformed payload tried. So the v1 finding is real in the module and unreachable in this program, and no upgrade can ever clear it; only dropping the v1 dependency (107 non-test + 179 test files) can. Second trap, same shape as the first scan's: klauspost/compress still has NO go.sum entry and no `go version -m` line at v1.20.3 — never compiled — yet raising the declared requirement is what clears a graph-reading scanner, so the one-line bump is worth it even though no code changes. A blank-import driver registration (`sql/drivers.go`) had no test at all, so a bump could drop a driver name and only fail at connect time in front of a user: `TestExternalDriversRegistered` now covers it, and fails with `driver \"sqlserver\" is not registered` when the import is removed", "refs": ["CVE-2026-107325", "GHSA-fpmh-frq8-7rj8", "ako/mxcli#1372"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index a0840307ef..d7bb9e5376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Security + +- **Dependency scan follow-up: `klauspost/compress` and `go-mssqldb` raised to their fixed versions** — `github.com/klauspost/compress` v1.19.2 -> v1.20.3 and `github.com/microsoft/go-mssqldb` v1.10.0 -> v1.11.2. A second scan of the published `.exe`, run after the mongo-driver upgrade, still blocked the release. `klauspost/compress` v1.19.2 predates GHSA-fpmh-frq8-7rj8 (a zstd `DecodeAll` crash on crafted input, fixed in v1.20.2) — the earlier upgrade had only raised it to whatever `mongo-driver/v2` declared, which is not a security floor; it is now an explicit requirement. As before, that module is compiled into no mxcli build (no `go.sum` entry, no `go version -m` line), so the bump moves only the requirement graph the scanner reads. `go-mssqldb` is a blank import in `sql/drivers.go` for driver registration and mxcli calls none of its API, so the bump has no API surface; `TestExternalDriversRegistered` now pins that contract, which had no coverage, and fails with `driver "sqlserver" is not registered` when the import is dropped. +- **`go.mongodb.org/mongo-driver` v1 remains flagged and cannot be upgraded out** — CVE-2026-107325 has no fix on the v1 line (v1.17.10 is its last release and the module is deprecated). mxcli is not exposed: the panic is in `bsoncore.Array.Validate`, which mxcli imports in no file, every `.Validate()` call site in the tree resolves to the v2 driver, and v1's `bson.Unmarshal` — the API mxcli does use, on `.mpr` bytes — does not panic on the malformed payloads tried. Measured on v1.17.10: `bsoncore.Array.Validate([]byte{0,0,0,0})` panics with `index out of range [-1]`, while `bson.Raw.Validate` on CVE-2026-107324's `math.MaxInt32` payload does not, so that second CVE's code path is v2-only and the v1 report overstates it by one. Clearing the finding at source needs the remaining v1 `bson` imports moved to v2, which is tracked separately. + ### Added - **`layoutheader` and native layout parts — every Atlas layout copies whole** — `describe layout` → `create layout` of Atlas's Phone_ and Tablet_ layouts dropped their header bar (`Forms$Header`, printed as NOT re-executable), so pages bound to its `HeaderLeft` / `HeaderRight` placeholders failed CE1613 and a copy of Phone_Sidebar or Tablet_Sidebar failed CE0611 "A sidebar toggle button is required if a region can toggle". `layoutheader header1 { region left { … } region right { … } }` writes it. A native layout's copy silently lost its header placeholder, bottom bar and sidebar — pages bound to `Header` or a sidebar placeholder then failed CE1613; they are `region header { placeholder Header }`, `showbottombar: true` and `region sidebar { … }`, and the native content is written with the eight keys Studio Pro stores. Measured on a new 11.13.0 app: all 22 Atlas_Core layouts copied, with pages bound to the copied header and sidebar placeholders, build with 0 errors. diff --git a/go.mod b/go.mod index 85f9069c08..20c67c5cb0 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/jpillora/chisel v1.11.8 github.com/mattn/go-runewidth v0.0.24 - github.com/microsoft/go-mssqldb v1.10.0 + github.com/microsoft/go-mssqldb v1.11.2 github.com/pmezard/go-difflib v1.0.0 github.com/sergi/go-diff v1.4.0 github.com/sijms/go-ora/v2 v2.9.0 @@ -63,6 +63,7 @@ require ( github.com/jpillora/backoff v1.0.0 // indirect github.com/jpillora/requestlog v1.0.0 // indirect github.com/jpillora/sizestr v1.0.0 // indirect + github.com/klauspost/compress v1.20.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 52a52b5884..6cbc1ceb27 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,19 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -108,6 +112,8 @@ github.com/jpillora/requestlog v1.0.0 h1:bg++eJ74T7DYL3DlIpiwknrtfdUA9oP/M4fL+Pp github.com/jpillora/requestlog v1.0.0/go.mod h1:HTWQb7QfDc2jtHnWe2XEIEeJB7gJPnVdpNn52HXPvy8= github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw= github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -127,6 +133,8 @@ github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/a github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= +github.com/microsoft/go-mssqldb v1.11.2 h1:FCgeBIK8um2+X4tbun6Q71N1KsfyCDPKY41e1yGVjSE= +github.com/microsoft/go-mssqldb v1.11.2/go.mod h1:CYgwG5AMXFojbjTg+GNP5G/y6uz1BhTyZaPqQWzkGnQ= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -167,6 +175,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -197,6 +206,7 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= diff --git a/sql/drivers_test.go b/sql/drivers_test.go new file mode 100644 index 0000000000..4690b50062 --- /dev/null +++ b/sql/drivers_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sql + +import ( + dbsql "database/sql" + "testing" +) + +// The external-SQL drivers are wired in by blank import for their registration +// side effect alone — this package calls none of their APIs. A dependency bump +// can therefore change or drop a driver name without breaking the build, and +// the failure would only surface as "unknown driver" at connect time, in front +// of a user with a real database. +// +// Added while bumping github.com/microsoft/go-mssqldb v1.10.0 -> v1.11.2 for a +// customer's blocking dependency scan, which had no coverage to back it. +func TestExternalDriversRegistered(t *testing.T) { + registered := make(map[string]bool) + for _, d := range dbsql.Drivers() { + registered[d] = true + } + + for _, name := range []string{"sqlserver", "pgx", "oracle"} { + if !registered[name] { + t.Errorf("driver %q is not registered; database/sql knows %v", name, dbsql.Drivers()) + } + } +} From 5e494af03dc48fe4015bf046cfe93e6a51f7c3af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:50:37 +0000 Subject: [PATCH 10/28] refactor(navigation): rename AlterNavigationStmt to CreateNavigationStmt The AST node and executor handler named "alter" implement `create [or modify] navigation`. Renaming them to CreateNavigationStmt / execCreateNavigation frees the name for the item-level `alter navigation` of the accepted proposal. No behaviour change. The rename made the create statement visible to TestEveryCreateStmtIsClassified, which lists Create* types from mdl/ast; it is recorded as not classified, because exec never refuses a plain CREATE of an existing profile - it writes the stated clauses into it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...con-refs-1008-placeholder-snippet-layout-menu.mdl | 2 +- mdl/ast/ast_navigation.go | 9 +++++---- mdl/executor/cmd_navigation.go | 8 ++++---- mdl/executor/cmd_navigation_action_render_test.go | 4 ++-- mdl/executor/cmd_navigation_icon_test.go | 2 +- mdl/executor/cmd_navigation_keep_action_test.go | 2 +- mdl/executor/cmd_navigation_native_test.go | 2 +- mdl/executor/describe_create_or_modify_test.go | 2 +- .../issue1341_submenu_keeps_page_action_test.go | 2 +- mdl/executor/issue1343_menu_caption_quote_test.go | 2 +- mdl/executor/navigation_report_mutation_test.go | 2 +- mdl/executor/offline_profile_warning_test.go | 12 ++++++------ mdl/executor/r2_rest_describe_test.go | 2 +- mdl/executor/register_stubs.go | 4 ++-- mdl/executor/registry_test.go | 2 +- mdl/executor/stmt_summary.go | 2 +- mdl/executor/validate_definition_order.go | 2 +- mdl/executor/validate_duplicates_coverage_test.go | 2 ++ mdl/executor/validate_flow_access.go | 4 ++-- mdl/executor/validate_glyph_codes_test.go | 6 +++--- mdl/executor/validate_icon_refs.go | 2 +- mdl/executor/validate_icon_refs_test.go | 2 +- mdl/executor/validate_navigation_icons.go | 2 +- mdl/executor/validate_navigation_icons_test.go | 12 ++++++------ mdl/executor/validate_navigation_roles.go | 4 ++-- mdl/executor/validate_navigation_roles_test.go | 10 +++++----- mdl/executor/validate_navigation_targets.go | 2 +- mdl/executor/validate_navigation_targets_test.go | 4 ++-- mdl/executor/validate_protected_module_refs.go | 6 +++--- mdl/executor/xpath_string_escape_test.go | 2 +- mdl/visitor/nav_sync_mode_enum_test.go | 2 +- mdl/visitor/visitor_navigation.go | 4 ++-- mdl/visitor/visitor_navigation_action_test.go | 6 +++--- mdl/visitor/visitor_navigation_icon_test.go | 4 ++-- mdl/visitor/visitor_navigation_test.go | 4 ++-- mdl/visitor/visitor_xpath_string_escape_test.go | 2 +- 36 files changed, 72 insertions(+), 69 deletions(-) diff --git a/mdl-examples/bug-tests/icon-refs-1008-placeholder-snippet-layout-menu.mdl b/mdl-examples/bug-tests/icon-refs-1008-placeholder-snippet-layout-menu.mdl index 5ac3f35e9c..38375974a4 100644 --- a/mdl-examples/bug-tests/icon-refs-1008-placeholder-snippet-layout-menu.mdl +++ b/mdl-examples/bug-tests/icon-refs-1008-placeholder-snippet-layout-menu.mdl @@ -13,7 +13,7 @@ mdl 1; -- The reported case — a button in a page body — was already caught (the -- resolver shipped in 0.18.0). Four OTHER shapes were not, because -- iconRefsInStatement walked only CreatePageStmtV3.Widgets, AlterPageStmt and --- AlterNavigationStmt. Each of these holds its widgets in a field the walk never +-- CreateNavigationStmt. Each of these holds its widgets in a field the walk never -- visited, so the icon was never seen at all: -- -- 1. `placeholder X { … }` — CreatePageStmtV3.Placeholders, held apart from diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go index e422934710..bb98564e33 100644 --- a/mdl/ast/ast_navigation.go +++ b/mdl/ast/ast_navigation.go @@ -4,9 +4,10 @@ package ast import "github.com/mendixlabs/mxcli/mdl/types" -// AlterNavigationStmt represents: CREATE [OR REPLACE] NAVIGATION [clauses...] -// This is a full-replacement command: omitted clauses clear that section. -type AlterNavigationStmt struct { +// CreateNavigationStmt represents: CREATE [OR MODIFY] NAVIGATION [clauses...] +// A clause it states replaces what it names (a { } menu block the whole menu); +// an omitted clause keeps the stored value (mendixlabs/mxcli#1522). +type CreateNavigationStmt struct { ProfileName string // e.g. "Responsive" HomePages []NavHomePageDef // HOME PAGE/MICROFLOW ... [FOR role] LoginPage *QualifiedName // LOGIN PAGE ... @@ -27,7 +28,7 @@ type AlterNavigationStmt struct { CreateOrModify bool // true if CREATE OR REPLACE/MODIFY was used } -func (s *AlterNavigationStmt) isStatement() {} +func (s *CreateNavigationStmt) isStatement() {} // NavSyncDef represents one `SYNC ` line inside a SYNC block. // diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index ecf455ecf8..02a6d19bfd 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -14,11 +14,11 @@ import ( "github.com/mendixlabs/mxcli/mdl/visitor" ) -// execAlterNavigation handles CREATE [OR REPLACE] NAVIGATION command. +// execCreateNavigation handles CREATE [OR MODIFY] NAVIGATION command. // A clause the statement states replaces what it names; an omitted clause keeps // the stored value (mendixlabs/mxcli#1522) -- the writer's rule, see // navPatchWebProfile. -func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { +func execCreateNavigation(ctx *ExecContext, s *ast.CreateNavigationStmt) error { if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } @@ -171,7 +171,7 @@ func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { // web app settings. Mendix then registers no service worker -- index.js carries // "registerServiceWorker": false -- so no page opens without a network, while // check, exec and mx check all pass (mendixlabs/mxcli#1377). -func warnOfflineWithoutPWA(ctx *ExecContext, s *ast.AlterNavigationStmt, kind string, stored *types.NavPWASettings) { +func warnOfflineWithoutPWA(ctx *ExecContext, s *ast.CreateNavigationStmt, kind string, stored *types.NavPWASettings) { if !types.IsOfflineProfileKind(kind) { return } @@ -196,7 +196,7 @@ func warnOfflineWithoutPWA(ctx *ExecContext, s *ast.AlterNavigationStmt, kind st // stayed as it was. Its flow home is a nanoflow — `home microflow` there is // what describe used to print, so it is read as the nanoflow and warned about — // while a web profile's is a microflow, so `home nanoflow` there is refused. -func checkProfileClauses(ctx *ExecContext, s *ast.AlterNavigationStmt, isNative bool) error { +func checkProfileClauses(ctx *ExecContext, s *ast.CreateNavigationStmt, isNative bool) error { if !isNative { for _, hp := range s.HomePages { if hp.IsNanoflow { diff --git a/mdl/executor/cmd_navigation_action_render_test.go b/mdl/executor/cmd_navigation_action_render_test.go index 29ccf4fe45..c85e14e546 100644 --- a/mdl/executor/cmd_navigation_action_render_test.go +++ b/mdl/executor/cmd_navigation_action_render_test.go @@ -68,7 +68,7 @@ func TestDescribeNavigation_EveryMenuActionKind(t *testing.T) { if len(errs) > 0 { t.Fatalf("does not parse: %v", errs[0]) } - if got := prog.Statements[0].(*ast.AlterNavigationStmt).MenuItems[0].Action; got == nil || got.Type != c.kind { + if got := prog.Statements[0].(*ast.CreateNavigationStmt).MenuItems[0].Action; got == nil || got.Type != c.kind { t.Errorf("re-parsed as %+v, want %s", got, c.kind) } }) @@ -98,7 +98,7 @@ func TestNavigationRewrite_BuildsTheStatedAction(t *testing.T) { if len(errs) > 0 { t.Fatal(errs[0]) } - assertNoError(t, execAlterNavigation(ctx, prog.Statements[0].(*ast.AlterNavigationStmt))) + assertNoError(t, execCreateNavigation(ctx, prog.Statements[0].(*ast.CreateNavigationStmt))) it := got.MenuItems[0] if it.Action == nil || it.KeepAction != nil || !bytes.Equal(it.StoredAction, stored) { t.Errorf("stated action: Action=%T KeepAction=%q StoredAction=%q", it.Action, it.KeepAction, it.StoredAction) diff --git a/mdl/executor/cmd_navigation_icon_test.go b/mdl/executor/cmd_navigation_icon_test.go index 0d9fd0bce9..1a8de1055b 100644 --- a/mdl/executor/cmd_navigation_icon_test.go +++ b/mdl/executor/cmd_navigation_icon_test.go @@ -165,7 +165,7 @@ func TestPrintMenuMDL_EmittedIconsReParse(t *testing.T) { if len(errs) > 0 { t.Fatalf("DESCRIBE emitted output its own parser rejects:\n%s\nerrors: %v", script, errs) } - stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + stmt, ok := prog.Statements[0].(*ast.CreateNavigationStmt) if !ok || len(stmt.MenuItems) != 1 { t.Fatalf("re-parsed to %T", prog.Statements[0]) } diff --git a/mdl/executor/cmd_navigation_keep_action_test.go b/mdl/executor/cmd_navigation_keep_action_test.go index fb0b9cea35..97009c97c7 100644 --- a/mdl/executor/cmd_navigation_keep_action_test.go +++ b/mdl/executor/cmd_navigation_keep_action_test.go @@ -51,7 +51,7 @@ func TestNavigationRewrite_KeepsAnActionMDLCannotExpress(t *testing.T) { if len(errs) > 0 { t.Fatal(errs[0]) } - assertNoError(t, execAlterNavigation(ctx, prog.Statements[0].(*ast.AlterNavigationStmt))) + assertNoError(t, execCreateNavigation(ctx, prog.Statements[0].(*ast.CreateNavigationStmt))) if len(got.MenuItems) != 3 { t.Fatalf("spec has %d items, want 3", len(got.MenuItems)) diff --git a/mdl/executor/cmd_navigation_native_test.go b/mdl/executor/cmd_navigation_native_test.go index 0bba57f8f9..5866c5a012 100644 --- a/mdl/executor/cmd_navigation_native_test.go +++ b/mdl/executor/cmd_navigation_native_test.go @@ -34,7 +34,7 @@ func runNav(t *testing.T, mb *mock.MockBackend, script string) (string, error) { if len(errs) > 0 { t.Fatal(errs[0]) } - err := execAlterNavigation(ctx, prog.Statements[0].(*ast.AlterNavigationStmt)) + err := execCreateNavigation(ctx, prog.Statements[0].(*ast.CreateNavigationStmt)) return buf.String(), err } diff --git a/mdl/executor/describe_create_or_modify_test.go b/mdl/executor/describe_create_or_modify_test.go index 31fd6a67c7..e16ad95118 100644 --- a/mdl/executor/describe_create_or_modify_test.go +++ b/mdl/executor/describe_create_or_modify_test.go @@ -175,7 +175,7 @@ func TestDescribeNavigation_EmitsCreateOrModify(t *testing.T) { if len(prog.Deprecations) != 0 { t.Errorf("describe navigation emitted a deprecated spelling %+v:\n%s", prog.Deprecations, buf.String()) } - stmt := firstStatement[*ast.AlterNavigationStmt](t, buf.String()) + stmt := firstStatement[*ast.CreateNavigationStmt](t, buf.String()) if !stmt.CreateOrModify { t.Errorf("describe navigation is not an idempotent create:\n%s", buf.String()) } diff --git a/mdl/executor/issue1341_submenu_keeps_page_action_test.go b/mdl/executor/issue1341_submenu_keeps_page_action_test.go index 1e93172e3d..eb8170792d 100644 --- a/mdl/executor/issue1341_submenu_keeps_page_action_test.go +++ b/mdl/executor/issue1341_submenu_keeps_page_action_test.go @@ -62,7 +62,7 @@ func TestNavigationRewrite_SubMenuDoesNotKeepAStoredAction(t *testing.T) { if len(errs) > 0 { t.Fatal(errs[0]) } - assertNoError(t, execAlterNavigation(ctx, prog.Statements[0].(*ast.AlterNavigationStmt))) + assertNoError(t, execCreateNavigation(ctx, prog.Statements[0].(*ast.CreateNavigationStmt))) if len(got.MenuItems) != 2 || got.MenuItems[0].Caption != "Work" { t.Fatalf("spec items = %+v, want 'Work' and 'Reports'", got.MenuItems) diff --git a/mdl/executor/issue1343_menu_caption_quote_test.go b/mdl/executor/issue1343_menu_caption_quote_test.go index 2f1e70a807..19104b9549 100644 --- a/mdl/executor/issue1343_menu_caption_quote_test.go +++ b/mdl/executor/issue1343_menu_caption_quote_test.go @@ -33,7 +33,7 @@ func TestDescribeNavigation_CaptionWithApostropheRoundTrips(t *testing.T) { if len(errs) > 0 { t.Fatalf("describe output does not re-parse: %v\n%s", errs[0], out) } - menu := prog.Statements[0].(*ast.AlterNavigationStmt).MenuItems + menu := prog.Statements[0].(*ast.CreateNavigationStmt).MenuItems if len(menu) != 1 || menu[0].Caption != "Customer's menu" { t.Fatalf("sub-menu re-parsed as %+v\n%s", menu, out) } diff --git a/mdl/executor/navigation_report_mutation_test.go b/mdl/executor/navigation_report_mutation_test.go index bc868525d1..b418287512 100644 --- a/mdl/executor/navigation_report_mutation_test.go +++ b/mdl/executor/navigation_report_mutation_test.go @@ -47,7 +47,7 @@ func TestCreateOrModifyNavigation_ReportsWhatHappened(t *testing.T) { ctx, out := newMockCtx(t) ctx.Backend = cb prog := parseMDL(t, "create or modify navigation Responsive {\n menu item 'Reports'\n};") - assertNoError(t, execAlterNavigation(ctx, prog.Statements[0].(*ast.AlterNavigationStmt))) + assertNoError(t, execCreateNavigation(ctx, prog.Statements[0].(*ast.CreateNavigationStmt))) got := out.String() if !strings.Contains(got, tc.want) { t.Fatalf("output %q, want it to contain %q", got, tc.want) diff --git a/mdl/executor/offline_profile_warning_test.go b/mdl/executor/offline_profile_warning_test.go index 3de82e789b..9087c2c484 100644 --- a/mdl/executor/offline_profile_warning_test.go +++ b/mdl/executor/offline_profile_warning_test.go @@ -71,8 +71,8 @@ func TestCreatingAnOfflineProfileWarnsAboutMultiStepPaths(t *testing.T) { mb := navBackendWithTwoHopPage(t) ctx, buf := newMockCtx(t, withBackend(mb)) - stmt := &ast.AlterNavigationStmt{ProfileName: "TabletOffline"} - assertNoError(t, execAlterNavigation(ctx, stmt)) + stmt := &ast.CreateNavigationStmt{ProfileName: "TabletOffline"} + assertNoError(t, execCreateNavigation(ctx, stmt)) out := buf.String() assertContainsStr(t, out, "created") @@ -88,8 +88,8 @@ func TestCreatingAnOnlineProfileSaysNothingAboutOfflinePaths(t *testing.T) { mb := navBackendWithTwoHopPage(t) ctx, buf := newMockCtx(t, withBackend(mb)) - stmt := &ast.AlterNavigationStmt{ProfileName: "Tablet"} - assertNoError(t, execAlterNavigation(ctx, stmt)) + stmt := &ast.CreateNavigationStmt{ProfileName: "Tablet"} + assertNoError(t, execCreateNavigation(ctx, stmt)) out := buf.String() assertContainsStr(t, out, "created") @@ -115,7 +115,7 @@ func TestReplacingAnExistingOfflineProfileDoesNotWarn(t *testing.T) { } ctx, buf := newMockCtx(t, withBackend(mb)) - assertNoError(t, execAlterNavigation(ctx, &ast.AlterNavigationStmt{ProfileName: "TabletOffline"})) + assertNoError(t, execCreateNavigation(ctx, &ast.CreateNavigationStmt{ProfileName: "TabletOffline"})) out := buf.String() assertContainsStr(t, out, "updated") @@ -139,7 +139,7 @@ func TestOfflineProfileWarningIsSilentOnACleanProject(t *testing.T) { mb.GetRawUnitBytesFunc = func(model.ID) ([]byte, error) { return clean, nil } ctx, buf := newMockCtx(t, withBackend(mb)) - assertNoError(t, execAlterNavigation(ctx, &ast.AlterNavigationStmt{ProfileName: "PhoneOffline"})) + assertNoError(t, execCreateNavigation(ctx, &ast.CreateNavigationStmt{ProfileName: "PhoneOffline"})) if strings.Contains(buf.String(), "CE6206") { t.Errorf("warned about a project with no multi-step path:\n%s", buf.String()) } diff --git a/mdl/executor/r2_rest_describe_test.go b/mdl/executor/r2_rest_describe_test.go index 206564570b..806f83be7c 100644 --- a/mdl/executor/r2_rest_describe_test.go +++ b/mdl/executor/r2_rest_describe_test.go @@ -41,7 +41,7 @@ func TestDescribeNavigation_MenuItemsAreChildren(t *testing.T) { "menu item 'Users' ( OnClick: call microflow M.ShowUsers )", "menu item 'Out' ( OnClick: sign out )", "};") - stmt := findStmt[*ast.AlterNavigationStmt](t, reparse(t, out), out) + stmt := findStmt[*ast.CreateNavigationStmt](t, reparse(t, out), out) if len(stmt.MenuItems) != 2 || len(stmt.MenuItems[1].Items) != 2 || !stmt.MenuItems[1].Items[1].SignOut || stmt.MenuItems[1].IconCode != 57345 || stmt.MenuItems[0].Page == nil { t.Errorf("menu did not round-trip: %+v", stmt.MenuItems) diff --git a/mdl/executor/register_stubs.go b/mdl/executor/register_stubs.go index b0f0a67c82..5b9474b114 100644 --- a/mdl/executor/register_stubs.go +++ b/mdl/executor/register_stubs.go @@ -214,8 +214,8 @@ func registerSecurityHandlers(r *Registry) { } func registerNavigationHandlers(r *Registry) { - r.Register(&ast.AlterNavigationStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { - return execAlterNavigation(ctx, stmt.(*ast.AlterNavigationStmt)) + r.Register(&ast.CreateNavigationStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { + return execCreateNavigation(ctx, stmt.(*ast.CreateNavigationStmt)) }) r.Register(&ast.CreateMenuStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { return execCreateMenu(ctx, stmt.(*ast.CreateMenuStmt)) diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index 5a5746a059..c4c6d42ea1 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -169,7 +169,7 @@ func allKnownStatements() []ast.Statement { &ast.AlterKnowledgeBaseStmt{}, &ast.AlterModelStmt{}, &ast.AlterModuleJarDepStmt{}, - &ast.AlterNavigationStmt{}, + &ast.CreateNavigationStmt{}, &ast.CreateMenuStmt{}, &ast.CreateTranslationsStmt{}, &ast.DescribeTranslationsStmt{}, diff --git a/mdl/executor/stmt_summary.go b/mdl/executor/stmt_summary.go index fbf7d80182..85a656ea07 100644 --- a/mdl/executor/stmt_summary.go +++ b/mdl/executor/stmt_summary.go @@ -157,7 +157,7 @@ func stmtSummary(stmt ast.Statement) string { return fmt.Sprintf("alter settings %s", s.Section) // Navigation - case *ast.AlterNavigationStmt: + case *ast.CreateNavigationStmt: return fmt.Sprintf("create navigation %s", s.ProfileName) // Query diff --git a/mdl/executor/validate_definition_order.go b/mdl/executor/validate_definition_order.go index 9c02a16bc8..d03fd80eec 100644 --- a/mdl/executor/validate_definition_order.go +++ b/mdl/executor/validate_definition_order.go @@ -287,7 +287,7 @@ func eagerDefRefs(stmt ast.Statement) []defRef { case *ast.GrantPageAccessStmt: return oneDefRef(s.Page, defPage, "grant view on page "+s.Page.String()) - case *ast.AlterNavigationStmt, *ast.CreateMenuStmt: + case *ast.CreateNavigationStmt, *ast.CreateMenuStmt: // The home, login and not-found pages and every menu item's target, // which the handler resolves before it writes the profile or menu. return navigationDefRefs(stmt) diff --git a/mdl/executor/validate_duplicates_coverage_test.go b/mdl/executor/validate_duplicates_coverage_test.go index 98ce1bd615..fe2afd84ac 100644 --- a/mdl/executor/validate_duplicates_coverage_test.go +++ b/mdl/executor/validate_duplicates_coverage_test.go @@ -209,6 +209,8 @@ var createStmtsNotClassified = map[string]string{ "CheckExecRefusals' (ako/mxcli#906)", "CreateExternalEntitiesStmt": "a bulk import with no name of its own: each entity it would create is looked up " + "in the service contract, which a script-level name check cannot see", + "CreateNavigationStmt": "a navigation profile is one of Mendix's fixed set, and exec never refuses a plain " + + "CREATE of one that exists: it writes the clauses the statement states into it (execCreateNavigation)", "CreateAnnotationStmt": "KNOWN GAP (ako/mxcli#557 follow-up): an annotation has no name — exec refuses a plain " + "CREATE whose caption's first line matches an existing note in the module, which a qualified-name set " + "cannot express", diff --git a/mdl/executor/validate_flow_access.go b/mdl/executor/validate_flow_access.go index 2857533b53..f7d5b52116 100644 --- a/mdl/executor/validate_flow_access.go +++ b/mdl/executor/validate_flow_access.go @@ -176,7 +176,7 @@ func scriptTouchesFlowAccess(prog *ast.Program) bool { case *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt, *ast.DropMicroflowStmt, *ast.DropNanoflowStmt, *ast.RevokeMicroflowAccessStmt, *ast.RevokeNanoflowAccessStmt, *ast.CreatePageStmtV3, *ast.CreateSnippetStmtV3, *ast.AlterPageStmt, - *ast.CreateMenuStmt, *ast.AlterNavigationStmt, *ast.DropModuleRoleStmt: + *ast.CreateMenuStmt, *ast.CreateNavigationStmt, *ast.DropModuleRoleStmt: return true } } @@ -592,7 +592,7 @@ func (s *flowAccessSim) apply(stmt ast.Statement) { s.usesBy["menu "+st.Name.String()] = menuItemFlowRefs(st.Items) case *ast.DropMenuStmt: delete(s.usesBy, "menu "+st.Name.String()) - case *ast.AlterNavigationStmt: + case *ast.CreateNavigationStmt: refs := menuItemFlowRefs(st.MenuItems) for _, hp := range st.HomePages { if !hp.IsPage && hp.Target.Module != "" { diff --git a/mdl/executor/validate_glyph_codes_test.go b/mdl/executor/validate_glyph_codes_test.go index 3ecb679f9f..5cdfb60c22 100644 --- a/mdl/executor/validate_glyph_codes_test.go +++ b/mdl/executor/validate_glyph_codes_test.go @@ -30,8 +30,8 @@ import ( // Atlas_Core ships (glyphicons-halflings-regular.woff): 247 codes, and it agrees // with mxbuild on both measured points. -func glyphStmt(code int) *ast.AlterNavigationStmt { - return &ast.AlterNavigationStmt{ +func glyphStmt(code int) *ast.CreateNavigationStmt { + return &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{{ Caption: "Overview", @@ -109,7 +109,7 @@ func TestMDL078_IgnoresNonGlyphIcons(t *testing.T) { // Sub-items render in the flyout a collapsed rail opens, so a bad code there // breaks the same build. The walk is recursive for the same reason MDL077's is. func TestMDL078_WalksSubItems(t *testing.T) { - stmt := &ast.AlterNavigationStmt{ + stmt := &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{{ Caption: "Group", diff --git a/mdl/executor/validate_icon_refs.go b/mdl/executor/validate_icon_refs.go index a0cc675e13..7041621082 100644 --- a/mdl/executor/validate_icon_refs.go +++ b/mdl/executor/validate_icon_refs.go @@ -210,7 +210,7 @@ func iconRefsInStatement(stmt ast.Statement) []iconRef { } } } - case *ast.AlterNavigationStmt: + case *ast.CreateNavigationStmt: for _, item := range s.MenuItems { out = append(out, iconRefsInMenu(item)...) } diff --git a/mdl/executor/validate_icon_refs_test.go b/mdl/executor/validate_icon_refs_test.go index 8ecaa4deb7..5c305dc71f 100644 --- a/mdl/executor/validate_icon_refs_test.go +++ b/mdl/executor/validate_icon_refs_test.go @@ -153,7 +153,7 @@ func TestIconRefsInStatement(t *testing.T) { }, { name: "navigation menu, including sub-items", - stmt: &ast.AlterNavigationStmt{MenuItems: []ast.NavMenuItemDef{ + stmt: &ast.CreateNavigationStmt{MenuItems: []ast.NavMenuItemDef{ {Caption: "Home", Icon: "Atlas_Core.Atlas.home", Items: []ast.NavMenuItemDef{ {Caption: "Nested", Icon: "Atlas_Core.Atlas.pencil"}, }}, diff --git a/mdl/executor/validate_navigation_icons.go b/mdl/executor/validate_navigation_icons.go index efc06c5da9..b2158c5bfd 100644 --- a/mdl/executor/validate_navigation_icons.go +++ b/mdl/executor/validate_navigation_icons.go @@ -50,7 +50,7 @@ func validateMenuItemIcons(stmt ast.Statement) []linter.Violation { var where string switch s := stmt.(type) { - case *ast.AlterNavigationStmt: + case *ast.CreateNavigationStmt: items, where = s.MenuItems, "navigation "+s.ProfileName case *ast.CreateMenuStmt: items, where = s.Items, "menu "+s.Name.String() diff --git a/mdl/executor/validate_navigation_icons_test.go b/mdl/executor/validate_navigation_icons_test.go index 601ff4c2c0..9421ec7f0e 100644 --- a/mdl/executor/validate_navigation_icons_test.go +++ b/mdl/executor/validate_navigation_icons_test.go @@ -32,7 +32,7 @@ func navIconWarnings(vs []linter.Violation) []linter.Violation { // written without one built cleanly, passed `mx check`, and only looked wrong in // a browser. func TestValidateMenuItemIcons_FlagsMissingIcon(t *testing.T) { - stmt := &ast.AlterNavigationStmt{ + stmt := &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{ {Caption: "Home", Icon: "Atlas_Core.Atlas.home", IconKind: types.MenuIconCollection}, @@ -56,7 +56,7 @@ func TestValidateMenuItemIcons_FlagsMissingIcon(t *testing.T) { // The control: an item WITH an icon must stay silent, or the rule is "warn on // every menu item" and the first assertion proves nothing. func TestValidateMenuItemIcons_IconedItemIsSilent(t *testing.T) { - stmt := &ast.AlterNavigationStmt{ + stmt := &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{ {Caption: "Home", Icon: "Atlas_Core.Atlas.home", IconKind: types.MenuIconCollection}, @@ -73,7 +73,7 @@ func TestValidateMenuItemIcons_IconedItemIsSilent(t *testing.T) { // Sub-items are menu items too. A submenu's children render in the flyout the // collapsed rail opens, and the parent itself sits ON the rail. func TestValidateMenuItemIcons_RecursesIntoSubItems(t *testing.T) { - stmt := &ast.AlterNavigationStmt{ + stmt := &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{ {Caption: "Admin", Icon: "Atlas_Core.Atlas.cog", IconKind: types.MenuIconCollection, Items: []ast.NavMenuItemDef{ @@ -121,7 +121,7 @@ func TestValidateMenuItemIcons_CoversMenuDocuments(t *testing.T) { // Nothing to say about a statement with no menu at all, or another statement // type entirely. func TestValidateMenuItemIcons_Quiet(t *testing.T) { - if got := validateMenuItemIcons(&ast.AlterNavigationStmt{ProfileName: "Responsive"}); len(got) != 0 { + if got := validateMenuItemIcons(&ast.CreateNavigationStmt{ProfileName: "Responsive"}); len(got) != 0 { t.Errorf("a profile with no MENU block warned: %+v", got) } if got := validateMenuItemIcons(&ast.CreateEntityStmt{}); len(got) != 0 { @@ -134,7 +134,7 @@ func TestValidateMenuItemIcons_Quiet(t *testing.T) { // trap that hid four defects in the widget work. func TestValidateMenuItemIcons_RunsWithoutAProject(t *testing.T) { prog := &ast.Program{Statements: []ast.Statement{ - &ast.AlterNavigationStmt{ + &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{{Caption: "Orders"}}, }, @@ -150,7 +150,7 @@ func TestValidateMenuItemIcons_RunsWithoutAProject(t *testing.T) { // Home item carries glyph 57377 — so the naive rule warned about the majority of // real menus while claiming they had no icon. func TestValidateMenuItemIcons_AGlyphIconIsAnIcon(t *testing.T) { - stmt := &ast.AlterNavigationStmt{ + stmt := &ast.CreateNavigationStmt{ ProfileName: "Responsive", MenuItems: []ast.NavMenuItemDef{ {Caption: "Home", IconKind: types.MenuIconGlyph, IconCode: 57377}, diff --git a/mdl/executor/validate_navigation_roles.go b/mdl/executor/validate_navigation_roles.go index 2e5eec73ef..a6b9412359 100644 --- a/mdl/executor/validate_navigation_roles.go +++ b/mdl/executor/validate_navigation_roles.go @@ -52,7 +52,7 @@ type navRoleRef struct { // navigationRoleRefs collects the role references in one statement. func navigationRoleRefs(stmt ast.Statement) []navRoleRef { - s, ok := stmt.(*ast.AlterNavigationStmt) + s, ok := stmt.(*ast.CreateNavigationStmt) if !ok { return nil } @@ -124,7 +124,7 @@ func validateNavigationRoles(ctx *ExecContext, prog *ast.Program) []error { // validateNavigationRoleForExec is the same check at execution time. By the time // a navigation statement runs, a role the script creates is already in the // project, so the stored roles alone are the right set. -func validateNavigationRoleForExec(ctx *ExecContext, stmt *ast.AlterNavigationStmt) error { +func validateNavigationRoleForExec(ctx *ExecContext, stmt *ast.CreateNavigationStmt) error { refs := navigationRoleRefs(stmt) if len(refs) == 0 { return nil diff --git a/mdl/executor/validate_navigation_roles_test.go b/mdl/executor/validate_navigation_roles_test.go index 30a2f14196..6108c31983 100644 --- a/mdl/executor/validate_navigation_roles_test.go +++ b/mdl/executor/validate_navigation_roles_test.go @@ -122,13 +122,13 @@ func TestNavigationRoleRefs(t *testing.T) { }{ { name: "default home page carries no role", - stmt: &ast.AlterNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ + stmt: &ast.CreateNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ {IsPage: true, Target: ast.QualifiedName{Module: "M", Name: "Home"}}, }}, }, { name: "role-based home page", - stmt: &ast.AlterNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ + stmt: &ast.CreateNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ {IsPage: true, Target: ast.QualifiedName{Module: "M", Name: "Home"}}, {IsPage: true, Target: ast.QualifiedName{Module: "M", Name: "Admin"}, ForRole: navRole("", "Administrator")}, }}, @@ -138,7 +138,7 @@ func TestNavigationRoleRefs(t *testing.T) { // Several roles in one profile: checking only the first would let the // rest through. name: "every role-based home page is collected", - stmt: &ast.AlterNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ + stmt: &ast.CreateNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ {ForRole: navRole("", "Administrator")}, {ForRole: navRole("M", "Manager")}, }}, @@ -147,7 +147,7 @@ func TestNavigationRoleRefs(t *testing.T) { { // HOME MICROFLOW takes a role the same way HOME PAGE does. name: "home microflow carries a role too", - stmt: &ast.AlterNavigationStmt{ProfileName: "Phone", HomePages: []ast.NavHomePageDef{ + stmt: &ast.CreateNavigationStmt{ProfileName: "Phone", HomePages: []ast.NavHomePageDef{ {IsPage: false, Target: ast.QualifiedName{Module: "M", Name: "MF"}, ForRole: navRole("", "User")}, }}, want: []string{"User"}, @@ -180,7 +180,7 @@ func TestScriptUserRoles(t *testing.T) { prog := &ast.Program{Statements: []ast.Statement{ &ast.CreateUserRoleStmt{Name: "Supervisor"}, &ast.CreateUserRoleStmt{Name: ""}, // must not become an empty known role - &ast.AlterNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ + &ast.CreateNavigationStmt{ProfileName: "Responsive", HomePages: []ast.NavHomePageDef{ {ForRole: navRole("", "Supervisor")}, }}, }} diff --git a/mdl/executor/validate_navigation_targets.go b/mdl/executor/validate_navigation_targets.go index f12dfc0f1f..169fa9e9d6 100644 --- a/mdl/executor/validate_navigation_targets.go +++ b/mdl/executor/validate_navigation_targets.go @@ -80,7 +80,7 @@ type navTarget struct { // nanoflow it is (see checkProfileClauses), so the name is looked up as one. func navigationTargets(stmt ast.Statement, native func(profile string) bool) (label string, out []navTarget, ok bool) { switch s := stmt.(type) { - case *ast.AlterNavigationStmt: + case *ast.CreateNavigationStmt: label = "navigation " + s.ProfileName for _, hp := range s.HomePages { where, kind := "home page", navPage diff --git a/mdl/executor/validate_navigation_targets_test.go b/mdl/executor/validate_navigation_targets_test.go index a28d532896..2fce4d41d1 100644 --- a/mdl/executor/validate_navigation_targets_test.go +++ b/mdl/executor/validate_navigation_targets_test.go @@ -119,8 +119,8 @@ func (f *navFixture) exec(t *testing.T, script string) error { for _, stmt := range buildProg(t, script).Statements { var err error switch s := stmt.(type) { - case *ast.AlterNavigationStmt: - err = execAlterNavigation(f.ctx, s) + case *ast.CreateNavigationStmt: + err = execCreateNavigation(f.ctx, s) case *ast.CreateMenuStmt: err = execCreateMenu(f.ctx, s) default: diff --git a/mdl/executor/validate_protected_module_refs.go b/mdl/executor/validate_protected_module_refs.go index adaa50d924..ad6065ce5e 100644 --- a/mdl/executor/validate_protected_module_refs.go +++ b/mdl/executor/validate_protected_module_refs.go @@ -69,7 +69,7 @@ func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { func hasProtectedModuleCandidates(prog *ast.Program) bool { for _, stmt := range prog.Statements { switch stmt.(type) { - case *ast.AlterNavigationStmt, *ast.CreateMenuStmt, *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt: + case *ast.CreateNavigationStmt, *ast.CreateMenuStmt, *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt: return true } } @@ -149,7 +149,7 @@ func (r *protectedRefResolver) recordDefinition(stmt ast.Statement) { func (r *protectedRefResolver) check(stmt ast.Statement) []string { switch s := stmt.(type) { - case *ast.AlterNavigationStmt: + case *ast.CreateNavigationStmt: return r.checkNavigation(s) case *ast.CreateMenuStmt: if r.protected[s.Name.Module] { @@ -166,7 +166,7 @@ func (r *protectedRefResolver) check(stmt ast.Statement) []string { return nil } -func (r *protectedRefResolver) checkNavigation(s *ast.AlterNavigationStmt) []string { +func (r *protectedRefResolver) checkNavigation(s *ast.CreateNavigationStmt) []string { const src = "project level documents" who := fmt.Sprintf("navigation profile '%s'", s.ProfileName) var out []string diff --git a/mdl/executor/xpath_string_escape_test.go b/mdl/executor/xpath_string_escape_test.go index cc0a547355..42f7246294 100644 --- a/mdl/executor/xpath_string_escape_test.go +++ b/mdl/executor/xpath_string_escape_test.go @@ -49,7 +49,7 @@ var xpathDescribers = []xpathDescriber{ return "create or replace navigation TabletOffline sync ( sync M.E " + syncModeMDL(ctx, "Constrained", x) + "; );" }, func(t *testing.T, prog *ast.Program) string { - return prog.Statements[0].(*ast.AlterNavigationStmt).SyncEntries[0].Constraint + return prog.Statements[0].(*ast.CreateNavigationStmt).SyncEntries[0].Constraint }}, {"page datasource", func(ctx *ExecContext, x string) string { diff --git a/mdl/visitor/nav_sync_mode_enum_test.go b/mdl/visitor/nav_sync_mode_enum_test.go index 4ccf0af136..aef9a2bbf3 100644 --- a/mdl/visitor/nav_sync_mode_enum_test.go +++ b/mdl/visitor/nav_sync_mode_enum_test.go @@ -112,7 +112,7 @@ func TestBothWhereFormsProduceTheSameConstraint(t *testing.T) { if len(errs) > 0 { t.Fatalf("%s form failed to parse: %v", name, errs[0]) } - stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + stmt, ok := prog.Statements[0].(*ast.CreateNavigationStmt) if !ok || len(stmt.SyncEntries) != 1 { t.Fatalf("%s form did not produce one sync entry: %+v", name, prog.Statements[0]) } diff --git a/mdl/visitor/visitor_navigation.go b/mdl/visitor/visitor_navigation.go index 6f1bace7f4..82ed4a00db 100644 --- a/mdl/visitor/visitor_navigation.go +++ b/mdl/visitor/visitor_navigation.go @@ -26,7 +26,7 @@ func (b *Builder) ExitCreateNavigationStatement(ctx *parser.CreateNavigationStat return } - stmt := &ast.AlterNavigationStmt{ + stmt := &ast.CreateNavigationStmt{ ProfileName: profileName, } @@ -48,7 +48,7 @@ func (b *Builder) ExitCreateNavigationStatement(ctx *parser.CreateNavigationStat } // processNavigationClause processes a single navigation clause. -func (b *Builder) processNavigationClause(stmt *ast.AlterNavigationStmt, ctx *parser.NavigationClauseContext) { +func (b *Builder) processNavigationClause(stmt *ast.CreateNavigationStmt, ctx *parser.NavigationClauseContext) { if ctx.HOME() != nil { // HOME PAGE/MICROFLOW qualifiedName [FOR qualifiedName] names := ctx.AllQualifiedName() diff --git a/mdl/visitor/visitor_navigation_action_test.go b/mdl/visitor/visitor_navigation_action_test.go index cb9902afbf..026cb5d244 100644 --- a/mdl/visitor/visitor_navigation_action_test.go +++ b/mdl/visitor/visitor_navigation_action_test.go @@ -32,7 +32,7 @@ func TestNavMenuItem_OnClickTakesEveryMenuActionKind(t *testing.T) { if len(errs) > 0 { t.Fatalf("%q: %v", c.onClick, errs[0]) } - item := prog.Statements[0].(*ast.AlterNavigationStmt).MenuItems[0] + item := prog.Statements[0].(*ast.CreateNavigationStmt).MenuItems[0] if item.Action == nil || item.Action.Type != c.kind { t.Fatalf("%q: action %+v, want kind %s", c.onClick, item.Action, c.kind) } @@ -47,7 +47,7 @@ func TestNavMenuItem_OnClickTakesEveryMenuActionKind(t *testing.T) { if len(errs) > 0 { t.Fatal(errs[0]) } - items := prog.Statements[0].(*ast.AlterNavigationStmt).MenuItems + items := prog.Statements[0].(*ast.CreateNavigationStmt).MenuItems if items[0].Page == nil || items[0].Page.String() != "M.P" || items[1].Page == nil || items[1].Action == nil { t.Errorf("simple targets: %+v / %+v", items[0], items[1]) } @@ -73,7 +73,7 @@ func TestNavigation_HomeNanoflowParses(t *testing.T) { if len(errs) > 0 { t.Fatal(errs[0]) } - hp := prog.Statements[0].(*ast.AlterNavigationStmt).HomePages + hp := prog.Statements[0].(*ast.CreateNavigationStmt).HomePages if len(hp) != 2 || !hp[0].IsNanoflow || hp[0].IsPage || hp[1].IsNanoflow { t.Errorf("home pages = %+v", hp) } diff --git a/mdl/visitor/visitor_navigation_icon_test.go b/mdl/visitor/visitor_navigation_icon_test.go index b8fcebce34..4234ddd773 100644 --- a/mdl/visitor/visitor_navigation_icon_test.go +++ b/mdl/visitor/visitor_navigation_icon_test.go @@ -17,9 +17,9 @@ func navMenuItems(t *testing.T, menuBody string) []ast.NavMenuItemDef { if len(errs) > 0 { t.Fatalf("parsing the menu: %v", errs) } - stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + stmt, ok := prog.Statements[0].(*ast.CreateNavigationStmt) if !ok { - t.Fatalf("expected AlterNavigationStmt, got %T", prog.Statements[0]) + t.Fatalf("expected CreateNavigationStmt, got %T", prog.Statements[0]) } return stmt.MenuItems } diff --git a/mdl/visitor/visitor_navigation_test.go b/mdl/visitor/visitor_navigation_test.go index af1bf127a4..ad1e21fe6e 100644 --- a/mdl/visitor/visitor_navigation_test.go +++ b/mdl/visitor/visitor_navigation_test.go @@ -22,9 +22,9 @@ func TestCreateNavigation(t *testing.T) { } return } - stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + stmt, ok := prog.Statements[0].(*ast.CreateNavigationStmt) if !ok { - t.Fatalf("Expected AlterNavigationStmt, got %T", prog.Statements[0]) + t.Fatalf("Expected CreateNavigationStmt, got %T", prog.Statements[0]) } if stmt.ProfileName != "Responsive" { t.Errorf("Got ProfileName %q", stmt.ProfileName) diff --git a/mdl/visitor/visitor_xpath_string_escape_test.go b/mdl/visitor/visitor_xpath_string_escape_test.go index eb7c876faa..db4219d46e 100644 --- a/mdl/visitor/visitor_xpath_string_escape_test.go +++ b/mdl/visitor/visitor_xpath_string_escape_test.go @@ -80,7 +80,7 @@ var xpathKinds = []struct { }}, {"navigation sync", "create or replace navigation TabletOffline sync ( sync M.E where [Name = LIT]; );", "[Name = LIT]", func(t *testing.T, prog *ast.Program) string { - return prog.Statements[0].(*ast.AlterNavigationStmt).SyncEntries[0].Constraint + return prog.Statements[0].(*ast.CreateNavigationStmt).SyncEntries[0].Constraint }}, } From 87f61ef713cdaad5e67eb44df4ce57d43943dc22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:53:36 +0000 Subject: [PATCH 11/28] refactor(text): share the page rule for reading and writing a caption The rule DESCRIBE reads a page's texts with (selectTranslationText: the project's default language even when empty, else en_US, else the first non-empty translation) moves to model.SelectTranslation, and the rule ALTER PAGE writes one with (setTextsTextTranslation: update or add the authoring language's translation, keep every other language) moves to bsonnav.SetTextsTextTranslation, so the navigation reader and the menu splice can call the same code rather than a copy of it. Both callers delegate unchanged; no behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- mdl/backend/bsonnav/texts.go | 97 ++++++++++++++++++++++++++++++ mdl/backend/pagemutator/mutator.go | 59 ++---------------- mdl/executor/describe_language.go | 35 +++-------- model/select_translation_test.go | 33 ++++++++++ model/text_language.go | 48 +++++++++++++++ 5 files changed, 192 insertions(+), 80 deletions(-) create mode 100644 mdl/backend/bsonnav/texts.go create mode 100644 model/select_translation_test.go diff --git a/mdl/backend/bsonnav/texts.go b/mdl/backend/bsonnav/texts.go new file mode 100644 index 0000000000..a83dd72b46 --- /dev/null +++ b/mdl/backend/bsonnav/texts.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package bsonnav + +import ( + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/model" +) + +// SetTextsTextTranslation writes text into a Texts$Text as the translation for +// model.AuthoringLanguage() — the project's default language, the one DESCRIBE +// shows — updating that entry in place, or appending one when the text has no +// translation in that language yet. Every other language's translation is kept. +// +// This is the one rule for every text an ALTER sets: ALTER PAGE's texts and +// a menu item's caption in ALTER NAVIGATION / ALTER MENU. Before it there were +// four: a page Title and a column caption overwrote EVERY translation with the +// same string (English text written into nl_NL); a button caption or content +// overwrote whichever translation was listed first, whatever its language; and +// a Caption/Label stored as a Texts$Text looked for a `Translations` key no +// Mendix document has, then tried to set a stray `Text` field that DSet could +// not add — reporting success with nothing written. The case that surfaced it: +// a project switched to de_DE, a tab caption with only en_US → mxbuild CE4899 +// "Empty caption. [German, Germany]", and `set Caption` had to fill de_DE +// without discarding the en_US text. +// +// Returns false when doc carries no Items list (not a Texts$Text). +func SetTextsTextTranslation(doc bson.D, text string) bool { + raw := ToBsonA(DGet(doc, "Items")) + if raw == nil && !HasKey(doc, "Items") { + return false + } + lang := model.AuthoringLanguage() + for _, item := range DGetArrayElements(raw) { + itemDoc, ok := item.(bson.D) + if !ok || DGetString(itemDoc, "LanguageCode") != lang { + continue + } + if DSet(itemDoc, "Text", text) { + return true + } + } + newItem := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: lang}, + {Key: "Text", Value: text}, + } + var newArr bson.A + if len(raw) > 0 && IsListMarker(raw[0]) { + newArr = append(newArr, raw...) + } else { + // Texts$Text.Items is a marker-3 list in every document Studio Pro writes. + newArr = append(append(newArr, int32(3)), raw...) + } + newArr = append(newArr, newItem) + return DSet(doc, "Items", newArr) +} + +// HasKey reports whether doc has the key at all, null value included. +func HasKey(doc bson.D, key string) bool { + for _, e := range doc { + if e.Key == key { + return true + } + } + return false +} + +// IsListMarker reports whether v is the int32 type marker at index 0 of a +// Mendix list. +func IsListMarker(v any) bool { + switch v.(type) { + case int32, int: + return true + } + return false +} + +// TextsTextTranslations returns a Texts$Text document's translations in +// document order, for model.SelectTranslation. nil for anything else. +func TextsTextTranslations(doc bson.D) []model.TranslationEntry { + var out []model.TranslationEntry + for _, item := range DGetArrayElements(DGet(doc, "Items")) { + itemDoc, ok := item.(bson.D) + if !ok || DGetString(itemDoc, "$Type") != "Texts$Translation" { + continue + } + out = append(out, model.TranslationEntry{ + LanguageCode: DGetString(itemDoc, "LanguageCode"), + Text: DGetString(itemDoc, "Text"), + }) + } + return out +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 1f8d805e44..f774aa995d 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -2789,63 +2789,14 @@ func setColumnPropertyMut(colDoc bson.D, propKeyMap map[string]string, propKindM } // updateClientTemplateText writes the literal text of a Forms$ClientTemplate's -// Template in the authoring language (see setTextsTextTranslation). Returns false +// Template in the authoring language (see bsonnav.SetTextsTextTranslation). Returns false // when the template has no Texts$Text to write into. func updateClientTemplateText(clientTemplate bson.D, text string) bool { template := bsonnav.DGetDoc(clientTemplate, "Template") if template == nil { return false } - return setTextsTextTranslation(template, text) -} - -// setTextsTextTranslation writes text into a Texts$Text as the translation for -// model.AuthoringLanguage() — the project's default language, the one DESCRIBE -// shows — updating that entry in place, or appending one when the text has no -// translation in that language yet. Every other language's translation is kept. -// -// This is the one rule for every text ALTER PAGE sets. Before it there were -// four: a page Title and a column caption overwrote EVERY translation with the -// same string (English text written into nl_NL); a button caption or content -// overwrote whichever translation was listed first, whatever its language; and -// a Caption/Label stored as a Texts$Text looked for a `Translations` key no -// Mendix document has, then tried to set a stray `Text` field that DSet could -// not add — reporting success with nothing written. The case that surfaced it: -// a project switched to de_DE, a tab caption with only en_US → mxbuild CE4899 -// "Empty caption. [German, Germany]", and `set Caption` had to fill de_DE -// without discarding the en_US text. -// -// Returns false when doc carries no Items list (not a Texts$Text). -func setTextsTextTranslation(doc bson.D, text string) bool { - raw := bsonnav.ToBsonA(bsonnav.DGet(doc, "Items")) - if raw == nil && !hasKey(doc, "Items") { - return false - } - lang := model.AuthoringLanguage() - for _, item := range bsonnav.DGetArrayElements(raw) { - itemDoc, ok := item.(bson.D) - if !ok || bsonnav.DGetString(itemDoc, "LanguageCode") != lang { - continue - } - if bsonnav.DSet(itemDoc, "Text", text) { - return true - } - } - newItem := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: lang}, - {Key: "Text", Value: text}, - } - var newArr bson.A - if len(raw) > 0 && isListMarker(raw[0]) { - newArr = append(newArr, raw...) - } else { - // Texts$Text.Items is a marker-3 list in every document Studio Pro writes. - newArr = append(append(newArr, int32(3)), raw...) - } - newArr = append(newArr, newItem) - return bsonnav.DSet(doc, "Items", newArr) + return bsonnav.SetTextsTextTranslation(template, text) } // hasKey reports whether doc has the key at all, null value included. @@ -2876,7 +2827,7 @@ func applyPageLevelSetMut(rawData bson.D, prop string, value any) (bson.D, error if titleDoc == nil { return rawData, fmt.Errorf("page has no Title field") } - if !setTextsTextTranslation(titleDoc, strVal) { + if !bsonnav.SetTextsTextTranslation(titleDoc, strVal) { return rawData, fmt.Errorf("could not update Title text") } case "Url": @@ -3996,7 +3947,7 @@ func noPluggableObjectError(widget bson.D, propName string) error { // Forms$Label's Caption) or a Forms$ClientTemplate (a button's CaptionTemplate, // a text's Content, an input's LabelTemplate). Either way the translation for // the authoring language is written and the others are kept — see -// setTextsTextTranslation. Any other shape is refused: writing nothing and +// bsonnav.SetTextsTextTranslation. Any other shape is refused: writing nothing and // returning nil is how `set Caption` on a tab page reported success unchanged. func setTranslatableText(text bson.D, label string, value any) error { strVal, ok := value.(string) @@ -4005,7 +3956,7 @@ func setTranslatableText(text bson.D, label string, value any) error { } switch typ := bsonnav.DGetString(text, "$Type"); typ { case "Texts$Text": - if setTextsTextTranslation(text, strVal) { + if bsonnav.SetTextsTextTranslation(text, strVal) { return nil } return fmt.Errorf("%s is a Texts$Text without an Items list", label) diff --git a/mdl/executor/describe_language.go b/mdl/executor/describe_language.go index cb42821fe1..2cf48407cd 100644 --- a/mdl/executor/describe_language.go +++ b/mdl/executor/describe_language.go @@ -45,40 +45,23 @@ func describeDefaultLanguage(ctx *ExecContext) string { } // selectTranslationText picks the best translation string from a Texts$Text -// Items array ([marker, Texts$Translation{LanguageCode, Text}, …]): the preferred -// language, else en_US, else the first non-empty translation. This replaces the -// old "return the first item" behaviour that surfaced the wrong language (#702). -// -// A preferred-language translation that is present but EMPTY is returned as the -// empty string. DESCRIBE output is re-executed, and whatever it prints is written -// back into the preferred language — so falling back to another language here -// turned a caption stored as en_US "" + nl_NL "Knop" into en_US "Knop" -// (ako/mxcli#705). The fallbacks are for a text with no entry in that language. +// Items array ([marker, Texts$Translation{LanguageCode, Text}, …]) by +// model.SelectTranslation: the preferred language (even when empty), else +// en_US, else the first non-empty translation. This replaces the old "return +// the first item" behaviour that surfaced the wrong language (#702), and is the +// rule navigation and menu captions are read with too. func selectTranslationText(items []any, preferredLang string) string { - byLang := make(map[string]string) - firstNonEmpty := "" + var entries []model.TranslationEntry for _, item := range items { m, ok := item.(map[string]any) if !ok { continue } text, _ := m["Text"].(string) - if lang, _ := m["LanguageCode"].(string); lang != "" { - if _, exists := byLang[lang]; !exists { - byLang[lang] = text - } - } - if firstNonEmpty == "" && text != "" { - firstNonEmpty = text - } - } - if v, ok := byLang[preferredLang]; ok && preferredLang != "" { - return v - } - if byLang[fallbackLanguageCode] != "" { - return byLang[fallbackLanguageCode] + lang, _ := m["LanguageCode"].(string) + entries = append(entries, model.TranslationEntry{LanguageCode: lang, Text: text}) } - return firstNonEmpty + return model.SelectTranslation(entries, preferredLang) } // pickTextTranslation selects the best translation from a model.Text map: the diff --git a/model/select_translation_test.go b/model/select_translation_test.go new file mode 100644 index 0000000000..f23d28d448 --- /dev/null +++ b/model/select_translation_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +package model + +import "testing" + +func TestSelectTranslation(t *testing.T) { + tr := func(pairs ...string) []TranslationEntry { + var out []TranslationEntry + for i := 0; i+1 < len(pairs); i += 2 { + out = append(out, TranslationEntry{LanguageCode: pairs[i], Text: pairs[i+1]}) + } + return out + } + cases := []struct { + name string + entries []TranslationEntry + lang string + want string + }{ + {"preferred wins over earlier entries", tr("en_US", "Home", "nl_NL", "Start"), "nl_NL", "Start"}, + {"preferred present but empty is empty", tr("nl_NL", "", "en_US", "Home"), "nl_NL", ""}, + {"en_US is the fallback", tr("de_DE", "Haus", "en_US", "Home"), "nl_NL", "Home"}, + {"first non-empty is the last resort", tr("de_DE", "", "fr_FR", "Maison"), "nl_NL", "Maison"}, + {"first entry of a repeated language", tr("nl_NL", "A", "nl_NL", "B"), "nl_NL", "A"}, + {"nothing stored", nil, "en_US", ""}, + } + for _, c := range cases { + if got := SelectTranslation(c.entries, c.lang); got != c.want { + t.Errorf("%s: SelectTranslation = %q, want %q", c.name, got, c.want) + } + } +} diff --git a/model/text_language.go b/model/text_language.go index 014a67ff8d..1d0cfa97a7 100644 --- a/model/text_language.go +++ b/model/text_language.go @@ -57,3 +57,51 @@ func SetAuthoringLanguage(code string) { } textLang = code } + +// TranslationEntry is one Texts$Translation as stored: its language and its +// text. A Texts$Text is read as a slice of them, in document order. +type TranslationEntry struct { + LanguageCode string + Text string +} + +// FallbackTextLanguage is the language a text is read in when it has no +// translation in the preferred one. +const FallbackTextLanguage = "en_US" + +// SelectTranslation is the one rule for reading a translatable text: the one +// DESCRIBE prints for a page's or widget's text (issue #702), a navigation or +// menu item's caption, and the caption an ALTER NAVIGATION / ALTER MENU +// address is compared with — so an address copied from describe output is the +// string the resolver matches, by construction. +// +// It returns the preferred language's translation, else en_US's, else the +// first non-empty translation in document order. +// +// A preferred-language translation that is present but EMPTY is returned as +// the empty string. DESCRIBE output is re-executed, and whatever it prints is +// written back into the preferred language — so falling back to another +// language here turned a caption stored as en_US "" + nl_NL "Knop" into en_US +// "Knop" (ako/mxcli#705). The fallbacks are for a text with no entry in that +// language. When a language occurs twice, its first entry counts. +func SelectTranslation(entries []TranslationEntry, preferredLang string) string { + byLang := make(map[string]string) + firstNonEmpty := "" + for _, e := range entries { + if e.LanguageCode != "" { + if _, exists := byLang[e.LanguageCode]; !exists { + byLang[e.LanguageCode] = e.Text + } + } + if firstNonEmpty == "" && e.Text != "" { + firstNonEmpty = e.Text + } + } + if v, ok := byLang[preferredLang]; ok && preferredLang != "" { + return v + } + if byLang[FallbackTextLanguage] != "" { + return byLang[FallbackTextLanguage] + } + return firstNonEmpty +} From e15a33c49f6ab0233276e92544896cfb5a0e57e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:03:15 +0000 Subject: [PATCH 12/28] fix(navigation): read menu captions with the page language rule A menu item's caption was read by textOf as the first non-empty translation in document order. Page describe reads the same kind of text in the project's default language (an empty entry in it included), else en_US, else the first non-empty translation - so on a multi-language project describe navigation and describe page disagreed, and the ALTER NAVIGATION address resolver (which matches what describe prints) would have matched a different string than a page-style read. Accepted proposal, decision 4. textOf now calls model.SelectTranslation with model.AuthoringLanguage(). Because the backend reads the language through that process-level value, describe navigation, list navigation menu, describe menu and the create handlers resolve the project's default language before they read. The reader also dropped an item whose caption read empty and whose action was not a page, so describe never printed it and a describe -> exec round trip deleted it; reading captions in the default language would have made that reach every item not translated into it. Every Menus$MenuItem is read. Effect on describe output (intended): unchanged on a single-language project; on a multi-language one the default-language caption is printed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...gation-caption-not-page-language-rule.json | 1 + .../navigation_caption_language_test.go | 168 ++++++++++++++++++ mdl/backend/modelsdk/navigation_read.go | 27 ++- mdl/executor/cmd_menus.go | 4 + mdl/executor/cmd_navigation.go | 11 ++ .../navigation_caption_language_test.go | 62 +++++++ 6 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-20-navigation-caption-not-page-language-rule.json create mode 100644 mdl/backend/modelsdk/navigation_caption_language_test.go create mode 100644 mdl/executor/navigation_caption_language_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-navigation-caption-not-page-language-rule.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-navigation-caption-not-page-language-rule.json new file mode 100644 index 0000000000..8618c54802 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-navigation-caption-not-page-language-rule.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-20", "symptom": "On a multi-language project `describe navigation` / `describe menu` printed a menu item's caption in whichever language was stored first, while `describe page` prints the same kind of text in the project's default language; and an item whose caption read empty and whose action was not a page (an icon-only microflow item) was missing from describe entirely, so a describe -> exec round trip deleted it.", "cause": "The navigation reader had its own text rule, `textOf` = the first non-empty Texts$Translation in document order, instead of the page rule (selectTranslationText: the default language even when empty, else en_US, else the first non-empty); and navMenuItemFromGen returned nil for an item with an empty caption, no page and no sub-items (a legacy-reader filter).", "file": "`mdl/backend/modelsdk/navigation_read.go` (`textOf` now calls `model.SelectTranslation` with `model.AuthoringLanguage()`; the filter in `navMenuItemFromGen` is gone); the executor resolves the language before reading (`describeDefaultLanguage` / `authoringLanguage` in describeNavigation, listNavigationMenu, describeMenu, execCreateNavigation, execCreateMenu)", "insight": "A translatable text has ONE read rule (model.SelectTranslation) and ONE write rule (bsonnav.SetTextsTextTranslation); a per-document copy drifts, and the drift only shows on a project whose default language is not the first one stored. Because the backend reads the language through the process-level model.AuthoringLanguage, every executor path that reads captions must call describeDefaultLanguage(ctx) first — a test that mocks GetNavigation and records the language seen at call time catches a missing call. A reader filter that drops an element is a delete on the next rewrite: compare the elements read with the elements stored.", "refs": ["ako/mxcli#1404", "mendixlabs/mxcli#1522"]} diff --git a/mdl/backend/modelsdk/navigation_caption_language_test.go b/mdl/backend/modelsdk/navigation_caption_language_test.go new file mode 100644 index 0000000000..59ac54beda --- /dev/null +++ b/mdl/backend/modelsdk/navigation_caption_language_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/model" +) + +// testCaption builds a Texts$Text with one translation per (language, text) +// pair, in the order given. +func testCaption(pairs ...string) bson.D { + items := bson.A{int32(3)} + for i := 0; i+1 < len(pairs); i += 2 { + items = append(items, bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: pairs[i]}, + {Key: "Text", Value: pairs[i+1]}, + }) + } + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: items}, + } +} + +// testMenuItem builds a Studio Pro-shaped Menus$MenuItem with a microflow +// action (not a page, so the reader's old "no caption and no page" filter +// applies to it) and the given sub-items. +func testMenuItem(caption bson.D, subs ...bson.D) bson.D { + items := bson.A{int32(3)} + for _, s := range subs { + items = append(items, s) + } + action := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$MicroflowAction"}, + {Key: "DisabledDuringExecution", Value: false}, + {Key: "MicroflowSettings", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$MicroflowSettings"}, + {Key: "Microflow", Value: "MyFirstModule.ACT_Something"}, + }}, + } + if len(subs) > 0 { + action = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NoAction"}, + {Key: "DisabledDuringExecution", Value: true}, + } + } + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Menus$MenuItem"}, + {Key: "Action", Value: action}, + {Key: "AlternativeText", Value: nil}, + {Key: "Caption", Value: caption}, + {Key: "Icon", Value: nil}, + {Key: "Items", Value: items}, + } +} + +// storeProfileMenu replaces the first web profile's menu with items, written +// as a patch so nothing is re-paired or carried, and returns the profile name. +func storeProfileMenu(t *testing.T, b *Backend, items ...bson.D) string { + t.Helper() + nav, err := b.GetNavigation() + if err != nil || nav == nil { + t.Fatalf("GetNavigation: %v", err) + } + raw, err := b.reader.GetRawUnitBytes(string(nav.ID)) + if err != nil { + t.Fatalf("read unit: %v", err) + } + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + profiles := navGetArray(doc, "Profiles") + name := "" + for i, p := range profiles { + prof, ok := p.(bson.D) + if !ok || navGetString(prof, "$Type") != "Navigation$NavigationProfile" { + continue + } + coll := bson.A{int32(3)} + for _, it := range items { + coll = append(coll, it) + } + prof = navSetField(prof, "Menu", bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Menus$MenuItemCollection"}, + {Key: "Items", Value: coll}, + }) + profiles[i] = prof + name = navGetString(prof, "Name") + break + } + if name == "" { + t.Fatal("fixture has no web profile") + } + doc = navSetField(doc, "Profiles", profiles) + out, err := bson.Marshal(doc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := b.writer.UpdateRawUnitPatch(string(nav.ID), out); err != nil { + t.Fatalf("write: %v", err) + } + return name +} + +// A navigation menu item's caption is read with the rule a page's texts are +// (model.SelectTranslation): the project's default language, an empty entry +// in it included, else en_US, else the first non-empty translation. The reader +// used to take the first non-empty translation in document order, so on a +// project whose default language is nl_NL it printed the en_US caption a page +// describe would not, and an alter address copied from a page-style read would +// not have matched. +// +// It also keeps an item whose caption reads empty and whose action is not a +// page. The reader dropped one, so describe never printed it and a describe -> +// exec round trip deleted it. +func TestGetNavigation_CaptionUsesDefaultLanguage(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + t.Cleanup(func() { model.SetAuthoringLanguage("") }) + + profile := storeProfileMenu(t, b, + testMenuItem(testCaption("en_US", "Home", "nl_NL", "Start")), + testMenuItem(testCaption("en_US", "Docs")), + testMenuItem(testCaption("nl_NL", "", "en_US", "Icon only")), + ) + + model.SetAuthoringLanguage("nl_NL") + nav, err := b.GetNavigation() + if err != nil { + t.Fatalf("GetNavigation: %v", err) + } + var got []string + for _, p := range nav.Profiles { + if p.Name != profile { + continue + } + for _, it := range p.MenuItems { + got = append(got, it.Caption) + } + } + want := []string{"Start", "Docs", ""} + if len(got) != len(want) { + t.Fatalf("captions = %q, want %q (an item whose caption reads empty must not be dropped)", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("caption %d = %q, want %q (default language nl_NL, then en_US, then first non-empty)", i, got[i], want[i]) + } + } +} diff --git a/mdl/backend/modelsdk/navigation_read.go b/mdl/backend/modelsdk/navigation_read.go index 9671d7f951..613b1a60b5 100644 --- a/mdl/backend/modelsdk/navigation_read.go +++ b/mdl/backend/modelsdk/navigation_read.go @@ -259,9 +259,11 @@ func navMenuItemFromGen(el element.Element) *types.NavMenuItem { item.Items = append(item.Items, sub) } } - if item.Caption == "" && item.Page == "" && len(item.Items) == 0 { - return nil - } + // Every Menus$MenuItem is an item, whatever its caption reads as. An item + // whose caption reads empty and whose action was not a page used to be + // dropped here, so describe never printed it and a describe -> exec round + // trip deleted it; with captions read in the default language that would + // have reached any item translated in another language only. return item } @@ -348,21 +350,28 @@ func resolveMenuAction(item *types.NavMenuItem, action element.Element) { } } -// textOf extracts the first non-empty translation from a Texts$Text element, -// mirroring the legacy extractTextFromBson. +// textOf reads a Texts$Text element — a menu item's or bottom bar item's +// caption — with the rule a page's texts are read with (model.SelectTranslation): +// the project's default language, an empty entry in it included, else en_US, +// else the first non-empty translation. model.AuthoringLanguage is that default +// language once the executor has resolved the project's settings, which it does +// before describing, listing or altering navigation. +// +// It used to return the first non-empty translation in document order, so on a +// multi-language project describe printed whichever language was stored first, +// not the one a page describe prints for the same texts. func textOf(el element.Element) string { t, ok := el.(*genTexts.Text) if !ok || t == nil { return "" } + var entries []model.TranslationEntry for _, trEl := range t.TranslationsItems() { if tr, ok := trEl.(*genTexts.Translation); ok { - if s := tr.Text(); s != "" { - return s - } + entries = append(entries, model.TranslationEntry{LanguageCode: tr.LanguageCode(), Text: tr.Text()}) } } - return "" + return model.SelectTranslation(entries, model.AuthoringLanguage()) } // throwPartialSyncError reads a property NEITHER modelsdk/gen NOR diff --git a/mdl/executor/cmd_menus.go b/mdl/executor/cmd_menus.go index 78db228a35..41288c7381 100644 --- a/mdl/executor/cmd_menus.go +++ b/mdl/executor/cmd_menus.go @@ -24,6 +24,7 @@ func describeMenu(ctx *ExecContext, name ast.QualifiedName) error { return mdlerrors.NewNotConnected() } + describeDefaultLanguage(ctx) // captions are read in it, as a page's texts are md, err := ctx.Backend.GetMenuDocumentByQualifiedName(name.Module, name.Name) if err != nil { if strings.Contains(err.Error(), "not found") { @@ -65,6 +66,9 @@ func execCreateMenu(ctx *ExecContext, s *ast.CreateMenuStmt) error { return mdlerrors.NewNotFound("module", s.Name.Module) } + // Captions are read (to pair items) and written in the project's default + // language, as a page's texts are. + authoringLanguage(ctx) existing, _ := ctx.Backend.GetMenuDocumentByQualifiedName(s.Name.Module, s.Name.Name) if existing != nil && !s.CreateOrModify { return mdlerrors.NewAlreadyExists("menu", s.Name.String()) diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 02a6d19bfd..171236f493 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -38,6 +38,12 @@ func execCreateNavigation(ctx *ExecContext, s *ast.CreateNavigationStmt) error { return err } + // Captions are read (to pair the script's items with the stored ones) and + // written in the project's default language, as a page's texts are: resolve + // it before the first read, since the backend reads it through + // model.AuthoringLanguage. + authoringLanguage(ctx) + nav, err := ctx.Backend.GetNavigation() if err != nil { return mdlerrors.NewBackend("get navigation", err) @@ -567,6 +573,7 @@ func listNavigation(ctx *ExecContext) error { // listNavigationMenu handles SHOW NAVIGATION MENU [profile] command. // Displays the menu tree for a specific profile, or all profiles if none specified. func listNavigationMenu(ctx *ExecContext, profileName *ast.QualifiedName) error { + describeDefaultLanguage(ctx) // captions are read in it, as describe reads them nav, err := ctx.Backend.GetNavigation() if err != nil { return mdlerrors.NewBackend("get navigation", err) @@ -642,6 +649,10 @@ func flowHomeWord(p *types.NavigationProfile) string { // describeNavigation handles DESCRIBE NAVIGATION [profile] command. // Outputs a complete MDL-style description of a navigation profile. func describeNavigation(ctx *ExecContext, name ast.QualifiedName) error { + // A menu item's caption is read in the project's default language, as a + // page's texts are (model.SelectTranslation). The backend reads it through + // model.AuthoringLanguage, so it is resolved first. + describeDefaultLanguage(ctx) nav, err := ctx.Backend.GetNavigation() if err != nil { return mdlerrors.NewBackend("get navigation", err) diff --git a/mdl/executor/navigation_caption_language_test.go b/mdl/executor/navigation_caption_language_test.go new file mode 100644 index 0000000000..4faa1e7aa6 --- /dev/null +++ b/mdl/executor/navigation_caption_language_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// The backend reads a menu item's caption in model.AuthoringLanguage (the +// page rule, model.SelectTranslation), so every executor path that reads or +// writes navigation must resolve the project's default language BEFORE it +// reads. Otherwise a fresh process reads in en_US whatever the project's +// language is, and describe disagrees with describe page about the same texts. +func TestNavigationReadsResolveTheDefaultLanguageFirst(t *testing.T) { + type call struct { + name string + run func(ctx *ExecContext) error + } + calls := []call{ + {"describe navigation", func(ctx *ExecContext) error { + return describeNavigation(ctx, ast.QualifiedName{Name: "Responsive"}) + }}, + {"list navigation menu", func(ctx *ExecContext) error { return listNavigationMenu(ctx, nil) }}, + {"describe menu", func(ctx *ExecContext) error { + return describeMenu(ctx, ast.QualifiedName{Module: "M", Name: "Menu"}) + }}, + } + for _, c := range calls { + t.Run(c.name, func(t *testing.T) { + model.SetAuthoringLanguage("") + t.Cleanup(func() { model.SetAuthoringLanguage("") }) + var seen string + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{Language: &model.LanguageSettings{DefaultLanguageCode: "nl_NL"}}, nil + }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + seen = model.AuthoringLanguage() + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{{Name: "Responsive", Kind: "Responsive"}}}, nil + }, + GetMenuDocumentByQualifiedNameFunc: func(string, string) (*types.MenuDocument, error) { + seen = model.AuthoringLanguage() + return &types.MenuDocument{Name: "Menu"}, nil + }, + } + ctx := &ExecContext{Backend: mb, Cache: &executorCache{}, Output: &bytes.Buffer{}} + if err := c.run(ctx); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if seen != "nl_NL" { + t.Errorf("%s read navigation in %q, want the project's default nl_NL", c.name, seen) + } + }) + } +} From 2f874cbe1739c2aa039aac1a97075ded8f7b0612 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:05:11 +0000 Subject: [PATCH 13/28] feat(navigation): menu item addresses and their resolver The address of the accepted ALTER NAVIGATION / ALTER MENU proposal: `'Admin' > 'Users'`, a caption path written outer to inner. The first segment matches anywhere in the tree, each further one a direct sub-item of the previous match, so a path matches as a suffix; @n picks the n-th match of its own segment in describe order. A miss lists what is at the level searched and any case-only match; an ambiguity lists the shortest unique address of every candidate. types.MenuItemAddress is the address (the AST carries it); backend.ResolveMenuItemAddress and ShortestMenuItemAddress work on a MenuTreeNode tree, which both a navigation profile's menu and a menu document are read into. A property test checks that every printed address resolves back to its item on generated trees with heavy caption reuse (control: accepting a non-unique suffix fails it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- mdl/backend/menu_address.go | 323 +++++++++++++++++++++++++++++++ mdl/backend/menu_address_test.go | 212 ++++++++++++++++++++ mdl/types/menu_address.go | 51 +++++ 3 files changed, 586 insertions(+) create mode 100644 mdl/backend/menu_address.go create mode 100644 mdl/backend/menu_address_test.go create mode 100644 mdl/types/menu_address.go diff --git a/mdl/backend/menu_address.go b/mdl/backend/menu_address.go new file mode 100644 index 0000000000..f0e07d8036 --- /dev/null +++ b/mdl/backend/menu_address.go @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// MenuTreeNode is one menu item as an ALTER NAVIGATION / ALTER MENU target +// sees it: the caption describe prints (model.SelectTranslation), whether its +// action is Studio Pro's "Do nothing" (so it may take sub-items without +// CE0548), and its sub-items. Both a navigation profile's menu and a menu +// document are read into this shape, so one resolver serves both. +type MenuTreeNode struct { + Caption string + NoAction bool + Items []*MenuTreeNode +} + +// MenuItemPath locates an item by its index at each level, outermost first. +// It is how a resolved address is handed to a MenuMutator, never something MDL +// spells: ADR-0012 rules out positional addresses. +type MenuItemPath []int + +// Parent is the path of the item's parent; nil for a top-level item. +func (p MenuItemPath) Parent() MenuItemPath { + if len(p) <= 1 { + return nil + } + return append(MenuItemPath(nil), p[:len(p)-1]...) +} + +// Index is the item's position among its siblings. +func (p MenuItemPath) Index() int { + if len(p) == 0 { + return -1 + } + return p[len(p)-1] +} + +// HasPrefix reports whether p lies in the subtree of prefix (or is it). +func (p MenuItemPath) HasPrefix(prefix MenuItemPath) bool { + if len(prefix) > len(p) { + return false + } + for i := range prefix { + if p[i] != prefix[i] { + return false + } + } + return true +} + +// MenuNodeAt returns the node at path, or nil. +func MenuNodeAt(roots []*MenuTreeNode, path MenuItemPath) *MenuTreeNode { + level := roots + var n *MenuTreeNode + for _, i := range path { + if i < 0 || i >= len(level) { + return nil + } + n = level[i] + level = n.Items + } + return n +} + +// MenuTreeFromNavItems builds the target tree from the semantic menu items a +// backend reads, for listings. +func MenuTreeFromNavItems(items []*types.NavMenuItem) []*MenuTreeNode { + out := make([]*MenuTreeNode, 0, len(items)) + for _, it := range items { + if it == nil { + continue + } + out = append(out, &MenuTreeNode{ + Caption: it.Caption, + NoAction: it.ActionType == "" || it.ActionType == "NoAction", + Items: MenuTreeFromNavItems(it.Items), + }) + } + return out +} + +// menuEntry is a node with its path and its place in describe order. +type menuEntry struct { + node *MenuTreeNode + path MenuItemPath + order int +} + +// preorder lists every node in describe order: an item, then its sub-items, +// then its next sibling. +func preorder(roots []*MenuTreeNode) []menuEntry { + var out []menuEntry + var walk func(level []*MenuTreeNode, prefix MenuItemPath) + walk = func(level []*MenuTreeNode, prefix MenuItemPath) { + for i, n := range level { + p := append(append(MenuItemPath(nil), prefix...), i) + out = append(out, menuEntry{node: n, path: p, order: len(out)}) + walk(n.Items, p) + } + } + walk(roots, nil) + return out +} + +// MenuAddressError is a menu item address that did not resolve to exactly one +// item. Its message names the address in MDL and, for an ambiguity, lists the +// shortest unique address of every candidate — so the author can copy one. +type MenuAddressError struct { + Address types.MenuItemAddress + // Candidates are the shortest unique addresses of the matches of an + // ambiguous address; empty for a miss. + Candidates []types.MenuItemAddress + // Detail is the miss's or the out-of-range ordinal's explanation. + Detail string +} + +func (e *MenuAddressError) Error() string { + if len(e.Candidates) > 0 { + lines := make([]string, len(e.Candidates)) + for i, c := range e.Candidates { + lines[i] = " " + c.String() + } + return fmt.Sprintf("menu item %s is ambiguous: it matches %d items — write one of these:\n%s", + e.Address, len(e.Candidates), strings.Join(lines, "\n")) + } + return fmt.Sprintf("menu item %s %s", e.Address, e.Detail) +} + +// ResolveMenuItemAddress finds the one item an address names. +// +// The first segment matches any item in the tree; each following segment +// matches only the direct sub-items of what the segment before it matched, so +// a path matches as a suffix of an item's caption path. A segment's @n picks +// the n-th of its matches in describe order; without @n every match is kept, +// so an intermediate segment may be ambiguous as long as the whole path is not. +// Captions compare exactly, as describe prints them. +// +// It never guesses: a miss, an ambiguity and an @n beyond the matches are each +// a *MenuAddressError. +func ResolveMenuItemAddress(addr types.MenuItemAddress, roots []*MenuTreeNode) (MenuItemPath, error) { + matches, err := resolveMenuMatches(addr, roots) + if err != nil { + return nil, err + } + if len(matches) == 1 { + return matches[0].path, nil + } + cands := make([]types.MenuItemAddress, len(matches)) + for i, m := range matches { + cands[i] = ShortestMenuItemAddress(roots, m.path) + } + return nil, &MenuAddressError{Address: addr, Candidates: cands} +} + +// resolveMenuMatches applies the address's segments and returns every item +// the whole address matches, in describe order. A miss or an @n out of range +// is an error; several matches are not (the caller decides). +func resolveMenuMatches(addr types.MenuItemAddress, roots []*MenuTreeNode) ([]menuEntry, error) { + if len(addr.Segments) == 0 { + return nil, &MenuAddressError{Address: addr, Detail: "is empty"} + } + all := preorder(roots) + order := make(map[*MenuTreeNode]menuEntry, len(all)) + for _, e := range all { + order[e.node] = e + } + + var current []menuEntry + for i, seg := range addr.Segments { + var next []menuEntry + if i == 0 { + for _, e := range all { + if e.node.Caption == seg.Caption { + next = append(next, e) + } + } + } else { + for _, c := range current { + for _, child := range c.node.Items { + if child.Caption == seg.Caption { + next = append(next, order[child]) + } + } + } + sort.Slice(next, func(a, b int) bool { return next[a].order < next[b].order }) + } + if len(next) == 0 { + return nil, &MenuAddressError{Address: addr, Detail: menuMissDetail(addr, i, current, all, roots)} + } + if seg.Ordinal > 0 { + if seg.Ordinal > len(next) { + noun := "items match" + if len(next) == 1 { + noun = "item matches" + } + return nil, &MenuAddressError{Address: addr, Detail: fmt.Sprintf( + "has no @%d: only %d %s %s", seg.Ordinal, len(next), noun, + types.MenuItemAddress{Segments: prefixWithoutOrdinal(addr.Segments[:i+1])})} + } + next = []menuEntry{next[seg.Ordinal-1]} + } + current = next + } + return current, nil +} + +// prefixWithoutOrdinal is segs with the last segment's @n removed, for a +// message about how many items that segment matched. +func prefixWithoutOrdinal(segs []types.MenuItemAddressSegment) []types.MenuItemAddressSegment { + out := append([]types.MenuItemAddressSegment(nil), segs...) + out[len(out)-1].Ordinal = 0 + return out +} + +// menuMissDetail explains a miss at segment i: what is at the level that was +// searched, and any item whose caption differs only in letter case. +func menuMissDetail(addr types.MenuItemAddress, i int, context []menuEntry, all []menuEntry, roots []*MenuTreeNode) string { + seg := addr.Segments[i] + var level []string + seen := map[string]bool{} + add := func(c string) { + q := types.QuoteMenuCaption(c) + if !seen[q] { + seen[q] = true + level = append(level, q) + } + } + where := "the menu has" + if i == 0 { + for _, n := range roots { + add(n.Caption) + } + if len(roots) == 0 { + return "not found: the menu is empty — `insert { … }` adds its first items" + } + } else { + where = types.MenuItemAddress{Segments: addr.Segments[:i]}.String() + " has the sub-items" + for _, c := range context { + for _, ch := range c.node.Items { + add(ch.Caption) + } + } + if len(level) == 0 { + return fmt.Sprintf("not found: %s has no sub-items", + types.MenuItemAddress{Segments: addr.Segments[:i]}) + } + } + msg := fmt.Sprintf("not found: %s %s", where, strings.Join(level, ", ")) + if i == 0 { + msg += " at the top level, and a caption matches at any depth" + } + var folded []string + for _, e := range all { + if e.node.Caption != seg.Caption && strings.EqualFold(e.node.Caption, seg.Caption) { + folded = append(folded, ShortestMenuItemAddress(roots, e.path).String()) + } + } + if len(folded) > 0 { + msg += "; captions compare exactly, and these differ only in letter case: " + strings.Join(folded, ", ") + } + return msg +} + +// ShortestMenuItemAddress is the address describe-order readers should write +// for the item at path: the shortest suffix of its caption path that resolves +// to it alone; when none does (siblings sharing a caption), its parent's +// shortest address followed by `> 'Caption'@n`, n counting the parent's +// sub-items with that caption — or `'Caption'@n` over the whole tree for a +// top-level item. It always resolves back to the item. +func ShortestMenuItemAddress(roots []*MenuTreeNode, path MenuItemPath) types.MenuItemAddress { + captions := make([]string, 0, len(path)) + level := roots + for _, i := range path { + if i < 0 || i >= len(level) { + return types.MenuItemAddress{} + } + captions = append(captions, level[i].Caption) + level = level[i].Items + } + target := MenuNodeAt(roots, path) + for l := 1; l <= len(captions); l++ { + addr := types.MenuItemAddress{} + for _, c := range captions[len(captions)-l:] { + addr.Segments = append(addr.Segments, types.MenuItemAddressSegment{Caption: c}) + } + if m, err := resolveMenuMatches(addr, roots); err == nil && len(m) == 1 && m[0].node == target { + return addr + } + } + caption := captions[len(captions)-1] + if len(path) == 1 { + n := 0 + for _, e := range preorder(roots) { + if e.node.Caption == caption { + n++ + } + if e.node == target { + break + } + } + return types.MenuItemAddress{Segments: []types.MenuItemAddressSegment{{Caption: caption, Ordinal: n}}} + } + parent := path.Parent() + addr := ShortestMenuItemAddress(roots, parent) + n := 0 + for i, sib := range MenuNodeAt(roots, parent).Items { + if sib.Caption == caption { + n++ + } + if i == path.Index() { + break + } + } + addr.Segments = append(addr.Segments, types.MenuItemAddressSegment{Caption: caption, Ordinal: n}) + return addr +} diff --git a/mdl/backend/menu_address_test.go b/mdl/backend/menu_address_test.go new file mode 100644 index 0000000000..662c748a63 --- /dev/null +++ b/mdl/backend/menu_address_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "errors" + "math/rand" + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +func node(caption string, items ...*MenuTreeNode) *MenuTreeNode { + return &MenuTreeNode{Caption: caption, NoAction: len(items) > 0, Items: items} +} + +func addr(segs ...any) types.MenuItemAddress { + var a types.MenuItemAddress + for _, s := range segs { + switch v := s.(type) { + case string: + a.Segments = append(a.Segments, types.MenuItemAddressSegment{Caption: v}) + case int: + a.Segments[len(a.Segments)-1].Ordinal = v + } + } + return a +} + +// The proposal's example menu: two items captioned Users under different +// parents. +func exampleMenu() []*MenuTreeNode { + return []*MenuTreeNode{ + node("Home"), + node("Admin", node("Users"), node("Old import")), + node("Reports", node("Users")), + node("Help"), + node("Log out"), + } +} + +func TestResolveMenuItemAddress_Forms(t *testing.T) { + tree := []*MenuTreeNode{ + node("Home"), + node("Admin", node("Users"), node("Users"), node("Deep", node("Users"))), + node("Admin", node("Roles")), + node("Reports", node("Users")), + node(""), + node(""), + } + cases := []struct { + name string + a types.MenuItemAddress + want MenuItemPath + }{ + {"bare caption, top level", addr("Home"), MenuItemPath{0}}, + {"bare caption, any depth", addr("Roles"), MenuItemPath{2, 0}}, + {"path is a suffix", addr("Deep", "Users"), MenuItemPath{1, 2, 0}}, + {"longer path", addr("Admin", "Deep", "Users"), MenuItemPath{1, 2, 0}}, + {"ambiguous intermediate segment is fine when the whole path is unique", addr("Admin", "Roles"), MenuItemPath{2, 0}}, + {"@n on the last segment", addr("Admin", "Users", 2), MenuItemPath{1, 1}}, + {"@n on the first segment", addr("Admin", 2, "Roles"), MenuItemPath{2, 0}}, + {"@n over the whole tree, describe order", addr("Users", 3), MenuItemPath{1, 2, 0}}, + {"@n over the whole tree, last", addr("Users", 4), MenuItemPath{3, 0}}, + {"empty caption with @n", addr("", 2), MenuItemPath{5}}, + } + for _, c := range cases { + got, err := ResolveMenuItemAddress(c.a, tree) + if err != nil { + t.Errorf("%s: %s: %v", c.name, c.a, err) + continue + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: %s resolved to %v, want %v", c.name, c.a, got, c.want) + } + } +} + +func TestResolveMenuItemAddress_DirectStepOnly(t *testing.T) { + // `'Admin' > 'Users'` is one direct step: a Users two levels below Admin + // is not it. + tree := []*MenuTreeNode{node("Admin", node("Deep", node("Users")))} + if _, err := ResolveMenuItemAddress(addr("Admin", "Users"), tree); err == nil { + t.Fatal("'Admin' > 'Users' resolved through an intermediate level; `>` must be a direct parent/child step") + } +} + +func TestResolveMenuItemAddress_Ambiguous(t *testing.T) { + _, err := ResolveMenuItemAddress(addr("Users"), exampleMenu()) + var ae *MenuAddressError + if !errors.As(err, &ae) { + t.Fatalf("want a *MenuAddressError, got %v", err) + } + var got []string + for _, c := range ae.Candidates { + got = append(got, c.String()) + } + want := []string{"'Admin' > 'Users'", "'Reports' > 'Users'"} + if !reflect.DeepEqual(got, want) { + t.Errorf("candidates = %q, want %q", got, want) + } + msg := err.Error() + for _, w := range []string{"menu item 'Users' is ambiguous: it matches 2 items", " 'Admin' > 'Users'", " 'Reports' > 'Users'"} { + if !strings.Contains(msg, w) { + t.Errorf("message %q lacks %q", msg, w) + } + } +} + +func TestResolveMenuItemAddress_Misses(t *testing.T) { + tree := exampleMenu() + cases := []struct { + a types.MenuItemAddress + want []string + }{ + {addr("Userz"), []string{"menu item 'Userz' not found", "'Home', 'Admin', 'Reports', 'Help', 'Log out'"}}, + {addr("users"), []string{"differ only in letter case: 'Admin' > 'Users', 'Reports' > 'Users'"}}, + {addr("Admin", "Roles"), []string{"'Admin' has the sub-items 'Users', 'Old import'"}}, + {addr("Home", "Roles"), []string{"'Home' has no sub-items"}}, + {addr("Users", 3), []string{"has no @3: only 2 items match 'Users'"}}, + {addr("Admin", "Users", 2), []string{"has no @2: only 1 item matches 'Admin' > 'Users'"}}, + } + for _, c := range cases { + _, err := ResolveMenuItemAddress(c.a, tree) + if err == nil { + t.Errorf("%s resolved; want a refusal", c.a) + continue + } + for _, w := range c.want { + if !strings.Contains(err.Error(), w) { + t.Errorf("%s: message %q lacks %q", c.a, err.Error(), w) + } + } + } + if _, err := ResolveMenuItemAddress(addr("Home"), nil); err == nil || !strings.Contains(err.Error(), "the menu is empty") { + t.Errorf("empty menu: %v", err) + } +} + +func TestMenuItemAddressString(t *testing.T) { + a := addr("it's", 2, "Users") + if got, want := a.String(), "'it''s'@2 > 'Users'"; got != want { + t.Errorf("String = %q, want %q", got, want) + } +} + +func TestShortestMenuItemAddress_Preferences(t *testing.T) { + tree := []*MenuTreeNode{ + node("Admin", node("Users"), node("Users")), + node("Reports", node("Users")), + node("Home"), + node("Home"), + } + cases := []struct { + path MenuItemPath + want string + }{ + {MenuItemPath{1, 0}, "'Reports' > 'Users'"}, + {MenuItemPath{0, 1}, "'Admin' > 'Users'@2"}, + {MenuItemPath{3}, "'Home'@2"}, + {MenuItemPath{0}, "'Admin'"}, + } + for _, c := range cases { + if got := ShortestMenuItemAddress(tree, c.path).String(); got != c.want { + t.Errorf("ShortestMenuItemAddress(%v) = %s, want %s", c.path, got, c.want) + } + } +} + +// Every address ShortestMenuItemAddress prints resolves back to exactly the +// item it was computed for — over generated trees with heavy caption reuse, +// which is where an ordinal or a too-short suffix would go wrong. +func TestShortestMenuItemAddress_RoundTripsOnGeneratedTrees(t *testing.T) { + rng := rand.New(rand.NewSource(1522)) + captions := []string{"A", "B", "C", ""} + var gen func(depth int) []*MenuTreeNode + gen = func(depth int) []*MenuTreeNode { + n := rng.Intn(4) + if depth == 0 { + n = 1 + rng.Intn(4) + } + var out []*MenuTreeNode + for i := 0; i < n; i++ { + nd := &MenuTreeNode{Caption: captions[rng.Intn(len(captions))]} + if depth < 3 && rng.Intn(2) == 0 { + nd.Items = gen(depth + 1) + } + out = append(out, nd) + } + return out + } + checked := 0 + for round := 0; round < 300; round++ { + tree := gen(0) + for _, e := range preorder(tree) { + a := ShortestMenuItemAddress(tree, e.path) + got, err := ResolveMenuItemAddress(a, tree) + if err != nil { + t.Fatalf("round %d: address %s of %v does not resolve: %v", round, a, e.path, err) + } + if !reflect.DeepEqual(got, e.path) { + t.Fatalf("round %d: address %s of %v resolves to %v", round, a, e.path, got) + } + checked++ + } + } + if checked < 1000 { + t.Fatalf("only %d items checked; the generator is too small to mean anything", checked) + } +} diff --git a/mdl/types/menu_address.go b/mdl/types/menu_address.go new file mode 100644 index 0000000000..43f3ded0bc --- /dev/null +++ b/mdl/types/menu_address.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "strconv" + "strings" +) + +// MenuItemAddress is how ALTER NAVIGATION / ALTER MENU name a menu item. An +// item has no name in the model (Studio Pro stores none), so it is addressed +// by the caption describe prints for it, as a path written outer to inner: +// +// 'Users' the item captioned Users, anywhere in the tree +// 'Admin' > 'Users' Users whose parent is captioned Admin +// 'Admin'@2 > 'Users' Users inside the 2nd item captioned Admin +// 'Admin' > 'Users'@2 the 2nd Users among the sub-items of Admin +// +// A path matches as a suffix of an item's caption path, one direct parent/child +// step per `>`. @n belongs to the segment it follows. The rules are +// backend.ResolveMenuItemAddress's. +type MenuItemAddress struct { + Segments []MenuItemAddressSegment +} + +// MenuItemAddressSegment is one `'Caption'[@n]` of an address. +type MenuItemAddressSegment struct { + // Caption is the caption as describe prints it, unquoted. + Caption string + // Ordinal is @n, 1-based; 0 when absent. + Ordinal int +} + +// String spells the address as MDL writes it, which is also how every message +// names a menu item, so a message can be copied into a statement. +func (a MenuItemAddress) String() string { + parts := make([]string, len(a.Segments)) + for i, s := range a.Segments { + parts[i] = QuoteMenuCaption(s.Caption) + if s.Ordinal > 0 { + parts[i] += "@" + strconv.Itoa(s.Ordinal) + } + } + return strings.Join(parts, " > ") +} + +// QuoteMenuCaption writes a caption as an MDL string literal, apostrophes +// doubled (mendixlabs/mxcli#1343). +func QuoteMenuCaption(caption string) string { + return "'" + strings.ReplaceAll(caption, "'", "''") + "'" +} From 916397a4a4ddc1ddb65d02cfe005a3c36a59e5ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:14:38 +0000 Subject: [PATCH 14/28] feat(navigation): menu item splice for navigation profiles and menu documents The backend half of ALTER NAVIGATION / ALTER MENU. menumutator edits the stored Menus$MenuItemCollection of a web profile (Menu) or a menu document (ItemCollection) - the same item tree under a different key - item by item: insert, replace, move, drop, set caption / action / icon, plus the profile's page clauses (home page per role, login page, not-found page). It is a splice, not a rebuild: an item no operation names re-encodes to its stored bytes, a moved item is the same document in another list (its $ID, sub-items, translations and an action MDL cannot spell go with it), and only new nodes get fresh $IDs. A caption is set as ALTER PAGE sets a text (bsonnav.SetTextsTextTranslation), a replacement carries the other-language translations of a caption it keeps, and the page clauses update stored elements in place and clear as Studio Pro does (Form "" for the login page, null for the not-found page). Saved through UpdateRawUnitPatch, so canon elides a no-op and does not re-pair $IDs. The mutators take positions, not addresses: the executor resolves an address against MenuTree and applies its refusals once, for check and exec alike. NavigationBackend gains OpenNavigationProfileForMutation and OpenMenuDocumentForMutation (MCP: unsupported; the mock gets an in-memory MenuMutator). A native profile is refused. Tests on a fixture copy with a Studio Pro-shaped two-language nested menu: every operation keeps the items it does not name byte-identical; the control - the same post-drop menu written by the create path's rebuild - fails that assertion. A restating set is elided; a real change lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- mdl/backend/mcp/unsupported_gen.go | 10 + mdl/backend/menumutator/mutator.go | 677 ++++++++++++++++++ mdl/backend/menumutator/mutator_test.go | 101 +++ mdl/backend/mock/backend.go | 5 + mdl/backend/mock/mock_menu_mutator.go | 217 ++++++ mdl/backend/modelsdk/navigation_mutator.go | 87 +++ .../modelsdk/navigation_mutator_test.go | 598 ++++++++++++++++ mdl/backend/modelsdk/unimplemented_gen.go | 10 + mdl/backend/navigation.go | 67 ++ 9 files changed, 1772 insertions(+) create mode 100644 mdl/backend/menumutator/mutator.go create mode 100644 mdl/backend/menumutator/mutator_test.go create mode 100644 mdl/backend/mock/mock_menu_mutator.go create mode 100644 mdl/backend/modelsdk/navigation_mutator.go create mode 100644 mdl/backend/modelsdk/navigation_mutator_test.go diff --git a/mdl/backend/mcp/unsupported_gen.go b/mdl/backend/mcp/unsupported_gen.go index 6046722bbd..17d7a1a01e 100644 --- a/mdl/backend/mcp/unsupported_gen.go +++ b/mdl/backend/mcp/unsupported_gen.go @@ -1007,11 +1007,21 @@ func (unsupportedBackend) MoveViewEntitySourceDocument(_ string, _ model.ID, _ s return } +func (unsupportedBackend) OpenMenuDocumentForMutation(_ model.ID) (r0 backend.MenuMutator, err1 error) { + err1 = errUnsupported("OpenMenuDocumentForMutation") + return +} + func (unsupportedBackend) OpenMicroflowForMutation(_ model.ID) (r0 backend.MicroflowMutator, err1 error) { err1 = errUnsupported("OpenMicroflowForMutation") return } +func (unsupportedBackend) OpenNavigationProfileForMutation(_ string) (r0 backend.NavigationProfileMutator, err1 error) { + err1 = errUnsupported("OpenNavigationProfileForMutation") + return +} + func (unsupportedBackend) OpenPageForMutation(_ model.ID) (r0 backend.PageMutator, err1 error) { err1 = errUnsupported("OpenPageForMutation") return diff --git a/mdl/backend/menumutator/mutator.go b/mdl/backend/menumutator/mutator.go new file mode 100644 index 0000000000..c2fbd4d1c9 --- /dev/null +++ b/mdl/backend/menumutator/mutator.go @@ -0,0 +1,677 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package menumutator is the splice behind ALTER NAVIGATION and ALTER MENU: it +// edits the Menus$MenuItemCollection of a navigation profile or of a menu +// document in the stored BSON, item by item. +// +// The two documents hold the same item tree (Menus$MenuItem, Items marker 3); +// only the key that holds the collection differs — `Menu` on a +// Navigation$NavigationProfile, `ItemCollection` on a Menus$MenuDocument — so +// one mutator serves both, and the profile's page clauses are a small addition +// on top. +// +// It is a splice, not a rebuild (ADR-0012 decision 3). An item no operation +// names stays the bson.D it was decoded into and re-encodes to the same bytes; +// a moved item is the same bson.D in another list, so it keeps its $ID, its +// sub-items, every caption translation and an action MDL cannot spell. Only new +// nodes get fresh $IDs, and no stored $ID is ever rewritten (CLAUDE.md rules 1 +// and 3). The unit is saved as a patch (UpdateRawUnitPatch), so canon does not +// re-pair element $IDs the splice deliberately kept, nor carry back a +// translation it deliberately left out. +// +// Resolution and refusals are not here: the mutator takes positions the caller +// resolved (backend.ResolveMenuItemAddress over MenuTree), so check and exec +// run one function over it and refuse alike. +package menumutator + +import ( + "bytes" + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// Typed-array markers Studio Pro writes for the lists this package creates +// (censused in modelsdkbackend's navMarker* comment). +const ( + markerItems = int32(3) + markerHomeItems = int32(2) + markerParams = int32(2) +) + +// Deps builds the new nodes, the way `create navigation` builds them, and +// persists the unit. The backend implements it. +type Deps interface { + // BuildMenuItem is a Studio Pro-shaped Menus$MenuItem, sub-items included, + // with fresh $IDs. + BuildMenuItem(spec types.NavMenuItemSpec) (bson.D, error) + // BuildMenuAction is the client action an item's OnClick stores. + BuildMenuAction(spec types.NavMenuItemSpec) (bson.D, error) + // BuildMenuIcon is the icon element, or nil for no icon. + BuildMenuIcon(spec types.NavMenuItemSpec) any + // SaveUnit writes the unit as a patch. + SaveUnit(unitID string, contents []byte) error +} + +// Mutator edits one document's menu. Create it with NewProfile or +// NewMenuDocument. +type Mutator struct { + root bson.D + unitID string + deps Deps + profile bson.D // the profile, for a navigation document; nil for a menu document + collKey string // the key holding the collection in its owner +} + +var ( + _ backend.MenuMutator = (*Mutator)(nil) + _ backend.NavigationProfileMutator = (*Mutator)(nil) +) + +// NewProfile opens the menu of the web profile named profileName in a +// Navigation$NavigationDocument. A native profile is refused: its bottom bar +// is a different element (BottomBarItems) that mxcli does not write. +func NewProfile(root bson.D, unitID, profileName string, deps Deps) (*Mutator, error) { + for _, p := range bsonnav.DGetArrayElements(bsonnav.DGet(root, "Profiles")) { + prof, ok := p.(bson.D) + if !ok || !strings.EqualFold(bsonnav.DGetString(prof, "Name"), profileName) { + continue + } + if t := bsonnav.DGetString(prof, "$Type"); t != "Navigation$NavigationProfile" { + return nil, fmt.Errorf("navigation profile %s is a %s, not a web profile: its menu is a bottom bar mxcli does not write", profileName, t) + } + return &Mutator{root: root, unitID: unitID, deps: deps, profile: prof, collKey: "Menu"}, nil + } + return nil, fmt.Errorf("navigation profile not found: %s", profileName) +} + +// NewMenuDocument opens a Menus$MenuDocument's items. +func NewMenuDocument(root bson.D, unitID string, deps Deps) (*Mutator, error) { + if t := bsonnav.DGetString(root, "$Type"); t != "Menus$MenuDocument" { + return nil, fmt.Errorf("unit %s is a %s, not a menu document", unitID, t) + } + return &Mutator{root: root, unitID: unitID, deps: deps, collKey: "ItemCollection"}, nil +} + +// owner is the document holding the collection. +func (m *Mutator) owner() bson.D { + if m.profile != nil { + return m.profile + } + return m.root +} + +// collection is the Menus$MenuItemCollection, created (as Studio Pro shapes +// it) when the owner stores none. +func (m *Mutator) collection() bson.D { + if c := bsonnav.DGetDoc(m.owner(), m.collKey); c != nil { + return c + } + c := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Menus$MenuItemCollection"}, + {Key: "Items", Value: bson.A{markerItems}}, + } + m.setOwnerField(m.collKey, c) + return c +} + +// setOwnerField sets key on the owner document, appending it when absent. The +// owner is a slice inside its parent, so an append is written back there. +func (m *Mutator) setOwnerField(key string, value any) { + if bsonnav.DSet(m.owner(), key, value) { + return + } + grown := append(m.owner(), bson.E{Key: key, Value: value}) + if m.profile == nil { + m.root = grown + return + } + m.replaceProfile(grown) +} + +// replaceProfile swaps the profile document for grown in the Profiles list. +func (m *Mutator) replaceProfile(grown bson.D) { + for _, e := range m.root { + if e.Key != "Profiles" { + continue + } + arr, _ := e.Value.(bson.A) + for i, p := range arr { + if d, ok := p.(bson.D); ok && sameDoc(d, m.profile) { + arr[i] = grown + } + } + } + m.profile = grown +} + +// sameDoc reports whether a and b share their first element, i.e. are the same +// decoded document. +func sameDoc(a, b bson.D) bool { + return len(a) > 0 && len(b) > 0 && &a[0] == &b[0] +} + +// list is the Items list (marker included) of the item at parent, or of the +// collection for a nil parent, with the document that holds it. +func (m *Mutator) list(parent backend.MenuItemPath) (bson.A, bson.D, error) { + holder := m.collection() + for depth, i := range parent { + items := itemElements(holder) + if i < 0 || i >= len(items) { + return nil, nil, fmt.Errorf("menu position %v does not exist (depth %d)", parent, depth) + } + holder = items[i] + } + arr, _ := bsonnav.DGet(holder, "Items").(bson.A) + return arr, holder, nil +} + +// itemElements are the Menus$MenuItem documents of a holder's Items, without +// the marker. +func itemElements(holder bson.D) []bson.D { + var out []bson.D + for _, v := range bsonnav.DGetArrayElements(bsonnav.DGet(holder, "Items")) { + if d, ok := v.(bson.D); ok { + out = append(out, d) + } + } + return out +} + +// setItems stores elements (without marker) as the holder's Items, keeping the +// stored marker, or Studio Pro's 3 for a list that had none. +func setItems(holder bson.D, elements []bson.D) error { + marker := any(markerItems) + if arr, ok := bsonnav.DGet(holder, "Items").(bson.A); ok && len(arr) > 0 && bsonnav.IsListMarker(arr[0]) { + marker = arr[0] + } + out := bson.A{marker} + for _, e := range elements { + out = append(out, e) + } + if !bsonnav.DSet(holder, "Items", out) { + return fmt.Errorf("menu item %s has no Items list", idString(holder)) + } + return nil +} + +// item is the Menus$MenuItem at path. +func (m *Mutator) item(path backend.MenuItemPath) (bson.D, error) { + if len(path) == 0 { + return nil, fmt.Errorf("empty menu position") + } + _, holder, err := m.list(path.Parent()) + if err != nil { + return nil, err + } + items := itemElements(holder) + if path.Index() < 0 || path.Index() >= len(items) { + return nil, fmt.Errorf("menu position %v does not exist", path) + } + return items[path.Index()], nil +} + +// MenuTree is the menu as a target sees it: each item's caption read with the +// page rule (model.SelectTranslation in the authoring language), whether its +// action is "Do nothing", and its sub-items. +func (m *Mutator) MenuTree() []*backend.MenuTreeNode { + return treeOf(m.collection()) +} + +func treeOf(holder bson.D) []*backend.MenuTreeNode { + var out []*backend.MenuTreeNode + for _, it := range itemElements(holder) { + out = append(out, &backend.MenuTreeNode{ + Caption: captionOf(it), + NoAction: isNoAction(bsonnav.DGetDoc(it, "Action")), + Items: treeOf(it), + }) + } + return out +} + +// captionOf reads an item's caption as describe prints it. +func captionOf(item bson.D) string { + return model.SelectTranslation(bsonnav.TextsTextTranslations(bsonnav.DGetDoc(item, "Caption")), model.AuthoringLanguage()) +} + +// isNoAction reports Studio Pro's "Do nothing" (or no action at all). +func isNoAction(action bson.D) bool { + switch bsonnav.DGetString(action, "$Type") { + case "", "Forms$NoAction", "Forms$NoClientAction", "Pages$NoClientAction": + return true + } + return false +} + +// InsertMenuItems builds items and places them in the list of parent (nil: +// the top level) at index; an index equal to the list's length appends. +func (m *Mutator) InsertMenuItems(parent backend.MenuItemPath, index int, specs []types.NavMenuItemSpec) error { + built, err := m.build(specs) + if err != nil { + return err + } + _, holder, err := m.list(parent) + if err != nil { + return err + } + items := itemElements(holder) + if index < 0 || index > len(items) { + return fmt.Errorf("menu position %d is outside a list of %d items", index, len(items)) + } + out := append(append(append([]bson.D(nil), items[:index]...), built...), items[index:]...) + return setItems(holder, out) +} + +func (m *Mutator) build(specs []types.NavMenuItemSpec) ([]bson.D, error) { + var out []bson.D + for _, s := range specs { + d, err := m.deps.BuildMenuItem(s) + if err != nil { + return nil, err + } + out = append(out, d) + } + return out, nil +} + +// ReplaceMenuItem puts new items where the item at path was. The new items get +// fresh $IDs; one whose caption equals a replaced item's (paired by caption at +// each level) is given that caption's other-language translations, which the +// patch write would otherwise not carry. +func (m *Mutator) ReplaceMenuItem(path backend.MenuItemPath, specs []types.NavMenuItemSpec) error { + old, err := m.item(path) + if err != nil { + return err + } + built, err := m.build(specs) + if err != nil { + return err + } + carryCaptionTranslations([]bson.D{old}, built) + _, holder, err := m.list(path.Parent()) + if err != nil { + return err + } + items := itemElements(holder) + i := path.Index() + out := append(append(append([]bson.D(nil), items[:i]...), built...), items[i+1:]...) + return setItems(holder, out) +} + +// carryCaptionTranslations pairs each new item with the first unclaimed old +// item of the same displayed caption and copies the old caption's translations +// in languages the new one lacks; then the same one level down. +func carryCaptionTranslations(old, built []bson.D) { + used := make([]bool, len(old)) + for _, b := range built { + caption := captionOf(b) + for i, o := range old { + if used[i] || captionOf(o) != caption { + continue + } + used[i] = true + copyMissingTranslations(bsonnav.DGetDoc(o, "Caption"), bsonnav.DGetDoc(b, "Caption")) + carryCaptionTranslations(itemElements(o), itemElements(b)) + break + } + } +} + +// copyMissingTranslations appends to dst a copy (fresh $ID) of each of src's +// translations in a language dst has no entry for. +func copyMissingTranslations(src, dst bson.D) { + if src == nil || dst == nil { + return + } + have := map[string]bool{} + for _, e := range bsonnav.TextsTextTranslations(dst) { + have[e.LanguageCode] = true + } + arr, ok := bsonnav.DGet(dst, "Items").(bson.A) + if !ok { + return + } + for _, e := range bsonnav.TextsTextTranslations(src) { + if have[e.LanguageCode] { + continue + } + have[e.LanguageCode] = true + arr = append(arr, translationDoc(e.LanguageCode, e.Text)) + } + bsonnav.DSet(dst, "Items", arr) +} + +func translationDoc(lang, text string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: lang}, + {Key: "Text", Value: text}, + } +} + +// MoveMenuItem relocates the stored item at from — the same bson.D, so its +// bytes, $ID, sub-items and translations go with it — before or after the +// item at target, or as target's last sub-item. Moving an item into its own +// subtree, or relative to itself, is refused. +func (m *Mutator) MoveMenuItem(from, target backend.MenuItemPath, position backend.MenuPosition) error { + if target.HasPrefix(from) { + if len(target) == len(from) { + return fmt.Errorf("an item cannot be moved relative to itself") + } + return fmt.Errorf("an item cannot be moved into its own sub-items") + } + moved, err := m.item(from) + if err != nil { + return err + } + targetDoc, err := m.item(target) + if err != nil { + return err + } + targetID := idOf(targetDoc) + + // Take it out of its list first; the target is then found again by $ID, + // since removing an earlier sibling shifts its position. + _, srcHolder, err := m.list(from.Parent()) + if err != nil { + return err + } + src := itemElements(srcHolder) + rest := append(append([]bson.D(nil), src[:from.Index()]...), src[from.Index()+1:]...) + if err := setItems(srcHolder, rest); err != nil { + return err + } + + tpath := m.findByID(targetID) + if tpath == nil { + return fmt.Errorf("move target lost after removing the moved item") + } + switch position { + case backend.MenuInto: + _, holder, err := m.list(tpath) + if err != nil { + return err + } + return setItems(holder, append(itemElements(holder), moved)) + case backend.MenuBefore, backend.MenuAfter: + _, holder, err := m.list(tpath.Parent()) + if err != nil { + return err + } + items := itemElements(holder) + at := tpath.Index() + if position == backend.MenuAfter { + at++ + } + out := append(append(append([]bson.D(nil), items[:at]...), moved), items[at:]...) + return setItems(holder, out) + } + return fmt.Errorf("unknown move position %q", position) +} + +// findByID is the path of the item whose $ID equals id. +func (m *Mutator) findByID(id []byte) backend.MenuItemPath { + var walk func(holder bson.D, prefix backend.MenuItemPath) backend.MenuItemPath + walk = func(holder bson.D, prefix backend.MenuItemPath) backend.MenuItemPath { + for i, it := range itemElements(holder) { + p := append(append(backend.MenuItemPath(nil), prefix...), i) + if bytes.Equal(idOf(it), id) { + return p + } + if found := walk(it, p); found != nil { + return found + } + } + return nil + } + return walk(m.collection(), nil) +} + +func idOf(doc bson.D) []byte { + switch v := bsonnav.DGet(doc, "$ID").(type) { + case primitive.Binary: + return v.Data + case string: + return []byte(v) + } + return nil +} + +func idString(doc bson.D) string { + return fmt.Sprintf("%x", idOf(doc)) +} + +// DropMenuItem removes the item at path and its sub-items. +func (m *Mutator) DropMenuItem(path backend.MenuItemPath) error { + if _, err := m.item(path); err != nil { + return err + } + _, holder, err := m.list(path.Parent()) + if err != nil { + return err + } + items := itemElements(holder) + i := path.Index() + return setItems(holder, append(append([]bson.D(nil), items[:i]...), items[i+1:]...)) +} + +// SetMenuItemCaption writes caption as the authoring language's translation of +// the item's caption — updated in place, or added — and keeps every other +// language's, as ALTER PAGE sets a text (bsonnav.SetTextsTextTranslation). +func (m *Mutator) SetMenuItemCaption(path backend.MenuItemPath, caption string) error { + it, err := m.item(path) + if err != nil { + return err + } + if text := bsonnav.DGetDoc(it, "Caption"); text != nil && bsonnav.SetTextsTextTranslation(text, caption) { + return nil + } + // No Texts$Text stored (a null caption): build the one create writes. + text := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{markerItems, translationDoc(model.AuthoringLanguage(), caption)}}, + } + if !bsonnav.DSet(it, "Caption", text) { + return fmt.Errorf("menu item %s has no Caption property", idString(it)) + } + return nil +} + +// SetMenuItemAction replaces the item's client action with the one spec +// states. The stored action travels as spec.StoredAction, so a rebuilt action +// that equals it except for what MDL cannot spell keeps the stored bytes. +func (m *Mutator) SetMenuItemAction(path backend.MenuItemPath, spec types.NavMenuItemSpec) error { + it, err := m.item(path) + if err != nil { + return err + } + if stored := bsonnav.DGetDoc(it, "Action"); stored != nil { + if raw, err := bson.Marshal(stored); err == nil { + spec.StoredAction = raw + } + } + action, err := m.deps.BuildMenuAction(spec) + if err != nil { + return err + } + if !bsonnav.DSet(it, "Action", action) { + return fmt.Errorf("menu item %s has no Action property", idString(it)) + } + return nil +} + +// SetMenuItemIcon replaces the item's icon; a spec with no icon stores null. +func (m *Mutator) SetMenuItemIcon(path backend.MenuItemPath, spec types.NavMenuItemSpec) error { + it, err := m.item(path) + if err != nil { + return err + } + if !bsonnav.DSet(it, "Icon", m.deps.BuildMenuIcon(spec)) { + return fmt.Errorf("menu item %s has no Icon property", idString(it)) + } + return nil +} + +// --- the profile's page clauses --- + +func (m *Mutator) requireProfile(what string) error { + if m.profile == nil { + return fmt.Errorf("%s: a menu document has no page clauses", what) + } + return nil +} + +// SetHomePage sets the default home page (ForRole empty) or one role's. A +// stored element is updated in place, keeping its $ID; a role with none gets a +// new Navigation$RoleBasedHomePage, appended after the others. +func (m *Mutator) SetHomePage(home types.NavHomePageSpec) error { + if err := m.requireProfile("set home"); err != nil { + return err + } + page, mf := home.Target, "" + if !home.IsPage { + page, mf = "", home.Target + } + if home.ForRole == "" { + if hp := bsonnav.DGetDoc(m.profile, "HomePage"); hp != nil && bsonnav.DGetString(hp, "$Type") == "Navigation$HomePage" { + bsonnav.DSet(hp, "Page", page) + bsonnav.DSet(hp, "Microflow", mf) + return nil + } + m.setOwnerField("HomePage", bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Navigation$HomePage"}, + {Key: "Microflow", Value: mf}, + {Key: "Page", Value: page}, + }) + return nil + } + arr, _ := bsonnav.DGet(m.profile, "HomeItems").(bson.A) + for _, v := range arr { + d, ok := v.(bson.D) + if ok && strings.EqualFold(bsonnav.DGetString(d, "UserRole"), home.ForRole) { + bsonnav.DSet(d, "Page", page) + bsonnav.DSet(d, "Microflow", mf) + return nil + } + } + if len(arr) == 0 || !bsonnav.IsListMarker(arr[0]) { + arr = append(bson.A{markerHomeItems}, arr...) + } + arr = append(arr, bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Navigation$RoleBasedHomePage"}, + {Key: "Microflow", Value: mf}, + {Key: "Page", Value: page}, + {Key: "UserRole", Value: home.ForRole}, + }) + m.setOwnerField("HomeItems", arr) + return nil +} + +// DropRoleHomePage removes a role's home page, whatever its kind. A role with +// none is an error, not a silent no-op. +func (m *Mutator) DropRoleHomePage(role string) error { + if err := m.requireProfile("drop home page"); err != nil { + return err + } + arr, _ := bsonnav.DGet(m.profile, "HomeItems").(bson.A) + out := bson.A{} + found := false + var roles []string + for _, v := range arr { + d, ok := v.(bson.D) + if !ok { + out = append(out, v) // the marker + continue + } + r := bsonnav.DGetString(d, "UserRole") + if !found && strings.EqualFold(r, role) { + found = true + continue + } + roles = append(roles, r) + out = append(out, d) + } + if !found { + if len(roles) == 0 { + return fmt.Errorf("role %s has no home page: the profile has no role-based home pages", role) + } + return fmt.Errorf("role %s has no home page; the roles with one are %s", role, strings.Join(roles, ", ")) + } + m.setOwnerField("HomeItems", out) + return nil +} + +// SetLoginPage sets the login page, or clears it for "" the way Studio Pro +// does: Form "" in the stored Forms$FormSettings, which stays. +func (m *Mutator) SetLoginPage(page string) error { + if err := m.requireProfile("login page"); err != nil { + return err + } + if ls := bsonnav.DGetDoc(m.profile, "LoginPageSettings"); ls != nil && bsonnav.HasKey(ls, "Form") { + bsonnav.DSet(ls, "Form", page) + return nil + } + if page == "" { + return nil // nothing stored, nothing to clear + } + m.setOwnerField("LoginPageSettings", bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$FormSettings"}, + {Key: "Form", Value: page}, + {Key: "ParameterMappings", Value: bson.A{markerParams}}, + {Key: "TitleOverride", Value: nil}, + }) + return nil +} + +// SetNotFoundPage sets the fallback page, or clears it for "": Studio Pro +// stores null there. +func (m *Mutator) SetNotFoundPage(page string) error { + if err := m.requireProfile("not found page"); err != nil { + return err + } + if page == "" { + m.setOwnerField("NotFoundHomepage", nil) + return nil + } + if nf := bsonnav.DGetDoc(m.profile, "NotFoundHomepage"); nf != nil && bsonnav.DGetString(nf, "$Type") == "Navigation$NotFoundHomePage" { + bsonnav.DSet(nf, "Page", page) + bsonnav.DSet(nf, "Microflow", "") + return nil + } + m.setOwnerField("NotFoundHomepage", bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Navigation$NotFoundHomePage"}, + {Key: "Microflow", Value: ""}, + {Key: "Page", Value: page}, + }) + return nil +} + +// Bytes is the unit as it would be saved. +func (m *Mutator) Bytes() ([]byte, error) { + return bson.Marshal(m.root) +} + +// Save writes the unit as a patch. +func (m *Mutator) Save() error { + out, err := m.Bytes() + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + return m.deps.SaveUnit(m.unitID, out) +} diff --git a/mdl/backend/menumutator/mutator_test.go b/mdl/backend/menumutator/mutator_test.go new file mode 100644 index 0000000000..8ca03bdd40 --- /dev/null +++ b/mdl/backend/menumutator/mutator_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +package menumutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/types" +) + +type fakeDeps struct{ saved []byte } + +func (f *fakeDeps) BuildMenuItem(spec types.NavMenuItemSpec) (bson.D, error) { + return item(spec.Caption), nil +} +func (f *fakeDeps) BuildMenuAction(types.NavMenuItemSpec) (bson.D, error) { return nil, nil } +func (f *fakeDeps) BuildMenuIcon(types.NavMenuItemSpec) any { return nil } +func (f *fakeDeps) SaveUnit(_ string, b []byte) error { f.saved = b; return nil } + +func item(caption string, subs ...bson.D) bson.D { + items := bson.A{int32(3)} + for _, s := range subs { + items = append(items, s) + } + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Menus$MenuItem"}, + {Key: "Action", Value: bson.D{{Key: "$Type", Value: "Forms$NoAction"}}}, + {Key: "AlternativeText", Value: nil}, + {Key: "Caption", Value: bson.D{{Key: "$Type", Value: "Texts$Text"}, {Key: "Items", Value: bson.A{int32(3), + bson.D{{Key: "$Type", Value: "Texts$Translation"}, {Key: "LanguageCode", Value: "en_US"}, {Key: "Text", Value: caption}}}}}}, + {Key: "Icon", Value: nil}, + {Key: "Items", Value: items}, + } +} + +func navDoc(profiles ...bson.D) bson.D { + arr := bson.A{int32(2)} + for _, p := range profiles { + arr = append(arr, p) + } + return bson.D{{Key: "$Type", Value: "Navigation$NavigationDocument"}, {Key: "Profiles", Value: arr}} +} + +func TestNewProfile_Refusals(t *testing.T) { + native := bson.D{{Key: "$Type", Value: "Navigation$NativeNavigationProfile"}, {Key: "Name", Value: "NativePhone"}} + web := bson.D{{Key: "$Type", Value: "Navigation$NavigationProfile"}, {Key: "Name", Value: "Responsive"}, {Key: "Menu", Value: nil}} + doc := navDoc(web, native) + if _, err := NewProfile(doc, "u", "NativePhone", &fakeDeps{}); err == nil || !strings.Contains(err.Error(), "bottom bar") { + t.Errorf("native profile: %v, want a refusal naming the bottom bar", err) + } + if _, err := NewProfile(doc, "u", "Tablet", &fakeDeps{}); err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("missing profile: %v", err) + } + // A web profile whose Menu is null gets a collection on the first insert, + // shaped as Studio Pro writes one. + m, err := NewProfile(doc, "u", "responsive", &fakeDeps{}) + if err != nil { + t.Fatal(err) + } + if err := m.InsertMenuItems(nil, 0, []types.NavMenuItemSpec{{Caption: "First"}}); err != nil { + t.Fatal(err) + } + if tree := m.MenuTree(); len(tree) != 1 || tree[0].Caption != "First" { + t.Errorf("tree after insert into an empty menu = %+v", tree) + } + coll := m.collection() + if got := coll[1].Value; got != "Menus$MenuItemCollection" { + t.Errorf("created collection $Type = %v", got) + } + if err := m.SetHomePage(types.NavHomePageSpec{}); err != nil { + t.Errorf("SetHomePage on a profile: %v", err) + } +} + +func TestMenuDocument_HasNoPageClauses(t *testing.T) { + doc := bson.D{{Key: "$Type", Value: "Menus$MenuDocument"}, {Key: "ItemCollection", Value: bson.D{ + {Key: "$Type", Value: "Menus$MenuItemCollection"}, {Key: "Items", Value: bson.A{int32(3), item("A", item("B"))}}}}} + m, err := NewMenuDocument(doc, "u", &fakeDeps{}) + if err != nil { + t.Fatal(err) + } + if err := m.SetLoginPage("M.P"); err == nil { + t.Error("a menu document accepted a login page") + } + a := backend.MenuItemPath{0} + if err := m.MoveMenuItem(a, a, backend.MenuAfter); err == nil || !strings.Contains(err.Error(), "itself") { + t.Errorf("move relative to itself: %v", err) + } + if err := m.MoveMenuItem(a, backend.MenuItemPath{0, 0}, backend.MenuInto); err == nil || !strings.Contains(err.Error(), "own sub-items") { + t.Errorf("move into own subtree: %v", err) + } + if _, err := NewMenuDocument(navDoc(), "u", &fakeDeps{}); err == nil { + t.Error("a navigation document was opened as a menu document") + } +} diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index eb2671bb24..96125b5f8f 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -183,6 +183,11 @@ type MockBackend struct { ListNavigationDocumentsFunc func() ([]*types.NavigationDocument, error) GetNavigationFunc func() (*types.NavigationDocument, error) UpdateNavigationProfileFunc func(navDocID model.ID, profileName string, spec types.NavigationProfileSpec) error + // OpenNavigationProfileForMutationFunc / OpenMenuDocumentForMutationFunc + // back ALTER NAVIGATION / ALTER MENU; MenuMutator in this package is an + // in-memory mutator for them. + OpenNavigationProfileForMutationFunc func(profileName string) (backend.NavigationProfileMutator, error) + OpenMenuDocumentForMutationFunc func(id model.ID) (backend.MenuMutator, error) // ServiceBackend ListConsumedODataServicesFunc func() ([]*model.ConsumedODataService, error) diff --git a/mdl/backend/mock/mock_menu_mutator.go b/mdl/backend/mock/mock_menu_mutator.go new file mode 100644 index 0000000000..d3c8db3e54 --- /dev/null +++ b/mdl/backend/mock/mock_menu_mutator.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +func (m *MockBackend) OpenNavigationProfileForMutation(profileName string) (backend.NavigationProfileMutator, error) { + if m.OpenNavigationProfileForMutationFunc != nil { + return m.OpenNavigationProfileForMutationFunc(profileName) + } + return nil, fmt.Errorf("MockBackend.OpenNavigationProfileForMutation not configured") +} + +func (m *MockBackend) OpenMenuDocumentForMutation(id model.ID) (backend.MenuMutator, error) { + if m.OpenMenuDocumentForMutationFunc != nil { + return m.OpenMenuDocumentForMutationFunc(id) + } + return nil, fmt.Errorf("MockBackend.OpenMenuDocumentForMutation not configured") +} + +// MenuMutator is an in-memory backend.NavigationProfileMutator over a +// MenuTreeNode tree, for executor tests: each operation changes Tree the way +// the BSON splice changes the stored menu, and is logged in Calls. Saved says +// whether Save ran — a refused statement must leave it false. +type MenuMutator struct { + Tree []*backend.MenuTreeNode + Calls []string + Saved bool + + Home types.NavHomePageSpec + RoleHomes map[string]types.NavHomePageSpec + LoginPage string + NotFound string +} + +var _ backend.NavigationProfileMutator = (*MenuMutator)(nil) + +func (m *MenuMutator) log(format string, args ...any) { + m.Calls = append(m.Calls, fmt.Sprintf(format, args...)) +} + +func (m *MenuMutator) MenuTree() []*backend.MenuTreeNode { return m.Tree } + +// level is the list holding the item at path's parent, with a setter. +func (m *MenuMutator) level(parent backend.MenuItemPath) (*[]*backend.MenuTreeNode, error) { + if len(parent) == 0 { + return &m.Tree, nil + } + n := backend.MenuNodeAt(m.Tree, parent) + if n == nil { + return nil, fmt.Errorf("no item at %v", parent) + } + return &n.Items, nil +} + +func nodesOf(specs []types.NavMenuItemSpec) []*backend.MenuTreeNode { + var out []*backend.MenuTreeNode + for _, s := range specs { + out = append(out, &backend.MenuTreeNode{ + Caption: s.Caption, + NoAction: s.Action == nil && s.Page == "" && s.Microflow == "" && !s.SignOut, + Items: nodesOf(s.Items), + }) + } + return out +} + +func (m *MenuMutator) InsertMenuItems(parent backend.MenuItemPath, index int, items []types.NavMenuItemSpec) error { + l, err := m.level(parent) + if err != nil { + return err + } + if index < 0 || index > len(*l) { + return fmt.Errorf("index %d outside %d items", index, len(*l)) + } + *l = append(append(append([]*backend.MenuTreeNode(nil), (*l)[:index]...), nodesOf(items)...), (*l)[index:]...) + m.log("insert %v %d %s", parent, index, captions(items)) + return nil +} + +func (m *MenuMutator) ReplaceMenuItem(path backend.MenuItemPath, items []types.NavMenuItemSpec) error { + l, err := m.level(path.Parent()) + if err != nil { + return err + } + i := path.Index() + *l = append(append(append([]*backend.MenuTreeNode(nil), (*l)[:i]...), nodesOf(items)...), (*l)[i+1:]...) + m.log("replace %v %s", path, captions(items)) + return nil +} + +func (m *MenuMutator) MoveMenuItem(from, target backend.MenuItemPath, position backend.MenuPosition) error { + moved := backend.MenuNodeAt(m.Tree, from) + tnode := backend.MenuNodeAt(m.Tree, target) + if moved == nil || tnode == nil { + return fmt.Errorf("no item at %v or %v", from, target) + } + src, _ := m.level(from.Parent()) + i := from.Index() + *src = append(append([]*backend.MenuTreeNode(nil), (*src)[:i]...), (*src)[i+1:]...) + // find target again + var place func(l *[]*backend.MenuTreeNode) bool + place = func(l *[]*backend.MenuTreeNode) bool { + for j, n := range *l { + if n == tnode { + switch position { + case backend.MenuInto: + n.Items = append(n.Items, moved) + case backend.MenuBefore: + *l = append(append(append([]*backend.MenuTreeNode(nil), (*l)[:j]...), moved), (*l)[j:]...) + default: + *l = append(append(append([]*backend.MenuTreeNode(nil), (*l)[:j+1]...), moved), (*l)[j+1:]...) + } + return true + } + if place(&n.Items) { + return true + } + } + return false + } + if !place(&m.Tree) { + return fmt.Errorf("target lost") + } + m.log("move %v %s %v", from, position, target) + return nil +} + +func (m *MenuMutator) DropMenuItem(path backend.MenuItemPath) error { + l, err := m.level(path.Parent()) + if err != nil { + return err + } + i := path.Index() + *l = append(append([]*backend.MenuTreeNode(nil), (*l)[:i]...), (*l)[i+1:]...) + m.log("drop %v", path) + return nil +} + +func (m *MenuMutator) SetMenuItemCaption(path backend.MenuItemPath, caption string) error { + n := backend.MenuNodeAt(m.Tree, path) + if n == nil { + return fmt.Errorf("no item at %v", path) + } + n.Caption = caption + m.log("caption %v %q", path, caption) + return nil +} + +func (m *MenuMutator) SetMenuItemAction(path backend.MenuItemPath, item types.NavMenuItemSpec) error { + n := backend.MenuNodeAt(m.Tree, path) + if n == nil { + return fmt.Errorf("no item at %v", path) + } + n.NoAction = item.Action == nil && item.Page == "" && item.Microflow == "" && !item.SignOut + m.log("action %v %T", path, item.Action) + return nil +} + +func (m *MenuMutator) SetMenuItemIcon(path backend.MenuItemPath, item types.NavMenuItemSpec) error { + m.log("icon %v %s%s", path, item.IconKind, item.Icon) + return nil +} + +func (m *MenuMutator) SetHomePage(home types.NavHomePageSpec) error { + if home.ForRole == "" { + m.Home = home + } else { + if m.RoleHomes == nil { + m.RoleHomes = map[string]types.NavHomePageSpec{} + } + m.RoleHomes[home.ForRole] = home + } + m.log("home %s %s", home.Target, home.ForRole) + return nil +} + +func (m *MenuMutator) DropRoleHomePage(role string) error { + if _, ok := m.RoleHomes[role]; !ok { + return fmt.Errorf("role %s has no home page", role) + } + delete(m.RoleHomes, role) + m.log("drop home %s", role) + return nil +} + +func (m *MenuMutator) SetLoginPage(page string) error { + m.LoginPage = page + m.log("login %q", page) + return nil +} + +func (m *MenuMutator) SetNotFoundPage(page string) error { + m.NotFound = page + m.log("notfound %q", page) + return nil +} + +func (m *MenuMutator) Save() error { + m.Saved = true + return nil +} + +func captions(items []types.NavMenuItemSpec) string { + var out []string + for _, it := range items { + out = append(out, "'"+it.Caption+"'") + } + return strings.Join(out, ",") +} diff --git a/mdl/backend/modelsdk/navigation_mutator.go b/mdl/backend/modelsdk/navigation_mutator.go new file mode 100644 index 0000000000..993442fd72 --- /dev/null +++ b/mdl/backend/modelsdk/navigation_mutator.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "fmt" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/menumutator" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// OpenNavigationProfileForMutation opens the navigation document's raw unit +// and returns the menu splice over one web profile (ALTER NAVIGATION). +func (b *Backend) OpenNavigationProfileForMutation(profileName string) (backend.NavigationProfileMutator, error) { + if b.writer == nil { + return nil, fmt.Errorf("OpenNavigationProfileForMutation: not connected for writing") + } + nav, err := b.GetNavigation() + if err != nil { + return nil, err + } + if nav == nil { + return nil, fmt.Errorf("the project has no navigation document") + } + doc, err := b.rawUnitDoc(nav.ID) + if err != nil { + return nil, fmt.Errorf("OpenNavigationProfileForMutation: %w", err) + } + return menumutator.NewProfile(doc, string(nav.ID), profileName, codecMenuDeps{b: b}) +} + +// OpenMenuDocumentForMutation opens a menu document's raw unit and returns the +// menu splice over its items (ALTER MENU). +func (b *Backend) OpenMenuDocumentForMutation(id model.ID) (backend.MenuMutator, error) { + if b.writer == nil { + return nil, fmt.Errorf("OpenMenuDocumentForMutation: not connected for writing") + } + doc, err := b.rawUnitDoc(id) + if err != nil { + return nil, fmt.Errorf("OpenMenuDocumentForMutation: %w", err) + } + return menumutator.NewMenuDocument(doc, string(id), codecMenuDeps{b: b}) +} + +func (b *Backend) rawUnitDoc(id model.ID) (bson.D, error) { + raw, err := b.reader.GetRawUnitBytes(string(id)) + if err != nil { + return nil, fmt.Errorf("load unit: %w", err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + return d, nil +} + +// codecMenuDeps builds new menu nodes with the writers `create navigation` +// uses, so an inserted item has exactly the property set a created one has +// (Studio Pro's, measured: $ID, $Type, Action, AlternativeText, Caption, Icon, +// Items), in both a profile and a menu document, whose items are the same +// element. +type codecMenuDeps struct{ b *Backend } + +var _ menumutator.Deps = codecMenuDeps{} + +func (d codecMenuDeps) BuildMenuItem(spec types.NavMenuItemSpec) (bson.D, error) { + return navMenuItemBson(spec) +} + +func (d codecMenuDeps) BuildMenuAction(spec types.NavMenuItemSpec) (bson.D, error) { + return navMenuAction(spec) +} + +func (d codecMenuDeps) BuildMenuIcon(spec types.NavMenuItemSpec) any { + return navMenuIconBson(spec) +} + +// SaveUnit writes the splice as a patch: canon elides an unchanged unit and +// guards storage GUIDs as for every write, but does not re-pair the element +// $IDs the splice kept, nor carry back a translation it left out. +func (d codecMenuDeps) SaveUnit(unitID string, contents []byte) error { + return d.b.writer.UpdateRawUnitPatch(unitID, contents) +} diff --git a/mdl/backend/modelsdk/navigation_mutator_test.go b/mdl/backend/modelsdk/navigation_mutator_test.go new file mode 100644 index 0000000000..a42965c61c --- /dev/null +++ b/mdl/backend/modelsdk/navigation_mutator_test.go @@ -0,0 +1,598 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "bytes" + "fmt" + "reflect" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// spliceFixture is a fixture copy whose first web profile holds a +// Studio Pro-shaped nested, two-language menu: +// +// 'Home' en_US Home, nl_NL Start +// 'Admin' { 'Users', 'Old import' } +// 'Reports' { 'Users' } +// 'Help' +func spliceFixture(t *testing.T) (*Backend, string) { + t.Helper() + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + t.Cleanup(func() { model.SetAuthoringLanguage("") }) + profile := storeProfileMenu(t, b, + testMenuItem(testCaption("en_US", "Home", "nl_NL", "Start")), + testMenuItem(testCaption("en_US", "Admin"), + testMenuItem(testCaption("en_US", "Users", "nl_NL", "Gebruikers")), + testMenuItem(testCaption("en_US", "Old import"))), + testMenuItem(testCaption("en_US", "Reports"), + testMenuItem(testCaption("en_US", "Users"))), + testMenuItem(testCaption("en_US", "Help")), + ) + return b, profile +} + +// storedMenu reads the profile's Menus$MenuItem documents back from storage, +// keyed by $ID, each with its encoded bytes, and the caption tree. +func storedMenu(t *testing.T, b *Backend, profile string) (map[string][]byte, []*backend.MenuTreeNode) { + t.Helper() + m, err := b.OpenNavigationProfileForMutation(profile) + if err != nil { + t.Fatalf("open: %v", err) + } + nav, _ := b.GetNavigation() + doc, err := b.rawUnitDoc(nav.ID) + if err != nil { + t.Fatal(err) + } + items := map[string][]byte{} + var walk func(v any) + walk = func(v any) { + switch d := v.(type) { + case bson.D: + if navGetString(d, "$Type") == "Menus$MenuItem" { + raw, err := bson.Marshal(d) + if err != nil { + t.Fatal(err) + } + items[idHex(d)] = raw + } + for _, e := range d { + walk(e.Value) + } + case bson.A: + for _, e := range d { + walk(e) + } + } + } + for _, p := range navGetArray(doc, "Profiles") { + if pd, ok := p.(bson.D); ok && strings.EqualFold(navGetString(pd, "Name"), profile) { + walk(navGetDoc(pd, "Menu")) + } + } + return items, m.MenuTree() +} + +func idHex(d bson.D) string { + for _, e := range d { + if e.Key == "$ID" { + if bin, ok := e.Value.(primitive.Binary); ok { + return fmt.Sprintf("%x", bin.Data) + } + } + } + return "" +} + +// survivorsChanged lists every item of before that is still stored but no +// longer byte-identical — the splice's promise for items no operation names. +// Items whose subtree an operation changed are listed in touched and skipped. +func survivorsChanged(before, after map[string][]byte, touched map[string]bool) []string { + var out []string + for id, raw := range before { + if touched[id] { + continue + } + got, ok := after[id] + if !ok { + out = append(out, id+" missing") + continue + } + if !bytes.Equal(got, raw) { + out = append(out, id+" re-encoded") + } + } + return out +} + +func captionsOf(tree []*backend.MenuTreeNode) string { + var parts []string + for _, n := range tree { + s := "'" + n.Caption + "'" + if len(n.Items) > 0 { + s += "{" + captionsOf(n.Items) + "}" + } + parts = append(parts, s) + } + return strings.Join(parts, " ") +} + +func resolve(t *testing.T, tree []*backend.MenuTreeNode, segs ...string) backend.MenuItemPath { + t.Helper() + var a types.MenuItemAddress + for _, s := range segs { + a.Segments = append(a.Segments, types.MenuItemAddressSegment{Caption: s}) + } + p, err := backend.ResolveMenuItemAddress(a, tree) + if err != nil { + t.Fatalf("resolve %s: %v", a, err) + } + return p +} + +// idOfPath is the stored $ID of the item at path. +func idOfPath(t *testing.T, b *Backend, profile string, path backend.MenuItemPath) string { + t.Helper() + nav, _ := b.GetNavigation() + doc, _ := b.rawUnitDoc(nav.ID) + for _, p := range navGetArray(doc, "Profiles") { + pd, ok := p.(bson.D) + if !ok || !strings.EqualFold(navGetString(pd, "Name"), profile) { + continue + } + holder := navGetDoc(pd, "Menu") + var it bson.D + for _, i := range path { + var els []bson.D + for _, v := range navGetArray(holder, "Items") { + if d, ok := v.(bson.D); ok { + els = append(els, d) + } + } + it = els[i] + holder = it + } + return idHex(it) + } + return "" +} + +// Dropping an item keeps every other item byte-identical, $ID included. +// +// The control is the rebuild the splice replaces: the same post-drop menu +// written by navMenuItemBson, as `create or modify navigation` writes it, and +// saved as a patch so canon does not re-pair the $IDs. It must fail the same +// assertion — otherwise the assertion could not tell a splice from a rebuild. +func TestMenuSplice_DropKeepsSurvivorsByteIdentical(t *testing.T) { + b, profile := spliceFixture(t) + before, tree := storedMenu(t, b, profile) + dropped := idOfPath(t, b, profile, resolve(t, tree, "Old import")) + admin := idOfPath(t, b, profile, resolve(t, tree, "Admin")) + + m, err := b.OpenNavigationProfileForMutation(profile) + if err != nil { + t.Fatal(err) + } + if err := m.DropMenuItem(resolve(t, m.MenuTree(), "Old import")); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + after, tree2 := storedMenu(t, b, profile) + if got, want := captionsOf(tree2), "'Home' 'Admin'{'Users'} 'Reports'{'Users'} 'Help'"; got != want { + t.Fatalf("menu after drop = %s, want %s", got, want) + } + if _, still := after[dropped]; still { + t.Error("the dropped item is still stored") + } + touched := map[string]bool{dropped: true, admin: true} // Admin's Items list changed + if bad := survivorsChanged(before, after, touched); len(bad) > 0 { + t.Errorf("items the drop did not name changed: %v", bad) + } + + // Control: the rebuild. + b2, profile2 := spliceFixture(t) + before2, _ := storedMenu(t, b2, profile2) + nav, _ := b2.GetNavigation() + if err := b2.UpdateNavigationProfile(nav.ID, profile2, types.NavigationProfileSpec{HasMenu: true, MenuItems: []types.NavMenuItemSpec{ + {Caption: "Home", Microflow: "MyFirstModule.ACT_Something"}, + {Caption: "Admin", Items: []types.NavMenuItemSpec{{Caption: "Users", Microflow: "MyFirstModule.ACT_Something"}}}, + {Caption: "Reports", Items: []types.NavMenuItemSpec{{Caption: "Users", Microflow: "MyFirstModule.ACT_Something"}}}, + {Caption: "Help", Microflow: "MyFirstModule.ACT_Something"}, + }}); err != nil { + t.Fatal(err) + } + after2, _ := storedMenu(t, b2, profile2) + if bad := survivorsChanged(before2, after2, map[string]bool{}); len(bad) == 0 { + t.Error("control: a rebuilt menu passed the byte-identity assertion, so it cannot detect a rebuild") + } +} + +func TestMenuSplice_InsertPositions(t *testing.T) { + b, profile := spliceFixture(t) + before, _ := storedMenu(t, b, profile) + m, err := b.OpenNavigationProfileForMutation(profile) + if err != nil { + t.Fatal(err) + } + item := func(c string) []types.NavMenuItemSpec { + return []types.NavMenuItemSpec{{Caption: c, Page: "MyFirstModule.Home"}} + } + tree := m.MenuTree() + home := resolve(t, tree, "Home") + steps := []struct { + name string + do func() error + }{ + {"after", func() error { return m.InsertMenuItems(home.Parent(), home.Index()+1, item("Orders")) }}, + {"before", func() error { + p := resolve(t, m.MenuTree(), "Help") + return m.InsertMenuItems(p.Parent(), p.Index(), item("FAQ")) + }}, + {"into", func() error { + p := resolve(t, m.MenuTree(), "Admin") + return m.InsertMenuItems(p, len(backend.MenuNodeAt(m.MenuTree(), p).Items), item("Audit log")) + }}, + {"top level", func() error { return m.InsertMenuItems(nil, len(m.MenuTree()), item("Last")) }}, + {"nested fragment", func() error { + return m.InsertMenuItems(nil, 0, []types.NavMenuItemSpec{{Caption: "Group", Items: item("Child")}}) + }}, + } + for _, s := range steps { + if err := s.do(); err != nil { + t.Fatalf("%s: %v", s.name, err) + } + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + after, tree2 := storedMenu(t, b, profile) + want := "'Group'{'Child'} 'Home' 'Orders' 'Admin'{'Users' 'Old import' 'Audit log'} 'Reports'{'Users'} 'FAQ' 'Help' 'Last'" + if got := captionsOf(tree2); got != want { + t.Fatalf("menu = %s\nwant %s", got, want) + } + adminID := idOfPath(t, b, profile, resolve(t, tree2, "Admin")) + if bad := survivorsChanged(before, after, map[string]bool{adminID: true}); len(bad) > 0 { + t.Errorf("items no insert named changed: %v", bad) + } + + // The inserted item carries Studio Pro's property set, and the sub-menu of + // the nested fragment stores Forms$NoAction (CE0548 otherwise). + nav, _ := b.GetNavigation() + doc, _ := b.rawUnitDoc(nav.ID) + raw, _ := bson.Marshal(doc) + var group bson.D + var find func(v any) + find = func(v any) { + switch d := v.(type) { + case bson.D: + if navGetString(d, "$Type") == "Menus$MenuItem" && group == nil { + for _, tr := range navGetArray(navGetDoc(d, "Caption"), "Items") { + if td, ok := tr.(bson.D); ok && navGetString(td, "Text") == "Group" { + group = d + } + } + } + for _, e := range d { + find(e.Value) + } + case bson.A: + for _, e := range d { + find(e) + } + } + } + _ = raw + find(doc) + if group == nil { + t.Fatal("inserted group not found") + } + var keys []string + for _, e := range group { + keys = append(keys, e.Key) + } + if want := []string{"$ID", "$Type", "Action", "AlternativeText", "Caption", "Icon", "Items"}; !reflect.DeepEqual(keys, want) { + t.Errorf("inserted item keys = %v, want Studio Pro's %v", keys, want) + } + if got := navGetString(navGetDoc(group, "Action"), "$Type"); got != "Forms$NoAction" { + t.Errorf("sub-menu action = %s, want Forms$NoAction", got) + } +} + +// A move relocates the stored item itself: same bytes, $ID, sub-items and +// translations. A move into its own subtree is refused. +func TestMenuSplice_MoveKeepsTheItemsBytes(t *testing.T) { + b, profile := spliceFixture(t) + before, tree := storedMenu(t, b, profile) + users := resolve(t, tree, "Admin", "Users") + usersID := idOfPath(t, b, profile, users) + adminID := idOfPath(t, b, profile, resolve(t, tree, "Admin")) + helpPath := resolve(t, tree, "Help") + + m, _ := b.OpenNavigationProfileForMutation(profile) + if err := m.MoveMenuItem(resolve(t, m.MenuTree(), "Admin"), resolve(t, m.MenuTree(), "Admin", "Users"), backend.MenuInto); err == nil { + t.Error("moving Admin into its own sub-item was accepted") + } + if err := m.MoveMenuItem(users, helpPath, backend.MenuAfter); err != nil { + t.Fatal(err) + } + if err := m.MoveMenuItem(resolve(t, m.MenuTree(), "Home"), resolve(t, m.MenuTree(), "Reports"), backend.MenuInto); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + after, tree2 := storedMenu(t, b, profile) + if got, want := captionsOf(tree2), "'Admin'{'Old import'} 'Reports'{'Users' 'Home'} 'Help' 'Users'"; got != want { + t.Fatalf("menu = %s, want %s", got, want) + } + if !bytes.Equal(after[usersID], before[usersID]) { + t.Error("the moved item's bytes changed (its $ID, caption translations or action)") + } + reportsID := idOfPath(t, b, profile, resolve(t, tree2, "Reports")) + if bad := survivorsChanged(before, after, map[string]bool{adminID: true, reportsID: true}); len(bad) > 0 { + t.Errorf("items changed: %v", bad) + } +} + +// set Caption writes the authoring language's translation and keeps the +// others, and the caption element keeps its $ID; Icon none stores null. +func TestMenuSplice_SetProperties(t *testing.T) { + b, profile := spliceFixture(t) + model.SetAuthoringLanguage("nl_NL") + before, tree := storedMenu(t, b, profile) + home := resolve(t, tree, "Start") // nl_NL is the default: describe prints Start + homeID := idOfPath(t, b, profile, home) + + m, _ := b.OpenNavigationProfileForMutation(profile) + if err := m.SetMenuItemCaption(home, "Begin"); err != nil { + t.Fatal(err) + } + if err := m.SetMenuItemIcon(home, types.NavMenuItemSpec{IconKind: types.MenuIconGlyph, IconCode: 57377}); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + after, _ := storedMenu(t, b, profile) + var oldDoc, newDoc bson.D + _ = bson.Unmarshal(before[homeID], &oldDoc) + _ = bson.Unmarshal(after[homeID], &newDoc) + if newDoc == nil { + t.Fatal("the item lost its $ID") + } + got := map[string]string{} + for _, tr := range navGetArray(navGetDoc(newDoc, "Caption"), "Items") { + if d, ok := tr.(bson.D); ok { + got[navGetString(d, "LanguageCode")] = navGetString(d, "Text") + } + } + if want := map[string]string{"en_US": "Home", "nl_NL": "Begin"}; !reflect.DeepEqual(got, want) { + t.Errorf("caption translations = %v, want %v", got, want) + } + if idHex(navGetDoc(newDoc, "Caption")) != idHex(navGetDoc(oldDoc, "Caption")) { + t.Error("the caption element's $ID changed") + } + if got := navGetString(navGetDoc(newDoc, "Icon"), "$Type"); got != "Forms$GlyphIcon" { + t.Errorf("icon = %s, want Forms$GlyphIcon", got) + } + if bad := survivorsChanged(before, after, map[string]bool{homeID: true}); len(bad) > 0 { + t.Errorf("items changed: %v", bad) + } + + m2, _ := b.OpenNavigationProfileForMutation(profile) + if err := m2.SetMenuItemIcon(resolve(t, m2.MenuTree(), "Begin"), types.NavMenuItemSpec{}); err != nil { + t.Fatal(err) + } + _ = m2.Save() + after2, _ := storedMenu(t, b, profile) + var d2 bson.D + _ = bson.Unmarshal(after2[homeID], &d2) + for _, e := range d2 { + if e.Key == "Icon" && e.Value != nil { + t.Errorf("Icon none stored %v, want null", e.Value) + } + } +} + +// replace gives the replacement fresh $IDs and carries the other-language +// translations of a caption it keeps. +func TestMenuSplice_ReplaceCarriesTranslations(t *testing.T) { + b, profile := spliceFixture(t) + before, tree := storedMenu(t, b, profile) + homeID := idOfPath(t, b, profile, resolve(t, tree, "Home")) + m, _ := b.OpenNavigationProfileForMutation(profile) + if err := m.ReplaceMenuItem(resolve(t, m.MenuTree(), "Home"), []types.NavMenuItemSpec{ + {Caption: "Home", Page: "MyFirstModule.Home"}, + {Caption: "Extra", Page: "MyFirstModule.Home"}, + }); err != nil { + t.Fatal(err) + } + _ = m.Save() + after, tree2 := storedMenu(t, b, profile) + if got := captionsOf(tree2); !strings.HasPrefix(got, "'Home' 'Extra' 'Admin'") { + t.Fatalf("menu = %s", got) + } + if _, still := after[homeID]; still { + t.Error("the replaced item kept its $ID; a replacement is new content") + } + newID := idOfPath(t, b, profile, resolve(t, tree2, "Home")) + var d bson.D + _ = bson.Unmarshal(after[newID], &d) + langs := map[string]string{} + for _, tr := range navGetArray(navGetDoc(d, "Caption"), "Items") { + if td, ok := tr.(bson.D); ok { + langs[navGetString(td, "LanguageCode")] = navGetString(td, "Text") + } + } + if langs["nl_NL"] != "Start" || langs["en_US"] != "Home" { + t.Errorf("replacement caption = %v, want en_US Home and the carried nl_NL Start", langs) + } + if bad := survivorsChanged(before, after, map[string]bool{homeID: true}); len(bad) > 0 { + t.Errorf("items changed: %v", bad) + } +} + +// The page clauses edit the stored elements in place and clear the way Studio +// Pro does: Form "" for the login page, null for the not-found page. +func TestMenuSplice_PageClauses(t *testing.T) { + b, profile := spliceFixture(t) + nav, _ := b.GetNavigation() + profileDoc := func() bson.D { + doc, _ := b.rawUnitDoc(nav.ID) + for _, p := range navGetArray(doc, "Profiles") { + if pd, ok := p.(bson.D); ok && strings.EqualFold(navGetString(pd, "Name"), profile) { + return pd + } + } + return nil + } + homeID := idHex(navGetDoc(profileDoc(), "HomePage")) + + m, _ := b.OpenNavigationProfileForMutation(profile) + steps := []error{ + m.SetHomePage(types.NavHomePageSpec{IsPage: true, Target: "MyFirstModule.Home"}), + m.SetHomePage(types.NavHomePageSpec{IsPage: true, Target: "MyFirstModule.Home", ForRole: "Administrator"}), + m.SetHomePage(types.NavHomePageSpec{IsPage: false, Target: "MyFirstModule.ACT", ForRole: "User"}), + m.SetLoginPage("MyFirstModule.Login"), + m.SetNotFoundPage("MyFirstModule.NotFound"), + } + for i, err := range steps { + if err != nil { + t.Fatalf("step %d: %v", i, err) + } + } + _ = m.Save() + p := profileDoc() + hp := navGetDoc(p, "HomePage") + if idHex(hp) != homeID || navGetString(hp, "Page") != "MyFirstModule.Home" { + t.Errorf("HomePage = %v (want the stored element, $ID %s, updated in place)", hp, homeID) + } + var roles []string + for _, v := range navGetArray(p, "HomeItems") { + if d, ok := v.(bson.D); ok { + roles = append(roles, navGetString(d, "UserRole")+"="+navGetString(d, "Page")+navGetString(d, "Microflow")) + } + } + if want := []string{"Administrator=MyFirstModule.Home", "User=MyFirstModule.ACT"}; !reflect.DeepEqual(roles, want) { + t.Errorf("HomeItems = %v, want %v", roles, want) + } + loginID := idHex(navGetDoc(p, "LoginPageSettings")) + + m2, _ := b.OpenNavigationProfileForMutation(profile) + if err := m2.DropRoleHomePage("NoSuchRole"); err == nil || !strings.Contains(err.Error(), "Administrator, User") { + t.Errorf("dropping a role with no home page: %v", err) + } + for _, err := range []error{m2.DropRoleHomePage("administrator"), m2.SetLoginPage(""), m2.SetNotFoundPage("")} { + if err != nil { + t.Fatal(err) + } + } + _ = m2.Save() + p = profileDoc() + ls := navGetDoc(p, "LoginPageSettings") + if ls == nil || navGetString(ls, "Form") != "" || idHex(ls) != loginID { + t.Errorf("LoginPageSettings after drop = %v, want the same element with Form \"\"", ls) + } + for _, e := range p { + if e.Key == "NotFoundHomepage" && e.Value != nil { + t.Errorf("NotFoundHomepage after drop = %v, want null", e.Value) + } + } + var left []string + for _, v := range navGetArray(p, "HomeItems") { + if d, ok := v.(bson.D); ok { + left = append(left, navGetString(d, "UserRole")) + } + } + if !reflect.DeepEqual(left, []string{"User"}) { + t.Errorf("HomeItems after drop = %v, want [User]", left) + } +} + +// An alter that restates what is stored writes nothing (ADR-0008); the write +// that changes something lands — the control that makes "nothing written" +// mean the elision, not a dead write path. +func TestMenuSplice_NoChangeIsElided(t *testing.T) { + b, profile := spliceFixture(t) + m, _ := b.OpenNavigationProfileForMutation(profile) + if err := m.SetMenuItemCaption(resolve(t, m.MenuTree(), "Help"), "Help"); err != nil { + t.Fatal(err) + } + s0 := b.WriteStats() + if err := m.Save(); err != nil { + t.Fatal(err) + } + s1 := b.WriteStats() + if s1.Written != s0.Written { + t.Errorf("a set to the stored value was written (%d -> %d)", s0.Written, s1.Written) + } + m2, _ := b.OpenNavigationProfileForMutation(profile) + _ = m2.SetMenuItemCaption(resolve(t, m2.MenuTree(), "Help"), "Help!") + _ = m2.Save() + if s2 := b.WriteStats(); s2.Written != s1.Written+1 { + t.Errorf("control: a real change was not written (%d -> %d)", s1.Written, s2.Written) + } +} + +// A menu document's items are the same element under ItemCollection, so the +// same splice edits them. +func TestMenuSplice_MenuDocument(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("module: %v", err) + } + md := &types.MenuDocument{Name: "SpliceMenu", ContainerID: mod.ID, Items: []*types.NavMenuItem{ + {Caption: "One", Page: "MyFirstModule.Home"}, + {Caption: "Two", Page: "MyFirstModule.Home"}, + }} + if err := b.CreateMenuDocument(md); err != nil { + t.Fatal(err) + } + m, err := b.OpenMenuDocumentForMutation(md.ID) + if err != nil { + t.Fatal(err) + } + two := resolve(t, m.MenuTree(), "Two") + if err := m.InsertMenuItems(nil, two.Index(), []types.NavMenuItemSpec{{Caption: "Between", Page: "MyFirstModule.Home"}}); err != nil { + t.Fatal(err) + } + if err := m.DropMenuItem(resolve(t, m.MenuTree(), "One")); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + got, err := b.GetMenuDocumentByQualifiedName("MyFirstModule", "SpliceMenu") + if err != nil { + t.Fatal(err) + } + var caps []string + for _, it := range got.Items { + caps = append(caps, it.Caption) + } + if !reflect.DeepEqual(caps, []string{"Between", "Two"}) { + t.Errorf("menu document items = %v, want [Between Two]", caps) + } +} diff --git a/mdl/backend/modelsdk/unimplemented_gen.go b/mdl/backend/modelsdk/unimplemented_gen.go index 607bc789a5..83166e5cc1 100644 --- a/mdl/backend/modelsdk/unimplemented_gen.go +++ b/mdl/backend/modelsdk/unimplemented_gen.go @@ -904,11 +904,21 @@ func (unimplemented) MoveViewEntitySourceDocument(_ string, _ model.ID, _ string return errUnimplemented("MoveViewEntitySourceDocument") } +func (unimplemented) OpenMenuDocumentForMutation(_ model.ID) (backend.MenuMutator, error) { + var r0 backend.MenuMutator + return r0, errUnimplemented("OpenMenuDocumentForMutation") +} + func (unimplemented) OpenMicroflowForMutation(_ model.ID) (backend.MicroflowMutator, error) { var r0 backend.MicroflowMutator return r0, errUnimplemented("OpenMicroflowForMutation") } +func (unimplemented) OpenNavigationProfileForMutation(_ string) (backend.NavigationProfileMutator, error) { + var r0 backend.NavigationProfileMutator + return r0, errUnimplemented("OpenNavigationProfileForMutation") +} + func (unimplemented) OpenPageForMutation(_ model.ID) (backend.PageMutator, error) { var r0 backend.PageMutator return r0, errUnimplemented("OpenPageForMutation") diff --git a/mdl/backend/navigation.go b/mdl/backend/navigation.go index f726c9583c..d3e6daaa9e 100644 --- a/mdl/backend/navigation.go +++ b/mdl/backend/navigation.go @@ -18,6 +18,12 @@ type NavigationBackend interface { // empty and UpdateNavigationProfile fills it. AddNavigationProfile(navDocID model.ID, name string) error + // OpenNavigationProfileForMutation loads the navigation document and + // returns a mutator over one web profile's menu and page clauses, for + // ALTER NAVIGATION. A native profile is refused. Nothing is written until + // Save; a mutator that is never saved (check -p) changes nothing. + OpenNavigationProfileForMutation(profileName string) (NavigationProfileMutator, error) + // Menu documents are standalone reusable menus (Menus$MenuDocument), not // the menu embedded in a navigation profile. ListMenuDocuments() ([]*types.MenuDocument, error) @@ -25,4 +31,65 @@ type NavigationBackend interface { CreateMenuDocument(md *types.MenuDocument) error UpdateMenuDocument(md *types.MenuDocument) error DeleteMenuDocument(id model.ID) error + // OpenMenuDocumentForMutation loads a menu document and returns a mutator + // over its items, for ALTER MENU. + OpenMenuDocumentForMutation(id model.ID) (MenuMutator, error) +} + +// MenuPosition is where an inserted or moved item goes relative to a target. +type MenuPosition string + +const ( + MenuBefore MenuPosition = "before" + MenuAfter MenuPosition = "after" + MenuInto MenuPosition = "into" // as the target's last sub-item +) + +// MenuMutator splices one document's menu items in the stored BSON (ALTER +// NAVIGATION / ALTER MENU, ADR-0012 decision 3). Untouched items keep their +// stored bytes; new items get fresh $IDs; no stored $ID is rewritten. +// +// It takes positions, not addresses: the caller resolves an address against +// MenuTree with ResolveMenuItemAddress and applies its refusals there, so +// `check -p` and `exec` run one function and agree. MenuTree reflects every +// operation applied so far, which is what makes resolution sequential. +type MenuMutator interface { + // MenuTree is the menu as a target sees it, now. + MenuTree() []*MenuTreeNode + // InsertMenuItems places new items in the list of parent (nil: the top + // level) at index; index == the list's length appends. + InsertMenuItems(parent MenuItemPath, index int, items []types.NavMenuItemSpec) error + // ReplaceMenuItem puts new items where the item at path was, carrying the + // other-language translations of a caption the replacement keeps. + ReplaceMenuItem(path MenuItemPath, items []types.NavMenuItemSpec) error + // MoveMenuItem relocates the stored item (its bytes, $ID, sub-items and + // translations) before or after target, or into it as its last sub-item. + MoveMenuItem(from, target MenuItemPath, position MenuPosition) error + // DropMenuItem removes the item at path and its sub-items. + DropMenuItem(path MenuItemPath) error + // SetMenuItemCaption writes the authoring language's translation and keeps + // the others, as ALTER PAGE sets a text. + SetMenuItemCaption(path MenuItemPath, caption string) error + // SetMenuItemAction replaces the item's action with the one item states. + SetMenuItemAction(path MenuItemPath, item types.NavMenuItemSpec) error + // SetMenuItemIcon replaces the item's icon; no icon in item stores null. + SetMenuItemIcon(path MenuItemPath, item types.NavMenuItemSpec) error + // Save writes the document as a patch (canon.Reconcile, elided when + // nothing changed). + Save() error +} + +// NavigationProfileMutator is a MenuMutator over a web profile's menu that also +// sets and clears the profile's page clauses. +type NavigationProfileMutator interface { + MenuMutator + // SetHomePage sets the default home (ForRole empty) or one role's, + // updating a stored element in place. + SetHomePage(home types.NavHomePageSpec) error + // DropRoleHomePage removes a role's home page; a role with none is an error. + DropRoleHomePage(role string) error + // SetLoginPage sets the login page; "" clears it (Form "", as Studio Pro). + SetLoginPage(page string) error + // SetNotFoundPage sets the fallback page; "" clears it (null). + SetNotFoundPage(page string) error } From d912e0a410e5da32142834afdceef0390ff02bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:24:01 +0000 Subject: [PATCH 15/28] feat(mdl): parse ALTER NAVIGATION and ALTER MENU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The syntax of the accepted proposal: alter navigation { insert (before | after | into) { } insert { } replace with { } move (before | after | into) drop , … set ( Caption: '…', OnClick: , Icon: | none ) on set home (page | microflow) M.X [for Role] drop home page for Role set login page M.P drop login page set not found page M.P drop not found page } alter menu Module.Menu { } An item is addressed as a caption path, `'Admin'@2 > 'Users'`. A fragment is navMenuChildren, the block `create navigation` and `create menu` take, and is built by the same visitor, so an OnClick a menu item cannot carry is refused as there. `set ( … )` is strict: a key written twice is an error. A bare `drop home page` parses so the executor can refuse it with the reason (CE0527). No new tokens. The statements are not executed yet; the executor follows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- mdl/ast/ast_navigation.go | 163 ++++++++++++++- mdl/grammar/MDLParser.g4 | 72 +++++++ mdl/visitor/visitor_alter.go | 9 + mdl/visitor/visitor_alter_navigation.go | 204 +++++++++++++++++++ mdl/visitor/visitor_alter_navigation_test.go | 144 +++++++++++++ 5 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 mdl/visitor/visitor_alter_navigation.go create mode 100644 mdl/visitor/visitor_alter_navigation_test.go diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go index bb98564e33..082824d7e8 100644 --- a/mdl/ast/ast_navigation.go +++ b/mdl/ast/ast_navigation.go @@ -2,7 +2,11 @@ package ast -import "github.com/mendixlabs/mxcli/mdl/types" +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/types" +) // CreateNavigationStmt represents: CREATE [OR MODIFY] NAVIGATION [clauses...] // A clause it states replaces what it names (a { } menu block the whole menu); @@ -105,3 +109,160 @@ type DropMenuStmt struct { } func (s *DropMenuStmt) isStatement() {} + +// AlterNavigationStmt is `alter navigation { … }`: item-level edits +// to a web profile's menu and its page clauses, applied in order to the stored +// profile and written once (accepted proposal +// PROPOSAL_alter_navigation_menu_items). Not to be confused with +// CreateNavigationStmt, which writes the clauses it states. +type AlterNavigationStmt struct { + ProfileName string + Operations []MenuAlterOp +} + +func (s *AlterNavigationStmt) isStatement() {} + +// AlterMenuStmt is `alter menu Module.Menu { … }`: the item operations of +// AlterNavigationStmt on a menu document, which holds the same items. +type AlterMenuStmt struct { + Name QualifiedName + Operations []MenuAlterOp +} + +func (s *AlterMenuStmt) isStatement() {} + +// MenuAlterOp is one operation of an ALTER NAVIGATION / ALTER MENU block. +// String spells it as MDL, for messages. +type MenuAlterOp interface { + menuAlterOp() + String() string +} + +// InsertMenuItemsOp is `insert before|after|into { … }`, or `insert +// { … }` (Position "", Target nil), which appends to the top level. +type InsertMenuItemsOp struct { + Position string // "before", "after", "into", or "" for the top level + Target *types.MenuItemAddress + Items []NavMenuItemDef +} + +// ReplaceMenuItemOp is `replace with { … }`. +type ReplaceMenuItemOp struct { + Target types.MenuItemAddress + Items []NavMenuItemDef +} + +// MoveMenuItemOp is `move before|after|into `. +type MoveMenuItemOp struct { + Item types.MenuItemAddress + Position string // "before", "after", "into" + Target types.MenuItemAddress +} + +// DropMenuItemsOp is `drop , …`. +type DropMenuItemsOp struct { + Targets []types.MenuItemAddress +} + +// SetMenuItemOp is `set ( Caption: …, OnClick: …, Icon: … ) on `. Only +// the keys written are changed. Item carries the parsed OnClick (Action, Page, +// Microflow, SignOut) and Icon (Icon, IconKind, IconCode — IconKind none with +// SetIcon is `Icon: none`), as a `menu item` would. +type SetMenuItemOp struct { + Target types.MenuItemAddress + Caption *string + SetAction bool + SetIcon bool + Item NavMenuItemDef +} + +// SetNavPageClauseOp is a profile clause after `set`: `set home page|microflow +// M.X [for Role]` (Clause "home"), `set login page M.P` ("login"), `set not +// found page M.P` ("notfound"). +type SetNavPageClauseOp struct { + Clause string + Home NavHomePageDef // Clause "home" + Page QualifiedName // Clause "login" / "notfound" +} + +// DropNavPageClauseOp is a profile clause after `drop`: `drop home page for +// Role` (Clause "home"; Role nil is the bare form, which is refused), `drop +// login page`, `drop not found page`. +type DropNavPageClauseOp struct { + Clause string + Role *QualifiedName +} + +func (*InsertMenuItemsOp) menuAlterOp() {} +func (*ReplaceMenuItemOp) menuAlterOp() {} +func (*MoveMenuItemOp) menuAlterOp() {} +func (*DropMenuItemsOp) menuAlterOp() {} +func (*SetMenuItemOp) menuAlterOp() {} +func (*SetNavPageClauseOp) menuAlterOp() {} +func (*DropNavPageClauseOp) menuAlterOp() {} + +func (o *InsertMenuItemsOp) String() string { + if o.Target == nil { + return "insert { … }" + } + return "insert " + o.Position + " " + o.Target.String() +} + +func (o *ReplaceMenuItemOp) String() string { return "replace " + o.Target.String() } + +func (o *MoveMenuItemOp) String() string { + return "move " + o.Item.String() + " " + o.Position + " " + o.Target.String() +} + +func (o *DropMenuItemsOp) String() string { + parts := make([]string, len(o.Targets)) + for i, t := range o.Targets { + parts[i] = t.String() + } + return "drop " + strings.Join(parts, ", ") +} + +func (o *SetMenuItemOp) String() string { + var keys []string + if o.Caption != nil { + keys = append(keys, "Caption") + } + if o.SetAction { + keys = append(keys, "OnClick") + } + if o.SetIcon { + keys = append(keys, "Icon") + } + return "set ( " + strings.Join(keys, ", ") + " ) on " + o.Target.String() +} + +func (o *SetNavPageClauseOp) String() string { + switch o.Clause { + case "home": + kind := "microflow" + if o.Home.IsPage { + kind = "page" + } + s := "set home " + kind + " " + o.Home.Target.String() + if o.Home.ForRole != nil { + s += " for " + o.Home.ForRole.String() + } + return s + case "login": + return "set login page " + o.Page.String() + } + return "set not found page " + o.Page.String() +} + +func (o *DropNavPageClauseOp) String() string { + switch o.Clause { + case "home": + if o.Role == nil { + return "drop home page" + } + return "drop home page for " + o.Role.String() + case "login": + return "drop login page" + } + return "drop not found page" +} diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 485936fcdf..b31d8cfd7e 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -274,6 +274,13 @@ alterStatement // The old per-action form: each alternative of alterWorkflowAction is a // registered alias (MDL-DEPR140-149). | ALTER WORKFLOW qualifiedName alterWorkflowAction+ SEMICOLON? + // The generic ALTER on a navigation profile's menu and page clauses, and + // on a menu document's items (accepted proposal + // PROPOSAL_alter_navigation_menu_items): its targets are menu items, + // addressed by caption path, and its fragments are menu items written as + // `create navigation` writes them. + | ALTER NAVIGATION (qualifiedName | IDENTIFIER) LBRACE alterNavigationOperation+ RBRACE + | ALTER MENU_KW qualifiedName LBRACE alterMenuOperation+ RBRACE | alterMessageDefinitionCollectionStatement | alterMessageDefinitionStatement | ALTER PUBLISHED REST SERVICE qualifiedName alterPublishedRestServiceAction (COMMA? alterPublishedRestServiceAction)* @@ -631,6 +638,71 @@ navigationClause | PROGRESSIVE WEB APP (settingsItemOptions | OFF)? ; +/** + * `alter navigation` / `alter menu` operations (ADR-0012 decision 2, accepted + * proposal PROPOSAL_alter_navigation_menu_items): + * + * ```mdl + * alter navigation Responsive { + * insert after 'Home' { menu item 'Orders' ( OnClick: show page Shop.Orders ) } + * insert into 'Admin' { menu item 'Audit log' ( OnClick: show page Shop.Audit ) } + * insert { menu item 'Last' ( OnClick: sign out ) } -- append to the top level + * replace 'Help' with { menu 'Help' { … } } + * move 'Log out' after 'Help'; + * drop 'Old import', 'Admin' > 'Legacy'; + * set ( Caption: 'User accounts', Icon: none ) on 'Admin' > 'Users'; + * set home page Shop.Home for Administrator; + * drop home page for Administrator; + * set login page Shop.Login; drop login page; + * set not found page Shop.Oops; drop not found page; + * } + * ``` + * + * A fragment is navMenuChildren, exactly the block `create navigation` and + * `create menu` take, so describe output pastes into it. A page clause is the + * `create navigation` clause after `set` or `drop`. A bare `drop home page` is + * parsed so the refusal can say why (CE0527), not to be executed. + */ +alterNavigationOperation + : alterMenuOperation + | SET HOME (PAGE | MICROFLOW) qualifiedName (FOR qualifiedName)? SEMICOLON? + | DROP HOME PAGE (FOR qualifiedName)? SEMICOLON? + | SET LOGIN PAGE qualifiedName SEMICOLON? + | DROP LOGIN PAGE SEMICOLON? + | SET NOT FOUND PAGE qualifiedName SEMICOLON? + | DROP NOT FOUND PAGE SEMICOLON? + ; + +alterMenuOperation + : INSERT (BEFORE | AFTER | INTO) alterMenuTarget navMenuChildren SEMICOLON? + | INSERT navMenuChildren SEMICOLON? + | REPLACE alterMenuTarget WITH navMenuChildren SEMICOLON? + | MOVE alterMenuTarget (BEFORE | AFTER | INTO) alterMenuTarget SEMICOLON? + | DROP alterMenuTarget (COMMA alterMenuTarget)* SEMICOLON? + | SET LPAREN alterMenuItemProp (COMMA alterMenuItemProp)* COMMA? RPAREN ON alterMenuTarget SEMICOLON? + ; + +// A menu item has no name, so it is addressed by the caption describe prints, +// as a path written outer to inner: 'Admin' > 'Users'. The first segment +// matches anywhere in the tree, each further one a direct sub-item of the one +// before; @n picks the n-th match of its own segment (backend.ResolveMenuItemAddress). +alterMenuTarget + : alterMenuTargetSegment (GREATER_THAN alterMenuTargetSegment)* + ; + +alterMenuTargetSegment + : STRING_LITERAL (AT NUMBER_LITERAL)? + ; + +// The keys `create` uses for an item, plus Caption, which `create` writes +// positionally. `Icon: none` stores no icon. NONE comes first: a qualifiedName +// would also accept the keyword. +alterMenuItemProp + : CAPTION COLON STRING_LITERAL + | ONCLICK COLON navMenuAction + | ICON COLON (NONE | navMenuIconValue) + ; + // Offline synchronization, one statement per entity, mirroring the MENU block: // a list of rules rather than a property bag, so it diffs a line at a time. // diff --git a/mdl/visitor/visitor_alter.go b/mdl/visitor/visitor_alter.go index c0e70edec4..05968da86c 100644 --- a/mdl/visitor/visitor_alter.go +++ b/mdl/visitor/visitor_alter.go @@ -22,6 +22,15 @@ func (b *Builder) ExitAlterStatement(ctx *parser.AlterStatementContext) { b.exitAlterFlowStatement(ctx) return } + // ALTER NAVIGATION / ALTER MENU: item-level menu edits. + if ctx.NAVIGATION() != nil { + b.exitAlterNavigationStatement(ctx) + return + } + if ctx.MENU_KW() != nil { + b.exitAlterMenuStatement(ctx) + return + } // Handle ALTER PAGES … SET LAYOUT (the bulk repoint) if sub := ctx.AlterPagesStylingStatement(); sub != nil { diff --git a/mdl/visitor/visitor_alter_navigation.go b/mdl/visitor/visitor_alter_navigation.go new file mode 100644 index 0000000000..96053bdca6 --- /dev/null +++ b/mdl/visitor/visitor_alter_navigation.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "strconv" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// alterNavigationExample is what an error about the statement shows. +const alterNavigationExample = `alter navigation Responsive { + insert after 'Home' { menu item 'Orders' ( OnClick: show page Shop.Orders ) } + set ( Caption: 'User accounts' ) on 'Admin' > 'Users'; + drop 'Old import'; + set login page Shop.Login; +}` + +// exitAlterNavigationStatement builds `alter navigation { … }`. +func (b *Builder) exitAlterNavigationStatement(ctx *parser.AlterStatementContext) { + name := "" + if qn := ctx.QualifiedName(); qn != nil { + name = getQualifiedNameText(qn) + } else if id := ctx.IDENTIFIER(); id != nil { + name = id.GetText() + } + stmt := &ast.AlterNavigationStmt{ProfileName: name} + for _, opCtx := range ctx.AllAlterNavigationOperation() { + if op := b.buildAlterNavigationOperation(opCtx.(*parser.AlterNavigationOperationContext)); op != nil { + stmt.Operations = append(stmt.Operations, op) + } + } + b.statements = append(b.statements, stmt) +} + +// exitAlterMenuStatement builds `alter menu Module.Menu { … }`. +func (b *Builder) exitAlterMenuStatement(ctx *parser.AlterStatementContext) { + stmt := &ast.AlterMenuStmt{} + if qn := ctx.QualifiedName(); qn != nil { + stmt.Name = buildQualifiedName(qn) + } + for _, opCtx := range ctx.AllAlterMenuOperation() { + if op := b.buildAlterMenuOperation(opCtx.(*parser.AlterMenuOperationContext)); op != nil { + stmt.Operations = append(stmt.Operations, op) + } + } + b.statements = append(b.statements, stmt) +} + +func (b *Builder) buildAlterNavigationOperation(ctx *parser.AlterNavigationOperationContext) ast.MenuAlterOp { + if m := ctx.AlterMenuOperation(); m != nil { + return b.buildAlterMenuOperation(m.(*parser.AlterMenuOperationContext)) + } + names := ctx.AllQualifiedName() + switch { + case ctx.SET() != nil && ctx.HOME() != nil: + op := &ast.SetNavPageClauseOp{Clause: "home"} + op.Home.IsPage = ctx.PAGE() != nil + if len(names) > 0 { + op.Home.Target = buildQualifiedName(names[0]) + } + if ctx.FOR() != nil && len(names) > 1 { + role := buildQualifiedName(names[1]) + op.Home.ForRole = &role + } + return op + case ctx.DROP() != nil && ctx.HOME() != nil: + op := &ast.DropNavPageClauseOp{Clause: "home"} + if ctx.FOR() != nil && len(names) > 0 { + role := buildQualifiedName(names[0]) + op.Role = &role + } + return op + case ctx.LOGIN() != nil: + if ctx.DROP() != nil { + return &ast.DropNavPageClauseOp{Clause: "login"} + } + op := &ast.SetNavPageClauseOp{Clause: "login"} + if len(names) > 0 { + op.Page = buildQualifiedName(names[0]) + } + return op + case ctx.FOUND() != nil: + if ctx.DROP() != nil { + return &ast.DropNavPageClauseOp{Clause: "notfound"} + } + op := &ast.SetNavPageClauseOp{Clause: "notfound"} + if len(names) > 0 { + op.Page = buildQualifiedName(names[0]) + } + return op + } + return nil +} + +func (b *Builder) buildAlterMenuOperation(ctx *parser.AlterMenuOperationContext) ast.MenuAlterOp { + targets := ctx.AllAlterMenuTarget() + position := func() string { + switch { + case ctx.BEFORE() != nil: + return "before" + case ctx.AFTER() != nil: + return "after" + case ctx.INTO() != nil: + return "into" + } + return "" + } + items := func() []ast.NavMenuItemDef { + var out []ast.NavMenuItemDef + if ch, ok := ctx.NavMenuChildren().(*parser.NavMenuChildrenContext); ok && ch != nil { + for _, it := range ch.AllNavMenuItemDef() { + out = append(out, b.buildNavMenuItemDef(it)) + } + } + return out + } + switch { + case ctx.INSERT() != nil: + op := &ast.InsertMenuItemsOp{Items: items()} + if len(targets) > 0 { + t := buildMenuItemAddress(targets[0]) + op.Target, op.Position = &t, position() + } + return op + case ctx.REPLACE() != nil: + return &ast.ReplaceMenuItemOp{Target: buildMenuItemAddress(targets[0]), Items: items()} + case ctx.MOVE() != nil: + if len(targets) < 2 { + return nil + } + return &ast.MoveMenuItemOp{Item: buildMenuItemAddress(targets[0]), Position: position(), Target: buildMenuItemAddress(targets[1])} + case ctx.DROP() != nil: + op := &ast.DropMenuItemsOp{} + for _, t := range targets { + op.Targets = append(op.Targets, buildMenuItemAddress(t)) + } + return op + case ctx.SET() != nil: + return b.buildSetMenuItemOp(ctx, targets) + } + return nil +} + +// buildSetMenuItemOp reads `set ( Key: value, … ) on `. The list is new +// syntax, so it is strict: a key written twice is an error. +func (b *Builder) buildSetMenuItemOp(ctx *parser.AlterMenuOperationContext, targets []parser.IAlterMenuTargetContext) ast.MenuAlterOp { + op := &ast.SetMenuItemOp{} + if len(targets) > 0 { + op.Target = buildMenuItemAddress(targets[0]) + } + line := ctx.GetStart().GetLine() + seen := map[string]bool{} + for _, pc := range ctx.AllAlterMenuItemProp() { + prop := pc.(*parser.AlterMenuItemPropContext) + key := prop.GetStart().GetText() + if seen[strings.ToLower(key)] { + b.addErrorWithExample(fmt.Sprintf("line %d: set … on %s: %s is written twice", line, op.Target, key), alterNavigationExample) + continue + } + seen[strings.ToLower(key)] = true + switch { + case prop.CAPTION() != nil: + c := unquoteStringLit(prop.STRING_LITERAL()) + op.Caption = &c + case prop.ONCLICK() != nil: + op.SetAction = true + if n := len(op.Target.Segments); n > 0 { + op.Item.Caption = op.Target.Segments[n-1].Caption // names the item in the refusal + } + b.applyNavMenuAction(&op.Item, prop.NavMenuAction(), line) + case prop.ICON() != nil: + op.SetIcon = true + if prop.NONE() == nil { + applyNavMenuIcon(&op.Item, prop.NavMenuIconValue()) + } + } + } + return op +} + +// buildMenuItemAddress reads `'Admin'@2 > 'Users'`. +func buildMenuItemAddress(ctx parser.IAlterMenuTargetContext) types.MenuItemAddress { + var a types.MenuItemAddress + c, ok := ctx.(*parser.AlterMenuTargetContext) + if !ok || c == nil { + return a + } + for _, sc := range c.AllAlterMenuTargetSegment() { + seg := sc.(*parser.AlterMenuTargetSegmentContext) + s := types.MenuItemAddressSegment{Caption: unquoteStringLit(seg.STRING_LITERAL())} + if n := seg.NUMBER_LITERAL(); n != nil { + if v, err := strconv.Atoi(n.GetText()); err == nil { + s.Ordinal = v + } + } + a.Segments = append(a.Segments, s) + } + return a +} diff --git a/mdl/visitor/visitor_alter_navigation_test.go b/mdl/visitor/visitor_alter_navigation_test.go new file mode 100644 index 0000000000..cf1489ebb1 --- /dev/null +++ b/mdl/visitor/visitor_alter_navigation_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" +) + +func buildOneStmt[T ast.Statement](t *testing.T, src string) T { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", src, errs) + } + if len(prog.Statements) != 1 { + t.Fatalf("want 1 statement, got %d", len(prog.Statements)) + } + s, ok := prog.Statements[0].(T) + if !ok { + t.Fatalf("got %T", prog.Statements[0]) + } + return s +} + +func TestAlterNavigation_EveryOperation(t *testing.T) { + s := buildOneStmt[*ast.AlterNavigationStmt](t, `alter navigation Responsive { + insert after 'Home' { menu item 'Orders' ( OnClick: show page Shop.Orders, Icon: Atlas_Core.Atlas."list-bullets" ) } + insert before 'Admin'@2 > 'Users' { menu item 'A' } + insert into 'Admin' { + menu 'Group' { menu item 'Child' ( OnClick: sign out ) } + } + insert { menu item 'Last' } + replace 'Help' with { menu item 'Help' ( OnClick: open link 'https://example.com' ) } + move 'Log out' after 'Help'; + move 'X' into 'Settings' > 'Y'@3 + drop 'Old import', 'Admin' > 'Legacy' + set ( Caption: 'it''s', OnClick: nothing, Icon: none ) on 'Admin' > 'Users'; + set ( Icon: glyph 57377 ) on ''@2 + set home page Shop.Home + set home microflow Shop.ACT_Home for Administrator; + drop home page for Administrator + drop home page + set login page Shop.Login + drop login page; + set not found page Shop.Oops + drop not found page +}`) + if s.ProfileName != "Responsive" { + t.Errorf("profile = %q", s.ProfileName) + } + var got []string + for _, op := range s.Operations { + got = append(got, op.String()) + } + want := []string{ + "insert after 'Home'", + "insert before 'Admin'@2 > 'Users'", + "insert into 'Admin'", + "insert { … }", + "replace 'Help'", + "move 'Log out' after 'Help'", + "move 'X' into 'Settings' > 'Y'@3", + "drop 'Old import', 'Admin' > 'Legacy'", + "set ( Caption, OnClick, Icon ) on 'Admin' > 'Users'", + "set ( Icon ) on ''@2", + "set home page Shop.Home", + "set home microflow Shop.ACT_Home for Administrator", + "drop home page for Administrator", + "drop home page", + "set login page Shop.Login", + "drop login page", + "set not found page Shop.Oops", + "drop not found page", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("operations:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + + ins := s.Operations[0].(*ast.InsertMenuItemsOp) + if ins.Items[0].Caption != "Orders" || ins.Items[0].Page == nil || ins.Items[0].IconKind != types.MenuIconCollection { + t.Errorf("fragment item = %+v", ins.Items[0]) + } + into := s.Operations[2].(*ast.InsertMenuItemsOp) + if len(into.Items) != 1 || len(into.Items[0].Items) != 1 || !into.Items[0].Items[0].SignOut { + t.Errorf("nested fragment = %+v", into.Items) + } + set := s.Operations[8].(*ast.SetMenuItemOp) + if set.Caption == nil || *set.Caption != "it's" { + t.Errorf("caption = %v", set.Caption) + } + if !set.SetAction || set.Item.Action == nil || set.Item.Action.Type != "none" { + t.Errorf("OnClick: nothing = %+v", set.Item.Action) + } + if !set.SetIcon || set.Item.IconKind != types.MenuIconNone { + t.Errorf("Icon: none = %v %v", set.SetIcon, set.Item.IconKind) + } + glyph := s.Operations[9].(*ast.SetMenuItemOp) + if glyph.Item.IconKind != types.MenuIconGlyph || glyph.Item.IconCode != 57377 || glyph.Target.Segments[0].Ordinal != 2 { + t.Errorf("glyph set = %+v", glyph) + } + home := s.Operations[11].(*ast.SetNavPageClauseOp) + if home.Home.IsPage || home.Home.ForRole == nil || home.Home.ForRole.String() != "Administrator" { + t.Errorf("role microflow home = %+v", home.Home) + } +} + +func TestAlterMenu_ItemOperations(t *testing.T) { + s := buildOneStmt[*ast.AlterMenuStmt](t, `alter menu Atlas_Core.Phone_Menu { + insert after 'Home' { menu item 'Orders' ( OnClick: show page Shop.Orders ) } + drop 'Old' +}`) + if s.Name.String() != "Atlas_Core.Phone_Menu" || len(s.Operations) != 2 { + t.Fatalf("got %s with %d operations", s.Name, len(s.Operations)) + } +} + +func TestAlterNavigation_Refusals(t *testing.T) { + cases := []struct { + src string + want string + }{ + {`alter navigation Responsive { set ( Caption: 'a', Caption: 'b' ) on 'X' }`, "Caption is written twice"}, + {`alter navigation Responsive { set ( OnClick: save changes ) on 'X' }`, "a menu item cannot save changes"}, + {`alter navigation Responsive { set ( Title: 'x' ) on 'X' }`, ""}, + // page clauses are navigation-only + {`alter menu M.Menu { set login page M.Login }`, ""}, + // positional addresses do not exist + {`alter navigation Responsive { drop item@3 }`, ""}, + } + for _, c := range cases { + _, errs := Build(c.src) + if len(errs) == 0 { + t.Errorf("%s: accepted", c.src) + continue + } + if c.want != "" && !strings.Contains(errs[0].Error(), c.want) { + t.Errorf("%s: error %q lacks %q", c.src, errs[0].Error(), c.want) + } + } +} From 334efbaa5800c9a8e6a04bf55d46a7efbe44b85e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:49:21 +0000 Subject: [PATCH 16/28] fix(menu): write Icon: null on every menu document item Every Menus$MenuItem Studio Pro writes carries `Icon: null` when it has no icon. `create menu` encodes items through the codec, which drops a null element property the type does not register, so an iconless item it wrote had no Icon key at all (CLAUDE.md rule 4: Studio Pro's exact property set). It surfaced once a second writer reached the same document: an ALTER MENU insert builds Studio Pro's shape (the navigation writer's), and the next describe -> `create or modify menu` of the unchanged menu reported "Modified", because the rebuild dropped the key the splice wrote. With Icon registered as a null field the two writers agree and the round trip is Unchanged (measured on a copy of testdata/expr-checker, 11.6.6). Test: TestCreateMenuDocument_ItemsCarryANullIcon (control: without the registration all three items lack the key). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...0-20-create-menu-items-omit-null-icon.json | 1 + mdl/backend/modelsdk/menu_icon_null_test.go | 78 +++++++++++++++++++ mdl/backend/modelsdk/menu_write.go | 14 ++-- 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-20-create-menu-items-omit-null-icon.json create mode 100644 mdl/backend/modelsdk/menu_icon_null_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-create-menu-items-omit-null-icon.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-create-menu-items-omit-null-icon.json new file mode 100644 index 0000000000..8fb2f4247f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-create-menu-items-omit-null-icon.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-20", "symptom": "After `alter menu M.Menu { insert … }`, a describe -> `create or modify menu` of the unchanged menu reported \"Modified menu M.Menu\" (a second round trip then said Unchanged); a fresh `create menu` round-tripped Unchanged. `set ( Icon: … ) on ` on an item `create menu` wrote failed with \"menu item has no Icon property\".", "cause": "`create menu` encodes items through the codec, which drops a null element property unless the type registers it; Menus$MenuItem registered only AlternativeText, so an iconless item had no Icon key, where every Studio Pro item (and every item the navigation writer and the ALTER splice build) carries Icon: null. Two writers of the same element disagreed on its property set.", "file": "`mdl/backend/modelsdk/menu_write.go` (init: codec.RegisterTypeDefaults(\"Menus$MenuItem\", NullFields AlternativeText + Icon)); `mdl/backend/menumutator/mutator.go` setItemField inserts a missing key in Studio Pro's alphabetical order instead of failing", "insight": "When two write paths build the same $Type (here the codec's create menu and the hand-built navMenuItemBson the splice reuses), compare their key sets on one document: the disagreement surfaces only when both write into it, as a describe -> exec that is not a fixed point. A splice that sets a property must not assume the key exists — data written by an older or different writer may lack it. Measured with real mx check 11.6.6: 0 errors before and after; the only symptom is the Modified/Unchanged report and the CLAUDE.md rule-4 property set.", "refs": ["mendixlabs/mxcli#1522"]} diff --git a/mdl/backend/modelsdk/menu_icon_null_test.go b/mdl/backend/modelsdk/menu_icon_null_test.go new file mode 100644 index 0000000000..95aa431792 --- /dev/null +++ b/mdl/backend/modelsdk/menu_icon_null_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// Every Menus$MenuItem Studio Pro writes carries `Icon: null` when it has no +// icon (ako/TestApp, PedApp, Fixture1115: the key set is $ID, $Type, Action, +// AlternativeText, Caption, Icon, Items on every item). `create menu` wrote +// its items through the codec, which drops a null element property unless the +// type registers it, so an iconless item had no Icon key at all. +// +// It was invisible until an item reached the same document by another path: +// an ALTER MENU insert writes Studio Pro's shape, and the next describe -> +// `create or modify menu` then reported "Modified" for a menu nothing had +// changed, because the rebuild dropped the key the splice had written. +func TestCreateMenuDocument_ItemsCarryANullIcon(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("module: %v", err) + } + md := &types.MenuDocument{Name: "IconNull", ContainerID: mod.ID, Items: []*types.NavMenuItem{ + {Caption: "Plain", Page: "MyFirstModule.Home_Web"}, + {Caption: "Group", Items: []*types.NavMenuItem{{Caption: "Child", Page: "MyFirstModule.Home_Web"}}}, + }} + if err := b.CreateMenuDocument(md); err != nil { + t.Fatal(err) + } + doc, err := b.rawUnitDoc(md.ID) + if err != nil { + t.Fatal(err) + } + items := 0 + var walk func(v any) + walk = func(v any) { + switch d := v.(type) { + case bson.D: + if navGetString(d, "$Type") == "Menus$MenuItem" { + items++ + has := false + for _, e := range d { + if e.Key == "Icon" { + has = true + if e.Value != nil { + t.Errorf("an iconless item stores Icon %v, want null", e.Value) + } + } + } + if !has { + t.Errorf("menu item has no Icon key; Studio Pro writes Icon: null") + } + } + for _, e := range d { + walk(e.Value) + } + case bson.A: + for _, e := range d { + walk(e) + } + } + } + walk(doc) + if items != 3 { + t.Fatalf("found %d menu items, want 3", items) + } +} diff --git a/mdl/backend/modelsdk/menu_write.go b/mdl/backend/modelsdk/menu_write.go index 9360a4458a..5f6576c120 100644 --- a/mdl/backend/modelsdk/menu_write.go +++ b/mdl/backend/modelsdk/menu_write.go @@ -18,14 +18,16 @@ import ( // menuDocumentType is the BSON storage name of a standalone menu document. const menuDocumentType = "Menus$MenuDocument" -// A menu item carries a null AlternativeText and an Items list, empty with -// marker 3, whether or not it has sub-items: every one of the items of -// Atlas_Core's Phone_Menu and Tablet_Menu in ako/TestApp and PedApp (11.14.0), -// and every navigation menu item. Without them a describe -> exec of either -// Atlas menu rewrote it (the round-trip allowlist's #721 entries). +// A menu item carries a null AlternativeText, a null Icon when it has none, and +// an Items list, empty with marker 3, whether or not it has sub-items: every one +// of the items of Atlas_Core's Phone_Menu and Tablet_Menu in ako/TestApp and +// PedApp (11.14.0), and every navigation menu item. Without them a describe -> +// exec of either Atlas menu rewrote it (the round-trip allowlist's #721 +// entries), and an item ALTER MENU inserted (Studio Pro's shape, Icon: null) +// was rewritten by the next describe -> exec. func init() { codec.RegisterTypeDefaults("Menus$MenuItem", codec.TypeDefaults{ - NullFields: []string{"AlternativeText"}, + NullFields: []string{"AlternativeText", "Icon"}, MandatoryLists: []string{"Items"}, }) } From e7febfff68923691f51d783383e48119c5200848 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:49:33 +0000 Subject: [PATCH 17/28] fix(navigation): splice sets a menu item key the stored item lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set ( Icon: … )` on an item an older `create menu` wrote failed with "menu item … has no Icon property": that writer left the Icon key out of an iconless item. Setting Caption, Action or Icon now inserts a missing key where Studio Pro keeps it (properties in alphabetical order after $ID and $Type) and writes the grown item back into its list, instead of assuming every stored item has every key. Found by the end-to-end run on a fixture copy (alter menu on a menu document created by `create menu`); TestSetMenuItemIcon_InsertsAMissingKeyInStudioProOrder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- mdl/backend/menumutator/mutator.go | 39 ++++++++++++++++++------- mdl/backend/menumutator/mutator_test.go | 37 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/mdl/backend/menumutator/mutator.go b/mdl/backend/menumutator/mutator.go index c2fbd4d1c9..34e6e7749b 100644 --- a/mdl/backend/menumutator/mutator.go +++ b/mdl/backend/menumutator/mutator.go @@ -483,10 +483,7 @@ func (m *Mutator) SetMenuItemCaption(path backend.MenuItemPath, caption string) {Key: "$Type", Value: "Texts$Text"}, {Key: "Items", Value: bson.A{markerItems, translationDoc(model.AuthoringLanguage(), caption)}}, } - if !bsonnav.DSet(it, "Caption", text) { - return fmt.Errorf("menu item %s has no Caption property", idString(it)) - } - return nil + return m.setItemField(path, "Caption", text) } // SetMenuItemAction replaces the item's client action with the one spec @@ -506,22 +503,42 @@ func (m *Mutator) SetMenuItemAction(path backend.MenuItemPath, spec types.NavMen if err != nil { return err } - if !bsonnav.DSet(it, "Action", action) { - return fmt.Errorf("menu item %s has no Action property", idString(it)) - } - return nil + return m.setItemField(path, "Action", action) } // SetMenuItemIcon replaces the item's icon; a spec with no icon stores null. func (m *Mutator) SetMenuItemIcon(path backend.MenuItemPath, spec types.NavMenuItemSpec) error { + return m.setItemField(path, "Icon", m.deps.BuildMenuIcon(spec)) +} + +// setItemField sets key on the item at path. A key the stored item lacks is +// inserted where Studio Pro keeps it — properties in alphabetical order after +// $ID and $Type — and the grown item is written back into its list: an item +// `create menu` wrote has no Icon key when it has no icon, where every Studio +// Pro item carries `Icon: null`. +func (m *Mutator) setItemField(path backend.MenuItemPath, key string, value any) error { it, err := m.item(path) if err != nil { return err } - if !bsonnav.DSet(it, "Icon", m.deps.BuildMenuIcon(spec)) { - return fmt.Errorf("menu item %s has no Icon property", idString(it)) + if bsonnav.DSet(it, key, value) { + return nil } - return nil + at := len(it) + for i, e := range it { + if !strings.HasPrefix(e.Key, "$") && e.Key > key { + at = i + break + } + } + grown := append(append(append(bson.D(nil), it[:at]...), bson.E{Key: key, Value: value}), it[at:]...) + _, holder, err := m.list(path.Parent()) + if err != nil { + return err + } + items := itemElements(holder) + items[path.Index()] = grown + return setItems(holder, items) } // --- the profile's page clauses --- diff --git a/mdl/backend/menumutator/mutator_test.go b/mdl/backend/menumutator/mutator_test.go index 8ca03bdd40..9bf04a603a 100644 --- a/mdl/backend/menumutator/mutator_test.go +++ b/mdl/backend/menumutator/mutator_test.go @@ -78,6 +78,43 @@ func TestNewProfile_Refusals(t *testing.T) { } } +// An item `create menu` wrote carries no Icon key when it has no icon (Studio +// Pro writes `Icon: null`). Setting one inserts the key where Studio Pro keeps +// it, between Caption and Items, rather than failing or appending it last. +func TestSetMenuItemIcon_InsertsAMissingKeyInStudioProOrder(t *testing.T) { + it := item("A") + var noIcon bson.D + for _, e := range it { + if e.Key != "Icon" { + noIcon = append(noIcon, e) + } + } + doc := bson.D{{Key: "$Type", Value: "Menus$MenuDocument"}, {Key: "ItemCollection", Value: bson.D{ + {Key: "$Type", Value: "Menus$MenuItemCollection"}, {Key: "Items", Value: bson.A{int32(3), noIcon}}}}} + deps := &iconDeps{} + m, err := NewMenuDocument(doc, "u", deps) + if err != nil { + t.Fatal(err) + } + if err := m.SetMenuItemIcon(backend.MenuItemPath{0}, types.NavMenuItemSpec{}); err != nil { + t.Fatalf("set icon on an item without an Icon key: %v", err) + } + got := itemElements(m.collection())[0] + var keys []string + for _, e := range got { + keys = append(keys, e.Key) + } + if strings.Join(keys, ",") != "$ID,$Type,Action,AlternativeText,Caption,Icon,Items" { + t.Errorf("keys = %v", keys) + } +} + +type iconDeps struct{ fakeDeps } + +func (iconDeps) BuildMenuIcon(types.NavMenuItemSpec) any { + return bson.D{{Key: "$Type", Value: "Forms$GlyphIcon"}, {Key: "Code", Value: int32(57377)}} +} + func TestMenuDocument_HasNoPageClauses(t *testing.T) { doc := bson.D{{Key: "$Type", Value: "Menus$MenuDocument"}, {Key: "ItemCollection", Value: bson.D{ {Key: "$Type", Value: "Menus$MenuItemCollection"}, {Key: "Items", Value: bson.A{int32(3), item("A", item("B"))}}}}} From c4066616f2fed873b4eb3a009a95569a42ccc6e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:52:39 +0000 Subject: [PATCH 18/28] test(init): pin the tolerant chmod and the file package in the devcontainer mendixlabs/mxcli#744 shipped without a test (its description says none covers the string). The test fails against main before #744: the chmod is unguarded and the image has no file package. Finding recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- ...tcreate-chmod-fails-on-podman-windows.json | 1 + cmd/mxcli/tool_templates_test.go | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-20-devcontainer-postcreate-chmod-fails-on-podman-windows.json diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-20-devcontainer-postcreate-chmod-fails-on-podman-windows.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-20-devcontainer-postcreate-chmod-fails-on-podman-windows.json new file mode 100644 index 0000000000..33811f36f3 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-20-devcontainer-postcreate-chmod-fails-on-podman-windows.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-20", "symptom": "On Podman-on-Windows, the devcontainer that `mxcli init --container-runtime podman` generates fails postCreateCommand after the mxcli download succeeded: `chmod +x ./mxcli` errors on the workspace bind mount, and every container start re-downloads the binary", "cause": "postCreateCommand ended in an unguarded `chmod +x ./mxcli`, which a mount that does not honour Unix modes rejects, failing the whole command. Its fast path, `file ./mxcli | grep -q Linux`, never matched because the generated image did not install `file`", "file": "`cmd/mxcli/tool_templates.go` (`generateDevcontainerJSON`, `generateDockerfile`); test `cmd/mxcli/tool_templates_test.go` (`TestDevcontainerPostCreate_ToleratesChmodAndHasFile`)", "insight": "A generated shell one-liner has two failure modes that no Linux CI run shows: a step that is fatal only on a foreign filesystem (a Windows bind mount ignores chmod), and a guard that silently never fires because its tool is missing from the image. Assert both on the generated text: every chmod on the mounted workspace is tolerant, and every command a guard runs is installed by the Dockerfile the same generator writes", "refs": ["mendixlabs/mxcli#742", "mendixlabs/mxcli#744"]} diff --git a/cmd/mxcli/tool_templates_test.go b/cmd/mxcli/tool_templates_test.go index 3b6251f914..66bcfea272 100644 --- a/cmd/mxcli/tool_templates_test.go +++ b/cmd/mxcli/tool_templates_test.go @@ -140,3 +140,32 @@ func TestGeneratePlaywrightConfig_PinsHeadlessShell(t *testing.T) { t.Errorf("executablePath = %q, want the stable Dockerfile symlink /usr/local/bin/mx-headless-shell", got) } } + +// On Podman-on-Windows the workspace bind mount does not honour chmod, so an +// unguarded `chmod +x ./mxcli` failed the whole postCreateCommand after the +// download had succeeded (mendixlabs/mxcli#742). The fast path's +// `file ./mxcli | grep -q Linux` also needs `file` in the image, or it never +// matches and every container start re-downloads the binary. +func TestDevcontainerPostCreate_ToleratesChmodAndHasFile(t *testing.T) { + for _, runtime := range []string{"docker", "podman"} { + t.Run(runtime, func(t *testing.T) { + var v struct { + PostCreateCommand string `json:"postCreateCommand"` + } + if err := json.Unmarshal([]byte(generateDevcontainerJSON("MyApp", "App.mpr", runtime)), &v); err != nil { + t.Fatal(err) + } + cmd := v.PostCreateCommand + if !strings.Contains(cmd, "file ./mxcli | grep -q Linux") { + t.Fatalf("postCreateCommand no longer checks the binary with file; this test is out of date:\n%s", cmd) + } + if strings.Contains(strings.ReplaceAll(cmd, "chmod +x ./mxcli 2>/dev/null || true", ""), "chmod +x ./mxcli") { + t.Errorf("an unguarded chmod +x ./mxcli fails postCreateCommand on a mount that ignores modes:\n%s", cmd) + } + dockerfile := generateDockerfile("MyApp", "App.mpr", runtime) + if !strings.Contains(dockerfile, " file \\\n") { + t.Errorf("the image lacks the file package that postCreateCommand's fast path runs:\n%s", dockerfile) + } + }) + } +} From 37d49e1c29140207e697397764d3812bb8039657 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:53:16 +0000 Subject: [PATCH 19/28] test(lint): assert the user-role security flags reach Starlark mendixlabs/mxcli#1238 added check_security, manage_all_roles, manage_users_without_roles and manageable_roles to user_roles() without a test. A role that sets none of them is the control. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- mdl/linter/starlark_catalog_tables_test.go | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/mdl/linter/starlark_catalog_tables_test.go b/mdl/linter/starlark_catalog_tables_test.go index c2a83d51bf..ddb636bc67 100644 --- a/mdl/linter/starlark_catalog_tables_test.go +++ b/mdl/linter/starlark_catalog_tables_test.go @@ -306,3 +306,37 @@ def check(): t.Errorf("user_roles() descriptions:\n%v\nwant:\n%v", got, want) } } + +// mendixlabs/mxcli#1238: user_roles() exposes the role's "Check security" flag +// and its user-management grants, so a rule such as "security is checked for +// every role" can be written. The second role sets none of them and is the +// control: every flag reads back false and its list empty. +func TestUserRolesExposeSecurityFlags(t *testing.T) { + cat, err := catalog.NewFromFile(filepath.Join(t.TempDir(), "cat.db")) + if err != nil { + t.Fatal(err) + } + defer cat.Close() + reader := &securityReader{ps: &security.ProjectSecurity{UserRoles: []*security.UserRole{ + {Name: "Administrator", CheckSecurity: true, ManageAllRoles: true, ManageUsersWithoutRoles: true, + ManageableRoles: []string{"User", "Planner"}}, + {Name: "User"}, + }}} + vs, _ := runSrc(t, linter.NewLintContext(cat, reader), ` +def check(): + return [violation(message="%s check=%s all=%s noroles=%s manage=%s" % ( + r.name, r.check_security, r.manage_all_roles, r.manage_users_without_roles, + ",".join(r.manageable_roles))) for r in user_roles()] +`) + var got []string + for _, v := range vs { + got = append(got, v.Message) + } + want := []string{ + "Administrator check=True all=True noroles=True manage=User,Planner", + "User check=False all=False noroles=False manage=", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("user_roles() security flags:\n%v\nwant:\n%v", got, want) + } +} From 8c6de22a4dc930955259f6d9fbe94443701196eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:54:35 +0000 Subject: [PATCH 20/28] fix(check): a script-created module qualifies a change member; test the reader's path Review follow-ups on mendixlabs/mxcli#1552: - check consulted only the project's module list, so a script that creates module M and then changes `M.Assoc` was told M.Assoc is a member reached through an association. The script's own modules now count. - The writer test mocked GetModuleByName as (nil, nil), so moduleExists never consulted the module list, which is the branch production takes: the reader answers a missing module with an error. New table test on that path, with an unreadable list and a real module qualifier as controls. - backendRefusesModule sat between associationNamedInModule and its doc comment; moved above it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .../cmd_microflows_member_identifier_test.go | 42 +++++++++++++++++++ mdl/executor/validate_member_refs.go | 11 +++-- mdl/executor/validate_member_refs_test.go | 19 +++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/mdl/executor/cmd_microflows_member_identifier_test.go b/mdl/executor/cmd_microflows_member_identifier_test.go index 196c75d861..8044f21217 100644 --- a/mdl/executor/cmd_microflows_member_identifier_test.go +++ b/mdl/executor/cmd_microflows_member_identifier_test.go @@ -3,6 +3,7 @@ package executor import ( + "errors" "strings" "testing" @@ -162,3 +163,44 @@ func TestResolveMemberChange_RejectsAssociationMemberPath(t *testing.T) { } } } + +// The production reader answers GetModuleByName for a missing module with an +// ERROR ("module not found"), not (nil, nil) as the mock does by default, so +// the module list is what decides there. The test above never reaches that +// branch. These three cover it: a list without the qualifier refuses, an +// unreadable list keeps the old silent fallback, and a real module qualifier +// (`Module.Assoc`) is never read as a member reached through an association. +func TestResolveMemberChange_AssociationMemberPathOnTheReadersErrorPath(t *testing.T) { + notFound := func(name string) (*model.Module, error) { + if name == "Hop" { + return &model.Module{BaseElement: model.BaseElement{ID: model.ID("hop")}, Name: "Hop"}, nil + } + return nil, errModuleNotFound + } + hopOnly := func() ([]*model.Module, error) { + return []*model.Module{{BaseElement: model.BaseElement{ID: model.ID("hop")}, Name: "Hop"}}, nil + } + const refusal = "is not a module" + cases := []struct { + name, member string + list func() ([]*model.Module, error) + wantRefusal bool + }{ + {"qualifier missing from a readable list", "A_B.Title", hopOnly, true}, + {"unreadable list stays silent", "A_B.Title", + func() ([]*model.Module, error) { return nil, errModuleNotFound }, false}, + {"a real module qualifier is an association-set", "Hop.A_B", hopOnly, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fb := &flowBuilder{backend: &mock.MockBackend{GetModuleByNameFunc: notFound, ListModulesFunc: c.list}} + fb.resolveMemberChange(µflows.MemberChange{}, c.member, "Hop.A") + got := strings.Join(fb.errors, "\n") + if c.wantRefusal != strings.Contains(got, refusal) { + t.Errorf("refusal = %v, want %v; errors:\n%s", !c.wantRefusal, c.wantRefusal, got) + } + }) + } +} + +var errModuleNotFound = errors.New("module not found") diff --git a/mdl/executor/validate_member_refs.go b/mdl/executor/validate_member_refs.go index 5aebe7f247..169b1950ab 100644 --- a/mdl/executor/validate_member_refs.go +++ b/mdl/executor/validate_member_refs.go @@ -121,9 +121,6 @@ func resolveMemberOnEntity(ctx *ExecContext, entityQN, memberName string) member return memberMissing } -// associationNamedInModule reports whether the domain model has an association -// of this bare name. A qualified member is handled by the caller; this covers -// the unqualified spelling, which resolves in the entity's own module. // backendRefusesModule reports whether the project's module list answers, // confidently, that no module of this name exists. A list that cannot be read // is NOT absence — this file's rule is silence over a guess @@ -145,6 +142,9 @@ func backendRefusesModule(ctx *ExecContext, qualifier string) bool { return true } +// associationNamedInModule reports whether the domain model has an association +// of this bare name. A qualified member is handled by the caller; this covers +// the unqualified spelling, which resolves in the entity's own module. func associationNamedInModule(dm *domainmodel.DomainModel, name string) bool { for _, a := range dm.Associations { if a != nil && a.Name == name { @@ -458,7 +458,10 @@ func checkMembers(ctx *ExecContext, sc *scriptContext, authored map[string]bool, // writer refuses the same shape. // (docs/12-bug-reports/2026-10-16-change-member-through-association.md) if dot := strings.Index(name, "."); dot >= 0 { - if strings.Count(name, ".") == 1 && backendRefusesModule(ctx, name[:dot]) { + // A module the script creates further up is a module, even though + // the project does not have it yet. + if strings.Count(name, ".") == 1 && !(sc != nil && sc.modules[name[:dot]]) && + backendRefusesModule(ctx, name[:dot]) { out = append(out, fmt.Sprintf( "%s: %q on %s is a member reached through an association — a change item cannot set it. "+ "Retrieve the associated object and change that instead (`retrieve $obj from $var/%s;` then `change $obj (%s = ...)`); "+ diff --git a/mdl/executor/validate_member_refs_test.go b/mdl/executor/validate_member_refs_test.go index 7a06419b16..e4bea209ee 100644 --- a/mdl/executor/validate_member_refs_test.go +++ b/mdl/executor/validate_member_refs_test.go @@ -426,3 +426,22 @@ func TestValidateMemberReferences_UntypedTargetIsUnchecked(t *testing.T) { t.Errorf("checked a member against an entity it could not establish: %v", msgs) } } + +// A module the script creates further up is not in the project's module list +// yet, but `NewMod.Order_Customer` over it is an association-set, not a member +// reached through an association. The check consults the script's own modules +// before the project's; the same member over a qualifier the script does NOT +// create is the control and is still refused. +func TestValidateMemberReferences_ScriptCreatedModuleIsAModule(t *testing.T) { + ctx := memberFixture(t) + prog := flowProgram(orderParam(), changeStmt("Order", "NewMod.Order_Customer")) + + sc := newScriptContext() + sc.modules["NewMod"] = true + if msgs := validateMemberReferences(ctx, prog, sc); len(msgs) != 0 { + t.Errorf("a module the script creates was read as an association: %v", msgs) + } + if msgs := validateMemberReferences(ctx, prog, newScriptContext()); len(msgs) != 1 { + t.Errorf("control: got %d messages without the script's module, want 1: %v", len(msgs), msgs) + } +} From 5e27f91006abb347ab239590e9125e3774dace87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:55:05 +0000 Subject: [PATCH 21/28] test(new): new takes init's --container-runtime flag mendixlabs/mxcli#740 had no test. Asserts the flag exists on new with init's default, init's own flag being the reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- cmd/mxcli/cmd_new_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/mxcli/cmd_new_test.go b/cmd/mxcli/cmd_new_test.go index 4d059cecfa..ba3aac22cc 100644 --- a/cmd/mxcli/cmd_new_test.go +++ b/cmd/mxcli/cmd_new_test.go @@ -163,3 +163,21 @@ func TestReportFirstBuildFailure(t *testing.T) { t.Errorf("an environment failure must still be reported, got:\n%s", buf.String()) } } + +// mendixlabs/mxcli#739: `mxcli new` runs `mxcli init` but had no way to pass +// the container runtime, so `new --container-runtime podman` was rejected and +// a Podman user had to re-run init by hand. The flag must exist on new with +// init's name and default, and init's own flag is the reference. +func TestNewHasInitsContainerRuntimeFlag(t *testing.T) { + want := initCmd.Flags().Lookup("container-runtime") + if want == nil { + t.Fatal("init has no --container-runtime; this test is out of date") + } + got := newCmd.Flags().Lookup("container-runtime") + if got == nil { + t.Fatal("mxcli new has no --container-runtime flag") + } + if got.DefValue != want.DefValue { + t.Errorf("new defaults --container-runtime to %q, init to %q", got.DefValue, want.DefValue) + } +} From fd29386a5a022f71e728f1cfdbe24535529a3605 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:55:59 +0000 Subject: [PATCH 22/28] docs(skills): regenerate the write-lint-rules section index mendixlabs/mxcli#1238 added four rows to the user_role table, which moved the sections after it. Regenerated with scripts/skill-index.py (make build). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .claude/skills/mendix/write-lint-rules/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 19e48ae43c..d85ff0913d 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -12,10 +12,10 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b |---|---| | 25-67 | Rule File Structure | | 68-161 | Available Query Functions | -| 162-602 | Object Properties | -| 603-616 | Helper Functions | -| 617-703 | Common Patterns | -| 704-713 | Validation | +| 162-606 | Object Properties | +| 607-620 | Helper Functions | +| 621-707 | Common Patterns | +| 708-717 | Validation | # Writing Custom Starlark Lint Rules From f202961fff2d2405b08043a560c6c9c9772a11a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:59:02 +0000 Subject: [PATCH 23/28] feat(navigation): execute ALTER NAVIGATION and ALTER MENU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding one menu item no longer means restating the menu (mendixlabs/mxcli#1522): `alter navigation { … }` and `alter menu M.Menu { … }` splice the operations into the stored document and write it once, through UpdateRawUnitPatch and canon.Reconcile. applyMenuAlterOps is the one function that resolves every target (against the menu as the earlier operations left it), applies every refusal and drives the mutator: exec runs it and saves; `check -p --references` runs it on a mutator it never saves (validateAlterNavigationReferences), sharing it across successive alters of one document, so the two report the same refusal with the same message. A refusal stops before Save: the statement is atomic. Refused, each naming the statement, operation and target: a missing or ambiguous address (the ambiguity lists every candidate's shortest unique address) and an @n out of range; insert/move into an item that has an action, and an OnClick other than `nothing` on an item with sub-items (CE0548); a move into the item's own subtree or relative to itself; a bare `drop home page` (CE0527) and `drop home page for` a role with none; a native profile and a profile the project lacks (each says what to write instead); an unknown menu. Only what the statement states is validated: its fragment items and set values go through the checks `create navigation` runs (references, icons MDL077/MDL078, user roles) via a create-statement view of the alter; stored items it does not name are not re-judged. `list navigation menu` is now a table with an Address column, the shortest unique address of every item. Measured on copies of testdata/expr-checker (11.6.6) and a blank 11.15.0 app: insert before/after/into/top level with a nested fragment, drop, replace, move, set Caption/OnClick/Icon, set and drop of every page clause, and alter menu on a menu document each leave `mx check` at 0 errors, and a describe -> exec afterwards reports Unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .../alter_navigation_validation_test.go | 41 +++ mdl/executor/cmd_alter_navigation.go | 333 ++++++++++++++++++ mdl/executor/cmd_alter_navigation_test.go | 239 +++++++++++++ mdl/executor/cmd_navigation.go | 59 ++-- mdl/executor/register_stubs.go | 6 + mdl/executor/registry_test.go | 2 + mdl/executor/stmt_summary.go | 4 + mdl/executor/validate.go | 3 + mdl/executor/validate_alter_navigation.go | 184 ++++++++++ mdl/executor/validate_definition_order.go | 2 +- mdl/executor/validate_flow_access.go | 22 +- mdl/executor/validate_icon_refs.go | 3 + mdl/executor/validate_navigation_icons.go | 6 + mdl/executor/validate_navigation_roles.go | 3 + mdl/executor/validate_navigation_targets.go | 3 + .../validate_protected_module_refs.go | 6 +- 16 files changed, 887 insertions(+), 29 deletions(-) create mode 100644 mdl/executor/alter_navigation_validation_test.go create mode 100644 mdl/executor/cmd_alter_navigation.go create mode 100644 mdl/executor/cmd_alter_navigation_test.go create mode 100644 mdl/executor/validate_alter_navigation.go diff --git a/mdl/executor/alter_navigation_validation_test.go b/mdl/executor/alter_navigation_validation_test.go new file mode 100644 index 0000000000..8ae3a55d8d --- /dev/null +++ b/mdl/executor/alter_navigation_validation_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// Only what an alter states is validated: a glyph the font does not define is +// refused on an item it inserts or an icon it sets (MDL078), and an inserted +// item without an icon is warned about (MDL077) — but a `set` naming an +// existing item is not, and nothing stored is looked at. That is the TestApp +// case: one caption change in a Studio Pro menu was refused over glyph 9993 +// on an item the statement never named. +func TestAlterNavigation_ValidatesOnlyWhatItStates(t *testing.T) { + prog := parseMDL(t, `alter navigation Responsive { + insert after 'Home' { menu item 'Bad' ( OnClick: sign out, Icon: glyph 9993 ) } + set ( Icon: glyph 9994 ) on 'Help' + set ( Caption: 'Start' ) on 'Home' +}`) + var codes []string + for _, v := range validateGlyphCodes(prog.Statements[0]) { + codes = append(codes, v.RuleID+":"+v.Message) + } + joined := strings.Join(codes, "\n") + if len(codes) != 2 || !strings.Contains(joined, "9993") || !strings.Contains(joined, "9994") { + t.Errorf("MDL078 findings = %v, want the inserted 9993 and the set 9994", codes) + } + icons := validateMenuItemIcons(parseMDL(t, `alter menu M.Menu { + insert { menu item 'NoIcon' ( OnClick: sign out ) } + set ( Caption: 'Renamed' ) on 'Existing' +}`).Statements[0]) + if len(icons) != 1 || !strings.Contains(icons[0].Message, "NoIcon") { + t.Errorf("MDL077 findings = %+v, want one, for the inserted item", icons) + } + clean := parseMDL(t, `alter navigation Responsive { set ( Caption: 'Start' ) on 'Item 4' }`) + if v := validateGlyphCodes(clean.Statements[0]); len(v) != 0 { + t.Errorf("a caption change was judged on icons: %v", v) + } +} diff --git a/mdl/executor/cmd_alter_navigation.go b/mdl/executor/cmd_alter_navigation.go new file mode 100644 index 0000000000..f4cddfe248 --- /dev/null +++ b/mdl/executor/cmd_alter_navigation.go @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// ALTER NAVIGATION / ALTER MENU (accepted proposal +// PROPOSAL_alter_navigation_menu_items): item-level edits to a web profile's +// menu and its page clauses, or to a menu document's items, spliced into the +// stored document (ADR-0012 decision 3) instead of rewriting the whole menu +// the way `create or modify` does. +// +// One function, applyMenuAlterOps, resolves every target, applies every +// refusal and drives the mutator. exec runs it and saves; `check -p` runs it on +// a mutator it never saves (validateAlterNavigationReferences). So the two +// cannot disagree about what a statement means or which one is refused. + +// execAlterNavigation handles ALTER NAVIGATION { … }. +func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { + if !ctx.ConnectedForWrite() { + return mdlerrors.NewNotConnectedWrite() + } + if err := validateMenuAlterForExec(ctx, s); err != nil { + return err + } + m, err := openAlterNavigation(ctx, s.ProfileName) + if err != nil { + return err + } + if err := applyMenuAlterOps(ctx, m, alterNavigationLabel(s.ProfileName), s.Operations); err != nil { + return err + } + if err := m.Save(); err != nil { + return mdlerrors.NewBackend("save navigation", err) + } + ctx.ReportMutation("Altered", "navigation %s", s.ProfileName) + return nil +} + +// execAlterMenu handles ALTER MENU Module.Menu { … }. +func execAlterMenu(ctx *ExecContext, s *ast.AlterMenuStmt) error { + if !ctx.ConnectedForWrite() { + return mdlerrors.NewNotConnectedWrite() + } + if err := validateMenuAlterForExec(ctx, s); err != nil { + return err + } + m, err := openAlterMenu(ctx, s.Name) + if err != nil { + return err + } + if err := applyMenuAlterOps(ctx, m, "alter menu "+s.Name.String(), s.Operations); err != nil { + return err + } + if err := m.Save(); err != nil { + return mdlerrors.NewBackend("save menu", err) + } + ctx.ReportMutation("Altered", "menu %s", s.Name.String()) + return nil +} + +func alterNavigationLabel(profile string) string { return "alter navigation " + profile } + +// openAlterNavigation finds the profile and opens its menu for mutation. A +// profile the project does not have, and a native one, are refused with what +// to write instead. +func openAlterNavigation(ctx *ExecContext, profile string) (backend.NavigationProfileMutator, error) { + // Captions are read and written in the project's default language, as a + // page's texts are; the backend reads it through model.AuthoringLanguage. + authoringLanguage(ctx) + nav, err := ctx.Backend.GetNavigation() + if err != nil { + return nil, mdlerrors.NewBackend("get navigation", err) + } + var found *types.NavigationProfile + if nav != nil { + for _, p := range nav.Profiles { + if strings.EqualFold(p.Name, profile) { + found = p + break + } + } + } + label := alterNavigationLabel(profile) + if found == nil { + names := "" + if nav != nil { + names = profileNames(nav) + } + return nil, mdlerrors.NewNotFoundMsg("navigation profile", profile, fmt.Sprintf( + "%s: the project has no navigation profile %s (it has: %s) — alter does not create one; "+ + "`create or modify navigation %s home page Module.Page` does", label, profile, names, profile)) + } + if found.IsNative { + return nil, mdlerrors.NewValidationf("%s: %s is a native profile, whose menu is a bottom bar mxcli does not write and whose home "+ + "pages are different elements — alter navigation edits a web profile; set a native profile's home with "+ + "`create or modify navigation %s home page Module.Page` (it keeps everything it does not state), and its bottom bar in Studio Pro", + label, found.Name, found.Name) + } + m, err := ctx.Backend.OpenNavigationProfileForMutation(found.Name) + if err != nil { + return nil, mdlerrors.NewBackend("open navigation for mutation", err) + } + return m, nil +} + +// openAlterMenu finds the menu document and opens its items for mutation. +func openAlterMenu(ctx *ExecContext, name ast.QualifiedName) (backend.MenuMutator, error) { + authoringLanguage(ctx) + md, err := ctx.Backend.GetMenuDocumentByQualifiedName(name.Module, name.Name) + if err != nil || md == nil { + return nil, mdlerrors.NewNotFoundMsg("menu", name.String(), fmt.Sprintf( + "alter menu %s: menu not found — `create menu %s { … }` creates one", name, name)) + } + m, err := ctx.Backend.OpenMenuDocumentForMutation(md.ID) + if err != nil { + return nil, mdlerrors.NewBackend("open menu for mutation", err) + } + return m, nil +} + +// applyMenuAlterOps applies the operations in order to m, each target resolved +// against the menu as the operations before it left it. The first refusal +// stops it; the caller then does not save, so the statement is atomic. +func applyMenuAlterOps(ctx *ExecContext, m backend.MenuMutator, label string, ops []ast.MenuAlterOp) error { + mb := newMenuActionBuilder(ctx) + for _, op := range ops { + if err := applyMenuAlterOp(ctx, mb, m, op); err != nil { + return mdlerrors.NewValidationf("%s: %s: %v", label, op.String(), err) + } + } + return nil +} + +// ce0548 is the refusal for an action on an item with sub-items. +const ce0548 = "an item with sub-items cannot have an action (Mendix CE0548)" + +func applyMenuAlterOp(ctx *ExecContext, mb *menuActionBuilder, m backend.MenuMutator, op ast.MenuAlterOp) error { + tree := m.MenuTree() + resolve := func(a types.MenuItemAddress) (backend.MenuItemPath, error) { + return backend.ResolveMenuItemAddress(a, tree) + } + switch o := op.(type) { + case *ast.InsertMenuItemsOp: + specs, err := menuFragmentSpecs(mb, o.Items) + if err != nil { + return err + } + if o.Target == nil { + return m.InsertMenuItems(nil, len(tree), specs) + } + path, err := resolve(*o.Target) + if err != nil { + return err + } + switch o.Position { + case "before": + return m.InsertMenuItems(path.Parent(), path.Index(), specs) + case "after": + return m.InsertMenuItems(path.Parent(), path.Index()+1, specs) + default: // into + n := backend.MenuNodeAt(tree, path) + if !n.NoAction { + return fmt.Errorf("%s is not a sub-menu: it has an action, and %s — "+ + "`set ( OnClick: nothing ) on %s` first", o.Target, ce0548, o.Target) + } + return m.InsertMenuItems(path, len(n.Items), specs) + } + + case *ast.ReplaceMenuItemOp: + path, err := resolve(o.Target) + if err != nil { + return err + } + specs, err := menuFragmentSpecs(mb, o.Items) + if err != nil { + return err + } + return m.ReplaceMenuItem(path, specs) + + case *ast.MoveMenuItemOp: + from, err := resolve(o.Item) + if err != nil { + return err + } + target, err := resolve(o.Target) + if err != nil { + return err + } + if target.HasPrefix(from) { + if len(target) == len(from) { + return fmt.Errorf("%s and %s are the same item", o.Item, o.Target) + } + return fmt.Errorf("%s is inside %s: an item cannot be moved into its own sub-items", o.Target, o.Item) + } + pos := backend.MenuPosition(o.Position) + if pos == backend.MenuInto && !backend.MenuNodeAt(tree, target).NoAction { + return fmt.Errorf("%s is not a sub-menu: it has an action, and %s — "+ + "`set ( OnClick: nothing ) on %s` first", o.Target, ce0548, o.Target) + } + return m.MoveMenuItem(from, target, pos) + + case *ast.DropMenuItemsOp: + // Every target is resolved against the menu before any is removed, so + // `drop 'A', 'B'` means the two items the reader sees. An item inside + // another one dropped goes with it; the rest are removed last-first, so + // earlier positions stay valid. + var paths []backend.MenuItemPath + for _, a := range o.Targets { + p, err := resolve(a) + if err != nil { + return err + } + paths = append(paths, p) + } + var keep []backend.MenuItemPath + for i, p := range paths { + inside := false + for j, q := range paths { + if i != j && p.HasPrefix(q) && (len(q) < len(p) || j < i) { + inside = true + } + } + if !inside { + keep = append(keep, p) + } + } + sort.Slice(keep, func(i, j int) bool { return pathLess(keep[j], keep[i]) }) + for _, p := range keep { + if err := m.DropMenuItem(p); err != nil { + return err + } + } + return nil + + case *ast.SetMenuItemOp: + path, err := resolve(o.Target) + if err != nil { + return err + } + node := backend.MenuNodeAt(tree, path) + if o.SetAction && len(node.Items) > 0 && (o.Item.Action == nil || o.Item.Action.Type != "none") { + return fmt.Errorf("%s has sub-items, and %s — only `OnClick: nothing` is allowed on it", o.Target, ce0548) + } + if o.Caption != nil { + if err := m.SetMenuItemCaption(path, *o.Caption); err != nil { + return err + } + } + if o.SetAction { + action, err := mb.build(o.Item, o.Target.String()) + if err != nil { + return err + } + if err := m.SetMenuItemAction(path, types.NavMenuItemSpec{Caption: node.Caption, Action: action}); err != nil { + return err + } + } + if o.SetIcon { + spec := types.NavMenuItemSpec{Icon: o.Item.Icon, IconKind: o.Item.IconKind, IconCode: o.Item.IconCode} + if err := m.SetMenuItemIcon(path, spec); err != nil { + return err + } + } + return nil + + case *ast.SetNavPageClauseOp, *ast.DropNavPageClauseOp: + pm, ok := m.(backend.NavigationProfileMutator) + if !ok { + return fmt.Errorf("a menu document has no page clauses; they belong to a navigation profile") + } + return applyNavPageClause(pm, op) + } + return fmt.Errorf("unsupported operation %T", op) +} + +func applyNavPageClause(pm backend.NavigationProfileMutator, op ast.MenuAlterOp) error { + switch o := op.(type) { + case *ast.SetNavPageClauseOp: + switch o.Clause { + case "home": + spec := types.NavHomePageSpec{IsPage: o.Home.IsPage, Target: o.Home.Target.String()} + if o.Home.ForRole != nil { + spec.ForRole = o.Home.ForRole.String() + } + return pm.SetHomePage(spec) + case "login": + return pm.SetLoginPage(o.Page.String()) + default: + return pm.SetNotFoundPage(o.Page.String()) + } + case *ast.DropNavPageClauseOp: + switch o.Clause { + case "home": + if o.Role == nil { + return fmt.Errorf("a web profile's default home page is required (Mendix CE0527), so it cannot be dropped — " + + "`set home page Module.Page` changes it; `drop home page for ` removes a role's") + } + return pm.DropRoleHomePage(o.Role.String()) + case "login": + return pm.SetLoginPage("") + default: + return pm.SetNotFoundPage("") + } + } + return nil +} + +// pathLess orders positions in describe order. +func pathLess(a, b backend.MenuItemPath) bool { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] != b[i] { + return a[i] < b[i] + } + } + return len(a) < len(b) +} + +// menuFragmentSpecs converts a fragment's items the way `create navigation` +// converts its menu block, each action built by the page builder. A fragment +// is new content, so nothing stored is paired with it. +func menuFragmentSpecs(mb *menuActionBuilder, defs []ast.NavMenuItemDef) ([]types.NavMenuItemSpec, error) { + return convertMenuItemDefs(mb, defs, nil, "", map[string]string{}) +} diff --git a/mdl/executor/cmd_alter_navigation_test.go b/mdl/executor/cmd_alter_navigation_test.go new file mode 100644 index 0000000000..56ce7bde30 --- /dev/null +++ b/mdl/executor/cmd_alter_navigation_test.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// leaf / sub build the mock menu: a leaf has an action, a sub-menu does not. +func leaf(c string) *backend.MenuTreeNode { return &backend.MenuTreeNode{Caption: c} } +func sub(c string, items ...*backend.MenuTreeNode) *backend.MenuTreeNode { + return &backend.MenuTreeNode{Caption: c, NoAction: true, Items: items} +} + +// The proposal's example menu. +func exampleNavTree() []*backend.MenuTreeNode { + return []*backend.MenuTreeNode{ + leaf("Home"), + sub("Admin", leaf("Users"), leaf("Old import")), + sub("Reports", leaf("Users")), + leaf("Help"), + leaf("Log out"), + } +} + +func treeString(tree []*backend.MenuTreeNode) string { + var parts []string + for _, n := range tree { + s := "'" + n.Caption + "'" + if len(n.Items) > 0 { + s += "{" + treeString(n.Items) + "}" + } + parts = append(parts, s) + } + return strings.Join(parts, " ") +} + +// alterNavCtx is a mock project with a web profile Responsive holding the +// example menu, a native profile NativePhone and a menu document M.Menu; the +// returned mutators record what reaches them. +func alterNavCtx(t *testing.T) (*ExecContext, *mock.MenuMutator, *mock.MenuMutator) { + t.Helper() + nav := &mock.MenuMutator{Tree: exampleNavTree(), RoleHomes: map[string]types.NavHomePageSpec{ + "Administrator": {IsPage: true, Target: "Shop.AdminHome", ForRole: "Administrator"}}} + menu := &mock.MenuMutator{Tree: []*backend.MenuTreeNode{leaf("One"), leaf("Two")}} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{ + {Name: "Responsive", Kind: "Responsive"}, + {Name: "NativePhone", Kind: "NativePhone", IsNative: true}, + }}, nil + }, + OpenNavigationProfileForMutationFunc: func(name string) (backend.NavigationProfileMutator, error) { + return nav, nil + }, + GetMenuDocumentByQualifiedNameFunc: func(module, name string) (*types.MenuDocument, error) { + if module+"."+name != "M.Menu" { + return nil, nil + } + return &types.MenuDocument{ID: model.ID("menu-1"), Name: name}, nil + }, + OpenMenuDocumentForMutationFunc: func(model.ID) (backend.MenuMutator, error) { return menu, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + return ctx, nav, menu +} + +func execAlter(t *testing.T, ctx *ExecContext, src string) error { + t.Helper() + prog := parseMDL(t, src) + switch s := prog.Statements[0].(type) { + case *ast.AlterNavigationStmt: + return execAlterNavigation(ctx, s) + case *ast.AlterMenuStmt: + return execAlterMenu(ctx, s) + } + t.Fatalf("not an alter: %T", prog.Statements[0]) + return nil +} + +func TestAlterNavigation_AppliesEveryOperationInOrder(t *testing.T) { + ctx, nav, _ := alterNavCtx(t) + err := execAlter(t, ctx, `alter navigation Responsive { + insert after 'Home' { menu item 'Orders' ( OnClick: sign out ) } + insert into 'Admin' { menu item 'Audit log' ( OnClick: open link 'https://example.com/audit' ) } + set ( Caption: 'User accounts' ) on 'Admin' > 'Users'; + replace 'Help' with { + menu 'Help' { + menu item 'Documentation' ( OnClick: open link 'https://example.com/docs' ) + } + } + move 'Log out' before 'Help'; + drop 'Old import'; + insert into 'Help' { menu item 'Contact' ( OnClick: nothing ) } + insert { menu item 'Last' } + set login page Shop.CustomLogin; + drop not found page; + drop home page for Administrator; +}`) + if err != nil { + t.Fatal(err) + } + want := "'Home' 'Orders' 'Admin'{'User accounts' 'Audit log'} 'Reports'{'Users'} 'Log out' 'Help'{'Documentation' 'Contact'} 'Last'" + if got := treeString(nav.Tree); got != want { + t.Errorf("menu after alter:\n got %s\nwant %s", got, want) + } + if !nav.Saved { + t.Error("the statement was not saved") + } + if nav.LoginPage != "Shop.CustomLogin" || nav.NotFound != "" || len(nav.RoleHomes) != 0 { + t.Errorf("page clauses: login %q notfound %q roles %v", nav.LoginPage, nav.NotFound, nav.RoleHomes) + } +} + +// Each refusal names the statement, the operation and the target, and nothing +// is saved — including the operations before the refused one (atomic). +func TestAlterNavigation_Refusals(t *testing.T) { + cases := []struct { + name string + src string + want []string + }{ + {"ambiguous", `alter navigation Responsive { insert after 'Home' { menu item 'X' } drop 'Users' }`, + []string{"alter navigation Responsive: drop 'Users': menu item 'Users' is ambiguous", "'Admin' > 'Users'", "'Reports' > 'Users'"}}, + {"missing", `alter navigation Responsive { drop 'Userz' }`, + []string{"menu item 'Userz' not found", "'Home', 'Admin', 'Reports', 'Help', 'Log out'"}}, + {"ordinal out of range", `alter navigation Responsive { drop 'Users'@3 }`, []string{"has no @3: only 2 items match 'Users'"}}, + {"insert into an item with an action", `alter navigation Responsive { insert into 'Home' { menu item 'X' } }`, + []string{"insert into 'Home'", "CE0548", "set ( OnClick: nothing ) on 'Home'"}}, + {"move into an item with an action", `alter navigation Responsive { move 'Help' into 'Home' }`, []string{"CE0548"}}, + {"move into own subtree", `alter navigation Responsive { move 'Admin' into 'Admin' > 'Users' }`, + []string{"cannot be moved into its own sub-items"}}, + {"move relative to itself", `alter navigation Responsive { move 'Help' after 'Help' }`, []string{"the same item"}}, + {"OnClick on a sub-menu", `alter navigation Responsive { set ( OnClick: sign out ) on 'Admin' }`, + []string{"'Admin' has sub-items", "CE0548", "OnClick: nothing"}}, + {"bare drop home page", `alter navigation Responsive { drop home page }`, []string{"CE0527", "drop home page for "}}, + {"role without a home page", `alter navigation Responsive { drop home page for User }`, []string{"role User has no home page"}}, + {"native profile", `alter navigation NativePhone { drop 'Home' }`, []string{"native profile", "create or modify navigation NativePhone home page"}}, + {"unknown profile", `alter navigation Tablet { drop 'Home' }`, []string{"no navigation profile Tablet", "Responsive, NativePhone", "create or modify navigation Tablet"}}, + {"unknown menu", `alter menu M.Nope { drop 'One' }`, []string{"menu not found", "create menu M.Nope"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ctx, nav, menu := alterNavCtx(t) + err := execAlter(t, ctx, c.src) + if err == nil { + t.Fatalf("accepted: %s", c.src) + } + for _, w := range c.want { + if !strings.Contains(err.Error(), w) { + t.Errorf("error %q lacks %q", err.Error(), w) + } + } + if nav.Saved || menu.Saved { + t.Error("a refused statement was saved") + } + }) + } +} + +// check -p --references runs the function exec runs over an unsaved copy, so +// it reports exactly what exec refuses, with exec's message, and saves +// nothing. A later alter of the same profile sees what an earlier one +// inserted; an alter of a profile the script rewrites first is not judged. +func TestAlterNavigation_CheckAgreesWithExec(t *testing.T) { + src := `alter navigation Responsive { insert after 'Home' { menu item 'New' } } +alter navigation Responsive { drop 'New' } +alter navigation Responsive { drop 'Users' } +create or modify navigation Responsive home page Shop.Home; +alter navigation Responsive { drop 'Nothing here' }` + ctx, nav, _ := alterNavCtx(t) + errs := validateAlterNavigationReferences(ctx, parseMDL(t, src)) + if len(errs) != 1 { + t.Fatalf("check reported %d problems, want 1 (statement 3): %v", len(errs), errs) + } + if nav.Saved { + t.Error("check saved the menu") + } + + ctx2, _, _ := alterNavCtx(t) + execErr := execAlter(t, ctx2, `alter navigation Responsive { drop 'Users' }`) + if execErr == nil { + t.Fatal("exec accepted the ambiguous drop") + } + if got, want := errs[0].Error(), "statement 3: "+execErr.Error(); got != want { + t.Errorf("check and exec disagree:\ncheck %s\nexec %s", got, want) + } +} + +func TestAlterMenu_ItemOperationsOnAMenuDocument(t *testing.T) { + ctx, nav, menu := alterNavCtx(t) + if err := execAlter(t, ctx, `alter menu M.Menu { + insert before 'Two' { menu item 'Between' ( OnClick: sign out ) } + drop 'One' +}`); err != nil { + t.Fatal(err) + } + if got := treeString(menu.Tree); got != "'Between' 'Two'" { + t.Errorf("menu = %s", got) + } + if !menu.Saved || nav.Saved { + t.Errorf("saved: menu %v, navigation %v", menu.Saved, nav.Saved) + } +} + +// `list navigation menu` prints the address alter takes for every item. +func TestListNavigationMenu_AddressColumn(t *testing.T) { + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{{ + Name: "Responsive", Kind: "Responsive", + MenuItems: []*types.NavMenuItem{ + {Caption: "Admin", Items: []*types.NavMenuItem{{Caption: "Users", Page: "Admin.Users"}}}, + {Caption: "Reports", Items: []*types.NavMenuItem{{Caption: "Users", Page: "Shop.Users"}}}, + {Caption: "Home", Page: "Shop.Home"}, + {Caption: "Home", Page: "Shop.Home2"}, + }, + }}}, nil + }, + } + ctx, out := newMockCtx(t, withBackend(mb)) + if err := listNavigationMenu(ctx, nil); err != nil { + t.Fatal(err) + } + for _, w := range []string{"Address", "'Admin' > 'Users'", "'Reports' > 'Users'", "'Home'@2", "Admin.Users"} { + if !strings.Contains(out.String(), w) { + t.Errorf("listing lacks %q:\n%s", w, out.String()) + } + } +} diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 171236f493..160f2c24fc 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/mdl/visitor" @@ -571,7 +572,11 @@ func listNavigation(ctx *ExecContext) error { } // listNavigationMenu handles SHOW NAVIGATION MENU [profile] command. -// Displays the menu tree for a specific profile, or all profiles if none specified. +// Lists the menu tree of a profile (or of every profile) as a table, one row +// per item in describe order, with the Address an ALTER NAVIGATION writes to +// reach it: the shortest unique caption path (`'Admin' > 'Users'`, `@n` where +// captions repeat). A native profile's bottom bar has no address: alter +// navigation does not edit it. func listNavigationMenu(ctx *ExecContext, profileName *ast.QualifiedName) error { describeDefaultLanguage(ctx) // captions are read in it, as describe reads them nav, err := ctx.Backend.GetNavigation() @@ -579,21 +584,35 @@ func listNavigationMenu(ctx *ExecContext, profileName *ast.QualifiedName) error return mdlerrors.NewBackend("get navigation", err) } - for _, p := range nav.Profiles { - if profileName != nil && !strings.EqualFold(p.Name, profileName.Name) { - continue - } - - fmt.Fprintf(ctx.Output, "-- Navigation Menu: %s (%s)\n", p.Name, p.Kind) - if len(p.MenuItems) == 0 { - fmt.Fprintln(ctx.Output, " (no menu items)") - } else { - printMenuTree(ctx.Output, p.MenuItems, 0) + result := &TableResult{Columns: []string{"Profile", "Item", "Action", "Address"}} + profiles, items := 0, 0 + if nav != nil { + for _, p := range nav.Profiles { + if profileName != nil && !strings.EqualFold(p.Name, profileName.Name) { + continue + } + profiles++ + tree := backend.MenuTreeFromNavItems(p.MenuItems) + var walk func(list []*types.NavMenuItem, prefix backend.MenuItemPath) + walk = func(list []*types.NavMenuItem, prefix backend.MenuItemPath) { + for i, it := range list { + path := append(append(backend.MenuItemPath(nil), prefix...), i) + address := "-" + if !p.IsNative { + address = backend.ShortestMenuItemAddress(tree, path).String() + } + result.Rows = append(result.Rows, []any{p.Name, + strings.Repeat(" ", len(prefix)) + it.Caption, + strings.TrimPrefix(menuItemTarget(it), " -> "), address}) + items++ + walk(it.Items, path) + } + } + walk(p.MenuItems, nil) } - fmt.Fprintln(ctx.Output) } - - return nil + result.Summary = fmt.Sprintf("(%d menu items in %d navigation profiles)", items, profiles) + return writeResult(ctx, result) } // listNavigationHomes handles SHOW NAVIGATION HOMES command. @@ -803,18 +822,6 @@ func countMenuItems(items []*types.NavMenuItem) int { return count } -// printMenuTree prints a menu tree with indentation to an io.Writer. -func printMenuTree(w io.Writer, items []*types.NavMenuItem, depth int) { - indent := strings.Repeat(" ", depth+1) - for _, item := range items { - target := menuItemTarget(item) - fmt.Fprintf(w, "%s%s%s\n", indent, item.Caption, target) - if len(item.Items) > 0 { - printMenuTree(w, item.Items, depth+1) - } - } -} - // menuItemTarget returns a display string for a menu item's action target. func menuItemTarget(item *types.NavMenuItem) string { if item.Page != "" { diff --git a/mdl/executor/register_stubs.go b/mdl/executor/register_stubs.go index 5b9474b114..f883442c37 100644 --- a/mdl/executor/register_stubs.go +++ b/mdl/executor/register_stubs.go @@ -220,6 +220,12 @@ func registerNavigationHandlers(r *Registry) { r.Register(&ast.CreateMenuStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { return execCreateMenu(ctx, stmt.(*ast.CreateMenuStmt)) }) + r.Register(&ast.AlterNavigationStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { + return execAlterNavigation(ctx, stmt.(*ast.AlterNavigationStmt)) + }) + r.Register(&ast.AlterMenuStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { + return execAlterMenu(ctx, stmt.(*ast.AlterMenuStmt)) + }) r.Register(&ast.DropMenuStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { return execDropMenu(ctx, stmt.(*ast.DropMenuStmt)) }) diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index c4c6d42ea1..a2dfca43cd 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -170,6 +170,8 @@ func allKnownStatements() []ast.Statement { &ast.AlterModelStmt{}, &ast.AlterModuleJarDepStmt{}, &ast.CreateNavigationStmt{}, + &ast.AlterNavigationStmt{}, + &ast.AlterMenuStmt{}, &ast.CreateMenuStmt{}, &ast.CreateTranslationsStmt{}, &ast.DescribeTranslationsStmt{}, diff --git a/mdl/executor/stmt_summary.go b/mdl/executor/stmt_summary.go index 85a656ea07..e58247c47a 100644 --- a/mdl/executor/stmt_summary.go +++ b/mdl/executor/stmt_summary.go @@ -159,6 +159,10 @@ func stmtSummary(stmt ast.Statement) string { // Navigation case *ast.CreateNavigationStmt: return fmt.Sprintf("create navigation %s", s.ProfileName) + case *ast.AlterNavigationStmt: + return fmt.Sprintf("alter navigation %s", s.ProfileName) + case *ast.AlterMenuStmt: + return fmt.Sprintf("alter menu %s", s.Name.String()) // Query case *ast.ShowStmt: diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 86a541ea03..5775f890ba 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -496,6 +496,9 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // inserted widgets carry: only the document was resolved, so a typo in // either passed check and stopped exec (mendixlabs/mxcli#1472). errors = append(errors, validateAlterPageReferences(ctx, prog, sc)...) + // The same for ALTER NAVIGATION / ALTER MENU: the function exec runs, on a + // copy of the stored menu that is never saved. + errors = append(errors, validateAlterNavigationReferences(ctx, prog)...) // `Module.Enum.Key` in any expression: the key must be one of the // enumeration's values (mendixlabs/mxcli#1472). errors = append(errors, validateEnumValueRefs(ctx, prog, sc)...) diff --git a/mdl/executor/validate_alter_navigation.go b/mdl/executor/validate_alter_navigation.go new file mode 100644 index 0000000000..f747dfd094 --- /dev/null +++ b/mdl/executor/validate_alter_navigation.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// Only what an ALTER NAVIGATION / ALTER MENU says is validated: the items its +// fragments add, the OnClick and Icon a `set ( … )` writes, and the page +// clauses it sets. Untouched stored items are not re-validated — that is what +// made one caption change in a Studio Pro menu refuse over a glyph on an item +// it never named (ako/TestApp, MDL078). +// +// The checks are the ones `create navigation` / `create menu` run, reached +// through a view: a CreateNavigationStmt / CreateMenuStmt holding just what the +// alter states. Every reference pass that switches on the create statements +// asks menuAlterView first, so an alter is checked by the same code. + +// menuAlterFragmentItems are the items the operations add as new content. +func menuAlterFragmentItems(ops []ast.MenuAlterOp) []ast.NavMenuItemDef { + var out []ast.NavMenuItemDef + for _, op := range ops { + switch o := op.(type) { + case *ast.InsertMenuItemsOp: + out = append(out, o.Items...) + case *ast.ReplaceMenuItemOp: + out = append(out, o.Items...) + } + } + return out +} + +// menuAlterSetItems is one item per `set ( … )` that writes an OnClick or an +// Icon, carrying just those, so their targets and icons are checked. +func menuAlterSetItems(ops []ast.MenuAlterOp) []ast.NavMenuItemDef { + var out []ast.NavMenuItemDef + for _, op := range ops { + if o, ok := op.(*ast.SetMenuItemOp); ok && (o.SetAction || o.SetIcon) { + it := o.Item + if n := len(o.Target.Segments); n > 0 { + it.Caption = o.Target.Segments[n-1].Caption + } + if !o.SetAction { + it.Action, it.Page, it.Microflow, it.SignOut = nil, nil, nil, false + } + out = append(out, it) + } + } + return out +} + +// menuAlterView is the create statement that states what an ALTER NAVIGATION +// or ALTER MENU states, for the reference checks; nil for any other statement. +func menuAlterView(stmt ast.Statement) ast.Statement { + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + v := &ast.CreateNavigationStmt{ + ProfileName: s.ProfileName, + MenuItems: append(menuAlterFragmentItems(s.Operations), menuAlterSetItems(s.Operations)...), + CreateOrModify: true, + } + for _, op := range s.Operations { + if o, ok := op.(*ast.SetNavPageClauseOp); ok { + switch o.Clause { + case "home": + v.HomePages = append(v.HomePages, o.Home) + case "login": + p := o.Page + v.LoginPage = &p + case "notfound": + p := o.Page + v.NotFoundPage = &p + } + } + } + return v + case *ast.AlterMenuStmt: + return &ast.CreateMenuStmt{ + Name: s.Name, + Items: append(menuAlterFragmentItems(s.Operations), menuAlterSetItems(s.Operations)...), + CreateOrModify: true, + } + } + return nil +} + +// validateMenuAlterForExec runs, before anything is written, the reference +// checks exec runs for `create navigation`: the user roles of `set home … for` +// (a module-qualified role makes the project unloadable, mendixlabs/mxcli#1001) +// and the pages and flows the fragments and page clauses name (CE1613). +func validateMenuAlterForExec(ctx *ExecContext, stmt ast.Statement) error { + view := menuAlterView(stmt) + if nv, ok := view.(*ast.CreateNavigationStmt); ok { + if err := validateNavigationRoleForExec(ctx, nv); err != nil { + return err + } + } + return validateNavigationTargetsForExec(ctx, view) +} + +// validateAlterNavigationReferences is `check -p --references` for ALTER +// NAVIGATION / ALTER MENU: every statement is run through applyMenuAlterOps — +// the function exec runs — on a mutator over the stored document that is never +// saved, so every target exec would refuse is reported with exec's message. +// Successive alters of one document share the mutator, so a later statement +// sees what an earlier one inserted. +// +// "Could not establish" stays silent: a document the script creates or +// rewrites before the alter (its stored state is not what the alter will +// meet), one that will not open, and anything after a refusal on the same +// document. +func validateAlterNavigationReferences(ctx *ExecContext, prog *ast.Program) []error { + if prog == nil || !ctx.Connected() { + return nil + } + opened := map[string]backend.MenuMutator{} + untrusted := map[string]bool{} + var errs []error + for i, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreateNavigationStmt: + untrusted["navigation "+strings.ToLower(s.ProfileName)] = true + case *ast.CreateMenuStmt: + untrusted["menu "+s.Name.String()] = true + case *ast.DropMenuStmt: + untrusted["menu "+s.Name.String()] = true + case *ast.AlterNavigationStmt: + key := "navigation " + strings.ToLower(s.ProfileName) + if untrusted[key] { + continue + } + m, ok := opened[key] + if !ok { + pm, err := openAlterNavigation(ctx, s.ProfileName) + if err != nil { + untrusted[key] = true + if !isBackendError(err) { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + } + continue + } + m, opened[key] = pm, pm + } + if err := applyMenuAlterOps(ctx, m, alterNavigationLabel(s.ProfileName), s.Operations); err != nil { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + untrusted[key] = true + } + case *ast.AlterMenuStmt: + key := "menu " + s.Name.String() + if untrusted[key] { + continue + } + m, ok := opened[key] + if !ok { + mm, err := openAlterMenu(ctx, s.Name) + if err != nil { + untrusted[key] = true + if !isBackendError(err) { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + } + continue + } + m, opened[key] = mm, mm + } + if err := applyMenuAlterOps(ctx, m, "alter menu "+s.Name.String(), s.Operations); err != nil { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + untrusted[key] = true + } + } + } + return errs +} + +// isBackendError reports a failure to read or open, which check cannot judge. +func isBackendError(err error) bool { + _, ok := err.(*mdlerrors.BackendError) + return ok +} diff --git a/mdl/executor/validate_definition_order.go b/mdl/executor/validate_definition_order.go index d03fd80eec..07443acc51 100644 --- a/mdl/executor/validate_definition_order.go +++ b/mdl/executor/validate_definition_order.go @@ -287,7 +287,7 @@ func eagerDefRefs(stmt ast.Statement) []defRef { case *ast.GrantPageAccessStmt: return oneDefRef(s.Page, defPage, "grant view on page "+s.Page.String()) - case *ast.CreateNavigationStmt, *ast.CreateMenuStmt: + case *ast.CreateNavigationStmt, *ast.CreateMenuStmt, *ast.AlterNavigationStmt, *ast.AlterMenuStmt: // The home, login and not-found pages and every menu item's target, // which the handler resolves before it writes the profile or menu. return navigationDefRefs(stmt) diff --git a/mdl/executor/validate_flow_access.go b/mdl/executor/validate_flow_access.go index f7d5b52116..4c63ac1bdf 100644 --- a/mdl/executor/validate_flow_access.go +++ b/mdl/executor/validate_flow_access.go @@ -176,7 +176,8 @@ func scriptTouchesFlowAccess(prog *ast.Program) bool { case *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt, *ast.DropMicroflowStmt, *ast.DropNanoflowStmt, *ast.RevokeMicroflowAccessStmt, *ast.RevokeNanoflowAccessStmt, *ast.CreatePageStmtV3, *ast.CreateSnippetStmtV3, *ast.AlterPageStmt, - *ast.CreateMenuStmt, *ast.CreateNavigationStmt, *ast.DropModuleRoleStmt: + *ast.CreateMenuStmt, *ast.CreateNavigationStmt, *ast.DropModuleRoleStmt, + *ast.AlterNavigationStmt, *ast.AlterMenuStmt: return true } } @@ -600,6 +601,25 @@ func (s *flowAccessSim) apply(stmt ast.Statement) { } } s.usesBy["navigation "+st.ProfileName] = refs + case *ast.AlterNavigationStmt, *ast.AlterMenuStmt: + // An alter adds to what the document uses; the items it does not name + // keep their flows. + var key string + var refs []flowRef + switch v := menuAlterView(st).(type) { + case *ast.CreateNavigationStmt: + key = "navigation " + v.ProfileName + refs = menuItemFlowRefs(v.MenuItems) + for _, hp := range v.HomePages { + if !hp.IsPage && hp.Target.Module != "" { + refs = append(refs, flowRef{"microflow", hp.Target.String()}) + } + } + case *ast.CreateMenuStmt: + key = "menu " + v.Name.String() + refs = menuItemFlowRefs(v.Items) + } + s.usesBy[key] = append(s.usesBy[key], refs...) } } diff --git a/mdl/executor/validate_icon_refs.go b/mdl/executor/validate_icon_refs.go index 7041621082..75b2b94282 100644 --- a/mdl/executor/validate_icon_refs.go +++ b/mdl/executor/validate_icon_refs.go @@ -160,6 +160,9 @@ type iconRef struct { // exists to prevent (mendixlabs/mxcli#1008). func iconRefsInStatement(stmt ast.Statement) []iconRef { var out []iconRef + if v := menuAlterView(stmt); v != nil { + stmt = v // the icons an ALTER NAVIGATION / ALTER MENU writes + } switch s := stmt.(type) { case *ast.CreatePageStmtV3: for _, w := range s.Widgets { diff --git a/mdl/executor/validate_navigation_icons.go b/mdl/executor/validate_navigation_icons.go index b2158c5bfd..8122e9e44c 100644 --- a/mdl/executor/validate_navigation_icons.go +++ b/mdl/executor/validate_navigation_icons.go @@ -54,6 +54,12 @@ func validateMenuItemIcons(stmt ast.Statement) []linter.Violation { items, where = s.MenuItems, "navigation "+s.ProfileName case *ast.CreateMenuStmt: items, where = s.Items, "menu "+s.Name.String() + case *ast.AlterNavigationStmt: + // Only the items the statement adds: a stored item it does not name is + // not re-judged, and a `set` names an item that already exists. + items, where = menuAlterFragmentItems(s.Operations), "navigation "+s.ProfileName + case *ast.AlterMenuStmt: + items, where = menuAlterFragmentItems(s.Operations), "menu "+s.Name.String() default: return nil } diff --git a/mdl/executor/validate_navigation_roles.go b/mdl/executor/validate_navigation_roles.go index a6b9412359..661f9e0b4b 100644 --- a/mdl/executor/validate_navigation_roles.go +++ b/mdl/executor/validate_navigation_roles.go @@ -52,6 +52,9 @@ type navRoleRef struct { // navigationRoleRefs collects the role references in one statement. func navigationRoleRefs(stmt ast.Statement) []navRoleRef { + if v := menuAlterView(stmt); v != nil { + stmt = v // `set home … for ` in an ALTER NAVIGATION + } s, ok := stmt.(*ast.CreateNavigationStmt) if !ok { return nil diff --git a/mdl/executor/validate_navigation_targets.go b/mdl/executor/validate_navigation_targets.go index 169fa9e9d6..e043451156 100644 --- a/mdl/executor/validate_navigation_targets.go +++ b/mdl/executor/validate_navigation_targets.go @@ -79,6 +79,9 @@ type navTarget struct { // exactly one clause: a native profile's `home microflow` is stored as the // nanoflow it is (see checkProfileClauses), so the name is looked up as one. func navigationTargets(stmt ast.Statement, native func(profile string) bool) (label string, out []navTarget, ok bool) { + if v := menuAlterView(stmt); v != nil { + stmt = v // an ALTER NAVIGATION / ALTER MENU: what it states + } switch s := stmt.(type) { case *ast.CreateNavigationStmt: label = "navigation " + s.ProfileName diff --git a/mdl/executor/validate_protected_module_refs.go b/mdl/executor/validate_protected_module_refs.go index ad6065ce5e..c34279d2fd 100644 --- a/mdl/executor/validate_protected_module_refs.go +++ b/mdl/executor/validate_protected_module_refs.go @@ -69,7 +69,8 @@ func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { func hasProtectedModuleCandidates(prog *ast.Program) bool { for _, stmt := range prog.Statements { switch stmt.(type) { - case *ast.CreateNavigationStmt, *ast.CreateMenuStmt, *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt: + case *ast.CreateNavigationStmt, *ast.CreateMenuStmt, *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt, + *ast.AlterNavigationStmt, *ast.AlterMenuStmt: return true } } @@ -148,6 +149,9 @@ func (r *protectedRefResolver) recordDefinition(stmt ast.Statement) { } func (r *protectedRefResolver) check(stmt ast.Statement) []string { + if v := menuAlterView(stmt); v != nil { + stmt = v // what an ALTER NAVIGATION / ALTER MENU states + } switch s := stmt.(type) { case *ast.CreateNavigationStmt: return r.checkNavigation(s) From af7ac9c88434d999605abb78aae79106a85d615a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:06:20 +0000 Subject: [PATCH 24/28] docs(skills): keep write-lint-rules within the 700-line body bound mendixlabs/mxcli#1238's four user_role rows took the body to 701 lines and TestLargeSkillsWereSplit failed. manage_all_roles and manageable_roles are one grant (every role, or the listed ones), so they share a row; nothing is dropped. Index regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .claude/skills/mendix/write-lint-rules/SKILL.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index d85ff0913d..dfa5cd19cf 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -12,10 +12,10 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b |---|---| | 25-67 | Rule File Structure | | 68-161 | Available Query Functions | -| 162-606 | Object Properties | -| 607-620 | Helper Functions | -| 621-707 | Common Patterns | -| 708-717 | Validation | +| 162-605 | Object Properties | +| 606-619 | Helper Functions | +| 620-706 | Common Patterns | +| 707-716 | Validation | # Writing Custom Starlark Lint Rules @@ -540,9 +540,8 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | `is_anonymous` | bool | True if this is the anonymous/guest role | | `module_roles` | list of string | `["Sales.Admin", "HR.Viewer"]` | | `check_security` | bool | Studio Pro's per-role "Check security" flag | -| `manage_all_roles` | bool | The role may hand out every user role | +| `manage_all_roles`, `manageable_roles` | bool, list of string | The role may hand out every user role; otherwise `manageable_roles` lists those it may | | `manage_users_without_roles` | bool | The role may manage users that have no role | -| `manageable_roles` | list of string | The user roles this role may hand out, when not all | ### module_role | Property | Type | Example | From 754de59c377b2b02d641315dfe13b90d62185ea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:15:07 +0000 Subject: [PATCH 25/28] docs(navigation): document ALTER NAVIGATION and ALTER MENU - `mxcli syntax navigation.alter` documented the describe/edit/re-run round trip; it now documents the statement. New topic navigation.menu-document.alter (alias menu.alter) for ALTER MENU; navigation.create and navigation.menu-document point at them instead of saying there is no way to clear a page clause or edit one item. - docs-site: the page titled ALTER NAVIGATION documented CREATE OR REPLACE NAVIGATION. It moves to create-navigation.md (CREATE [OR MODIFY] NAVIGATION, REPLACE named as the deprecated alias) and alter-navigation.md documents ALTER NAVIGATION: operations, addressing, page clauses, refusals. menu.md gains ALTER MENU; list-navigation.md the Address column; the language guide an ALTER NAVIGATION section. menu.md's note that glyph and image icons cannot be written was stale (all three kinds round-trip) and is corrected. - MDL_QUICK_REFERENCE: rows for alter navigation / alter menu; the navigation rows say create or modify. - manage-navigation skill: "Change One Item: ALTER NAVIGATION" is the default for editing an existing menu, "Adding a New Page to Navigation" uses it, and the detail (every operation, address rules, refusals, the two-level menu bar limit, CE0549) is in reference/alter-navigation.md; SKILL.md stays at 618 lines. Skill index regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .../skills/mendix/manage-navigation/SKILL.md | 112 +++--- .../reference/alter-navigation.md | 104 ++++++ cmd/mxcli/syntax/features_misc.go | 80 +++- cmd/mxcli/syntax/features_page.go | 34 +- cmd/mxcli/syntax/registry.go | 2 + docs-site/src/SUMMARY.md | 1 + docs-site/src/language/navigation-profiles.md | 29 +- docs-site/src/reference/navigation/README.md | 5 +- .../reference/navigation/alter-navigation.md | 348 +++++++++--------- .../reference/navigation/create-navigation.md | 203 ++++++++++ .../reference/navigation/list-navigation.md | 4 +- docs-site/src/reference/navigation/menu.md | 50 ++- docs/01-project/MDL_QUICK_REFERENCE.md | 9 +- 13 files changed, 728 insertions(+), 253 deletions(-) create mode 100644 .claude/skills/mendix/manage-navigation/reference/alter-navigation.md create mode 100644 docs-site/src/reference/navigation/create-navigation.md diff --git a/.claude/skills/mendix/manage-navigation/SKILL.md b/.claude/skills/mendix/manage-navigation/SKILL.md index 0a20907e61..afe158f955 100644 --- a/.claude/skills/mendix/manage-navigation/SKILL.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -10,18 +10,19 @@ description: "Inspect and change navigation profiles in MDL — home pages, menu | lines | section | |---|---| -| 31-40 | When to Use This Skill | -| 41-59 | Navigation Concepts | -| 60-77 | Show Commands (Read-Only) | -| 78-359 | CREATE OR MODIFY NAVIGATION | -| 360-381 | Round-Trip Workflow | -| 382-403 | Catalog Queries | -| 404-453 | Common Patterns | -| 454-505 | Menu Documents (standalone, reusable) | -| 506-576 | Offline Profiles | -| 577-591 | Checklist | -| 592-595 | Offline synchronization (`CREATE NAVIGATION … SYNC (…)`) | -| 596-598 | Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) | +| 32-41 | When to Use This Skill | +| 42-60 | Navigation Concepts | +| 61-78 | Show Commands (Read-Only) | +| 79-360 | CREATE OR MODIFY NAVIGATION | +| 361-399 | Change One Item: ALTER NAVIGATION | +| 400-410 | Round-Trip Workflow | +| 411-432 | Catalog Queries | +| 433-471 | Common Patterns | +| 472-524 | Menu Documents (standalone, reusable) | +| 525-595 | Offline Profiles | +| 596-611 | Checklist | +| 612-615 | Offline synchronization (`CREATE NAVIGATION … SYNC (…)`) | +| 616-618 | Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) | # Navigation Management Skill @@ -77,7 +78,7 @@ list navigation homes; ## CREATE OR MODIFY NAVIGATION -All clauses are optional, and **an omitted clause keeps the stored value** — a statement with only a `{ … }` menu keeps the home page, role home pages, login page and not-found page (mendixlabs/mxcli#1522; before that fix they were cleared, and the missing home page failed the build with CE0527). A stated clause replaces what it names: `{ … }` the whole menu, `sync ( … )` the whole list, `home page X for R` role R's home page only. There is no clause that clears a home, login or not-found page. The output from `describe navigation` can be pasted back directly. +All clauses are optional, and **an omitted clause keeps the stored value** — a statement with only a `{ … }` menu keeps the home page, role home pages, login page and not-found page (mendixlabs/mxcli#1522; before that fix they were cleared, and the missing home page failed the build with CE0527). A stated clause replaces what it names: `{ … }` the whole menu, `sync ( … )` the whole list, `home page X for R` role R's home page only. To change **one** item, or to clear a role's home page, the login page or the not-found page, use [`alter navigation`](#change-one-item-alter-navigation) instead — it edits the stored profile and touches nothing else. The output from `describe navigation` can be pasted back directly. ### Basic: Set Home and Login Page @@ -357,26 +358,54 @@ home pages and the `sync ( … )` block only — a `{ }` menu block (the bottom `login page`, `not found page` and `on sync error` are refused, and describe lists the bottom bar as comments. -## Round-Trip Workflow +## Change One Item: ALTER NAVIGATION -The DESCRIBE output is directly executable. Use this pattern to inspect, modify, and re-apply: +**Default to `alter navigation` for any edit to an existing menu.** It splices the +operations into the stored profile: items it does not name keep their stored +bytes, an item someone added in Studio Pro meanwhile survives, and a stored item +it does not name is not re-validated (a `create or modify` rewrite re-checks — and +can be refused over — every item it restates). Adding one item no longer means +restating the menu (mendixlabs/mxcli#1522). ```sql mdl 1; --- Step 1: Inspect current state -describe navigation Responsive; +list navigation menu Responsive; -- the Address column is what alter takes + +alter navigation Responsive { + insert after 'Home' { menu item 'Customers' ( OnClick: show page MyModule.Customer_Overview, Icon: Atlas_Core.Atlas.user ) } + insert into 'Admin' { menu item 'Audit log' ( OnClick: show page MyModule.AuditLog ) } + set ( Caption: 'User accounts' ) on 'Admin' > 'Users'; + move 'Log out' after 'Help'; + drop 'Old import'; + drop not found page; + drop home page for Administrator; +}; +``` --- Step 2: Copy the output, modify as needed, paste back -create or modify navigation Responsive - home page MyModule.Home_Web - login page Administration.Login - { - menu item 'Home' ( OnClick: show page MyModule.Home_Web ) - menu item 'New Feature' ( OnClick: show page MyModule.NewFeature ) - }; +- **Address** = the caption `describe` prints, as a path outer → inner: + `'Users'` (anywhere), `'Admin' > 'Users'` (direct child), `'Users'@2` (2nd of + several). An ambiguous or missing address is refused and the error lists the + address to write — copy it; never guess an `@n`. +- Fragments in `{ }` are written exactly as in `create navigation`. +- `insert into` / `move … into` needs a target with no action, and only + `OnClick: nothing` is allowed on an item with sub-items (CE0548). +- No bare `drop home page` (CE0527): `set home page M.P` changes it. +- A refusal anywhere writes nothing. `mxcli check -p … --references` runs the + same resolution against the stored menu. +- Native profiles are refused (their bottom bar is not written by mxcli). + +Every operation, the address rules and the refusals: [reference/alter-navigation.md](reference/alter-navigation.md), +or `mxcli syntax navigation.alter`. + +## Round-Trip Workflow + +Restating the whole profile is for a menu you are designing from scratch. The +DESCRIBE output is directly executable: --- Step 3: Verify +```sql +mdl 1; describe navigation Responsive; +-- copy, edit, and run the output as `create or modify navigation …` ``` ## Catalog Queries @@ -431,24 +460,13 @@ create or modify navigation Responsive ### Adding a New Page to Navigation -After creating a new page, add it to the menu: +After creating a new page, add it to the menu — one statement, nothing else restated: ```sql mdl 1; --- First inspect current menu -describe navigation Responsive; - --- Then re-apply with the new item added (copy existing + add new) -create or modify navigation Responsive - home page MyModule.Home_Web - login page Administration.Login - { - menu item 'Home' ( OnClick: show page MyModule.Home_Web ) - menu item 'Customers' ( OnClick: show page MyModule.Customer_Overview ) -- new - menu 'Admin' { - menu item 'Users' ( OnClick: show page Administration.Account_Overview ) - } - }; +alter navigation Responsive { + insert after 'Home' { menu item 'Customers' ( OnClick: show page MyModule.Customer_Overview ) } +}; ``` ## Menu Documents (standalone, reusable) @@ -490,7 +508,9 @@ keep the edit to the items you mean to change, then `describe` it again after **`or modify` replaces the whole item list.** An omitted item is a removed item, exactly as with `create or modify navigation`. The document's identity and -export level are preserved, so menu widgets pointing at it keep working. +export level are preserved, so menu widgets pointing at it keep working. To +change one item, use `alter menu Module.Menu { insert … / move … / drop … / set ( … ) on … };` +— the item operations of `alter navigation`, no page clauses. ### Gotchas @@ -498,10 +518,9 @@ export level are preserved, so menu widgets pointing at it keep working. nowhere to supply the argument, and Mendix reports **CE1571** ("No argument has been selected for parameter …") against `Menu item`. Point the item at a parameterless page, or call a microflow that opens the page. -- **Only icon-collection icons round-trip.** A glyph icon (numeric code) or an - image icon cannot be written by MDL; `describe` flags those on their own - comment line rather than dropping them silently, so re-running the output - loses that icon visibly. +- **All three icon kinds round-trip** (`Icon: M.Coll.name`, `glyph n`, + `image M.Images.name`). Only an icon `describe` cannot rebuild (a glyph with no + code, an unknown icon type) is flagged on its own comment line. ## Offline Profiles @@ -585,6 +604,7 @@ stored. MDL does not author per-entity sync modes — set those in Studio Pro. - [ ] A menu item's action is `( OnClick: … )` with show page, call microflow, call nanoflow, open link, create object, sign out or nothing — not save / delete / close page - [ ] `Icon:` is a qualified name (not a string); hyphenated segments are double-quoted - [ ] The icon exists — check with `describe icon collection Module.Name`, do not guess +- [ ] Editing an existing menu? Use `alter navigation` / `alter menu`, addressing items by the Address `list navigation menu` prints - [ ] Use `describe navigation` to verify changes after applying - [ ] For a **menu document**, confirm you want `create menu` and not a profile menu — `list navigation menu` vs `describe menu` tells them apart - [ ] No menu item targets a page with required parameters (CE1571) diff --git a/.claude/skills/mendix/manage-navigation/reference/alter-navigation.md b/.claude/skills/mendix/manage-navigation/reference/alter-navigation.md new file mode 100644 index 0000000000..3758ce587b --- /dev/null +++ b/.claude/skills/mendix/manage-navigation/reference/alter-navigation.md @@ -0,0 +1,104 @@ +# ALTER NAVIGATION / ALTER MENU — reference + +Item-level edits to a web navigation profile (`alter navigation `) or a +menu document (`alter menu Module.Menu`). The operations apply in order to the +stored document and it is written once; a refusal anywhere writes nothing. + +## Operations + +```sql +mdl 1; +alter navigation Responsive { + insert before 'Help' { menu item 'FAQ' ( OnClick: open link 'https://example.com/faq' ) } + insert after 'Home' { menu item 'Orders' ( OnClick: show page Shop.Order_Overview ) } + insert into 'Admin' { menu item 'Audit log' ( OnClick: show page Shop.AuditLog ) } + insert { menu item 'Last' ( OnClick: sign out ) } -- append to the top level + replace 'Help' with { + menu 'Help' { menu item 'Docs' ( OnClick: open link 'https://example.com/docs' ) } + } + move 'Log out' after 'Help'; -- before | after | into + drop 'Old import', 'Admin' > 'Legacy'; + set ( Caption: 'User accounts', OnClick: show page Admin.Users, Icon: glyph 57377 ) on 'Admin' > 'Users'; + set ( Icon: none ) on 'Home'; + set ( OnClick: nothing ) on 'Admin'; -- the only action a sub-menu may have + + -- page clauses (alter navigation only) + set home page Shop.Home_Web; set home microflow Shop.ACT_Home; + set home page Shop.AdminHome for Administrator; + drop home page for Administrator; + set login page Shop.Login; drop login page; + set not found page Shop.NotFound; drop not found page; +}; +``` + +- A fragment `{ … }` is written exactly as the `{ }` block of `create navigation` + (and `create menu`), so a line `describe` printed pastes in. +- `set ( … )` changes only the keys written. `Caption:` writes the project's + default language and keeps the other translations (as `alter page` does). +- `move` carries the stored item itself — sub-items, every translation, an + action MDL cannot spell (a page title override). It is not drop + insert. +- `replace` gives the new items fresh `$ID`s, but carries the other-language + translations of a caption it keeps. + +## Addressing + +An item has no name, so it is addressed by the caption `describe` prints (the +project's default language), as a path written outer → inner: + +| Address | Means | +|---|---| +| `'Users'` | the item captioned Users, anywhere | +| `'Admin' > 'Users'` | Users **directly** under Admin | +| `'Users'@2` | the 2nd Users in describe order | +| `'Admin'@2 > 'Users'` | Users under the 2nd Admin | +| `''@2` | the 2nd icon-only (empty caption) item | + +- The first segment matches at any depth; each `>` is one direct step. +- `@n` belongs to its own segment. +- Exact, case-sensitive comparison. +- No positional addresses (`item@3`, `1.2`) — they do not exist. +- `list navigation menu [Profile]` prints every item's shortest Address. + +A miss lists the captions at the level searched (and case-only matches); an +ambiguity lists the address of every candidate: + +``` +alter navigation Responsive: drop 'Users': menu item 'Users' is ambiguous: it matches 2 items — write one of these: + 'Admin' > 'Users' + 'Reports' > 'Users' +``` + +Copy one of the listed addresses. Do not guess an `@n`. + +## Refusals + +| Refused | Why / what to write | +|---|---| +| missing or ambiguous address, `@n` beyond the matches | write a listed address | +| `insert into` / `move … into` an item with an action | CE0548 — `set ( OnClick: nothing ) on ` first | +| `OnClick:` other than `nothing` on an item with sub-items | CE0548 | +| `move` into its own sub-items, or relative to itself | | +| bare `drop home page` | CE0527 — a web profile needs one; `set home page` changes it | +| `drop home page for R` when R has none | the error lists the roles that have one | +| a native profile | its bottom bar is not written by mxcli; home pages via `create or modify navigation

home …` | +| a profile the project lacks | `create or modify navigation

home page …` adds it | +| an OnClick a menu item cannot carry (save, delete, close page, complete task) | same as `create navigation` | + +## Validation + +Only what the statement states is validated: fragment items and `set` values go +through the `create navigation` checks (references CE1571/CE1613, icons +MDL077/MDL078, user roles). A stored item the statement does not name is not +re-judged — so a menu with a bad glyph on some other item can still be edited. + +`mxcli check script.mdl -p app.mpr --references` resolves every address with +the code `exec` runs, against the stored menu (successive alters of the same +document see each other's changes), and reports the same refusals. + +## Depth + +The menu bar renders two levels: a navigation menu three levels deep builds a +CE0549 error ("The selected menu has 3 levels and only 2 are allowed") in a +layout that uses a menu bar. `alter` does not refuse it — the limit belongs to +the widget, not the document — so keep sub-menus one level deep unless the +layout uses a navigation tree. diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 753e575a41..d2a00fe4ac 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -379,7 +379,9 @@ DISCONNECT;`, -- role home pages, the login page and the not-found page (mendixlabs/mxcli#1522). -- A stated clause replaces what it names: { } the whole menu, SYNC ( ) the -- whole list, HOME ... FOR R the home page of role R only (other roles keep --- theirs). There is no clause that clears a home, login or not-found page. +-- theirs). To change ONE menu item, or to clear a role's home page, the login +-- page or the not-found page, use ALTER NAVIGATION (mxcli syntax +-- navigation.alter): it edits the stored profile and touches nothing else. -- -- The menu items are the profile's CHILDREN, in { } after its clauses, like a -- page's widgets: no ; between them, since a child ends in ) or }. An item's @@ -491,14 +493,78 @@ CREATE OR MODIFY NAVIGATION TabletOffline Register(SyntaxFeature{ Path: "navigation.alter", - Summary: "Modify navigation via round-trip: DESCRIBE, edit, CREATE OR MODIFY", + Summary: "Edit one navigation profile's menu items and page clauses in place (ALTER NAVIGATION)", Keywords: []string{ - "alter navigation", "modify navigation", "update navigation", - "round-trip", "edit menu", + "alter navigation", "modify navigation", "update navigation", "edit menu", + "add menu item", "insert menu item", "remove menu item", "drop menu item", + "move menu item", "rename menu item", "menu item caption", "drop login page", + "drop not found page", "clear login page", "drop home page", "role home page", }, - Syntax: "-- Round-trip workflow:\n-- 1. DESCRIBE NAVIGATION ;\n-- 2. Copy output, modify\n-- 3. Paste as CREATE OR MODIFY NAVIGATION ...", - Example: "-- Inspect current state\nDESCRIBE NAVIGATION Responsive;\n-- Copy output, modify, paste back as CREATE OR MODIFY", - SeeAlso: []string{"navigation.create", "navigation.show"}, + Syntax: `ALTER NAVIGATION { + INSERT BEFORE | AFTER | INTO {

} + INSERT { } -- append to the top level + REPLACE WITH { } + MOVE BEFORE | AFTER | INTO + DROP [, ...] + SET ( Caption: '...', OnClick: | NOTHING, + Icon: Module.Collection.name | GLYPH | IMAGE Module.Images.name | NONE ) ON + + SET HOME PAGE | MICROFLOW Module.Name [FOR ] + DROP HOME PAGE FOR + SET LOGIN PAGE Module.Page DROP LOGIN PAGE + SET NOT FOUND PAGE Module.Page DROP NOT FOUND PAGE +}; + + is the caption DESCRIBE prints, as a path written outer to inner: + 'Users' the item captioned Users, anywhere in the menu + 'Admin' > 'Users' Users directly under Admin (one level per >) + 'Admin' > 'Users'@2 the 2nd Users under Admin; @n picks among same captions + ''@2 the 2nd item with an empty caption (icon-only) +LIST NAVIGATION MENU prints every item's shortest address. + +-- The operations apply in order to the stored profile, each target found in +-- the menu as the operations before it left it, and are written once: a +-- refusal anywhere writes nothing. Items the statement does not name keep +-- their stored bytes ($ID, translations, actions MDL cannot spell); MOVE +-- carries the item itself, sub-items included. Fragment items are written +-- exactly as in CREATE NAVIGATION, so DESCRIBE output pastes in. +-- +-- mxcli never guesses: an address that matches nothing or several items is +-- refused, listing the address of each candidate. Positional addresses do +-- not exist. +-- +-- Refused: INSERT/MOVE INTO an item that has an action, and an OnClick other +-- than NOTHING on an item with sub-items (CE0548) -- SET ( OnClick: NOTHING ) +-- first; DROP HOME PAGE without FOR (a web profile needs one, CE0527) -- SET +-- HOME PAGE changes it; a native profile (its bottom bar is not written by +-- mxcli); a profile the project lacks (CREATE OR MODIFY NAVIGATION adds one). +-- +-- Caption: writes the project's default language and keeps the others, as +-- ALTER PAGE does; an address matches the caption DESCRIBE prints, which is +-- the default-language one. +-- +-- Only what the statement says is checked (references, icons, roles): a +-- stored item it does not name is not re-validated. CHECK -p --references +-- runs the same resolution as exec against the stored menu.`, + Example: `mdl 1; +ALTER NAVIGATION Responsive { + INSERT AFTER 'Home' { + MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview, Icon: Atlas_Core.Atlas."list-bullets" ) + } + INSERT INTO 'Admin' { MENU ITEM 'Audit log' ( OnClick: SHOW PAGE Shop.AuditLog_Overview ) } + SET ( Caption: 'User accounts' ) ON 'Admin' > 'Users'; + REPLACE 'Help' WITH { + MENU 'Help' { + MENU ITEM 'Documentation' ( OnClick: OPEN LINK 'https://example.com/docs' ) + } + } + MOVE 'Log out' AFTER 'Help'; + DROP 'Old import'; + SET LOGIN PAGE Shop.CustomLogin; + DROP NOT FOUND PAGE; + DROP HOME PAGE FOR Administrator; +};`, + SeeAlso: []string{"navigation.create", "navigation.show", "navigation.menu-document.alter"}, }) // ── Settings ──────────────────────────────────────────────────────── diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index a089834adf..d746a7fcfe 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -723,6 +723,7 @@ LIST IMPACT OF htmlelement; " MENU '' [( Icon: Module.Collection.name )] { }\n" + "};\n" + "DESCRIBE MENU Module.Name;\n" + + "ALTER MENU Module.Name { }; -- one item at a time: mxcli syntax navigation.menu-document.alter\n" + "DROP MENU Module.Name;", Example: "CREATE OR MODIFY MENU MyModule.Main_Menu {\n" + " menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home )\n" + @@ -737,7 +738,8 @@ LIST IMPACT OF htmlelement; "-- NOT the menu inside a navigation profile — for that use\n" + "-- LIST 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" + + "-- The document's identity and export level are preserved. To add, move,\n" + + "-- rename or drop one item, use ALTER MENU, which keeps the rest as stored.\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" + @@ -748,7 +750,35 @@ LIST IMPACT OF htmlelement; "-- write the other two icon kinds.\n" + "-- * A page with required parameters cannot be opened from a menu item\n" + "-- without an argument — Mendix reports CE1571.", - SeeAlso: []string{"navigation.create", "navigation.show", "page.show"}, + SeeAlso: []string{"navigation.create", "navigation.show", "navigation.menu-document.alter", "page.show"}, + }) + + Register(SyntaxFeature{ + Path: "navigation.menu-document.alter", + Summary: "Edit a menu document's items in place (ALTER MENU)", + Keywords: []string{ + "alter menu", "edit menu document", "add menu item", "insert menu item", + "drop menu item", "move menu item", "rename menu item", + }, + Syntax: "ALTER MENU Module.Name {\n" + + " INSERT BEFORE | AFTER | INTO { }\n" + + " INSERT { }\n" + + " REPLACE WITH { }\n" + + " MOVE BEFORE | AFTER | INTO \n" + + " DROP [, ...]\n" + + " SET ( Caption: '...', OnClick: | NOTHING, Icon: | NONE ) ON \n" + + "};\n\n" + + "-- The item operations of ALTER NAVIGATION (mxcli syntax navigation.alter) on a\n" + + "-- Menus$MenuDocument, which holds the same items; there are no page clauses.\n" + + "-- is a caption path: 'Group' > 'Item', @n among equal captions.", + Example: "mdl 1;\n" + + "ALTER MENU MyModule.Main_Menu {\n" + + " INSERT AFTER 'Home' { MENU ITEM 'Orders' ( OnClick: SHOW PAGE MyModule.Order_Overview ) }\n" + + " MOVE 'Run' INTO 'Admin';\n" + + " SET ( Caption: 'Accounts and roles' ) ON 'Admin' > 'Accounts';\n" + + " DROP 'Plain';\n" + + "};", + SeeAlso: []string{"navigation.menu-document", "navigation.alter"}, }) // ── Fragment ────────────────────────────────────────────────────────── diff --git a/cmd/mxcli/syntax/registry.go b/cmd/mxcli/syntax/registry.go index b0114a5508..5ac05453a5 100644 --- a/cmd/mxcli/syntax/registry.go +++ b/cmd/mxcli/syntax/registry.go @@ -23,6 +23,8 @@ var registeredPaths = map[string]bool{} // topicAliases maps legacy topic names and common variants to registry paths. var topicAliases = map[string]string{ + // ALTER MENU lives with the menu document it edits. + "menu.alter": "navigation.menu-document.alter", // Connection aliases "connection": "connect", "open": "connect", diff --git a/docs-site/src/SUMMARY.md b/docs-site/src/SUMMARY.md index e2a915b856..2b2120dde0 100644 --- a/docs-site/src/SUMMARY.md +++ b/docs-site/src/SUMMARY.md @@ -266,6 +266,7 @@ - [CREATE DEMO USER](reference/security/create-demo-user.md) - [UPDATE SECURITY](reference/security/update-security.md) - [Navigation Statements](reference/navigation/README.md) + - [CREATE NAVIGATION](reference/navigation/create-navigation.md) - [ALTER NAVIGATION](reference/navigation/alter-navigation.md) - [LIST NAVIGATION](reference/navigation/list-navigation.md) - [CREATE MENU](reference/navigation/menu.md) diff --git a/docs-site/src/language/navigation-profiles.md b/docs-site/src/language/navigation-profiles.md index a6abd284e2..4ad07a3afa 100644 --- a/docs-site/src/language/navigation-profiles.md +++ b/docs-site/src/language/navigation-profiles.md @@ -17,7 +17,7 @@ A navigation profile defines the navigation structure for a specific device type The web kinds are a closed set and the profile is **created** if the project does not have it yet. The offline kinds are the online names plus `Offline`. -## CREATE OR REPLACE NAVIGATION +## CREATE OR MODIFY NAVIGATION Sets the clauses it states on a navigation profile. Every clause is optional, and an omitted clause keeps the stored value: a statement with only a menu @@ -26,7 +26,7 @@ keeps the home page, role home pages, login page and not-found page. The home page of that role only. ```sql -CREATE OR REPLACE NAVIGATION +CREATE OR MODIFY NAVIGATION HOME PAGE . [HOME PAGE . FOR ] [LOGIN PAGE .] @@ -100,7 +100,7 @@ The words are the values Mendix stores, not Studio Pro's captions — its "All Objects" is `all` and its "By XPath" is `where`. Copying a caption gives a parse error rather than a broken document. -See [ALTER NAVIGATION](../reference/navigation/alter-navigation.md#offline-synchronization) +See [CREATE NAVIGATION](../reference/navigation/create-navigation.md#offline-synchronization) for the full reference. ## DESCRIBE NAVIGATION @@ -115,7 +115,28 @@ DESCRIBE NAVIGATION; DESCRIBE NAVIGATION Responsive; ``` -The output is round-trippable -- you can copy it, modify it, and execute it as a `CREATE OR REPLACE NAVIGATION` statement. +The output is round-trippable -- you can copy it, modify it, and execute it as a `CREATE OR MODIFY NAVIGATION` statement. + +## ALTER NAVIGATION + +To change one menu item, or to clear a page clause, edit the stored profile +instead of restating it. The items are addressed by the captions DESCRIBE +prints, as a path (`'Admin' > 'Users'`); `LIST NAVIGATION MENU` prints each +item's address. + +```sql +ALTER NAVIGATION Responsive { + INSERT AFTER 'Home' { MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview ) } + SET ( Caption: 'User accounts' ) ON 'Admin' > 'Users'; + MOVE 'Log out' AFTER 'Help'; + DROP 'Old import'; + DROP NOT FOUND PAGE; +}; +``` + +Everything the statement does not name stays exactly as stored. See +[ALTER NAVIGATION](../reference/navigation/alter-navigation.md) for every +operation; `ALTER MENU` does the same for a menu document. ## See Also diff --git a/docs-site/src/reference/navigation/README.md b/docs-site/src/reference/navigation/README.md index 272ffcff67..c78c14e846 100644 --- a/docs-site/src/reference/navigation/README.md +++ b/docs-site/src/reference/navigation/README.md @@ -8,9 +8,10 @@ Mendix applications can have multiple navigation profiles (Responsive, Tablet, P | Statement | Description | |-----------|-------------| -| [ALTER NAVIGATION](alter-navigation.md) | Create or replace a navigation profile with home pages, login page, and menus | +| [CREATE NAVIGATION](create-navigation.md) | Create a navigation profile, or write the home pages, login page, menu and sync clauses it states | +| [ALTER NAVIGATION](alter-navigation.md) | Edit one profile's menu items and page clauses in place: insert, replace, move, drop, set | | [LIST NAVIGATION](list-navigation.md) | Display navigation profiles, menus, and home page assignments | -| [CREATE MENU](menu.md) | Create, describe and drop standalone menu documents (`Menus$MenuDocument`) | +| [CREATE MENU](menu.md) | Create, alter, describe and drop standalone menu documents (`Menus$MenuDocument`) | ## Related Statements diff --git a/docs-site/src/reference/navigation/alter-navigation.md b/docs-site/src/reference/navigation/alter-navigation.md index d5de0d5bd6..6e2a5d8e24 100644 --- a/docs-site/src/reference/navigation/alter-navigation.md +++ b/docs-site/src/reference/navigation/alter-navigation.md @@ -3,199 +3,203 @@ ## Synopsis ```sql -CREATE OR REPLACE NAVIGATION profile - HOME { PAGE module.PageName | MICROFLOW module.Flow | NANOFLOW module.Flow } - [ HOME { PAGE | MICROFLOW | NANOFLOW } module.Name FOR UserRole ] - [ LOGIN PAGE module.PageName ] - [ NOT FOUND PAGE module.PageName ] - [ ON SYNC ERROR { THROW | CONTINUE } ] - [ PROGRESSIVE WEB APP [ ( Precaching: bool, InstallPrompt: bool ) ] | PROGRESSIVE WEB APP OFF ] - [ SYNC ( - sync_rules - ) ] - [ { - menu_items - } ] +ALTER NAVIGATION profile { + item_operation | page_clause_operation + [ ... ] +}; + +-- item_operation +INSERT { BEFORE | AFTER | INTO } item { menu_items } +INSERT { menu_items } +REPLACE item WITH { menu_items } +MOVE item { BEFORE | AFTER | INTO } item +DROP item [, item ...] +SET ( [ Caption: 'text' ] [, OnClick: action | NOTHING ] [, Icon: icon | NONE ] ) ON item + +-- page_clause_operation +SET HOME { PAGE | MICROFLOW } module.Name [ FOR UserRole ] +DROP HOME PAGE FOR UserRole +SET LOGIN PAGE module.Page +DROP LOGIN PAGE +SET NOT FOUND PAGE module.Page +DROP NOT FOUND PAGE + +-- item +'caption' [ @n ] [ > 'caption' [ @n ] ... ] ``` ## Description -Creates or replaces a navigation profile. Each profile defines the home page, optional role-specific home pages, optional login page, optional 404 page, and a hierarchical menu structure. - -Every clause is optional, and a clause the statement omits leaves the stored value alone. A statement that states only a menu keeps the profile's home page, role-based home pages, login page and not-found page. A clause that is stated replaces what it names: the `{ }` block replaces the whole menu, the `SYNC ( )` block the whole sync list, and `HOME … FOR Role` the home page of that role only, so other roles keep theirs. MDL has no clause that clears a home page, a login page or a not-found page. - -The output of DESCRIBE NAVIGATION states every clause the profile has, so it can be edited and re-executed as it is. - -### Profile Types - -Mendix supports the following navigation profile types: - -| Profile | Description | -|---------|-------------| -| `Responsive` | Web browser (desktop and mobile responsive) | -| `Tablet` | Tablet-optimized web | -| `Phone` | Phone-optimized web | -| `ResponsiveOffline` | Responsive web, offline-capable (PWA) | -| `TabletOffline` | Tablet web, offline-capable (PWA) | -| `PhoneOffline` | Phone web, offline-capable (PWA) | -| `NativePhone` | Native mobile application | - -The web kinds are a closed set, and the profile is **created** if the project -does not have it yet. The three offline kinds are the online names plus -`Offline`, and each one needs a [`SYNC` block](#offline-synchronization) to -download anything. - -### Menu Items - -Menu items form a hierarchy. They are the profile's children, in `{ ... }` after its clauses, like a page's widgets. Top-level items appear in the main navigation bar. Nested submenus are created with the `MENU 'label' { ... }` syntax. - -Each `MENU ITEM` specifies a label and, in its property list, an action and an icon: `MENU ITEM 'Home' ( OnClick: SHOW PAGE M.Home, Icon: Atlas_Core.Atlas.home )`. A child ends in `)` or `}`, so no separator is written between items. - -The old spelling — `MENU ( MENU ITEM 'Home' PAGE M.Home; … )` — still parses and warns (MDL-DEPR121, MDL-DEPR122); `mxcli fmt --upgrade` rewrites it. +Edits one web navigation profile in place: its menu items at any depth, and its +home, login and not-found pages. Where +[CREATE OR MODIFY NAVIGATION](create-navigation.md) replaces the whole menu with +the `{ }` block it is given, `ALTER NAVIGATION` changes only what it names. Adding +one item does not mean restating the menu, an item someone added in Studio Pro +meanwhile is kept, and nothing the statement does not name is re-validated. + +The operations apply in order to the stored profile, each one against the menu as +the operations before it left it, so an item inserted by one operation can be the +target of the next. The statement is written once: if any operation is refused, +nothing is written. + +The same item operations apply to a menu document with +[ALTER MENU](menu.md). + +### Addressing an item + +A menu item has no name, so it is addressed by the caption `DESCRIBE NAVIGATION` +prints for it, written as a path from outer to inner: + +| Address | Means | +|---|---| +| `'Users'` | the item captioned `Users`, anywhere in the menu | +| `'Admin' > 'Users'` | `Users` directly under `Admin` | +| `'Settings' > 'Admin' > 'Users'` | the same, one level further out | +| `'Users'@2` | the 2nd item captioned `Users`, in describe order | +| `'Admin'@2 > 'Users'` | `Users` under the 2nd item captioned `Admin` | +| `''@2` | the 2nd item with an empty caption (an icon-only item) | + +- Captions compare exactly, case included, with the caption `DESCRIBE` prints — + the project's default language, as for a page's texts. +- A path matches as a **suffix**: the first segment matches at any depth, and + each `>` is one **direct** parent/child step. +- `@n` belongs to the segment it follows and picks the n-th of that segment's + matches in describe order. +- An address that matches nothing, or several items, is refused. The error for + an ambiguous address lists the shortest address of every candidate: + + ``` + menu item 'Users' is ambiguous: it matches 2 items — write one of these: + 'Admin' > 'Users' + 'Reports' > 'Users' + ``` + +- There are no positional addresses. [`LIST NAVIGATION MENU`](list-navigation.md) + prints the address of every item. + +### Item operations + +`INSERT BEFORE | AFTER item { … }` +: Places the items as siblings of `item`. + +`INSERT INTO item { … }` +: Appends them as `item`'s last sub-items. `item` must have no action + (Mendix CE0548); use `SET ( OnClick: NOTHING ) ON item` first. + +`INSERT { … }` +: Appends them to the top level. This is also how to add to an empty menu. + +`REPLACE item WITH { … }` +: Puts the items where `item` was; its sub-items go with it unless the new + items state them again. A replacement keeps the other-language translations + of a caption it does not change. + +`MOVE item BEFORE | AFTER | INTO item` +: Moves the stored item itself — its sub-items, every caption translation and + any action MDL cannot spell go with it. Moving an item into its own + sub-items is refused. + +`DROP item, …` +: Removes each item and its sub-items. + +`SET ( … ) ON item` +: Changes only the keys written. `Caption:` writes the project's default + language and keeps the other translations. `OnClick:` takes the action + expression a menu item takes in `CREATE NAVIGATION`; `NOTHING` stores + Studio Pro's "Do nothing", the only action an item with sub-items may have. + `Icon:` takes an icon-collection name, `GLYPH n` or `IMAGE module.Images.name`, + or `NONE` for no icon. + +The `{ menu_items }` of `INSERT` and `REPLACE` are written exactly as in +`CREATE NAVIGATION`, so a line `DESCRIBE NAVIGATION` printed can be pasted in. + +### Page clause operations + +Each is the `CREATE NAVIGATION` clause after `SET` or `DROP`. + +| Operation | Effect | +|---|---| +| `SET HOME PAGE M.P` / `SET HOME MICROFLOW M.F` | the default home | +| `SET HOME PAGE M.P FOR Role` | that user role's home; other roles keep theirs | +| `DROP HOME PAGE FOR Role` | removes that role's home, page or microflow | +| `SET LOGIN PAGE M.P` / `DROP LOGIN PAGE` | sets or clears the login page | +| `SET NOT FOUND PAGE M.P` / `DROP NOT FOUND PAGE` | sets or clears the fallback page | + +There is no `DROP HOME PAGE` without `FOR`: a web profile needs a default home +page (Mendix CE0527), so `SET HOME PAGE` changes it instead. `FOR` takes a bare +user role, as in `CREATE NAVIGATION`. + +### What is refused + +- an address that matches nothing or several items, or an `@n` beyond the matches; +- `INSERT INTO` or `MOVE … INTO` an item with an action, and an `OnClick:` other + than `NOTHING` on an item with sub-items (CE0548); +- `DROP HOME PAGE` without `FOR` (CE0527), and `DROP HOME PAGE FOR` a role that + has none; +- a native profile — its bottom bar is not written by mxcli; set its home pages + with `CREATE OR MODIFY NAVIGATION`; +- a profile the project does not have — `CREATE OR MODIFY NAVIGATION` adds one. + +`mxcli check script.mdl -p app.mpr --references` resolves every address against +the stored menu with the same code `exec` runs and reports the same refusals. ## Parameters `profile` -: The navigation profile type — see [Profile Types](#profile-types). One of - `Responsive`, `Tablet`, `Phone`, `ResponsiveOffline`, `TabletOffline`, - `PhoneOffline` or `NativePhone`. - -`HOME PAGE module.PageName` -: The default home page for the profile. The page must already exist. Omitting the clause keeps the stored home page. A web profile without a home page fails the build with CE0527. - -`HOME MICROFLOW module.Flow` / `HOME NANOFLOW module.Flow` -: A flow run as the home instead of a page: a microflow on a web profile, a - nanoflow on a native one. Each is refused on the other kind of profile - (`HOME MICROFLOW` on a native profile, which DESCRIBE used to print for its - nanoflow home, still runs with a warning). - -`HOME PAGE module.PageName FOR UserRole` -: Optional role-specific home page. Users with this role see a different home page than the default. Multiple role-specific home pages can be specified. Each one replaces the stored home page of its role only, and a role the statement does not mention keeps its home page. - - The role is a **user role**, written as a bare name (`FOR Administrator`). User roles are project-level and have no module part. A module-qualified name here — a module role, which often shares the name — produces a project Mendix cannot load: `StorageLoadException: … is not a valid UserRoleIdentifier`, raised before checking runs. `mxcli check --references` refuses it. - -`LOGIN PAGE module.PageName` -: Optional custom login page. A profile with no login page uses the default system login page. Omitting the clause keeps the stored login page. - -`NOT FOUND PAGE module.PageName` -: Optional custom 404 page shown when a requested page is not found. Omitting the clause keeps the stored page. - -`{ menu_items }` -: Optional menu structure. Contains `MENU ITEM` and nested `MENU` entries. An empty `{ }` clears the menu; omitting the block leaves it alone. - -`MENU ITEM 'label' ( OnClick: action, Icon: icon )` -: A leaf menu item. `OnClick` is `SHOW PAGE module.PageName`, `CALL MICROFLOW module.Microflow`, `CALL NANOFLOW module.Nanoflow`, `OPEN LINK 'url'`, `CREATE OBJECT module.Entity [THEN SHOW PAGE module.PageName]`, `SIGN OUT` or `NOTHING`, with a button action's `WITH ( … )` settings; `Icon` is `Module.Collection.icon`, `GLYPH n` or `IMAGE Module.Images.name`. Both are optional. DESCRIBE prints every stored action this way, and flags what it cannot spell (a page title override, a link type other than Web), which a rewrite keeps while the item is unchanged. - -`MENU 'label' [( Icon: icon )] { ... }` -: A submenu containing nested menu items and/or further submenus. - -### Offline Synchronization - -`SYNC ( ... )` configures which entities an offline profile downloads. **An -offline profile downloads nothing without it** — the app builds, routes and -installs as a PWA, and shows an empty screen. - -```sql -SYNC ( - SYNC Sales.Setting ONLINE; - SYNC Sales.Order ALL; - SYNC Sales.Trip WHERE [Distance > 0]; - SYNC Sales.Audit NEVER; - SYNC Sales.Lookup NONE; - SYNC Sales.Draft NONE PRESERVE DATA; -) -``` - -| Mode | Meaning | -|------|---------| -| `ONLINE` | Fetched from the server; never held on the device | -| `ALL` | Every object downloaded | -| `WHERE [ xpath ]` | Only the objects the XPath selects | -| `NEVER` | Not synchronized | -| `NONE` | Not downloaded; anything already on the device is dropped | -| `NONE PRESERVE DATA` | Not downloaded; what is on the device stays | - -These are the values Mendix stores, **not** the captions Studio Pro shows in -its *Customize offline synchronization* dialog: its "All Objects" is `ALL` and -its "By XPath" is `WHERE`. A caption is refused rather than written. - -`WHERE` implies the constrained mode rather than naming it, so a constraint -without a mode and a mode without a constraint are both unspellable. The XPath -goes in **brackets** and is taken verbatim — nothing inside is escaped. A -quoted `WHERE 'xpath'` still parses, but every quote inside it must be doubled. - -The block replaces the stored list, the way `MENU` replaces the menu. Omitting -it leaves the stored configuration alone. - -`ON SYNC ERROR THROW | CONTINUE` is Studio Pro's *"Throw error when server -rejects objects during synchronization"*, and defaults to `THROW`. It reuses the -phrase MDL already has for failure handling — a microflow's `ON ERROR CONTINUE` -— rather than introducing a keyword of its own. Omitting the clause leaves the -stored value alone, and `DESCRIBE NAVIGATION` emits it only when it is not the -default. - -`PROGRESSIVE WEB APP` is Studio Pro's *"Progressive web app"* settings. A profile -stores none until they are set, and an **offline** profile without them gets no -service worker: the built `index.js` has `"registerServiceWorker": false`, and no -page opens without a network, while `check`, `exec` and `mx check` all pass -(mendixlabs/mxcli#1377). `Precaching` pre-loads the app's pages and resources -(default `false`); `InstallPrompt` allows the *Add to home screen* prompt (default -`true`); a key the clause leaves out takes its default, so a bare -`PROGRESSIVE WEB APP` writes the defaults, `PROGRESSIVE WEB APP OFF` -stores none, and omitting the clause leaves the stored settings alone. -`DESCRIBE NAVIGATION` emits it when settings are stored, naming only the keys that -differ from the defaults, and `CREATE NAVIGATION` warns about an offline profile -left without them. - -```sql -CREATE OR MODIFY NAVIGATION PhoneOffline - HOME PAGE Field.WorkOrder_List - PROGRESSIVE WEB APP ( Precaching: true ) - SYNC ( SYNC Field.WorkOrder ALL; ); -``` - -An entity's *compatibility mode* flag has no MDL syntax. It is read, preserved -across a rewrite, and reported by `DESCRIBE NAVIGATION` — never silently -dropped. +: A web navigation profile the project has: `Responsive`, `Phone`, `Tablet` or + one of their `Offline` kinds. ## Examples -Minimal navigation with just a home page: +Given the menu `DESCRIBE NAVIGATION Responsive` prints: ```sql CREATE OR MODIFY NAVIGATION Responsive - HOME PAGE MyModule.Home_Web; + HOME PAGE Shop.Home_Web + HOME PAGE Shop.AdminHome FOR Administrator + LOGIN PAGE Administration.Login + NOT FOUND PAGE Shop.NotFound +{ + MENU ITEM 'Home' ( OnClick: SHOW PAGE Shop.Home_Web ) + MENU 'Admin' { + MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview ) + MENU ITEM 'Old import' ( OnClick: CALL MICROFLOW Shop.ACT_LegacyImport ) + } + MENU 'Reports' { + MENU ITEM 'Users' ( OnClick: SHOW PAGE Shop.Report_Users ) + } + MENU ITEM 'Help' ( OnClick: OPEN LINK 'https://example.com/help' ) + MENU ITEM 'Log out' ( OnClick: SIGN OUT ) +}; ``` -Full navigation with role-specific homes and menus: +one statement makes several unrelated edits and touches nothing else: ```sql -CREATE OR MODIFY NAVIGATION Responsive - HOME PAGE MyModule.Home_Web - HOME PAGE MyModule.AdminHome FOR Administrator - LOGIN PAGE Administration.Login - NOT FOUND PAGE MyModule.Custom404 - { - MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web ) - MENU 'Admin' { - MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview ) - MENU ITEM 'Settings' ( OnClick: SHOW PAGE MyModule.Settings ) +ALTER NAVIGATION Responsive { + INSERT AFTER 'Home' { + MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview ) + } + INSERT INTO 'Admin' { + MENU ITEM 'Audit log' ( OnClick: SHOW PAGE Shop.AuditLog_Overview ) + } + SET ( Caption: 'User accounts' ) ON 'Admin' > 'Users'; + REPLACE 'Help' WITH { + MENU 'Help' { + MENU ITEM 'Documentation' ( OnClick: OPEN LINK 'https://example.com/docs' ) + MENU ITEM 'Contact' ( OnClick: SHOW PAGE Shop.Contact ) } - MENU ITEM 'About' ( OnClick: SHOW PAGE MyModule.About ) - }; + } + MOVE 'Log out' AFTER 'Help'; + DROP 'Old import'; + SET LOGIN PAGE Shop.CustomLogin; + DROP NOT FOUND PAGE; +}; ``` -Native mobile navigation. A native profile's home is a page or a nanoflow -(`HOME NANOFLOW`); mxcli writes its home pages and `SYNC` block only, and refuses a -`{ }` menu block (the bottom bar), `LOGIN PAGE`, `NOT FOUND PAGE`, `ON SYNC ERROR` and -`PROGRESSIVE WEB APP` on one — set those in Studio Pro. DESCRIBE lists the bottom bar as comments: - -```sql -CREATE OR MODIFY NAVIGATION NativePhone - HOME NANOFLOW Mobile.NAV_Home - HOME PAGE Mobile.Dashboard FOR User; -``` +A bare `'Users'` in that statement would be refused: it matches two items. ## See Also -[LIST NAVIGATION](list-navigation.md) +[CREATE NAVIGATION](create-navigation.md), [LIST NAVIGATION](list-navigation.md), +[CREATE MENU](menu.md) diff --git a/docs-site/src/reference/navigation/create-navigation.md b/docs-site/src/reference/navigation/create-navigation.md new file mode 100644 index 0000000000..c505313a4b --- /dev/null +++ b/docs-site/src/reference/navigation/create-navigation.md @@ -0,0 +1,203 @@ +# CREATE NAVIGATION + +## Synopsis + +```sql +CREATE [ OR MODIFY ] NAVIGATION profile + HOME { PAGE module.PageName | MICROFLOW module.Flow | NANOFLOW module.Flow } + [ HOME { PAGE | MICROFLOW | NANOFLOW } module.Name FOR UserRole ] + [ LOGIN PAGE module.PageName ] + [ NOT FOUND PAGE module.PageName ] + [ ON SYNC ERROR { THROW | CONTINUE } ] + [ PROGRESSIVE WEB APP [ ( Precaching: bool, InstallPrompt: bool ) ] | PROGRESSIVE WEB APP OFF ] + [ SYNC ( + sync_rules + ) ] + [ { + menu_items + } ] +``` + +## Description + +Creates a navigation profile, or writes the clauses it states into an existing one. Each profile defines the home page, optional role-specific home pages, optional login page, optional 404 page, and a hierarchical menu structure. + +Every clause is optional, and a clause the statement omits leaves the stored value alone. A statement that states only a menu keeps the profile's home page, role-based home pages, login page and not-found page. A clause that is stated replaces what it names: the `{ }` block replaces the whole menu, the `SYNC ( )` block the whole sync list, and `HOME … FOR Role` the home page of that role only, so other roles keep theirs. To change one menu item, or to clear a role's home page, the login page or the not-found page, use [ALTER NAVIGATION](alter-navigation.md), which edits the stored profile and touches nothing else. + +`CREATE OR REPLACE NAVIGATION` is a deprecated alias of `CREATE OR MODIFY NAVIGATION` (MDL-DEPR001) with the same meaning. + +The output of DESCRIBE NAVIGATION states every clause the profile has, so it can be edited and re-executed as it is. + +### Profile Types + +Mendix supports the following navigation profile types: + +| Profile | Description | +|---------|-------------| +| `Responsive` | Web browser (desktop and mobile responsive) | +| `Tablet` | Tablet-optimized web | +| `Phone` | Phone-optimized web | +| `ResponsiveOffline` | Responsive web, offline-capable (PWA) | +| `TabletOffline` | Tablet web, offline-capable (PWA) | +| `PhoneOffline` | Phone web, offline-capable (PWA) | +| `NativePhone` | Native mobile application | + +The web kinds are a closed set, and the profile is **created** if the project +does not have it yet. The three offline kinds are the online names plus +`Offline`, and each one needs a [`SYNC` block](#offline-synchronization) to +download anything. + +### Menu Items + +Menu items form a hierarchy. They are the profile's children, in `{ ... }` after its clauses, like a page's widgets. Top-level items appear in the main navigation bar. Nested submenus are created with the `MENU 'label' { ... }` syntax. + +Each `MENU ITEM` specifies a label and, in its property list, an action and an icon: `MENU ITEM 'Home' ( OnClick: SHOW PAGE M.Home, Icon: Atlas_Core.Atlas.home )`. A child ends in `)` or `}`, so no separator is written between items. + +The old spelling — `MENU ( MENU ITEM 'Home' PAGE M.Home; … )` — still parses and warns (MDL-DEPR121, MDL-DEPR122); `mxcli fmt --upgrade` rewrites it. + +## Parameters + +`profile` +: The navigation profile type — see [Profile Types](#profile-types). One of + `Responsive`, `Tablet`, `Phone`, `ResponsiveOffline`, `TabletOffline`, + `PhoneOffline` or `NativePhone`. + +`HOME PAGE module.PageName` +: The default home page for the profile. The page must already exist. Omitting the clause keeps the stored home page. A web profile without a home page fails the build with CE0527. + +`HOME MICROFLOW module.Flow` / `HOME NANOFLOW module.Flow` +: A flow run as the home instead of a page: a microflow on a web profile, a + nanoflow on a native one. Each is refused on the other kind of profile + (`HOME MICROFLOW` on a native profile, which DESCRIBE used to print for its + nanoflow home, still runs with a warning). + +`HOME PAGE module.PageName FOR UserRole` +: Optional role-specific home page. Users with this role see a different home page than the default. Multiple role-specific home pages can be specified. Each one replaces the stored home page of its role only, and a role the statement does not mention keeps its home page. + + The role is a **user role**, written as a bare name (`FOR Administrator`). User roles are project-level and have no module part. A module-qualified name here — a module role, which often shares the name — produces a project Mendix cannot load: `StorageLoadException: … is not a valid UserRoleIdentifier`, raised before checking runs. `mxcli check --references` refuses it. + +`LOGIN PAGE module.PageName` +: Optional custom login page. A profile with no login page uses the default system login page. Omitting the clause keeps the stored login page. + +`NOT FOUND PAGE module.PageName` +: Optional custom 404 page shown when a requested page is not found. Omitting the clause keeps the stored page. + +`{ menu_items }` +: Optional menu structure. Contains `MENU ITEM` and nested `MENU` entries. An empty `{ }` clears the menu; omitting the block leaves it alone. + +`MENU ITEM 'label' ( OnClick: action, Icon: icon )` +: A leaf menu item. `OnClick` is `SHOW PAGE module.PageName`, `CALL MICROFLOW module.Microflow`, `CALL NANOFLOW module.Nanoflow`, `OPEN LINK 'url'`, `CREATE OBJECT module.Entity [THEN SHOW PAGE module.PageName]`, `SIGN OUT` or `NOTHING`, with a button action's `WITH ( … )` settings; `Icon` is `Module.Collection.icon`, `GLYPH n` or `IMAGE Module.Images.name`. Both are optional. DESCRIBE prints every stored action this way, and flags what it cannot spell (a page title override, a link type other than Web), which a rewrite keeps while the item is unchanged. + +`MENU 'label' [( Icon: icon )] { ... }` +: A submenu containing nested menu items and/or further submenus. + +### Offline Synchronization + +`SYNC ( ... )` configures which entities an offline profile downloads. **An +offline profile downloads nothing without it** — the app builds, routes and +installs as a PWA, and shows an empty screen. + +```sql +SYNC ( + SYNC Sales.Setting ONLINE; + SYNC Sales.Order ALL; + SYNC Sales.Trip WHERE [Distance > 0]; + SYNC Sales.Audit NEVER; + SYNC Sales.Lookup NONE; + SYNC Sales.Draft NONE PRESERVE DATA; +) +``` + +| Mode | Meaning | +|------|---------| +| `ONLINE` | Fetched from the server; never held on the device | +| `ALL` | Every object downloaded | +| `WHERE [ xpath ]` | Only the objects the XPath selects | +| `NEVER` | Not synchronized | +| `NONE` | Not downloaded; anything already on the device is dropped | +| `NONE PRESERVE DATA` | Not downloaded; what is on the device stays | + +These are the values Mendix stores, **not** the captions Studio Pro shows in +its *Customize offline synchronization* dialog: its "All Objects" is `ALL` and +its "By XPath" is `WHERE`. A caption is refused rather than written. + +`WHERE` implies the constrained mode rather than naming it, so a constraint +without a mode and a mode without a constraint are both unspellable. The XPath +goes in **brackets** and is taken verbatim — nothing inside is escaped. A +quoted `WHERE 'xpath'` still parses, but every quote inside it must be doubled. + +The block replaces the stored list, the way `MENU` replaces the menu. Omitting +it leaves the stored configuration alone. + +`ON SYNC ERROR THROW | CONTINUE` is Studio Pro's *"Throw error when server +rejects objects during synchronization"*, and defaults to `THROW`. It reuses the +phrase MDL already has for failure handling — a microflow's `ON ERROR CONTINUE` +— rather than introducing a keyword of its own. Omitting the clause leaves the +stored value alone, and `DESCRIBE NAVIGATION` emits it only when it is not the +default. + +`PROGRESSIVE WEB APP` is Studio Pro's *"Progressive web app"* settings. A profile +stores none until they are set, and an **offline** profile without them gets no +service worker: the built `index.js` has `"registerServiceWorker": false`, and no +page opens without a network, while `check`, `exec` and `mx check` all pass +(mendixlabs/mxcli#1377). `Precaching` pre-loads the app's pages and resources +(default `false`); `InstallPrompt` allows the *Add to home screen* prompt (default +`true`); a key the clause leaves out takes its default, so a bare +`PROGRESSIVE WEB APP` writes the defaults, `PROGRESSIVE WEB APP OFF` +stores none, and omitting the clause leaves the stored settings alone. +`DESCRIBE NAVIGATION` emits it when settings are stored, naming only the keys that +differ from the defaults, and `CREATE NAVIGATION` warns about an offline profile +left without them. + +```sql +CREATE OR MODIFY NAVIGATION PhoneOffline + HOME PAGE Field.WorkOrder_List + PROGRESSIVE WEB APP ( Precaching: true ) + SYNC ( SYNC Field.WorkOrder ALL; ); +``` + +An entity's *compatibility mode* flag has no MDL syntax. It is read, preserved +across a rewrite, and reported by `DESCRIBE NAVIGATION` — never silently +dropped. + +## Examples + +Minimal navigation with just a home page: + +```sql +CREATE OR MODIFY NAVIGATION Responsive + HOME PAGE MyModule.Home_Web; +``` + +Full navigation with role-specific homes and menus: + +```sql +CREATE OR MODIFY NAVIGATION Responsive + HOME PAGE MyModule.Home_Web + HOME PAGE MyModule.AdminHome FOR Administrator + LOGIN PAGE Administration.Login + NOT FOUND PAGE MyModule.Custom404 + { + MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web ) + MENU 'Admin' { + MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview ) + MENU ITEM 'Settings' ( OnClick: SHOW PAGE MyModule.Settings ) + } + MENU ITEM 'About' ( OnClick: SHOW PAGE MyModule.About ) + }; +``` + +Native mobile navigation. A native profile's home is a page or a nanoflow +(`HOME NANOFLOW`); mxcli writes its home pages and `SYNC` block only, and refuses a +`{ }` menu block (the bottom bar), `LOGIN PAGE`, `NOT FOUND PAGE`, `ON SYNC ERROR` and +`PROGRESSIVE WEB APP` on one — set those in Studio Pro. DESCRIBE lists the bottom bar as comments: + +```sql +CREATE OR MODIFY NAVIGATION NativePhone + HOME NANOFLOW Mobile.NAV_Home + HOME PAGE Mobile.Dashboard FOR User; +``` + +## See Also + +[ALTER NAVIGATION](alter-navigation.md), [LIST NAVIGATION](list-navigation.md) diff --git a/docs-site/src/reference/navigation/list-navigation.md b/docs-site/src/reference/navigation/list-navigation.md index e8cb4e3cab..ab1b0e3bfe 100644 --- a/docs-site/src/reference/navigation/list-navigation.md +++ b/docs-site/src/reference/navigation/list-navigation.md @@ -21,7 +21,7 @@ Displays a summary of all configured navigation profiles, showing the profile ty Displays the menu tree for one or all navigation profiles. When a profile is specified, only that profile's menu is shown. Without a profile, all profiles' menus are displayed. -The menu tree is rendered as an indented hierarchy showing menu labels and their target pages. +The menu is listed as a table, one row per item in the order DESCRIBE prints them: the profile, the caption indented by depth, the action's target, and the **Address** — the shortest unique caption path that [ALTER NAVIGATION](alter-navigation.md) takes to reach the item (`'Admin' > 'Users'`, or `'Home'@2` where captions repeat). A native profile's bottom bar has no address, because ALTER NAVIGATION does not edit it. ### LIST NAVIGATION HOMES @@ -64,4 +64,4 @@ DESCRIBE NAVIGATION Responsive; ## See Also -[ALTER NAVIGATION](alter-navigation.md) +[CREATE NAVIGATION](create-navigation.md), [ALTER NAVIGATION](alter-navigation.md) diff --git a/docs-site/src/reference/navigation/menu.md b/docs-site/src/reference/navigation/menu.md index e912881528..aa44586fa9 100644 --- a/docs-site/src/reference/navigation/menu.md +++ b/docs-site/src/reference/navigation/menu.md @@ -4,6 +4,7 @@ ```sql CREATE [ OR MODIFY ] MENU module.name [ FOLDER 'folder_path' ] { menu_item [ menu_item ... ] } +ALTER MENU module.name { item_operation [ item_operation ... ] } DESCRIBE MENU module.name DROP MENU module.name ``` @@ -11,10 +12,15 @@ DROP MENU module.name Where each `menu_item` is one of: ```sql -MENU ITEM 'caption' [ ( [ OnClick: action ] [, Icon: module.collection.icon ] ) ] -MENU 'caption' [ ( Icon: module.collection.icon ) ] { nested_items } +MENU ITEM 'caption' [ ( [ OnClick: action ] [, Icon: icon ] ) ] +MENU 'caption' [ ( Icon: icon ) ] { nested_items } ``` +and each `item_operation` is one of the item operations of +[ALTER NAVIGATION](alter-navigation.md#item-operations) — `INSERT`, `REPLACE`, +`MOVE`, `DROP`, `SET ( … ) ON` — with items addressed by caption path +(`'Group' > 'Item'`). + ## Description Manages standalone **menu documents** (`Menus$MenuDocument`) — reusable menus that a @@ -24,7 +30,7 @@ menu widget on a page points at. Atlas_Core ships two of them, `Phone_Menu` and A menu document is **not** the menu inside a navigation profile, although the two are easy to confuse: both are built from the same menu items, which is why the item syntax here is identical to the `{ ... }` menu block of -[ALTER NAVIGATION](alter-navigation.md). They differ in where they live and how they +[CREATE NAVIGATION](create-navigation.md). They differ in where they live and how they are read: | | Profile menu | Menu document | @@ -32,12 +38,27 @@ are read: | Lives in | a navigation profile | its own document | | Used by | the app's navigation | a menu widget you place on a page | | Read with | `LIST NAVIGATION MENU` | `DESCRIBE MENU module.name` | -| Written with | `CREATE OR REPLACE NAVIGATION` | `CREATE OR MODIFY MENU` | +| Written with | `CREATE OR MODIFY NAVIGATION` | `CREATE OR MODIFY MENU` | +| One item at a time | `ALTER NAVIGATION` | `ALTER MENU` | + +`OR MODIFY` replaces the item list **wholesale**, exactly as the `{ }` block of +`CREATE OR MODIFY NAVIGATION` does: the list you give is the document's complete +contents, so an omitted item is a removed item. The document's identity, container +and export level are preserved, so menu widgets pointing at it keep working. -`OR MODIFY` replaces the item list **wholesale**, exactly as `CREATE OR REPLACE -NAVIGATION` does: the list you give is the document's complete contents, so an -omitted item is a removed item. The document's identity, container and export level -are preserved, so menu widgets pointing at it keep working. +`ALTER MENU` edits the stored items instead: it inserts, replaces, moves, drops or +changes the items it names and keeps every other item exactly as stored. It takes +the item operations of [ALTER NAVIGATION](alter-navigation.md) — no page clauses, +since a menu document has none: + +```sql +ALTER MENU MyModule.Main_Menu { + INSERT AFTER 'Home' { MENU ITEM 'Orders' ( OnClick: SHOW PAGE MyModule.Order_Overview ) } + MOVE 'Run' INTO 'Admin'; + SET ( Caption: 'Accounts and roles' ) ON 'Admin' > 'Accounts'; + DROP 'Plain'; +}; +``` `DESCRIBE MENU` emits a re-executable `CREATE OR MODIFY MENU` statement, so describe → edit → exec is the normal editing loop, and describe → exec → describe is @@ -126,19 +147,18 @@ to supply the argument, and Mendix rejects the model with **CE1571** ("No argume has been selected for parameter …") reported against `Menu item`. Point the item at a parameterless page, or call a microflow that opens the page with its argument. -**Only icon-collection icons round-trip.** `ICON` writes a -`Forms$IconCollectionIcon`. A glyph icon (which carries a numeric code) or an image -icon cannot be expressed in MDL, so `DESCRIBE` reports those on their own comment -line rather than dropping them silently: +**All three icon kinds round-trip.** `Icon: Module.Collection.name` writes an +icon-collection icon, `Icon: GLYPH n` a glyph and `Icon: IMAGE Module.Images.name` +an image icon, and `DESCRIBE` prints each the same way. Only an icon it cannot +rebuild — a glyph stored without a code, or an icon type mxcli does not know — is +reported on its own comment line rather than dropped silently: ``` -- icon a numeric glyph code (Forms$GlyphIcon) is not reproducible by CREATE MENU; -- set it in Studio Pro ``` -Re-running such output therefore loses that icon — visibly, not silently. - ## See Also -- [ALTER NAVIGATION](alter-navigation.md) — the menu inside a navigation profile +- [CREATE NAVIGATION](create-navigation.md) and [ALTER NAVIGATION](alter-navigation.md) — the menu inside a navigation profile - [LIST NAVIGATION](list-navigation.md) — read profile menus and home pages diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 4c3c86c361..0d0dd07c89 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -911,15 +911,16 @@ alter workflow Module.OrderApproval { | Statement | Syntax | Notes | |-----------|--------|-------| | Show navigation | `list navigation;` | Summary of all profiles | -| Show menu tree | `list navigation menu [Profile];` | Menu tree for profile or all | +| Show menu tree | `list navigation menu [Profile];` | One row per item, with the **Address** `alter navigation` takes (`'Admin' > 'Users'`, `'Home'@2`) | | Show home pages | `list navigation homes;` | Home page assignments across profiles | | Describe navigation | `describe navigation [Profile];` | Full MDL output (round-trippable) | | Native home | `home nanoflow Mod.Flow` | A native profile's flow home; mxcli writes a native profile's home pages and sync only | -| Create/replace navigation | `create or replace navigation Profile ...;` | Full replacement — and **creates** the profile if the project does not have it | +| Create/modify navigation | `create or modify navigation Profile ...;` | Writes the clauses it states (a `{ }` block replaces the whole menu) — and **creates** the profile if the project does not have it | +| Alter navigation | `alter navigation Profile { insert after 'Home' { menu item … } drop 'Old'; … };` | Edits one profile in place: `insert before\|after\|into { … }`, `insert { … }`, `replace with { … }`, `move before\|after\|into `, `drop , …`, `set ( Caption:, OnClick:, Icon: \| none ) on `, `set home page\|microflow M.X [for Role]`, `drop home page for Role`, `set/drop login page`, `set/drop not found page`. `` is a caption path `'Admin' > 'Users'` (`@n` among equal captions). Untouched items stay as stored; a refusal writes nothing | | Offline sync | `sync ( sync Mod.Entity all; ... )` | A clause of CREATE NAVIGATION. Modes: `online`, `all`, `where ''`, `never`, `none`, `none preserve data`. **Not** Studio Pro's captions — its "All Objects" is `all`, its "By XPath" is `where`. An offline profile downloads nothing without this | | Progressive web app | `progressive web app ( Precaching: true )` / `progressive web app off` | A clause of CREATE NAVIGATION: Studio Pro's *Progressive web app* settings. An offline profile without them gets no service worker, so no page opens offline (mendixlabs/mxcli#1377). Keys: `Precaching` (default false), `InstallPrompt` (default true); omitted, the stored settings stay | | Profile kinds | `Responsive` · `Phone` · `Tablet` · `ResponsiveOffline` · `PhoneOffline` · `TabletOffline` | A closed set. An invented name (`Mobile`) is an error, not a new profile: the runtime routes on User-Agent to Mendix's own kinds. Native profiles are a different document type and are not creatable | -| Offline profiles | `create or replace navigation TabletOffline ...;` | Same properties as the online twin, but every page the profile can reach may bind an attribute across **at most one** association hop (**CE6206**). Creating one reports the documents that already exceed that | +| Offline profiles | `create or modify navigation TabletOffline ...;` | Same properties as the online twin, but every page the profile can reach may bind an attribute across **at most one** association hop (**CE6206**). Creating one reports the documents that already exceed that | Menu items are the profile's children, in `{ }` after its clauses, with no `;` between them: `menu item 'Caption' ( OnClick: show page M.P, Icon: … )`, where @@ -1564,9 +1565,11 @@ MDL uses explicit property declarations for pages: | Describe building block | `describe building block Module.Name;` | Informational (header comment + widget tree), not a `create` statement | | Create menu | `create [or modify] menu Module.Name [folder 'path'] { };` | Standalone `Menus$MenuDocument`. Full replacement: the item list is the document's complete contents | | Describe menu | `describe menu Module.Name;` | Round-trippable MDL. Not the navigation-profile menu — see `list navigation menu` | +| Alter menu | `alter menu Module.Name { insert … replace … move … drop … set ( … ) on … };` | The item operations of `alter navigation` on a menu document; no page clauses | | Drop menu | `drop menu [if exists] Module.Name;` | | | Create menu | `create [or modify] menu Module.Name [folder 'path'] { };` | Standalone `Menus$MenuDocument`. Full replacement: the item list is the document's complete contents | | Describe menu | `describe menu Module.Name;` | Round-trippable MDL. Not the navigation-profile menu — see `list navigation menu` | +| Alter menu | `alter menu Module.Name { insert … replace … move … drop … set ( … ) on … };` | The item operations of `alter navigation` on a menu document; no page clauses | | Drop menu | `drop menu [if exists] Module.Name;` | | **DataGrid Column Properties:** From f8a188708757cd7913669b4309bbe9ae1966b753 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:15:19 +0000 Subject: [PATCH 26/28] test(navigation): doctype script for ALTER NAVIGATION / ALTER MENU and #1522 repro mdl-examples/doctype-tests/navigation-alter.mdl has one block per operation - insert before/after/into/top level with a nested fragment, drop, replace, move, set Caption/OnClick/Icon (none, glyph, collection), set and drop of every page clause, and alter menu on a menu document - and restores the blank app's profile at the end. mdl-examples/bug-tests/1522-alter-navigation-adds-one-item.mdl is the reported case: adding one item to an existing menu keeps the others. Both pass `mxcli check -p --references` and exec on copies of testdata/expr-checker (11.6.6) and a blank 11.15.0 app, with `mx check` at 0 errors on each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .../1522-alter-navigation-adds-one-item.mdl | 45 +++++ .../doctype-tests/navigation-alter.mdl | 157 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 mdl-examples/bug-tests/1522-alter-navigation-adds-one-item.mdl create mode 100644 mdl-examples/doctype-tests/navigation-alter.mdl diff --git a/mdl-examples/bug-tests/1522-alter-navigation-adds-one-item.mdl b/mdl-examples/bug-tests/1522-alter-navigation-adds-one-item.mdl new file mode 100644 index 0000000000..9991a11290 --- /dev/null +++ b/mdl-examples/bug-tests/1522-alter-navigation-adds-one-item.mdl @@ -0,0 +1,45 @@ +mdl 1; +-- ============================================================================ +-- Bug #1522 (part a) — adding one menu item deleted the others. +-- +-- The reporter added one item to an existing menu with +-- `create or modify navigation Responsive { menu item 'Reports' … }` and lost +-- every item the statement did not restate: a `{ }` menu block replaces the +-- whole menu. The only way to change one item was to restate all of them +-- (describe, edit, re-run), which also re-validates — and can be refused over — +-- items the edit never touched. +-- +-- `alter navigation` adds the item to the stored menu and keeps the rest: +-- +-- mxcli exec this-file -p app.mpr ; mx check app.mpr +-- `describe navigation Responsive` lists Home, Orders and Reports; +-- mx check: 0 errors (measured on 11.6.6 and 11.15.0) +-- ============================================================================ + +create module Issue1522a; + +create page Issue1522a.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtHome (Content: 'Home') +}; + +create page Issue1522a.Orders (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtOrders (Content: 'Orders') +}; + +create page Issue1522a.Reports (Title: 'Reports', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtReports (Content: 'Reports') +}; + +create or modify navigation Responsive + home page Issue1522a.Home +{ + menu item 'Home' ( OnClick: show page Issue1522a.Home, Icon: Atlas_Core.Atlas.home ) + menu item 'Orders' ( OnClick: show page Issue1522a.Orders, Icon: Atlas_Core.Atlas."list-bullets" ) +}; + +-- The one item the reporter wanted to add. Home and Orders must survive. +alter navigation Responsive { + insert { menu item 'Reports' ( OnClick: show page Issue1522a.Reports, Icon: Atlas_Core.Atlas.home ) } +}; + +describe navigation Responsive; diff --git a/mdl-examples/doctype-tests/navigation-alter.mdl b/mdl-examples/doctype-tests/navigation-alter.mdl new file mode 100644 index 0000000000..7ea22a3086 --- /dev/null +++ b/mdl-examples/doctype-tests/navigation-alter.mdl @@ -0,0 +1,157 @@ +mdl 1; +-- ============================================================================ +-- ALTER NAVIGATION / ALTER MENU — item-level edits +-- ============================================================================ +-- +-- `alter navigation { … }` edits the stored profile: items it does +-- not name keep their stored bytes. `alter menu Module.Menu { … }` does the +-- same for a menu document. One block per operation below; each statement is +-- written once, and a refusal anywhere writes nothing. +-- +-- Items are addressed by the caption describe prints, as a path written outer +-- to inner: 'Admin' > 'Users', with @n among equal captions. +-- `list navigation menu` prints every item's address. +-- +-- Measured on copies of a Mendix 11.6.6 and an 11.15.0 app: every block +-- leaves `mx check` at 0 errors, and a describe -> exec afterwards reports +-- Unchanged. +-- ============================================================================ + +-- MARK: Setup + +create module AlterNav; + +create page AlterNav.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtHome (Content: 'Home') +}; + +create page AlterNav.Orders (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtOrders (Content: 'Orders') +}; + +create page AlterNav.Users (Title: 'Users', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtUsers (Content: 'Users') +}; + +create page AlterNav.Login (Title: 'Sign in', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtLogin (Content: 'Sign in') +}; + +create page AlterNav.NotFound (Title: 'Not found', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtNotFound (Content: 'Not found') +}; + +create or modify navigation Responsive + home page AlterNav.Home +{ + menu item 'Home' ( OnClick: show page AlterNav.Home, Icon: Atlas_Core.Atlas.home ) + menu 'Admin' ( Icon: Atlas_Core.Atlas.cog ) { + menu item 'Users' ( OnClick: show page AlterNav.Users, Icon: Atlas_Core.Atlas.user ) + menu item 'Old import' ( OnClick: show page AlterNav.Orders, Icon: Atlas_Core.Atlas.user ) + } + menu 'Reports' ( Icon: Atlas_Core.Atlas.cog ) { + menu item 'Users' ( OnClick: show page AlterNav.Users, Icon: Atlas_Core.Atlas.user ) + } + menu item 'Help' ( OnClick: open link 'https://example.com/help', Icon: Atlas_Core.Atlas.home ) + menu item 'Log out' ( OnClick: sign out, Icon: Atlas_Core.Atlas.home ) +}; + +list navigation menu Responsive; + +-- MARK: Insert + +alter navigation Responsive { + insert after 'Home' { menu item 'Orders' ( OnClick: show page AlterNav.Orders, Icon: Atlas_Core.Atlas."list-bullets" ) } + insert before 'Help' { menu item 'FAQ' ( OnClick: open link 'https://example.com/faq', Icon: Atlas_Core.Atlas.home ) } + insert into 'Admin' { menu item 'Audit log' ( OnClick: show page AlterNav.Orders, Icon: Atlas_Core.Atlas.user ) } + insert { + menu 'More' ( Icon: Atlas_Core.Atlas.cog ) { + menu item 'Deep' ( OnClick: show page AlterNav.Home, Icon: Atlas_Core.Atlas.home ) + } + } +}; + +-- MARK: Drop + +alter navigation Responsive { + drop 'Old import', 'FAQ'; +}; + +-- MARK: Replace + +alter navigation Responsive { + replace 'Help' with { + menu 'Help' ( Icon: Atlas_Core.Atlas.home ) { + menu item 'Documentation' ( OnClick: open link 'https://example.com/docs', Icon: Atlas_Core.Atlas.home ) + menu item 'Contact' ( OnClick: show page AlterNav.Users, Icon: Atlas_Core.Atlas.user ) + } + } +}; + +-- MARK: Move + +alter navigation Responsive { + move 'Log out' before 'Help'; + move 'Orders' into 'More'; + move 'Reports' > 'Users' after 'Admin' > 'Users'; +}; + +-- MARK: Set item properties + +-- Two siblings are now captioned Users: the second needs @2, and the error for +-- a bare 'Users' lists both addresses. +alter navigation Responsive { + set ( Caption: 'User accounts' ) on 'Admin' > 'Users'@1; + set ( Caption: 'Sessions' ) on 'Admin' > 'Users'; + set ( OnClick: show page AlterNav.Orders ) on 'Reports'; + set ( Icon: none ) on 'Home'; + set ( Icon: Atlas_Core.Atlas.cog ) on 'Help'; + set ( OnClick: nothing ) on 'More'; +}; + +-- MARK: Page clauses + +alter navigation Responsive { + set home page AlterNav.Users for Administrator; + set login page AlterNav.Login; + set not found page AlterNav.NotFound; +}; + +alter navigation Responsive { + drop home page for Administrator; + drop login page; + drop not found page; + set home page AlterNav.Home; +}; + +describe navigation Responsive; + +-- MARK: Menu documents + +create menu AlterNav.SideMenu { + menu item 'One' ( OnClick: show page AlterNav.Home, Icon: Atlas_Core.Atlas.home ) + menu 'Group' ( Icon: Atlas_Core.Atlas.cog ) { + menu item 'Two' ( OnClick: show page AlterNav.Users, Icon: Atlas_Core.Atlas.user ) + } + menu item 'Three' ( OnClick: sign out, Icon: Atlas_Core.Atlas.home ) +}; + +alter menu AlterNav.SideMenu { + insert after 'One' { menu item 'Inserted' ( OnClick: open link 'https://example.com', Icon: Atlas_Core.Atlas.home ) } + insert into 'Group' { menu item 'Into' ( OnClick: show page AlterNav.Orders, Icon: Atlas_Core.Atlas.user ) } + move 'Three' into 'Group'; + replace 'One' with { menu item 'One again' ( OnClick: show page AlterNav.Home, Icon: Atlas_Core.Atlas.home ) } + set ( Caption: 'Second', Icon: glyph 57377 ) on 'Group' > 'Two'; + drop 'Inserted'; +}; + +describe menu AlterNav.SideMenu; + +-- MARK: Cleanup + +-- Restore the profile the blank app ships with, so later scripts start from it. +create or modify navigation Responsive + home page MyFirstModule.Home_Web +{ + menu item 'Home' ( OnClick: show page MyFirstModule.Home_Web ) +}; From fd57cb6b9137543abe2d3e529444c7cffb7c0757 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:16:17 +0000 Subject: [PATCH 27/28] docs(skills): one row per user_role field; trim a blank line instead af7ac9c8 shared one row between manage_all_roles and manageable_roles, and TestLintSkillDocumentsEveryStructField wants a row per field, so both read as undocumented. The rows are separate again, and the line comes from the blank line before the Validation section's code fence (a fence may follow a paragraph directly). Body is 700 lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .claude/skills/mendix/write-lint-rules/SKILL.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index dfa5cd19cf..8d9ab2afbc 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -12,10 +12,10 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b |---|---| | 25-67 | Rule File Structure | | 68-161 | Available Query Functions | -| 162-605 | Object Properties | -| 606-619 | Helper Functions | -| 620-706 | Common Patterns | -| 707-716 | Validation | +| 162-606 | Object Properties | +| 607-620 | Helper Functions | +| 621-707 | Common Patterns | +| 708-716 | Validation | # Writing Custom Starlark Lint Rules @@ -540,8 +540,9 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | `is_anonymous` | bool | True if this is the anonymous/guest role | | `module_roles` | list of string | `["Sales.Admin", "HR.Viewer"]` | | `check_security` | bool | Studio Pro's per-role "Check security" flag | -| `manage_all_roles`, `manageable_roles` | bool, list of string | The role may hand out every user role; otherwise `manageable_roles` lists those it may | +| `manage_all_roles` | bool | The role may hand out every user role | | `manage_users_without_roles` | bool | The role may manage users that have no role | +| `manageable_roles` | list of string | The user roles this role may hand out, when not all | ### module_role | Property | Type | Example | @@ -707,7 +708,6 @@ def check(): ## Validation Test your rule by running the linter: - ```bash mxcli lint -p app.mpr --list-rules # Verify rule is loaded mxcli lint -p app.mpr # run all rules including yours From 87b4cbc48df7dcdf00e3d85904e441629d6db103 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:31:13 +0000 Subject: [PATCH 28/28] docs(navigation): fence the navigation synopses as text The conformance gate parses every ```sql block in the docs as MDL. The CREATE NAVIGATION and ALTER NAVIGATION synopses (and the SYNC fragment) are metasyntax, so they are fenced as ```text; the allowlist line the old alter-navigation.md needed is gone (it only shrinks). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- docs-site/src/reference/navigation/alter-navigation.md | 2 +- docs-site/src/reference/navigation/create-navigation.md | 4 ++-- mdl/conformance/allowlist.txt | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs-site/src/reference/navigation/alter-navigation.md b/docs-site/src/reference/navigation/alter-navigation.md index 6e2a5d8e24..b73bec8096 100644 --- a/docs-site/src/reference/navigation/alter-navigation.md +++ b/docs-site/src/reference/navigation/alter-navigation.md @@ -2,7 +2,7 @@ ## Synopsis -```sql +```text ALTER NAVIGATION profile { item_operation | page_clause_operation [ ... ] diff --git a/docs-site/src/reference/navigation/create-navigation.md b/docs-site/src/reference/navigation/create-navigation.md index c505313a4b..3c3d15ce65 100644 --- a/docs-site/src/reference/navigation/create-navigation.md +++ b/docs-site/src/reference/navigation/create-navigation.md @@ -2,7 +2,7 @@ ## Synopsis -```sql +```text CREATE [ OR MODIFY ] NAVIGATION profile HOME { PAGE module.PageName | MICROFLOW module.Flow | NANOFLOW module.Flow } [ HOME { PAGE | MICROFLOW | NANOFLOW } module.Name FOR UserRole ] @@ -97,7 +97,7 @@ The old spelling — `MENU ( MENU ITEM 'Home' PAGE M.Home; … )` — still pars offline profile downloads nothing without it** — the app builds, routes and installs as a PWA, and shows an empty screen. -```sql +```text SYNC ( SYNC Sales.Setting ONLINE; SYNC Sales.Order ALL; diff --git a/mdl/conformance/allowlist.txt b/mdl/conformance/allowlist.txt index ca59a571bb..584f0a3c59 100644 --- a/mdl/conformance/allowlist.txt +++ b/mdl/conformance/allowlist.txt @@ -140,7 +140,6 @@ 1 syntax docs-site/src/reference/microflow/create-javascript-action.md 16 syntax docs-site/src/reference/microflow/create-microflow.md 4 syntax docs-site/src/reference/microflow/create-nanoflow.md -2 syntax docs-site/src/reference/navigation/alter-navigation.md 1 syntax docs-site/src/reference/navigation/list-navigation.md 2 syntax docs-site/src/reference/navigation/menu.md 3 syntax docs-site/src/reference/organization/create-folder.md