diff --git a/.claude/lint-rules/conv018_module_folder_organization.star b/.claude/lint-rules/conv018_module_folder_organization.star index f2fe6eb819..20e609366a 100644 --- a/.claude/lint-rules/conv018_module_folder_organization.star +++ b/.claude/lint-rules/conv018_module_folder_organization.star @@ -72,7 +72,7 @@ FOLDERABLE_KINDS = [ "MENU", "PAGE_TEMPLATE", "SCHEDULED_EVENT", - "QUEUE", + "TASK_QUEUE", "REGULAR_EXPRESSION", "DATA_TRANSFORMER", "WORKFLOW", @@ -81,7 +81,7 @@ FOLDERABLE_KINDS = [ "KNOWLEDGE_BASE", "CONSUMED_MCP_SERVICE", "DATABASE_CONNECTION", - "REST_CLIENT", + "CONSUMED_REST_SERVICE", "PUBLISHED_REST_SERVICE", ] diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-12-test-local-hangs-when-gradle-daemon-holds-pipe.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-12-test-local-hangs-when-gradle-daemon-holds-pipe.json new file mode 100644 index 0000000000..3638721677 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-12-test-local-hangs-when-gradle-daemon-holds-pipe.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-12", "symptom": "`mxcli test --local` hangs indefinitely on Windows after the tests finish, when mxbuild starts a new Gradle daemon — no results, no JUnit file, no error; a run that reuses an idle daemon passes, and killing only the orphaned daemon unblocks mxcli (#1396). `mx sync-java-dependencies` (run --local) has the same shape", "cause": "`mxbuild --serve` and `mx sync-java-dependencies` write to a `syncBuffer`, so exec.Cmd creates a pipe and Wait blocks until every holder of its write end closes it. A freshly started Gradle daemon inherits that end and outlives mxbuild; the tree kill (taskkill /T, or the POSIX group signal) cannot reach a process that left the tree/group, and `ServeServer.Stop` runs before results are printed", "file": "`cmd/mxcli/docker/mxserve.go` (`newServeCmd`, `orphanPipeWaitDelay`), `cmd/mxcli/docker/javadeps.go` (`SyncJavaDependencies` treats `exec.ErrWaitDelay` as success — mx's own exit status wins)", "insight": "`cmd.WaitDelay` is the backstop for EVERY exec.Cmd whose child can spawn Gradle and whose output is not an *os.File, not only the watchdogged one-shot builds (sibling finding 2026-10-11): a tree kill bounds the tree, WaitDelay bounds what escaped it. The delay starts only after the process exits, so it cannot cut a live build short. The discriminating detail in the report was 'only when no idle daemon can be reused' — a reused daemon never held this run's pipe. Reproduces on Linux with a fake mx that starts `setsid sleep 30 &` (out of the process group, like the escaped daemon); revert proof: without WaitDelay both tests block past their 15s guard", "refs": ["mendixlabs/mxcli#1396"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-12-alter-insert-navigation-list-item.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-12-alter-insert-navigation-list-item.json new file mode 100644 index 0000000000..1aea2b471c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-12-alter-insert-navigation-list-item.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-12", "refs": ["mendixlabs/mxcli#1388"], "symptom": "ALTER SNIPPET/PAGE could not add a navigation list item: `insert into navList1 { item \u2026 }` failed with \"failed to insert: failed to build widgets: failed to build widget : item must be a direct child of navigationlist\", and `insert after t1 { \u2026 }` with t1 inside an existing item failed with `failed to resolve snippet target: widget \"t1\" not found`; the only workaround was REPLACE of the whole list, retyping every item", "cause": "Two gaps of one shape: the INSERT built every node as a standalone widget, and the builder refuses a lone `item` (it is only built inside a navigationlist); and the mutator's lookup/scope/data-source walks descended into Widgets, TabPages, ControlBar.Items, \u2026 but never a NavigationList's Items, so nothing inside an item was addressable", "file": "`mdl/backend/pagemutator/navlist.go` (InsertNavigationListItems), `mdl/backend/pagemutator/mutator.go` (findInWidgetChildren, collectWidgetScopeInChildren, findNearestDSInChildren), `mdl/executor/cmd_alter_page.go` (allNavigationListItems routing), `mdl/backend/mutation.go`", "insight": "Every non-widget child list (TabPages #1215, ListView Templates, DataGrid2 columns, now NavigationList Items) needs the same three things: an executor branch before buildWidgetsFromAST, a dedicated mutator insert that wraps the children in a throwaway parent and serializes it so they get CREATE's exact shape, and a descent in all three tree walks (find, scope, nearest data source) \u2014 grep the TabPages lines in mutator.go to find every walk at once. The ALTERed snippet's NDSL dump is identical to one CREATEd with both items (the cheapest proof of shape), and mx check 11.12.1 is clean. Items carry no Name (#1373), so BEFORE/AFTER an item is not offered: `insert after t1 { item }` is refused naming the INTO form rather than guessing it meant 'after the item holding t1'. Item names as an addressing feature would need a derived/positional address (cf. layoutContainer.top), not a stored Name."} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-navigation-list-item-class-described-as-buttonstyle.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-navigation-list-item-class-described-as-buttonstyle.json new file mode 100644 index 0000000000..e5ee82eed6 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-navigation-list-item-class-described-as-buttonstyle.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-12", "refs": ["mendixlabs/mxcli#1388"], "symptom": "describe of a navigation list item with a CSS class printed `item (Action: \u2026, ButtonStyle: nav-orders)` \u2014 not an item property, and unparseable unquoted (`nav-orders` reads as a subtraction: \"extraneous input '{' expecting {',', ')'}\") \u2014 and CREATE wrote an empty Forms$Appearance whatever the item said, so a styled item lost its class on describe \u2192 exec", "cause": "parseNavigationListItems copied Appearance.Class into rawWidget.ButtonStyle and the navigationlist emitter printed it as ButtonStyle; pages.NavigationListItem had no appearance fields, so buildNavigationListItemV3 had nowhere to put Class/Style and navListItemToGen hard-coded newAppearance(\"\", \"\", \"\", nil)", "file": "`mdl/executor/cmd_pages_describe_parse.go` (parseNavigationListItems), `mdl/executor/cmd_pages_describe_output.go` (navigationlist item header), `mdl/executor/cmd_pages_builder_v3_widgets.go` (buildNavigationListItemV3), `mdl/backend/modelsdk/widget_write.go` (navListItemToGen), `sdk/pages/pages_widgets_action.go`, `mdl/executor/cmd_page_wireframe.go`", "insight": "An item is not a pages.Widget, so applyWidgetAppearance never reached it and every appearance property had to be threaded by hand \u2014 check that path first whenever a non-widget child (item, tab page, template) loses a property. Every Studio Pro item measured (PedApp Grid_WithNavigation, WidgetDemo_Showcase) stores the same Forms$Appearance as a widget (Class, DesignProperties, DynamicClasses, Style), so no new shape was needed. The control that settles it is a describe \u2192 exec round trip on a styled item: the pre-fix describe did not even parse, the fixed one re-executes as \"Unchanged\". Design properties on an item are still not printed or written (theme key for NavigationListItem unmeasured)."} diff --git a/.claude/skills/fix-issue/findings/other/2026-10-12-studio-pro-document-names-describe-help-and-catalog.json b/.claude/skills/fix-issue/findings/other/2026-10-12-studio-pro-document-names-describe-help-and-catalog.json new file mode 100644 index 0000000000..c19fb5f3d1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-12-studio-pro-document-names-describe-help-and-catalog.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-10-12", "symptom": "After the document types took Studio Pro's names (ako/mxcli#755, R10), \"mxcli describe --help and the catalog.db still use the old names\": the help listed restclient / odataclient / odataservice / queue / projectsecurity / model, and the catalog exposed REST_CLIENTS, REST_OPERATIONS, ODATA_CLIENTS, ODATA_SERVICES, QUEUES with ObjectType REST_CLIENT / ODATA_CLIENT / ODATA_SERVICE / QUEUE", "cause": "The R10 rename was done on the MDL grammar and describe output only. The cobra describe command keeps its own type table, help text, error hint and auto-detect maps (objectTypeToDescribe, unitTypeToDescribe), and the catalog's view names and objects.ObjectType literals are a second vocabulary nobody migrated", "file": "`cmd/mxcli/cmd_describe.go`, `mdl/catalog/tables.go` (canonical views + `LegacyTableAliases`/`legacyAliasView`, objects view, schema 24), `mdl/catalog/catalog.go` (`Tables()`), consumers in `mdl/executor/describe_auto.go`, `cmd/mxcli/lsp_completion.go`, `.claude/lint-rules/conv018_module_folder_organization.star`", "insight": "A language rename has more vocabularies than the grammar: grep the OLD spelling in every form it takes — `rest client`, `restclient`, `REST_CLIENT`, `rest_clients` — not just the keyword, since the CLI subcommand table and catalog SQL never pass through the parser and the deprecation registry cannot see them. Table names can keep an alias (a `CREATE VIEW old AS SELECT * FROM new`, exempted from TestTables_CoversAllViews via LegacyTableAliases); ObjectType literal values cannot, so a user query filtering on the old value silently returns zero rows — keep the old keys in every Go map that reads ObjectType (a pre-bump cache still holds them) and say so in the docs. Renaming a view needs a CatalogSchemaVersion bump: views are CREATE VIEW IF NOT EXISTS, so a cached catalog keeps the old objects view. Repro mdl-examples/bug-tests/1395-studio-pro-document-names-catalog.mdl; end-to-end on testdata/testapp-views: OBJECTS reports PUBLISHED_ODATA_SERVICE, CATALOG.ODATA_SERVICES still answers, and `describe MyFirstModule.CarOdataApi` auto-detects through the new value. Issue mendixlabs/mxcli#1395", "refs": ["mendixlabs/mxcli#1395", "ako/mxcli#755"]} diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 89ffc00eb3..3bb7c8047b 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -12,15 +12,15 @@ description: "Modify an existing page or snippet's widget tree in place with ALT |---|---| | 28-31 | Overview | | 32-56 | When to Use | -| 57-106 | Syntax | -| 107-446 | Operations | -| 447-525 | Examples | -| 526-616 | DataGrid 2 columns: how to address them, and what you can set | -| 617-628 | Common Mistakes | -| 629-650 | Limitations — prefer binding at page creation (ledger finding #45) | -| 651-658 | Validation Checklist | -| 659-668 | Related Commands | -| 669-673 | Related Skills | +| 57-107 | Syntax | +| 108-460 | Operations | +| 461-539 | Examples | +| 540-630 | DataGrid 2 columns: how to address them, and what you can set | +| 631-642 | Common Mistakes | +| 643-664 | Limitations — prefer binding at page creation (ledger finding #45) | +| 665-672 | Validation Checklist | +| 673-682 | Related Commands | +| 683-687 | Related Skills | # ALTER PAGE / ALTER SNIPPET - Modify Existing Pages and Snippets @@ -83,7 +83,8 @@ alter page Module.PageName { ``` A `` on a page is a widget name, a DataGrid 2 column `grid column(Attr)` -(or `grid column('Caption')`), or a layout region `layoutContainer.top`. Properties go in parentheses with `:`, exactly as in +(or `grid column('Caption')`), a navigation list item `nav item(t1)` / +`nav item('Text')` / `nav item@n`, or a layout region `layoutContainer.top`. Properties go in parentheses with `:`, exactly as in `create page`. The older spellings `set Key = value on w`, `set Key: value` (no parentheses) and `drop widget w` still run, and warn with MDL-DEPR101, MDL-DEPR102 and MDL-DEPR103 — write the form above. @@ -321,6 +322,19 @@ page cannot go next to an ordinary widget or inside another tab page, and one in cannot mix tab pages and widgets. Dropping or reordering tab pages still needs `create or modify page`. +**Navigation list items:** `insert into { item … }` appends an item +and leaves the others untouched. An item has no name (Studio Pro stores none), so it +is addressed like a DataGrid 2 column — by what it holds or where it is: +`nav item(t1)` (the item holding widget `t1`), `nav item('Home')` (the item showing +that text), `nav item@2` (the second). `@n` also picks one of several content +matches; an ambiguous address is refused and a miss lists every item's addresses. +Prefer the content forms — a position shifts when an item is inserted before it. +`insert before|after { item … }`, `replace with { item … }`, +`drop `, `set (Action: …) on ` and `insert into { widgets }` all +work; widgets never go next to an item. An item takes `Class`, `Style` and +`DynamicClasses` like a widget: `item (Action: …, Class: 'nav-home') { … }`, or +`set (Class: 'nav-home') on navList1 item@1`. + **The context comes from the nearest enclosing data source, whatever kind it is** — a database or association source, a microflow/nanoflow source (the entity is the flow's return type), or `datasource: selection `, which takes the diff --git a/.claude/skills/mendix/browse-integrations/SKILL.md b/.claude/skills/mendix/browse-integrations/SKILL.md index fdb0172526..89e95ae649 100644 --- a/.claude/skills/mendix/browse-integrations/SKILL.md +++ b/.claude/skills/mendix/browse-integrations/SKILL.md @@ -130,13 +130,13 @@ where ee.Id IS null; -- All REST operations across all consumed services select ServiceQualifiedName, HttpMethod, path, Name -from CATALOG.REST_OPERATIONS +from CATALOG.CONSUMED_REST_OPERATIONS ORDER by ServiceQualifiedName, path; -- Cross-cutting: all integration services in a module select ObjectType, QualifiedName from CATALOG.OBJECTS -where ObjectType in ('ODATA_CLIENT', 'REST_CLIENT', 'ODATA_SERVICE', +where ObjectType in ('CONSUMED_ODATA_SERVICE', 'CONSUMED_REST_SERVICE', 'PUBLISHED_ODATA_SERVICE', 'PUBLISHED_REST_SERVICE', 'BUSINESS_EVENT_SERVICE', 'DATABASE_CONNECTION') and ModuleName = 'Integration'; ``` diff --git a/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md b/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md index 54c4ab7575..94e0b95b0f 100644 --- a/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md +++ b/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md @@ -308,7 +308,7 @@ select QualifiedName, RepeatDescription, Microflow from CATALOG.SCHEDULED_EVENTS where Enabled = 1 and IntervalSeconds < 60; -select QualifiedName, Parallelism, ClusterWide from CATALOG.QUEUES; +select QualifiedName, Parallelism, ClusterWide from CATALOG.TASK_QUEUES; ``` A scheduled event counts as a caller of the microflow it runs, so @@ -328,7 +328,7 @@ Starlark lint rules can iterate both: `scheduled_events()` yields ## Scheduled events — Mendix's cron (LIST/DESCRIBE/CREATE [OR MODIFY]/DROP). `Repeat:` names one of the eight `ScheduledEvents$*Schedule` variants and only that variant's fields are accepted; a field from another repeat is refused by `mxcli check` (MDL-SCHED01) and by exec, which call the same function. The document shape is pinned by re-serializing three whole Studio Pro-authored events (Workflow Commons 4.11.0, OIDC SSO 4.6.0, SAML 4.2.1) element by element — `modelsdk/gen` is **wrong** about two properties here -the integers are stored as int64 (gen says int32, the #585 mismatch) and `StartDateTime` is a BSON datetime (gen says string), so both engines share one raw-BSON codec in `mdl/scheduledevents`. `Interval`/`IntervalType` are legacy siblings of `Schedule` that Studio Pro writes and does not keep in sync — derived on CREATE, carried through untouched on MODIFY. Only the Day and Hour variants have a Studio Pro reference; the other six are metamodel-derived and verified to load. Both are in the catalog (`CATALOG.SCHEDULED_EVENTS`, `CATALOG.QUEUES`) and a scheduled event emits a `schedule` edge into `CATALOG.REFS` — without it a microflow run only by a scheduled event was reported as dead by `list callers`, `GRAPH_DEAD_ASSETS` and lint rule QUAL004. +the integers are stored as int64 (gen says int32, the #585 mismatch) and `StartDateTime` is a BSON datetime (gen says string), so both engines share one raw-BSON codec in `mdl/scheduledevents`. `Interval`/`IntervalType` are legacy siblings of `Schedule` that Studio Pro writes and does not keep in sync — derived on CREATE, carried through untouched on MODIFY. Only the Day and Hour variants have a Studio Pro reference; the other six are metamodel-derived and verified to load. Both are in the catalog (`CATALOG.SCHEDULED_EVENTS`, `CATALOG.TASK_QUEUES`) and a scheduled event emits a `schedule` edge into `CATALOG.REFS` — without it a microflow run only by a scheduled event was reported as dead by `list callers`, `GRAPH_DEAD_ASSETS` and lint rule QUAL004. ## Task queues (LIST/DESCRIBE/CREATE [OR MODIFY]/DROP TASK QUEUE). `Config.ParallelismExpression` is a **string** and the sibling int32 `Parallelism` is not written — matching all four Studio Pro queues in Business Events 3.12.1. Binding a *call* to a queue is not yet authorable, so `CREATE OR REPLACE|MODIFY MICROFLOW` is **refused** when the stored microflow has a queued call (guard-don't-drop, ADR-0005) diff --git a/cmd/mxcli/cmd_describe.go b/cmd/mxcli/cmd_describe.go index 03d5bbfe6f..70cde93b92 100644 --- a/cmd/mxcli/cmd_describe.go +++ b/cmd/mxcli/cmd_describe.go @@ -49,25 +49,25 @@ Types: jsonstructure Describe a JSON structure (also: "json structure") importmapping Describe an import mapping (also: "import mapping") exportmapping Describe an export mapping (also: "export mapping") - restclient Describe a consumed REST service (also: "rest client") - odataclient Describe a consumed OData service - odataservice Describe a published OData service + consumedrestservice Describe a consumed REST service (also: "consumed rest service"; old: restclient) + consumedodataservice Describe a consumed OData service (also: "consumed odata service"; old: odataclient) + publishedodataservice Describe a published OData service (also: "published odata service"; old: odataservice) publishedrestservice Describe a published REST service (also: "published rest service", "rest service") imagecollection Describe an image collection (also: "image collection") menu Describe a standalone menu document - queue Describe a task queue + taskqueue Describe a task queue (also: "task queue"; old: queue) scheduledevent Describe a scheduled event (also: "scheduled event") regularexpression Describe a regular expression (also: "regular expression") businesseventservice Describe a business event service (also: "business event service") databaseconnection Describe a database connection (also: "database connection") agent Describe an AI agent (also: "agent") - aimodel Describe an AI model (also: "model", "ai model") + aimodel Describe an AI model (also: "ai model"; old: model) knowledgebase Describe a knowledge base (also: "knowledge base") consumedmcpservice Describe a consumed MCP service (also: "consumed mcp service") datatransformer Describe a data transformer (also: "data transformer") modulerole Describe a module role userrole Describe a user role - projectsecurity Show project security settings + appsecurity Describe app security settings (also: "app security"; old: projectsecurity) settings Describe project settings demouser Describe a demo user navigation Describe a navigation profile @@ -86,13 +86,15 @@ Example: mxcli describe -p app.mpr json structure MyModule.CustomerResponse mxcli describe -p app.mpr import mapping MyModule.IMM_Customer mxcli describe -p app.mpr export mapping MyModule.EMM_Customer - mxcli describe -p app.mpr rest client MyModule.PetStoreAPI + mxcli describe -p app.mpr consumed rest service MyModule.PetStoreAPI + mxcli describe -p app.mpr consumed odata service MyModule.CrmApi + mxcli describe -p app.mpr task queue MyModule.EmailQueue mxcli describe -p app.mpr settings Settings mxcli describe -p app.mpr navigation Responsive mxcli describe -p app.mpr --format elk systemoverview SystemOverview mxcli describe -p app.mpr --format svg domainmodel MyModule mxcli describe -p app.mpr agent MyModule.MyAgent - mxcli describe -p app.mpr model MyModule.GPT4 + mxcli describe -p app.mpr ai model MyModule.GPT4 mxcli describe -p app.mpr knowledge base MyModule.ProductDocs mxcli describe -p app.mpr consumed mcp service MyModule.FilesystemMCP mxcli describe -p app.mpr data transformer MyModule.TransformCustomer @@ -131,8 +133,8 @@ Example: mdlCmd, ok := describeMDLCommand(objectType, name) if !ok { fmt.Fprintf(os.Stderr, "Unknown type: %s\n", strings.Join(args[:len(args)-1], " ")) - fmt.Fprintln(os.Stderr, "Valid types: module, entity, association, enumeration, constant, microflow, nanoflow, workflow, page, snippet, layout, javaaction, javascriptaction, jsonstructure, importmapping, exportmapping, restclient, odataclient, odataservice, publishedrestservice, imagecollection, menu, queue, scheduledevent, businesseventservice, databaseconnection, agent, aimodel, knowledgebase, consumedmcpservice, datatransformer, modulerole, userrole, projectsecurity, settings, demouser, navigation, systemoverview") - fmt.Fprintln(os.Stderr, "Multi-word types also accepted: json structure, import mapping, export mapping, rest client, published rest service, image collection, scheduled event, regular expression, business event service, agent, model, knowledge base, consumed mcp service, data transformer, etc.") + fmt.Fprintln(os.Stderr, "Valid types: module, entity, association, enumeration, constant, microflow, nanoflow, workflow, page, snippet, layout, javaaction, javascriptaction, jsonstructure, importmapping, exportmapping, consumedrestservice, consumedodataservice, publishedodataservice, publishedrestservice, imagecollection, menu, taskqueue, scheduledevent, businesseventservice, databaseconnection, agent, aimodel, knowledgebase, consumedmcpservice, datatransformer, modulerole, userrole, appsecurity, settings, demouser, navigation, systemoverview") + fmt.Fprintln(os.Stderr, "Multi-word types also accepted: json structure, import mapping, export mapping, consumed rest service, consumed odata service, published odata service, published rest service, task queue, app security, image collection, scheduled event, regular expression, business event service, ai model, knowledge base, consumed mcp service, data transformer, etc.") os.Exit(1) } @@ -269,7 +271,7 @@ func describeMDLCommand(objectType, name string) (mdlCmd string, ok bool) { return fmt.Sprintf("DESCRIBE MODULE ROLE %s", name), true case "USERROLE", "USER ROLE": return fmt.Sprintf("DESCRIBE USER ROLE '%s'", name), true - case "PROJECTSECURITY", "PROJECT SECURITY": + case "APPSECURITY", "APP SECURITY", "PROJECTSECURITY", "PROJECT SECURITY": return "DESCRIBE APP SECURITY", true case "SETTINGS": return "DESCRIBE SETTINGS", true @@ -287,11 +289,11 @@ func describeMDLCommand(objectType, name string) (mdlCmd string, ok bool) { return fmt.Sprintf("DESCRIBE IMPORT MAPPING %s", name), true case "EXPORTMAPPING", "EXPORT MAPPING": return fmt.Sprintf("DESCRIBE EXPORT MAPPING %s", name), true - case "RESTCLIENT", "REST CLIENT": + case "CONSUMEDRESTSERVICE", "CONSUMED REST SERVICE", "RESTCLIENT", "REST CLIENT": return fmt.Sprintf("DESCRIBE CONSUMED REST SERVICE %s", name), true - case "ODATACLIENT", "ODATA CLIENT": + case "CONSUMEDODATASERVICE", "CONSUMED ODATA SERVICE", "ODATACLIENT", "ODATA CLIENT": return fmt.Sprintf("DESCRIBE CONSUMED ODATA SERVICE %s", name), true - case "ODATASERVICE", "ODATA SERVICE": + case "PUBLISHEDODATASERVICE", "PUBLISHED ODATA SERVICE", "ODATASERVICE", "ODATA SERVICE": return fmt.Sprintf("DESCRIBE PUBLISHED ODATA SERVICE %s", name), true case "PUBLISHEDRESTSERVICE", "PUBLISHED REST SERVICE", "RESTSERVICE", "REST SERVICE": return fmt.Sprintf("DESCRIBE PUBLISHED REST SERVICE %s", name), true @@ -299,7 +301,7 @@ func describeMDLCommand(objectType, name string) (mdlCmd string, ok bool) { return fmt.Sprintf("DESCRIBE IMAGE COLLECTION %s", name), true case "MENU": return fmt.Sprintf("DESCRIBE MENU %s", name), true - case "QUEUE": + case "TASKQUEUE", "TASK QUEUE", "QUEUE": return fmt.Sprintf("DESCRIBE TASK QUEUE %s", name), true case "SCHEDULEDEVENT", "SCHEDULED EVENT": return fmt.Sprintf("DESCRIBE SCHEDULED EVENT %s", name), true @@ -324,7 +326,7 @@ func describeMDLCommand(objectType, name string) (mdlCmd string, ok bool) { case "AGENT": return fmt.Sprintf("DESCRIBE AGENT %s", name), true case "AIMODEL", "AI MODEL", "MODEL": - return fmt.Sprintf("DESCRIBE MODEL %s", name), true + return fmt.Sprintf("DESCRIBE AI MODEL %s", name), true case "KNOWLEDGEBASE", "KNOWLEDGE BASE": return fmt.Sprintf("DESCRIBE KNOWLEDGE BASE %s", name), true case "CONSUMEDMCPSERVICE", "CONSUMED MCP SERVICE", "MCP SERVICE": @@ -340,25 +342,31 @@ func describeMDLCommand(objectType, name string) (mdlCmd string, ok bool) { // objectTypeToDescribe maps a catalog `objects` view ObjectType to the `describe` // keyword. Only directly describable types with a `Module.Name` shape are listed. var objectTypeToDescribe = map[string]string{ - "MODULE": "module", - "ENTITY": "entity", - "EXTERNAL_ENTITY": "entity", - "ASSOCIATION": "association", - "MICROFLOW": "microflow", - "NANOFLOW": "nanoflow", - "PAGE": "page", - "SNIPPET": "snippet", - "LAYOUT": "layout", - "ENUMERATION": "enumeration", - "CONSTANT": "constant", - "JAVA_ACTION": "javaaction", - "WORKFLOW": "workflow", - "JSON_STRUCTURE": "jsonstructure", - "IMPORT_MAPPING": "importmapping", - "EXPORT_MAPPING": "exportmapping", - "ODATA_CLIENT": "odataclient", - "ODATA_SERVICE": "odataservice", - "REST_CLIENT": "restclient", + "MODULE": "module", + "ENTITY": "entity", + "EXTERNAL_ENTITY": "entity", + "ASSOCIATION": "association", + "MICROFLOW": "microflow", + "NANOFLOW": "nanoflow", + "PAGE": "page", + "SNIPPET": "snippet", + "LAYOUT": "layout", + "ENUMERATION": "enumeration", + "CONSTANT": "constant", + "JAVA_ACTION": "javaaction", + "WORKFLOW": "workflow", + "JSON_STRUCTURE": "jsonstructure", + "IMPORT_MAPPING": "importmapping", + "EXPORT_MAPPING": "exportmapping", + "CONSUMED_ODATA_SERVICE": "consumedodataservice", + "PUBLISHED_ODATA_SERVICE": "publishedodataservice", + "CONSUMED_REST_SERVICE": "consumedrestservice", + "TASK_QUEUE": "taskqueue", + // Pre-#1395 ObjectType values, for a catalog cached by an older build. + "ODATA_CLIENT": "consumedodataservice", + "ODATA_SERVICE": "publishedodataservice", + "REST_CLIENT": "consumedrestservice", + "QUEUE": "taskqueue", "PUBLISHED_REST_SERVICE": "publishedrestservice", "BUSINESS_EVENT_SERVICE": "businesseventservice", "DATABASE_CONNECTION": "databaseconnection", @@ -366,7 +374,7 @@ var objectTypeToDescribe = map[string]string{ "MENU": "menu", "DATA_TRANSFORMER": "datatransformer", "AGENT": "agent", - "AI_MODEL": "model", + "AI_MODEL": "aimodel", "KNOWLEDGE_BASE": "knowledgebase", "CONSUMED_MCP_SERVICE": "consumedmcpservice", "JAVASCRIPT_ACTION": "javascriptaction", @@ -394,7 +402,7 @@ var unitTypeToDescribe = map[string]string{ "Images$ImageCollection": "imagecollection", "Menus$MenuDocument": "menu", "Workflows$Workflow": "workflow", - "Queues$Queue": "queue", + "Queues$Queue": "taskqueue", "ScheduledEvents$ScheduledEvent": "scheduledevent", "RegularExpressions$RegularExpression": "regularexpression", "Rest$PublishedRestService": "publishedrestservice", diff --git a/cmd/mxcli/cmd_describe_test.go b/cmd/mxcli/cmd_describe_test.go index 4e37a7264e..67131716a3 100644 --- a/cmd/mxcli/cmd_describe_test.go +++ b/cmd/mxcli/cmd_describe_test.go @@ -124,3 +124,66 @@ func TestDescribeMDLCommand_PublishedRestService(t *testing.T) { t.Error("unit type Rest$PublishedRestService has no auto-detect keyword") } } + +// TestDescribeHelp_StudioProDocumentNames is mendixlabs/mxcli#1395: "mxcli +// describe --help and the catalog.db still use the old names" after ako/mxcli#755 +// renamed the document types to Studio Pro's (R10). The help, the auto-detected +// keyword and the ambiguity hint must lead with the canonical name; the old +// spelling stays accepted as an alias. +func TestDescribeHelp_StudioProDocumentNames(t *testing.T) { + renamed := []struct { + canonical, keyword, oldKeyword, mdl string + }{ + {"consumed rest service", "consumedrestservice", "restclient", "DESCRIBE CONSUMED REST SERVICE M.X"}, + {"consumed odata service", "consumedodataservice", "odataclient", "DESCRIBE CONSUMED ODATA SERVICE M.X"}, + {"published odata service", "publishedodataservice", "odataservice", "DESCRIBE PUBLISHED ODATA SERVICE M.X"}, + {"task queue", "taskqueue", "queue", "DESCRIBE TASK QUEUE M.X"}, + {"ai model", "aimodel", "model", "DESCRIBE AI MODEL M.X"}, + } + help := describeCmd.Long + for _, r := range renamed { + for _, typ := range []string{r.keyword, r.canonical, r.oldKeyword} { + got, ok := describeMDLCommand(strings.ToUpper(typ), "M.X") + if !ok { + t.Errorf("describe %s M.X: Unknown type", typ) + continue + } + if got != r.mdl { + t.Errorf("describe %s M.X = %q, want %q", typ, got, r.mdl) + } + } + if !strings.Contains(help, "\n "+r.keyword+" ") { + t.Errorf("describe --help lists no %q type line", r.keyword) + } + if strings.Contains(help, "\n "+r.oldKeyword+" ") { + t.Errorf("describe --help still lists the old name %q as a type", r.oldKeyword) + } + if strings.Contains(help, "app.mpr "+r.oldKeyword+" ") || strings.Contains(help, "app.mpr "+strings.ReplaceAll(r.oldKeyword, "client", " client")+" ") { + t.Errorf("describe --help example still uses the old name %q", r.oldKeyword) + } + } + for _, typ := range []string{"APPSECURITY", "APP SECURITY", "PROJECTSECURITY", "PROJECT SECURITY"} { + if got, ok := describeMDLCommand(typ, "x"); !ok || got != "DESCRIBE APP SECURITY" { + t.Errorf("describe %s = %q, %v; want DESCRIBE APP SECURITY", strings.ToLower(typ), got, ok) + } + } + if !strings.Contains(help, "\n appsecurity ") || strings.Contains(help, "\n projectsecurity ") { + t.Error("describe --help must list appsecurity, not projectsecurity") + } + + // Auto-detect and the ambiguity hint print these keywords back to the user. + for k, want := range map[string]string{ + "REST_CLIENT": "consumedrestservice", "CONSUMED_REST_SERVICE": "consumedrestservice", + "ODATA_CLIENT": "consumedodataservice", "CONSUMED_ODATA_SERVICE": "consumedodataservice", + "ODATA_SERVICE": "publishedodataservice", "PUBLISHED_ODATA_SERVICE": "publishedodataservice", + "QUEUE": "taskqueue", "TASK_QUEUE": "taskqueue", + "AI_MODEL": "aimodel", + } { + if got := objectTypeToDescribe[k]; got != want { + t.Errorf("objectTypeToDescribe[%q] = %q, want %q", k, got, want) + } + } + if got := unitTypeToDescribe["Queues$Queue"]; got != "taskqueue" { + t.Errorf("unitTypeToDescribe[Queues$Queue] = %q, want taskqueue", got) + } +} diff --git a/cmd/mxcli/docker/javadeps.go b/cmd/mxcli/docker/javadeps.go index 35aa3eb114..d1449f6c23 100644 --- a/cmd/mxcli/docker/javadeps.go +++ b/cmd/mxcli/docker/javadeps.go @@ -3,6 +3,7 @@ package docker import ( + "errors" "fmt" "io" "os" @@ -58,7 +59,11 @@ func SyncJavaDependencies(projectPath, mxPath, version string, w io.Writer) erro out := &syncBuffer{} cmd.Stdout = out cmd.Stderr = out - if err := cmd.Run(); err != nil { + // mx runs Gradle; a daemon it starts keeps the pipe open after mx exits + // (#1396). mx's own exit status is the result — ErrWaitDelay only says the + // daemon outlived it. + cmd.WaitDelay = orphanPipeWaitDelay + if err := cmd.Run(); err != nil && !errors.Is(err, exec.ErrWaitDelay) { return fmt.Errorf("mx sync-java-dependencies failed: %w\n%s", err, lastLines(out.String(), 20)) } if w != nil { diff --git a/cmd/mxcli/docker/mxserve.go b/cmd/mxcli/docker/mxserve.go index 6fe30a65e9..9366e76b64 100644 --- a/cmd/mxcli/docker/mxserve.go +++ b/cmd/mxcli/docker/mxserve.go @@ -289,18 +289,7 @@ func StartServe(opts ServeOptions) (*ServeServer, error) { port = 6543 } - cmd := exec.Command(mxbuildPath, "--serve", - fmt.Sprintf("--host=%s", host), - fmt.Sprintf("--port=%d", port), - fmt.Sprintf("--java-home=%s", javaHome), - fmt.Sprintf("--java-exe-path=%s", javaExe), - ) - PrepareMxCommand(cmd) // FreeType LD_PRELOAD workaround - setProcessGroup(cmd) // reap the JVM grandchild on Stop (mxbuild is a wrapper) - log := &syncBuffer{} - cmd.Stdout = log - cmd.Stderr = log - + cmd, log := newServeCmd(mxbuildPath, host, port, javaHome, javaExe) if err := cmd.Start(); err != nil { return nil, fmt.Errorf("starting mxbuild --serve: %w", err) } @@ -318,6 +307,25 @@ func StartServe(opts ServeOptions) (*ServeServer, error) { return s, nil } +// newServeCmd builds the `mxbuild --serve` command and the buffer its output +// goes to. Split out of StartServe so the process settings — which decide +// whether Stop can return at all — are tested on the command that ships. +func newServeCmd(mxbuildPath, host string, port int, javaHome, javaExe string) (*exec.Cmd, *syncBuffer) { + cmd := exec.Command(mxbuildPath, "--serve", + fmt.Sprintf("--host=%s", host), + fmt.Sprintf("--port=%d", port), + fmt.Sprintf("--java-home=%s", javaHome), + fmt.Sprintf("--java-exe-path=%s", javaExe), + ) + PrepareMxCommand(cmd) // FreeType LD_PRELOAD workaround + setProcessGroup(cmd) // reap the JVM grandchild on Stop (mxbuild is a wrapper) + log := &syncBuffer{} + cmd.Stdout = log + cmd.Stderr = log + cmd.WaitDelay = orphanPipeWaitDelay // a Gradle daemon may hold the pipe (#1396) + return cmd, log +} + func (s *ServeServer) baseURL() string { return fmt.Sprintf("http://%s:%d", s.Host, s.Port) } // waitReady polls the build endpoint until it responds (an empty POST yields a @@ -481,3 +489,18 @@ func (e *BuildFailedError) BuildErrors() []BuildProblem { } return e.Result.Errors() } + +// orphanPipeWaitDelay bounds how long Wait waits, after mx/mxbuild has exited, +// for its output pipe to close. +// +// A writer that is not an *os.File makes exec.Cmd create a pipe, and Wait does +// not return until every holder of its write end has closed it. When mxbuild or +// mx starts a NEW Gradle daemon, the daemon inherits that write end and outlives +// its parent — on Windows it is out of taskkill /T's reach once the parent is +// gone — so Wait blocked forever and `test --local` never printed its results or +// wrote its JUnit file (#1396). A reused daemon was started by an earlier run and +// never held this pipe, which is why only a cold Gradle home hung. +// +// The delay runs only once the process has exited, so it never cuts a live build +// short; Wait then closes the pipe and returns exec.ErrWaitDelay. +var orphanPipeWaitDelay = 10 * time.Second diff --git a/cmd/mxcli/docker/orphanpipe_test.go b/cmd/mxcli/docker/orphanpipe_test.go new file mode 100644 index 0000000000..b2de033788 --- /dev/null +++ b/cmd/mxcli/docker/orphanpipe_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package docker + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// #1396: "mxcli test --local hangs indefinitely ... after the tests finish, when +// mxbuild starts a new Gradle daemon". The daemon inherits the write end of the +// output pipe exec.Cmd creates for a non-*os.File writer, outlives mxbuild, and +// escapes the tree kill — so Wait, waiting for EOF, never returns. +// +// The fakes reproduce exactly that shape: a grandchild started with setsid +// (out of mxbuild's process group, as the daemon is out of taskkill /T's reach +// once its parent is gone) holds stdout open long after the direct child exits. + +// orphanFake writes an executable script that starts a setsid'd grandchild +// holding stdout for 30s, records its PID, and then runs body. The grandchild +// is killed when the test ends. +func orphanFake(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + pidFile := filepath.Join(dir, "daemon.pid") + script := filepath.Join(dir, "fake-mx") + src := "#!/bin/sh\n" + + "echo starting gradle daemon\n" + + "setsid sh -c 'echo $$ > \"" + pidFile + "\"; exec sleep 30' &\n" + + // Wait until the daemon has recorded itself, so cleanup can reap it. + "while [ ! -s \"" + pidFile + "\" ]; do sleep 0.01; done\n" + + body + "\n" + if err := os.WriteFile(script, []byte(src), 0o755); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + b, err := os.ReadFile(pidFile) + if err != nil { + return + } + if pid, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && pid > 0 { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + }) + return script +} + +func shortOrphanWait(t *testing.T) { + t.Helper() + old := orphanPipeWaitDelay + orphanPipeWaitDelay = 300 * time.Millisecond + t.Cleanup(func() { orphanPipeWaitDelay = old }) +} + +// ServeServer.Stop is what `test --local` runs before it prints results and +// writes the JUnit file; it must return once mxbuild itself has gone. +func TestServeStop_ReturnsWhenOrphanedDaemonHoldsPipe(t *testing.T) { + shortOrphanWait(t) + // The serve process blocks until signalled, like `mxbuild --serve`. + fake := orphanFake(t, "exec sleep 60") + + cmd, log := newServeCmd(fake, "127.0.0.1", 6543, "/jdk", "/jdk/bin/java") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + s := &ServeServer{Host: "127.0.0.1", Port: 6543, cmd: cmd, log: log} + // Let the fake reach its blocking exec before it is signalled. + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(log.String(), "starting gradle daemon") && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + time.Sleep(200 * time.Millisecond) + + done := make(chan struct{}) + start := time.Now() + go func() { _ = s.Stop(); close(done) }() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatalf("Stop() still blocked after 15s: an orphaned daemon holding mxbuild's output pipe hangs teardown (#1396)") + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Errorf("Stop() took %s", elapsed) + } +} + +// `mx sync-java-dependencies` runs Gradle too. Its own exit is success even if +// the daemon it started is still holding the pipe. +func TestSyncJavaDependencies_ReturnsWhenOrphanedDaemonHoldsPipe(t *testing.T) { + shortOrphanWait(t) + fake := orphanFake(t, "echo synced 1 jar; exit 0") + project := filepath.Join(t.TempDir(), "App.mpr") + + type result struct{ err error } + done := make(chan result, 1) + start := time.Now() + var out strings.Builder + go func() { done <- result{SyncJavaDependencies(project, fake, "11.12.5", &out)} }() + select { + case r := <-done: + if r.err != nil { + t.Fatalf("mx exited 0 but SyncJavaDependencies failed: %v", r.err) + } + case <-time.After(15 * time.Second): + t.Fatalf("SyncJavaDependencies still blocked after 15s: an orphaned daemon holding mx's output pipe hangs it (#1396)") + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Errorf("SyncJavaDependencies took %s", elapsed) + } + if !strings.Contains(out.String(), "synced 1 jar") { + t.Errorf("mx output not forwarded: %q", out.String()) + } +} + +// Control: a failing mx is still a failure — the orphan tolerance must not +// swallow a real exit status. +func TestSyncJavaDependencies_FailingMxStillFails(t *testing.T) { + shortOrphanWait(t) + fake := orphanFake(t, "echo resolution failed; exit 2") + err := SyncJavaDependencies(filepath.Join(t.TempDir(), "App.mpr"), fake, "11.12.5", nil) + if err == nil || !strings.Contains(err.Error(), "resolution failed") { + t.Fatalf("err = %v, want the mx failure with its output", err) + } +} diff --git a/cmd/mxcli/lsp_completion.go b/cmd/mxcli/lsp_completion.go index ab52b5afff..17c355d5db 100644 --- a/cmd/mxcli/lsp_completion.go +++ b/cmd/mxcli/lsp_completion.go @@ -429,14 +429,14 @@ func objectTypeToCompletionKind(objectType string) (protocol.CompletionItemKind, return protocol.CompletionItemKindFile, "import mapping" case "EXPORT_MAPPING": return protocol.CompletionItemKindFile, "export mapping" - case "REST_CLIENT": - return protocol.CompletionItemKindInterface, "rest client" + case "CONSUMED_REST_SERVICE", "REST_CLIENT": + return protocol.CompletionItemKindInterface, "consumed rest service" case "PUBLISHED_REST_SERVICE": return protocol.CompletionItemKindInterface, "published rest service" - case "ODATA_CLIENT": - return protocol.CompletionItemKindInterface, "odata client" - case "ODATA_SERVICE": - return protocol.CompletionItemKindInterface, "odata service" + case "CONSUMED_ODATA_SERVICE", "ODATA_CLIENT": + return protocol.CompletionItemKindInterface, "consumed odata service" + case "PUBLISHED_ODATA_SERVICE", "ODATA_SERVICE": + return protocol.CompletionItemKindInterface, "published odata service" case "DATABASE_CONNECTION": return protocol.CompletionItemKindInterface, "database connection" case "BUSINESS_EVENT_SERVICE": diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 4b91df2af8..dfa87af926 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -13,7 +13,7 @@ func init() { "odata", "rest", "business events", "database", }, Syntax: "LIST CONSUMED ODATA SERVICES [IN Module];\nLIST CONSUMED REST SERVICES [IN Module];\nLIST PUBLISHED REST SERVICES [IN Module];\nLIST BUSINESS EVENT SERVICES [IN Module];\nLIST DATABASE CONNECTIONS [IN Module];\nLIST EXTERNAL ENTITIES [IN Module];\nLIST EXTERNAL ACTIONS [IN Module];", - Example: "LIST CONSUMED ODATA SERVICES;\nLIST CONSUMED REST SERVICES IN MyModule;\nLIST EXTERNAL ENTITIES;\nSELECT * FROM CATALOG.REST_CLIENTS;", + Example: "LIST CONSUMED ODATA SERVICES;\nLIST CONSUMED REST SERVICES IN MyModule;\nLIST EXTERNAL ENTITIES;\nSELECT * FROM CATALOG.CONSUMED_REST_SERVICES;", SeeAlso: []string{"odata", "rest", "sql", "business-events"}, }) diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 99cb71dd23..c961a73faf 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -309,7 +309,7 @@ LIST IMPACT OF htmlelement; "drop template", "insert template", "list view template", "add parameter", "drop parameter", "page parameter", "add parameters", }, - Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `list design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n ADD PARAMETERS $Order: Module.Order; -- or a primitive: $Count: Integer.\n -- A page with a Url needs a {Order}\n -- segment: SET (Url: '…/{Order}') in the\n -- same statement (CE5601 otherwise).\n -- A snippet takes entities only (MDL087).\n DROP PARAMETERS $Order; -- refused while a data source or an\n -- expression on the page still uses it\n ADD VARIABLES $show: Boolean = true;\n DROP VARIABLES $show;\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", + Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `list design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n ADD PARAMETERS $Order: Module.Order; -- or a primitive: $Count: Integer.\n -- A page with a Url needs a {Order}\n -- segment: SET (Url: '…/{Order}') in the\n -- same statement (CE5601 otherwise).\n -- A snippet takes entities only (MDL087).\n DROP PARAMETERS $Order; -- refused while a data source or an\n -- expression on the page still uses it\n ADD VARIABLES $show: Boolean = true;\n DROP VARIABLES $show;\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match; or a\n-- navigation list item: nav item(widgetInIt), nav item('Text'), nav item@n. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", Example: "ALTER PAGE Module.EditPage {\n SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Attribute: MiddleName)\n };\n DROP txtUnused;\n};", SeeAlso: []string{"page.create", "page.show", "snippet.alter"}, }) diff --git a/docs-site/src/reference/odata/README.md b/docs-site/src/reference/odata/README.md index 7d113677eb..c0429592f2 100644 --- a/docs-site/src/reference/odata/README.md +++ b/docs-site/src/reference/odata/README.md @@ -113,8 +113,8 @@ After `REFRESH CATALOG`, the following tables are available for SQL queries: | Table | Contents | |-------|----------| -| `CATALOG.ODATA_CLIENTS` | Consumed OData services | -| `CATALOG.ODATA_SERVICES` | Published OData services | +| `CATALOG.CONSUMED_ODATA_SERVICES` | Consumed OData services (old name: `ODATA_CLIENTS`) | +| `CATALOG.PUBLISHED_ODATA_SERVICES` | Published OData services (old name: `ODATA_SERVICES`) | | `CATALOG.EXTERNAL_ENTITIES` | Imported external entities | | `CATALOG.EXTERNAL_ACTIONS` | External actions used in microflows | | `CATALOG.CONTRACT_ENTITIES` | Entity types from cached $metadata | diff --git a/docs-site/src/reference/page/alter-page.md b/docs-site/src/reference/page/alter-page.md index 8eea22a0ea..7db5e5b764 100644 --- a/docs-site/src/reference/page/alter-page.md +++ b/docs-site/src/reference/page/alter-page.md @@ -15,7 +15,8 @@ ALTER SNIPPET module.Name { `ALTER LAYOUT module.Name { … }` takes the same operations. This is the generic ALTER — `set` / `insert` / `replace` / `drop` against a target — which every document type shares; a page's targets are widget names, DataGrid 2 -columns `grid column(Attr)` / `grid column('Caption')`, or a layout region +columns `grid column(Attr)` / `grid column('Caption')`, navigation list items +`nav item(widget)` / `nav item('Text')` / `nav item@n`, or a layout region `container.top`. The older spellings still run and warn with a deprecation code: @@ -111,6 +112,51 @@ A tab page cannot go next to an ordinary widget or inside another tab page, and one `INSERT` cannot mix tab pages with widgets. Dropping, replacing or reordering tab pages still needs `CREATE OR MODIFY PAGE`. +A navigation list holds items, and only items. `INSERT INTO` the list appends +one and leaves the existing items as they are. The widgets inside an item are +targets by their own names. + +```sql +mdl 1; +alter snippet Shop.Menu { + insert into navList1 { item (Action: show page Shop.Home) { dynamictext t2 (Content: 'Second') } } +}; +alter snippet Shop.Menu { + insert after t2 { dynamictext t3 (Content: 'Third') } +}; +``` + +### Navigation List Item Operations + +A navigation list item has no stored name either, so — like a DataGrid 2 +column — it is addressed by what it holds or where it is: + +| Address | Means | +|---------|-------| +| `nav item(t1)` | the item holding the widget named `t1` | +| `nav item('Home')` | the item showing the text `Home` (any language) | +| `nav item@2` | the second item | +| `nav item('Home')@2` | the second of several items showing `Home` | + +An address that matches several items is refused unless `@n` picks one, and a +miss lists every item's addresses. Prefer the content forms: a position moves +when an item is inserted before it. + +```sql +mdl 1; +alter snippet Shop.Menu { + insert before navList1 item@1 { item (Action: show page Shop.Orders) { dynamictext t0 (Content: 'Orders') } } + insert after navList1 item(t1) { item (Action: show page Shop.Home) { dynamictext t5 (Content: 'Home') } } + set (Action: show page Shop.Orders) on navList1 item('Home')@2; + insert into navList1 item('Orders') { dynamictext t0b (Content: 'All orders') } + replace navList1 item(t2) with { item (Action: show page Shop.Home) { dynamictext t2 (Content: 'Second') } } + drop navList1 item@3; +}; +``` + +Only items go next to an item or in its place: `INSERT BEFORE` / `AFTER` an item +and `REPLACE` of one take `item` blocks, and widgets go `INTO` an item. + ### DROP Removes one or more widgets by name. The widget and all its children are removed from the tree. diff --git a/docs-site/src/tools/catalog-tables.md b/docs-site/src/tools/catalog-tables.md index 6ad769af9f..18587db6e7 100644 --- a/docs-site/src/tools/catalog-tables.md +++ b/docs-site/src/tools/catalog-tables.md @@ -336,9 +336,9 @@ A scheduled event also produces a `schedule` row in `CATALOG.REFS`, so `list callers of ` and the dead-asset analysis both see it. Without that edge a microflow run only by a scheduled event looked unreferenced. -### CATALOG.QUEUES +### CATALOG.TASK_QUEUES -Task queues. +Task queues. (`CATALOG.QUEUES` is the old name, still accepted.) | Column | Description | |--------|-------------| @@ -350,7 +350,7 @@ Task queues. not assume it parses as an integer. ```sql -select QualifiedName, Parallelism, ClusterWide from CATALOG.QUEUES; +select QualifiedName, Parallelism, ClusterWide from CATALOG.TASK_QUEUES; ``` ### Offline synchronization @@ -465,3 +465,20 @@ LIST CATALOG TABLES; ```bash mxcli -p app.mpr -c "LIST CATALOG TABLES" ``` + +### Renamed tables + +The integration and queue tables follow Studio Pro's document names. The old +names still answer as aliases, but `LIST CATALOG TABLES` shows only the new ones, +and `CATALOG.OBJECTS` reports the new `ObjectType` values: + +| Table | Old name | `ObjectType` (old) | +|-------|----------|--------------------| +| `CATALOG.CONSUMED_REST_SERVICES` | `REST_CLIENTS` | `CONSUMED_REST_SERVICE` (`REST_CLIENT`) | +| `CATALOG.CONSUMED_REST_OPERATIONS` | `REST_OPERATIONS` | — | +| `CATALOG.CONSUMED_ODATA_SERVICES` | `ODATA_CLIENTS` | `CONSUMED_ODATA_SERVICE` (`ODATA_CLIENT`) | +| `CATALOG.PUBLISHED_ODATA_SERVICES` | `ODATA_SERVICES` | `PUBLISHED_ODATA_SERVICE` (`ODATA_SERVICE`) | +| `CATALOG.TASK_QUEUES` | `QUEUES` | `TASK_QUEUE` (`QUEUE`) | + +A query that filters `OBJECTS` on an old `ObjectType` value returns no rows; +change it to the new value. diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index aa893d1acb..bacd54c402 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1720,6 +1720,8 @@ This is the generic ALTER — `alter Module.Name { set (Key: value) on ` | Add a page variable | | Drop variable | `drop variables $name` | Remove a page variable | | Add parameter | `add parameters $name: type` | Add a page/snippet parameter (entity or primitive; snippet: entity only). A page with a `Url` needs a `{name}` segment — `set (Url: …)` in the same statement | diff --git a/mdl-examples/bug-tests/1388-alter-insert-navigation-list-item.mdl b/mdl-examples/bug-tests/1388-alter-insert-navigation-list-item.mdl new file mode 100644 index 0000000000..5c5e00ea55 --- /dev/null +++ b/mdl-examples/bug-tests/1388-alter-insert-navigation-list-item.mdl @@ -0,0 +1,30 @@ +-- mendixlabs/mxcli#1388: ALTER SNIPPET could not add an item to a navigation +-- list. `insert into navList1 { item … }` failed with "item must be a direct +-- child of navigationlist", and `insert after t1 { … }` — t1 inside an existing +-- item — failed with `widget "t1" not found`. +-- +-- Expected: the second item is appended, the first is untouched, and t3 lands +-- after t2 inside the second item. `describe snippet BugTest1388.Menu` shows +-- two items; `mxcli docker check` reports 0 errors. +mdl 1; +create module BugTest1388; + +create page BugTest1388.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtHome (Content: 'Home') +}; + +create snippet BugTest1388.Menu { + navigationlist navList1 { + item (Action: show page BugTest1388.Home) { dynamictext t1 (Content: 'Home') } + } +}; + +alter snippet BugTest1388.Menu { + insert into navList1 { + item (Action: show page BugTest1388.Home) { dynamictext t2 (Content: 'Second') } + }; +}; + +alter snippet BugTest1388.Menu { + insert after t2 { dynamictext t3 (Content: 'Third') }; +}; diff --git a/mdl-examples/bug-tests/1388-alter-navigation-list-item-address.mdl b/mdl-examples/bug-tests/1388-alter-navigation-list-item-address.mdl new file mode 100644 index 0000000000..cf62913f0a --- /dev/null +++ b/mdl-examples/bug-tests/1388-alter-navigation-list-item-address.mdl @@ -0,0 +1,57 @@ +-- mendixlabs/mxcli#1388 (follow-up): a navigation list item has no name, so it +-- is addressed like a DataGrid 2 column — by a widget it holds `item(t1)`, a +-- text it shows `item('Home')`, or its position `item@n`. +-- +-- Expected `describe snippet BugTest1388b.Menu` afterwards: +-- item (Action: show page BugTest1388b.Orders) { t0 'Orders', t0b 'All orders' } +-- item (Action: show page BugTest1388b.Home) { t1 'Home' } +-- item (Action: show page BugTest1388b.Home) { t2 'Second, renamed' } +-- and `mxcli docker check` reports 0 errors. +mdl 1; +create module BugTest1388b; + +create page BugTest1388b.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtHome (Content: 'Home') +}; +create page BugTest1388b.Orders (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtOrders (Content: 'Orders') +}; + +create snippet BugTest1388b.Menu { + navigationlist navList1 { + item (Action: show page BugTest1388b.Home) { dynamictext t1 (Content: 'Home') } + item (Action: show page BugTest1388b.Home) { dynamictext t2 (Content: 'Second') } + } +}; + +-- by position +alter snippet BugTest1388b.Menu { + insert before navList1 item@1 { + item (Action: show page BugTest1388b.Orders) { dynamictext t0 (Content: 'Orders') } + }; +}; + +-- by a widget the item holds +alter snippet BugTest1388b.Menu { + insert after navList1 item(t1) { + item (Action: show page BugTest1388b.Home) { dynamictext t5 (Content: 'Home') } + }; +}; + +-- by text; two items show 'Home', so @2 picks the second +alter snippet BugTest1388b.Menu { + set (Action: show page BugTest1388b.Orders) on navList1 item('Home')@2; +}; + +-- widgets into an item +alter snippet BugTest1388b.Menu { + insert into navList1 item('Orders') { dynamictext t0b (Content: 'All orders') }; +}; + +-- replace one item, drop another +alter snippet BugTest1388b.Menu { + replace navList1 item(t2) with { + item (Action: show page BugTest1388b.Home) { dynamictext t2 (Content: 'Second, renamed') } + }; + drop navList1 item@3; +}; diff --git a/mdl-examples/bug-tests/1388-navigation-list-item-class-describe.mdl b/mdl-examples/bug-tests/1388-navigation-list-item-class-describe.mdl new file mode 100644 index 0000000000..1423420743 --- /dev/null +++ b/mdl-examples/bug-tests/1388-navigation-list-item-class-describe.mdl @@ -0,0 +1,31 @@ +-- A navigation list item's Class, Style and DynamicClasses (found while adding +-- item addresses for mendixlabs/mxcli#1388). Describe printed an item's class as +-- `ButtonStyle: nav-orders` — not an item property, and unparseable unquoted — +-- and CREATE wrote an empty appearance, so a styled item lost its class. +-- +-- Expected: `describe snippet BugTest1388c.Menu` prints +-- item (Action: …, Class: 'nav-home', Style: 'font-weight: bold;', DynamicClasses: 'is-current') +-- and re-executing that description changes nothing ("Unchanged snippet"). +-- `mxcli docker check` reports 0 errors. +mdl 1; +create module BugTest1388c; + +create page BugTest1388c.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtHome (Content: 'Home') +}; + +create snippet BugTest1388c.Menu { + navigationlist navList1 { + item (Action: show page BugTest1388c.Home, Class: 'nav-home', Style: 'font-weight: bold;', DynamicClasses: 'is-current') { + dynamictext t1 (Content: 'Home') + } + item (Action: show page BugTest1388c.Home) { + dynamictext t2 (Content: 'Plain') + } + } +}; + +-- set on an existing item, by address +alter snippet BugTest1388c.Menu { + set (Class: 'nav-plain') on navList1 item(t2); +}; diff --git a/mdl-examples/bug-tests/1395-studio-pro-document-names-catalog.mdl b/mdl-examples/bug-tests/1395-studio-pro-document-names-catalog.mdl new file mode 100644 index 0000000000..6067306061 --- /dev/null +++ b/mdl-examples/bug-tests/1395-studio-pro-document-names-catalog.mdl @@ -0,0 +1,25 @@ +mdl 1; + +/** + * mendixlabs/mxcli#1395: "mxcli describe --help and the catalog.db still use the + * old names" after the document types took Studio Pro's names (ako/mxcli#755). + * + * The catalog answers under the Studio Pro names, and OBJECTS reports them as + * ObjectType. The old table names (REST_CLIENTS, REST_OPERATIONS, ODATA_CLIENTS, + * ODATA_SERVICES, QUEUES) still answer as aliases but are no longer listed. + * + * `mxcli describe` takes the same names: consumedrestservice / "consumed rest + * service", consumedodataservice, publishedodataservice, taskqueue, aimodel, + * appsecurity; the old spellings stay accepted. + */ + +select QualifiedName, BaseUrl, OperationCount from catalog.CONSUMED_REST_SERVICES; +select ServiceQualifiedName, HttpMethod, Path from catalog.CONSUMED_REST_OPERATIONS; +select QualifiedName, MetadataUrl from catalog.CONSUMED_ODATA_SERVICES; +select QualifiedName, Path from catalog.PUBLISHED_ODATA_SERVICES; +select QualifiedName, Parallelism from catalog.TASK_QUEUES; + +select ObjectType, QualifiedName +from catalog.OBJECTS +where ObjectType in ('CONSUMED_REST_SERVICE', 'CONSUMED_ODATA_SERVICE', + 'PUBLISHED_ODATA_SERVICE', 'TASK_QUEUE'); diff --git a/mdl-examples/doctype-tests/10-odata-examples.mdl b/mdl-examples/doctype-tests/10-odata-examples.mdl index 50efff66b8..f2f908383c 100644 --- a/mdl-examples/doctype-tests/10-odata-examples.mdl +++ b/mdl-examples/doctype-tests/10-odata-examples.mdl @@ -132,15 +132,15 @@ list external entities in OdTest; /** * Level 7.1: Query OData clients from the catalog - * The CATALOG.ODATA_CLIENTS table contains all consumed OData services. + * The CATALOG.CONSUMED_ODATA_SERVICES table contains all consumed OData services. */ -select * from catalog.ODATA_CLIENTS; +select * from catalog.CONSUMED_ODATA_SERVICES; /** * Level 7.2: Query OData services from the catalog - * The CATALOG.ODATA_SERVICES table contains all published OData services. + * The CATALOG.PUBLISHED_ODATA_SERVICES table contains all published OData services. */ -select * from catalog.ODATA_SERVICES; +select * from catalog.PUBLISHED_ODATA_SERVICES; /** * Level 7.3: Find external entities via catalog diff --git a/mdl/ast/ast_alter_page.go b/mdl/ast/ast_alter_page.go index b1f70099f8..f714a95a51 100644 --- a/mdl/ast/ast_alter_page.go +++ b/mdl/ast/ast_alter_page.go @@ -37,6 +37,7 @@ type AlterPageOperation interface { // dg column(Name) Widget="dg", ColumnAttribute="Name" (a DataGrid 2 column by its attribute, #749) // dg column('Total') Widget="dg", ColumnCaption="Total" (… by its caption) // 'Approve order' Caption="Approve order" (content addressing, for elements with no name) +// nav item('Home') Widget="nav item('Home')", Item={List: "nav", Text: "Home"} (a navigation list item, #1388) // hdr@2 / 'x'@2 Ordinal=2 — picks one of several matches; never a guess // // The name stays WidgetRef because the page family is the first document type @@ -52,6 +53,39 @@ type WidgetRef struct { // then chooses among the grid's columns that match. ColumnAttribute string ColumnCaption string + // Item is set for the navigation list item address `nav item(…)` / + // `nav item@n`. Widget then holds the address as Item.Spell() writes it — + // the spelling the page mutator's widget lookup resolves — so every + // operation that takes a widget reaches the item unchanged. The item's @n + // lives in Item, never in Ordinal. + Item *NavItemRef +} + +// NavItemRef is a navigation list item address (mendixlabs/mxcli#1388). An item +// has no name, so it is addressed by a widget it holds, a text it shows, or its +// position: at most one of Widget and Text is set, and with neither, Ordinal +// counts every item of the list. +type NavItemRef struct { + List string + Widget string + Text string + Ordinal int +} + +// Spell writes the address as MDL does. backend.ParseNavItemSelector reads this +// spelling; a test in the executor pins the two together. +func (r NavItemRef) Spell() string { + s := r.List + " item" + switch { + case r.Text != "": + s += "('" + strings.ReplaceAll(r.Text, "'", "''") + "')" + case r.Widget != "": + s += "(" + r.Widget + ")" + } + if r.Ordinal > 0 { + s += "@" + strconv.Itoa(r.Ordinal) + } + return s } // IsColumnAddress reports whether the target is the explicit `grid column(…)` diff --git a/mdl/backend/mcp/page_mutator.go b/mdl/backend/mcp/page_mutator.go index 3239005803..370c0a7a06 100644 --- a/mdl/backend/mcp/page_mutator.go +++ b/mdl/backend/mcp/page_mutator.go @@ -506,6 +506,14 @@ func (m *mcpPageMutator) InsertTabPages(targetRef string, _ backend.InsertPositi return fmt.Errorf("adding tab pages at %s is not yet supported by the MCP backend", targetRef) } +func (m *mcpPageMutator) InsertNavigationListItems(targetRef string, _ backend.InsertPosition, _ []*pages.NavigationListItem) error { + return fmt.Errorf("adding navigation list items at %s is not yet supported by the MCP backend", targetRef) +} + +func (m *mcpPageMutator) ReplaceNavigationListItem(targetRef string, _ []*pages.NavigationListItem) error { + return fmt.Errorf("replacing navigation list item %s is not yet supported by the MCP backend", targetRef) +} + func (m *mcpPageMutator) DropListViewTemplate(listViewRef, specialization string) error { return fmt.Errorf("dropping the %s template from %s is not yet supported by the MCP backend", specialization, listViewRef) diff --git a/mdl/backend/mock/mock_page_mutator.go b/mdl/backend/mock/mock_page_mutator.go index ef42af9017..915008bb5e 100644 --- a/mdl/backend/mock/mock_page_mutator.go +++ b/mdl/backend/mock/mock_page_mutator.go @@ -33,6 +33,8 @@ type MockPageMutator struct { InsertColumnsFunc func(gridRef, afterColumnRef string, position backend.InsertPosition, columns []*backend.DataGridColumnSpec) error InsertListViewTemplatesFunc func(listViewRef string, templates []*pages.ListViewTemplate) error InsertTabPagesFunc func(targetRef string, position backend.InsertPosition, tabPages []*pages.TabPage) error + InsertNavigationListItemsFunc func(targetRef string, position backend.InsertPosition, items []*pages.NavigationListItem) error + ReplaceNavigationListItemFunc func(targetRef string, items []*pages.NavigationListItem) error DropListViewTemplateFunc func(listViewRef, specialization string) error ReplaceColumnFunc func(gridRef, columnRef string, columns []*backend.DataGridColumnSpec) error FindWidgetFunc func(name string) bool @@ -156,6 +158,20 @@ func (m *MockPageMutator) InsertTabPages(targetRef string, position backend.Inse return fmt.Errorf("MockBackend.InsertTabPages not configured") } +func (m *MockPageMutator) InsertNavigationListItems(targetRef string, position backend.InsertPosition, items []*pages.NavigationListItem) error { + if m.InsertNavigationListItemsFunc != nil { + return m.InsertNavigationListItemsFunc(targetRef, position, items) + } + return fmt.Errorf("MockBackend.InsertNavigationListItems not configured") +} + +func (m *MockPageMutator) ReplaceNavigationListItem(targetRef string, items []*pages.NavigationListItem) error { + if m.ReplaceNavigationListItemFunc != nil { + return m.ReplaceNavigationListItemFunc(targetRef, items) + } + return fmt.Errorf("MockBackend.ReplaceNavigationListItem not configured") +} + func (m *MockPageMutator) DropListViewTemplate(listViewRef, specialization string) error { if m.DropListViewTemplateFunc != nil { return m.DropListViewTemplateFunc(listViewRef, specialization) diff --git a/mdl/backend/modelsdk/widget_navlist_item_write_test.go b/mdl/backend/modelsdk/widget_navlist_item_write_test.go index 22a34aad65..3215edaa23 100644 --- a/mdl/backend/modelsdk/widget_navlist_item_write_test.go +++ b/mdl/backend/modelsdk/widget_navlist_item_write_test.go @@ -53,3 +53,29 @@ func TestNavListItemToGen_StudioProShape(t *testing.T) { } } } + +// An item's Class, Style and DynamicClasses go in its Forms$Appearance, the +// same place Studio Pro keeps them (and `set Class … on nav item@1` writes). +func TestNavListItemToGen_WritesAppearance(t *testing.T) { + el, err := navListItemToGen(&pages.NavigationListItem{ + BaseElement: model.BaseElement{ID: "item-1"}, + Class: "nav-orders", + Style: "color: red;", + DynamicClasses: "'x'", + }) + if err != nil { + t.Fatal(err) + } + out, err := (&codec.Encoder{}).Encode(el) + if err != nil { + t.Fatal(err) + } + var doc bson.M + if err := bson.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + app, _ := doc["Appearance"].(bson.M) + if app["Class"] != "nav-orders" || app["Style"] != "color: red;" || app["DynamicClasses"] != "'x'" { + t.Errorf("Appearance = %v, want the item's class, style and dynamic classes", app) + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index c3d3abd237..3e9999591a 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -971,7 +971,7 @@ func navListItemToGen(item *pages.NavigationListItem) (element.Element, error) { // document (mendixlabs/mxcli#1373). `mx check` is clean without it (measured // on 11.14.0 with two named items) — the CE7247 / CE0495 an older note // blamed on its absence is not reproduced. - g.SetAppearance(newAppearance("", "", "", nil)) + g.SetAppearance(newAppearance(item.Class, item.Style, item.DynamicClasses, nil)) act, err := clientActionToGen(item.Action) if err != nil { return nil, err diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index be8adbb092..7d0b76dba1 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -160,6 +160,17 @@ type PageMutator interface { // cannot go through InsertWidget (#1215). InsertTabPages(targetRef string, position InsertPosition, tabPages []*pages.TabPage) error + // InsertNavigationListItems adds items to a navigation list: INTO the list + // appends, BEFORE/AFTER an item places them next to it. An item lives in the + // list's Items and is not a widget, so it cannot go through InsertWidget. It + // has no Name either, so it is addressed as backend.NavItemSelector spells + // it — `nav item(t1)`, `nav item('Home')`, `nav item@2` (mendixlabs/mxcli#1388). + InsertNavigationListItems(targetRef string, position InsertPosition, items []*pages.NavigationListItem) error + + // ReplaceNavigationListItem replaces the item targetRef addresses with the + // given items, in its place (mendixlabs/mxcli#1388). + ReplaceNavigationListItem(targetRef string, items []*pages.NavigationListItem) error + // DropListViewTemplate removes the template rendering the given // specialization from a List View. A template has no name, so it is addressed // by entity. Returns an error naming the templates that ARE present when the diff --git a/mdl/backend/navitem_selector.go b/mdl/backend/navitem_selector.go new file mode 100644 index 0000000000..00111288ee --- /dev/null +++ b/mdl/backend/navitem_selector.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "strconv" + "strings" +) + +// NavItemSelector is the address of a navigation list item (mendixlabs/mxcli#1388): +// +// nav item(t1) the item holding the widget named t1 +// nav item('Home') the item whose text is Home +// nav item@2 the second item +// nav item('Home')@2 the second of several items whose text is Home +// +// Studio Pro stores no name on an item, so — like a DataGrid 2 column +// (ColumnSelector) — an item is addressed by what it shows, or by position. +// An address that matches more than one item is refused unless @n picks one. +// +// It travels through the PageMutator interface as the widgetRef string, spelled +// by String, so every operation that takes a widget reaches an item without a +// second set of methods. That spelling cannot be mistaken for a widget name: no +// widget name contains a space. +type NavItemSelector struct { + // List is the navigation list's name. + List string + // Widget is the name of a widget inside the item. At most one of Widget + // and Text is set; with neither, Ordinal counts every item. + Widget string + // Text is a text the item shows: a text or dynamic text's content, in any + // language. + Text string + // Ordinal is @n, 1-based; 0 when absent. + Ordinal int +} + +const navItemSelectorInfix = " item" + +// String spells the selector as MDL writes it. +func (s NavItemSelector) String() string { + var b strings.Builder + b.WriteString(s.List) + b.WriteString(navItemSelectorInfix) + switch { + case s.Text != "": + b.WriteString("('" + strings.ReplaceAll(s.Text, "'", "''") + "')") + case s.Widget != "": + b.WriteString("(" + s.Widget + ")") + } + if s.Ordinal > 0 { + b.WriteString("@" + strconv.Itoa(s.Ordinal)) + } + return b.String() +} + +// ParseNavItemSelector reads a widgetRef spelled by NavItemSelector.String. ok +// is false for anything else, including every widget name. +func ParseNavItemSelector(ref string) (NavItemSelector, bool) { + list, rest, found := strings.Cut(ref, navItemSelectorInfix) + if !found || list == "" || strings.ContainsAny(list, " ()'") { + return NavItemSelector{}, false + } + s := NavItemSelector{List: list} + switch { + case strings.HasPrefix(rest, "('"): + var b strings.Builder + i := 2 + for { + if i >= len(rest) { + return NavItemSelector{}, false + } + if rest[i] == '\'' { + if i+1 < len(rest) && rest[i+1] == '\'' { + b.WriteByte('\'') + i += 2 + continue + } + break + } + b.WriteByte(rest[i]) + i++ + } + s.Text = b.String() + rest = rest[i+1:] + if s.Text == "" || !strings.HasPrefix(rest, ")") { + return NavItemSelector{}, false + } + rest = rest[1:] + case strings.HasPrefix(rest, "("): + end := strings.IndexByte(rest, ')') + if end <= 1 { + return NavItemSelector{}, false + } + s.Widget = rest[1:end] + rest = rest[end+1:] + } + if rest == "" { + if s.Widget == "" && s.Text == "" { + return NavItemSelector{}, false // `nav item` alone addresses nothing + } + return s, true + } + n, found := strings.CutPrefix(rest, "@") + if !found { + return NavItemSelector{}, false + } + v, err := strconv.Atoi(n) + if err != nil || v < 1 { + return NavItemSelector{}, false + } + s.Ordinal = v + return s, true +} diff --git a/mdl/backend/navitem_selector_test.go b/mdl/backend/navitem_selector_test.go new file mode 100644 index 0000000000..578dd10a22 --- /dev/null +++ b/mdl/backend/navitem_selector_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import "testing" + +func TestNavItemSelector_RoundTrip(t *testing.T) { + for _, s := range []NavItemSelector{ + {List: "nav", Widget: "t1"}, + {List: "nav", Text: "Home"}, + {List: "nav", Text: "it's"}, + {List: "nav", Ordinal: 2}, + {List: "nav", Text: "Home", Ordinal: 3}, + {List: "nav", Widget: "t1", Ordinal: 1}, + } { + got, ok := ParseNavItemSelector(s.String()) + if !ok || got != s { + t.Errorf("ParseNavItemSelector(%q) = %+v, %v; want %+v", s.String(), got, ok, s) + } + } +} + +func TestNavItemSelector_NotAWidgetName(t *testing.T) { + for _, ref := range []string{"nav", "navItem", "nav item", "nav.item", "nav item()", "nav item@0", "nav item('Home'", "nav itemX@2", "grid column(Name)"} { + if s, ok := ParseNavItemSelector(ref); ok { + t.Errorf("ParseNavItemSelector(%q) = %+v; want not a selector", ref, s) + } + } +} diff --git a/mdl/backend/pagemutator/alter_target.go b/mdl/backend/pagemutator/alter_target.go index 0a150d3636..a8a990039a 100644 --- a/mdl/backend/pagemutator/alter_target.go +++ b/mdl/backend/pagemutator/alter_target.go @@ -41,6 +41,9 @@ func (m *Mutator) ResolveAlterTarget(t backend.AlterTarget) (backend.AlterTarget if result == nil { return backend.AlterTargetMatch{}, m.widgetNotFoundError(name) } + if widgetTypeName(result.widget) == navListItemType { + return backend.AlterTargetMatch{Kind: "item", Name: name}, nil + } if bsonnav.DGetString(result.widget, "$Type") != objectListItemType && len(result.colPropKeys) == 0 { // A real widget. Whether a same-named column elsewhere on the page makes // the address ambiguous is each operation's call, as it was before the diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index bbc9288608..63f45e9ea8 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -91,7 +91,7 @@ func New(rawData bson.D, unitID model.ID, deps Deps) *Mutator { containerType: containerType, unitID: unitID, deps: deps, - widgetFinder: finder, + widgetFinder: withNavItemAddresses(finder), } } @@ -480,6 +480,9 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back if err := refuseTabPageSiblingEdit(result, widgetRef, "insert "+strings.ToLower(string(position))); err != nil { return err } + if err := refuseNavItemWidgetEdit(result, widgetRef, "insert "+strings.ToLower(string(position))); err != nil { + return err + } } // Serialize widgets @@ -584,6 +587,7 @@ func containerAcceptsWidgets(container bson.D) bool { "Forms$GroupBox", "Forms$ScrollContainerRegion", "Forms$Section", + navListItemType, tabPageType: return true } @@ -648,6 +652,9 @@ func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pa if err := refuseTabPageSiblingEdit(result, widgetRef, "replace"); err != nil { return err } + if err := refuseNavItemWidgetEdit(result, widgetRef, "replace"); err != nil { + return err + } newBsonWidgets, err := m.serializeWidgets(widgets) if err != nil { @@ -1514,6 +1521,14 @@ func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { return result } + // NavigationList: Items[] hold no Name, but the widgets inside each one do + // (mendixlabs/mxcli#1388); findInWidgetArray descends into Items[].Widgets[]. + if isNavigationList(wDoc) { + if result := findInWidgetArray(wDoc, "Items", widgetName); result != nil { + return result + } + } + // ControlBar if controlBar := bsonnav.DGetDoc(wDoc, "ControlBar"); controlBar != nil { if result := findInWidgetArray(controlBar, "Items", widgetName); result != nil { @@ -1935,6 +1950,9 @@ func gridColumnNames(wDoc bson.D) []string { // column names — columns are keyed by a derived name (attribute or caption), not // the authored MDL name, which is the usual cause of the miss (ledger #78). func (m *Mutator) widgetNotFoundError(name string) error { + if err := m.navItemAddressError(name); err != nil { + return err + } var cols []string collectColumnNamesBson(m.rawData, &cols) if len(cols) > 0 { @@ -2152,6 +2170,11 @@ func sanitizeColumnName(caption string) string { // carries the nearest enclosing DataSource seen so far, so a nearer non-flow // source correctly shadows an outer flow. func findNearestDataSourceDoc(rawData bson.D, widgetName string) (bson.D, bool) { + // A navigation list item is not a data container, so it sees what its list + // sees (`nav item@2` → nav). + if sel, ok := backend.ParseNavItemSelector(widgetName); ok { + widgetName = sel.List + } if formCall := bsonnav.DGetDoc(rawData, "FormCall"); formCall != nil { for _, arg := range bsonnav.DGetArrayElements(bsonnav.DGet(formCall, "Arguments")) { argDoc, ok := arg.(bson.D) @@ -2222,6 +2245,11 @@ func findNearestDSInChildren(wDoc bson.D, widgetName string, curDS bson.D) (bson if ds, found := findNearestDSInWidgets(wDoc, "TabPages", widgetName, curDS); found { return ds, true } + if isNavigationList(wDoc) { + if ds, found := findNearestDSInWidgets(wDoc, "Items", widgetName, curDS); found { + return ds, true + } + } if controlBar := bsonnav.DGetDoc(wDoc, "ControlBar"); controlBar != nil { if ds, found := findNearestDSInWidgets(controlBar, "Items", widgetName, curDS); found { return ds, true @@ -2419,6 +2447,9 @@ func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) { } } collectWidgetScope(wDoc, "TabPages", scope) + if isNavigationList(wDoc) { + collectWidgetScope(wDoc, "Items", scope) + } if controlBar := bsonnav.DGetDoc(wDoc, "ControlBar"); controlBar != nil { collectWidgetScope(controlBar, "Items", scope) } diff --git a/mdl/backend/pagemutator/navlist.go b/mdl/backend/pagemutator/navlist.go new file mode 100644 index 0000000000..61356a5455 --- /dev/null +++ b/mdl/backend/pagemutator/navlist.go @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/sdk/pages" + "go.mongodb.org/mongo-driver/bson" +) + +// navListItemType is a navigation list's entry. It lives in the list's Items, +// not in a widget list, and Studio Pro stores no Name on it. +const navListItemType = "Forms$NavigationListItem" + +// isNavigationList reports whether a stored widget is a navigation list, whose +// entries — and the widgets inside them — sit under Items rather than Widgets. +func isNavigationList(widget bson.D) bool { + switch widgetTypeName(widget) { + case "Forms$NavigationList", "Pages$NavigationList": + return true + } + return false +} + +// InsertNavigationListItems adds items to an existing navigation list +// (mendixlabs/mxcli#1388). +// +// An item lives in the list's Items and is not a widget — the same reason tab +// pages and list view templates have their own paths. INSERT INTO +// appends; INSERT BEFORE/AFTER places them next to that item. An item has +// no Name (Studio Pro writes none, and mxcli stopped writing one in #1373), so +// an item is addressed instead by what it holds or where it is — `nav item(t1)`, +// `nav item('Home')`, `nav item@2` — which the widget finder resolves. A widget +// inside an item is that item's content, not its sibling, and is refused with +// the item address that would work, instead of guessing that "after t1" meant +// "after the item holding t1". +// +// The items are serialized by wrapping them in a throwaway navigation list and +// taking its Items back out, so they get exactly the shape CREATE writes. +func (m *Mutator) InsertNavigationListItems(targetRef string, position backend.InsertPosition, items []*pages.NavigationListItem) error { + result := m.widgetFinder(m.rawData, targetRef) + if result == nil { + return m.widgetNotFoundError(targetRef) + } + into := strings.EqualFold(string(position), "into") + isItem := widgetTypeName(result.widget) == navListItemType + switch { + case into && !isNavigationList(result.widget): + return fmt.Errorf("cannot insert a navigation list item into %q (%s): an item can only be added to a navigation list — "+ + "use `insert into { item … }`", targetRef, widgetTypeName(result.widget)) + case !into && !isItem: + hint := "address an item as ` item()`, ` item('')` or ` item@n`" + if list := m.enclosingNavigationList(targetRef); list != "" { + hint = fmt.Sprintf("to place it next to the item holding %s, write `insert %s %s { item … }`", + targetRef, strings.ToLower(string(position)), backend.NavItemSelector{List: list, Widget: targetRef}) + } + return fmt.Errorf("cannot insert a navigation list item %s %q (%s): an item's siblings are items — %s", + strings.ToLower(string(position)), targetRef, widgetTypeName(result.widget), hint) + } + + newDocs, err := m.serializeNavigationListItems(items) + if err != nil { + return err + } + + if !into { + insertIdx := result.index + if strings.EqualFold(string(position), "after") { + insertIdx++ + } + out := make([]any, 0, len(result.parentArr)+len(newDocs)) + out = append(out, result.parentArr[:insertIdx]...) + out = append(out, newDocs...) + out = append(out, result.parentArr[insertIdx:]...) + bsonnav.DSetArray(result.parentDoc, result.parentKey, out) + return nil + } + + list := result.widget + stored := bsonnav.ToBsonA(bsonnav.DGet(list, "Items")) + var out bson.A + if len(stored) == 0 { + out = append(out, int32(2)) // Items' list marker, as the codec registers it + } else { + out = append(out, stored...) + } + out = append(out, newDocs...) + if !bsonnav.DSet(list, "Items", out) { + list = append(list, bson.E{Key: "Items", Value: out}) + } + if result.parentArr == nil || result.index < 0 || result.index >= len(result.parentArr) { + return fmt.Errorf("cannot add items to %q: its parent slot could not be located", targetRef) + } + result.parentArr[result.index] = list + bsonnav.DSetArray(result.parentDoc, result.parentKey, result.parentArr) + return nil +} + +// enclosingNavigationList names the navigation list a widget sits in, or "". +func (m *Mutator) enclosingNavigationList(widgetRef string) string { + var found string + var walk func(node any) + walk = func(node any) { + switch v := node.(type) { + case bson.D: + if found != "" { + return + } + if isNavigationList(v) { + for _, it := range bsonnav.DGetArrayElements(bsonnav.DGet(v, "Items")) { + if itDoc, ok := it.(bson.D); ok && findInWidgetArray(itDoc, "Widgets", widgetRef) != nil { + found = bsonnav.DGetString(v, "Name") + return + } + } + } + for _, e := range v { + walk(e.Value) + } + case bson.A: + for _, e := range v { + walk(e) + } + } + } + walk(m.rawData) + return found +} + +// serializeNavigationListItems returns the stored form of each item, without a +// list marker, by serializing them inside a wrapper navigation list. +func (m *Mutator) serializeNavigationListItems(items []*pages.NavigationListItem) ([]any, error) { + if len(items) == 0 { + return nil, fmt.Errorf("no navigation list items to insert") + } + wrapper := &pages.NavigationList{Items: items} + wrapper.TypeName = "Forms$NavigationList" + doc := m.deps.SerializeWidget(wrapper) + var out []any + for _, el := range bsonnav.DGetArrayElements(bsonnav.DGet(doc, "Items")) { + if d, ok := el.(bson.D); ok { + out = append(out, d) + } + } + if len(out) != len(items) { + return nil, fmt.Errorf("serialize navigation list items: got %d stored items for %d inserted", len(out), len(items)) + } + return out, nil +} + +// withNavItemAddresses extends a widget finder with navigation list item +// addresses (`nav item(t1)`, `nav item('Home')`, `nav item@2`). The result for +// an item is the item itself, in the list's Items, so every operation that +// finds a widget — SET, INSERT INTO, DROP, and placing an item BEFORE/AFTER +// another — reaches an item through the path it already has. A miss or an +// ambiguous address finds nothing; widgetNotFoundError then says which. +func withNavItemAddresses(inner widgetFinder) widgetFinder { + return func(rawData bson.D, ref string) *bsonWidgetResult { + if sel, ok := backend.ParseNavItemSelector(ref); ok { + r, _ := resolveNavItem(rawData, inner, sel) + return r + } + return inner(rawData, ref) + } +} + +// resolveNavItem finds the one item a selector addresses. +func resolveNavItem(rawData bson.D, find widgetFinder, sel backend.NavItemSelector) (*bsonWidgetResult, error) { + list := find(rawData, sel.List) + if list == nil { + return nil, fmt.Errorf("%s: navigation list %q not found", sel, sel.List) + } + if !isNavigationList(list.widget) { + return nil, fmt.Errorf("%s: %q is a %s, not a navigation list — `item(…)` and `item@n` address a navigation list's items", + sel, sel.List, widgetTypeName(list.widget)) + } + items := bsonnav.DGetArrayElements(bsonnav.DGet(list.widget, "Items")) + var matches []int + for i, el := range items { + itemDoc, ok := el.(bson.D) + if !ok { + continue + } + switch { + case sel.Widget != "": + if findInWidgetArray(itemDoc, "Widgets", sel.Widget) == nil { + continue + } + case sel.Text != "": + if !containsString(navItemTexts(itemDoc), sel.Text) { + continue + } + } + matches = append(matches, i) + } + pick := -1 + switch { + case sel.Ordinal > 0 && sel.Ordinal <= len(matches): + pick = matches[sel.Ordinal-1] + case sel.Ordinal > 0: + return nil, fmt.Errorf("%s: %s, so there is no @%d. %s", + sel, countMatches(len(matches), sel), sel.Ordinal, navItemList(sel.List, items)) + case len(matches) == 1: + pick = matches[0] + case len(matches) == 0: + return nil, fmt.Errorf("%s: no item matches. %s", sel, navItemList(sel.List, items)) + default: + return nil, fmt.Errorf("%s is ambiguous: %s — add @n to choose one (@1 … @%d). %s", + sel, countMatches(len(matches), sel), len(matches), navItemList(sel.List, items)) + } + return &bsonWidgetResult{ + widget: items[pick].(bson.D), + parentArr: items, + parentKey: "Items", + parentDoc: list.widget, + index: pick, + }, nil +} + +func countMatches(n int, sel backend.NavItemSelector) string { + noun := "items" + if n == 1 { + noun = "item" + } + if sel.Widget != "" || sel.Text != "" { + return fmt.Sprintf("%d %s match", n, noun) + } + return fmt.Sprintf("the list has %d %s", n, noun) +} + +// navItemList lists each item's addresses, so a miss shows what would hit. +func navItemList(list string, items []any) string { + if len(items) == 0 { + return fmt.Sprintf("navigation list %q has no items", list) + } + var parts []string + for i, el := range items { + itemDoc, _ := el.(bson.D) + addrs := []string{fmt.Sprintf("item@%d", i+1)} + if names := navItemWidgetNames(itemDoc); len(names) > 0 { + addrs = append(addrs, "item("+names[0]+")") + } + if texts := navItemTexts(itemDoc); len(texts) > 0 { + addrs = append(addrs, "item('"+strings.ReplaceAll(texts[0], "'", "''")+"')") + } + parts = append(parts, strings.Join(addrs, " = ")) + } + return fmt.Sprintf("Items of %s: %s", list, strings.Join(parts, "; ")) +} + +// navItemTexts returns the texts an item shows: every translation of every +// text under its widgets, in document order. +func navItemTexts(itemDoc bson.D) []string { + var out []string + var walk func(v any) + walk = func(v any) { + switch d := v.(type) { + case bson.D: + if bsonnav.DGetString(d, "$Type") == "Texts$Translation" { + if t := bsonnav.DGetString(d, "Text"); t != "" && !containsString(out, t) { + out = append(out, t) + } + return + } + for _, e := range d { + walk(e.Value) + } + case bson.A: + for _, e := range d { + walk(e) + } + } + } + walk(bsonnav.DGet(itemDoc, "Widgets")) + return out +} + +// navItemWidgetNames returns the names of the widgets under an item. +func navItemWidgetNames(itemDoc bson.D) []string { + var out []string + var walk func(v any) + walk = func(v any) { + switch d := v.(type) { + case bson.D: + if n := bsonnav.DGetString(d, "Name"); n != "" && strings.Contains(bsonnav.DGetString(d, "$Type"), "$") { + out = append(out, n) + } + for _, e := range d { + walk(e.Value) + } + case bson.A: + for _, e := range d { + walk(e) + } + } + } + walk(bsonnav.DGet(itemDoc, "Widgets")) + return out +} + +func containsString(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// navItemAddressError is the precise miss for an item address, or nil when the +// ref is not one. +func (m *Mutator) navItemAddressError(ref string) error { + sel, ok := backend.ParseNavItemSelector(ref) + if !ok { + return nil + } + if _, err := resolveNavItem(m.rawData, m.widgetFinder, sel); err != nil { + return err + } + return nil +} + +// refuseNavItemWidgetEdit refuses putting ordinary widgets where only items +// belong: INSERT BEFORE/AFTER an item and REPLACE of an item with widgets +// would put them into the list's Items. What an item does support — SET, DROP, +// INSERT INTO it, placing an item next to it — goes through the other paths. +func refuseNavItemWidgetEdit(result *bsonWidgetResult, name, op string) error { + if result == nil || widgetTypeName(result.widget) != navListItemType { + return nil + } + return fmt.Errorf("%s is a navigation list item: %s would put widgets into the list's items, "+ + "where only items belong. Use `insert into %s { … }` to add widgets to the item, or write "+ + "`item (…) { … }` blocks to add or replace items", name, op, name) +} + +// ReplaceNavigationListItem replaces one navigation list item with the given +// items, in its place. The target must resolve to an item (`nav item@2`); +// replacing the whole list stays an ordinary widget REPLACE. +func (m *Mutator) ReplaceNavigationListItem(targetRef string, items []*pages.NavigationListItem) error { + result := m.widgetFinder(m.rawData, targetRef) + if result == nil { + return m.widgetNotFoundError(targetRef) + } + if widgetTypeName(result.widget) != navListItemType { + return fmt.Errorf("cannot replace %q (%s) with navigation list items: only an item can be — "+ + "address one as ` item()`, ` item('')` or ` item@n`", + targetRef, widgetTypeName(result.widget)) + } + newDocs, err := m.serializeNavigationListItems(items) + if err != nil { + return err + } + out := make([]any, 0, len(result.parentArr)-1+len(newDocs)) + out = append(out, result.parentArr[:result.index]...) + out = append(out, newDocs...) + out = append(out, result.parentArr[result.index+1:]...) + bsonnav.DSetArray(result.parentDoc, result.parentKey, out) + return nil +} diff --git a/mdl/backend/pagemutator/navlist_items_test.go b/mdl/backend/pagemutator/navlist_items_test.go new file mode 100644 index 0000000000..744cd76fb0 --- /dev/null +++ b/mdl/backend/pagemutator/navlist_items_test.go @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "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" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// navStubDeps serializes a navigation list the way the codec does: a +// Forms$NavigationList whose Items are Forms$NavigationListItem with no Name. +type navStubDeps struct{ stubWidgetDeps } + +func (d *navStubDeps) SerializeWidget(w pages.Widget) bson.D { + nl, ok := w.(*pages.NavigationList) + if !ok { + return d.stubWidgetDeps.SerializeWidget(w) + } + arr := bson.A{int32(2)} + for _, it := range nl.Items { + widgets := bson.A{int32(2)} + for _, c := range it.Widgets { + widgets = append(widgets, d.stubWidgetDeps.SerializeWidget(c)) + } + arr = append(arr, bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(string(it.ID))}, + {Key: "$Type", Value: "Forms$NavigationListItem"}, + {Key: "Widgets", Value: widgets}, + }) + } + return bson.D{{Key: "$Type", Value: "Forms$NavigationList"}, {Key: "Items", Value: arr}} +} + +// navItemDoc is a stored item the way Studio Pro writes it: no Name. +func navItemDoc(textName string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NavigationListItem"}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "Name", Value: textName}, + }}}, + } +} + +func makeNavListPage() bson.D { + return makeRawPage(bson.D{ + {Key: "$Type", Value: "Forms$NavigationList"}, + {Key: "Name", Value: "navList1"}, + {Key: "Items", Value: bson.A{int32(2), navItemDoc("t1")}}, + }) +} + +func newNavItem(textName string) *pages.NavigationListItem { + return &pages.NavigationListItem{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID()), TypeName: "Forms$NavigationListItem"}, + Widgets: []pages.Widget{&pages.DynamicText{BaseWidget: pages.BaseWidget{Name: textName}}}, + } +} + +// navItemTexts lists, per stored item, the name of its first widget. +func storedItemTexts(t *testing.T, raw bson.D) []string { + t.Helper() + nl := findBsonWidget(raw, "navList1") + if nl == nil { + t.Fatal("navList1 no longer resolves") + } + items := bsonnav.ToBsonA(bsonnav.DGet(nl.widget, "Items")) + if len(items) == 0 || !isListMarker(items[0]) { + t.Fatalf("Items lost its list marker: %v", items) + } + var out []string + for _, el := range items[1:] { + ws := bsonnav.DGetArrayElements(bsonnav.DGet(el.(bson.D), "Widgets")) + out = append(out, bsonnav.DGetString(ws[0].(bson.D), "Name")) + } + return out +} + +// mendixlabs/mxcli#1388: a widget inside a navigation list item could not be +// addressed — `insert after t1 { … }` failed with `widget "t1" not found` — +// because the lookup never descended into the list's Items. +func TestNavigationListItemWidgets_AreAddressable(t *testing.T) { + raw := makeNavListPage() + m := New(raw, model.ID("page-1"), &navStubDeps{}) + if findBsonWidget(raw, "t1") == nil { + t.Fatal(`findBsonWidget("t1") = nil; a widget inside a navigation list item must resolve`) + } + if _, ok := m.WidgetScope()["t1"]; !ok { + t.Error("WidgetScope lacks t1: a duplicate name inside an item would not be caught") + } + if _, found := findNearestDataSourceDoc(raw, "t1"); !found { + t.Error("findNearestDataSourceDoc does not find t1") + } + // A sibling inserted next to t1 lands in the item's own Widgets. + if err := m.InsertWidget("t1", "", toPos("after"), []pages.Widget{&pages.DynamicText{BaseWidget: pages.BaseWidget{Name: "t1b"}}}); err != nil { + t.Fatalf("insert after t1: %v", err) + } + ws := bsonnav.ToBsonA(bsonnav.DGet(findBsonWidget(m.rawData, "t1").parentDoc, "Widgets")) + if len(ws) != 3 || bsonnav.DGetString(ws[2].(bson.D), "Name") != "t1b" { + t.Errorf("item widgets = %v, want [marker t1 t1b]", ws) + } +} + +// mendixlabs/mxcli#1388: `insert into navList1 { item … }` appends the item to +// the list's Items and leaves the existing item untouched. +func TestInsertNavigationListItems_IntoAppends(t *testing.T) { + m := New(makeNavListPage(), model.ID("page-1"), &navStubDeps{}) + firstBefore := bsonnav.DGet(bsonnav.ToBsonA(bsonnav.DGet(findBsonWidget(m.rawData, "navList1").widget, "Items"))[1].(bson.D), "$ID") + if err := m.InsertNavigationListItems("navList1", toPos("into"), []*pages.NavigationListItem{newNavItem("t2")}); err != nil { + t.Fatalf("insert into navList1 { item }: %v", err) + } + if got := strings.Join(storedItemTexts(t, m.rawData), ","); got != "t1,t2" { + t.Errorf("items = %s, want t1,t2", got) + } + firstAfter := bsonnav.DGet(bsonnav.ToBsonA(bsonnav.DGet(findBsonWidget(m.rawData, "navList1").widget, "Items"))[1].(bson.D), "$ID") + if !equalIDs(firstBefore, firstAfter) { + t.Error("the existing item's $ID changed") + } +} + +// An empty navigation list gains an Items list with Studio Pro's marker. +func TestInsertNavigationListItems_IntoEmptyList(t *testing.T) { + raw := makeRawPage(bson.D{{Key: "$Type", Value: "Forms$NavigationList"}, {Key: "Name", Value: "navList1"}}) + m := New(raw, model.ID("page-1"), &navStubDeps{}) + if err := m.InsertNavigationListItems("navList1", toPos("into"), []*pages.NavigationListItem{newNavItem("t2")}); err != nil { + t.Fatalf("insert into empty navList1: %v", err) + } + if got := strings.Join(storedItemTexts(t, m.rawData), ","); got != "t2" { + t.Errorf("items = %s, want t2", got) + } +} + +// Items have no Name, so BEFORE/AFTER has nothing to name; a widget inside an +// item is not an item's sibling. Refuse rather than put an item into a widget +// list, and say which form works. +func TestInsertNavigationListItems_RefusesWrongTargets(t *testing.T) { + for _, tc := range []struct{ target, pos, want string }{ + {"t1", "after", "insert after navList1 item(t1) { item … }"}, + {"t1", "into", "only be added to a navigation list"}, + } { + m := New(makeNavListPage(), model.ID("page-1"), &navStubDeps{}) + err := m.InsertNavigationListItems(tc.target, toPos(tc.pos), []*pages.NavigationListItem{newNavItem("t2")}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("insert %s %s { item }: err = %v, want one naming %q", tc.pos, tc.target, err, tc.want) + } + if got := strings.Join(storedItemTexts(t, m.rawData), ","); got != "t1" { + t.Errorf("a refused insert changed the items: %s", got) + } + } +} + +// navItemWithText is a stored item holding one dynamic text, its name and its +// shown text given, and the Action every item carries. +func navItemWithText(textName, text string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NavigationListItem"}, + {Key: "Action", Value: bson.D{{Key: "$Type", Value: "Forms$NoAction"}}}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "Name", Value: textName}, + {Key: "Content", Value: bson.D{ + {Key: "$Type", Value: "Forms$ClientTemplate"}, + {Key: "Template", Value: textsText(translation("en_US", text))}, + }}, + }}}, + } +} + +// Three items: t1 'Home', t2 'Orders', t3 'Home' — two share a text. +func makeThreeItemNavList() *Mutator { + raw := makeRawPage(bson.D{ + {Key: "$Type", Value: "Forms$NavigationList"}, + {Key: "Name", Value: "navList1"}, + {Key: "Items", Value: bson.A{int32(2), + navItemWithText("t1", "Home"), navItemWithText("t2", "Orders"), navItemWithText("t3", "Home")}}, + }) + return New(raw, model.ID("page-1"), &navStubDeps{}) +} + +func navItemIndex(t *testing.T, m *Mutator, ref string) int { + t.Helper() + r := m.widgetFinder(m.rawData, ref) + if r == nil { + t.Fatalf("%s did not resolve: %v", ref, m.widgetNotFoundError(ref)) + } + if typ := widgetTypeName(r.widget); typ != navListItemType { + t.Fatalf("%s resolved to %s, want an item", ref, typ) + } + return r.index +} + +// mendixlabs/mxcli#1388: an item has no name, so it is addressed by a widget it +// holds, a text it shows, or its position — as a DataGrid 2 column is. +func TestNavItemAddress_Resolves(t *testing.T) { + m := makeThreeItemNavList() + for ref, want := range map[string]int{ + "navList1 item(t2)": 1, + "navList1 item('Orders')": 1, + "navList1 item@3": 2, + "navList1 item('Home')@2": 2, + "navList1 item(t1)@1": 0, + } { + if got := navItemIndex(t, m, ref); got != want { + t.Errorf("%s = item %d, want %d", ref, got+1, want+1) + } + } + if match, err := m.ResolveAlterTarget(backend.AlterTarget{Path: []string{"navList1 item@2"}}); err != nil || match.Kind != "item" { + t.Errorf("ResolveAlterTarget(navList1 item@2) = %+v, %v; want kind item", match, err) + } +} + +// An address that matches several items, none, or a widget that is not a +// navigation list is refused with what would work — never a guess. +func TestNavItemAddress_RefusesMissesAndAmbiguity(t *testing.T) { + m := makeThreeItemNavList() + for ref, want := range map[string]string{ + "navList1 item('Home')": "is ambiguous: 2 items match — add @n", + "navList1 item('Nope')": "no item matches", + "navList1 item@4": "the list has 3 items, so there is no @4", + "navList1 item('Home')@3": "2 items match, so there is no @3", + "navList1 item(zz)": "item@2 = item(t2) = item('Orders')", + "t1 item@1": "is a Forms$DynamicText, not a navigation list", + "nope item@1": `navigation list "nope" not found`, + } { + if m.widgetFinder(m.rawData, ref) != nil { + t.Errorf("%s resolved; want a refusal", ref) + continue + } + if err := m.widgetNotFoundError(ref); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v, want one containing %q", ref, err, want) + } + } +} + +func itemTexts(t *testing.T, m *Mutator) string { + t.Helper() + return strings.Join(storedItemTexts(t, m.rawData), ",") +} + +// Every operation reaches an item through its address. +func TestNavItemAddress_Operations(t *testing.T) { + t.Run("drop", func(t *testing.T) { + m := makeThreeItemNavList() + if err := m.DropWidget([]backend.WidgetRef{{Widget: "navList1 item('Orders')"}}); err != nil { + t.Fatal(err) + } + if got := itemTexts(t, m); got != "t1,t3" { + t.Errorf("items = %s, want t1,t3", got) + } + }) + t.Run("insert item after", func(t *testing.T) { + m := makeThreeItemNavList() + if err := m.InsertNavigationListItems("navList1 item(t1)", toPos("after"), []*pages.NavigationListItem{newNavItem("tNew")}); err != nil { + t.Fatal(err) + } + if got := itemTexts(t, m); got != "t1,tNew,t2,t3" { + t.Errorf("items = %s, want t1,tNew,t2,t3", got) + } + }) + t.Run("insert item before", func(t *testing.T) { + m := makeThreeItemNavList() + if err := m.InsertNavigationListItems("navList1 item@1", toPos("before"), []*pages.NavigationListItem{newNavItem("tNew")}); err != nil { + t.Fatal(err) + } + if got := itemTexts(t, m); got != "tNew,t1,t2,t3" { + t.Errorf("items = %s, want tNew,t1,t2,t3", got) + } + }) + t.Run("replace item", func(t *testing.T) { + m := makeThreeItemNavList() + if err := m.ReplaceNavigationListItem("navList1 item@2", []*pages.NavigationListItem{newNavItem("tNew")}); err != nil { + t.Fatal(err) + } + if got := itemTexts(t, m); got != "t1,tNew,t3" { + t.Errorf("items = %s, want t1,tNew,t3", got) + } + }) + t.Run("insert widgets into item", func(t *testing.T) { + m := makeThreeItemNavList() + if err := m.InsertWidget("navList1 item@2", "", toPos("into"), []pages.Widget{&pages.DynamicText{BaseWidget: pages.BaseWidget{Name: "t2b"}}}); err != nil { + t.Fatal(err) + } + ws := bsonnav.DGetArrayElements(bsonnav.DGet(m.widgetFinder(m.rawData, "navList1 item@2").widget, "Widgets")) + if len(ws) != 2 || bsonnav.DGetString(ws[1].(bson.D), "Name") != "t2b" { + t.Errorf("item 2 widgets = %v, want t2, t2b", ws) + } + }) + t.Run("set action", func(t *testing.T) { + m := makeThreeItemNavList() + var called bool + m.deps = &actionStubDeps{navStubDeps: navStubDeps{}, onAction: func() { called = true }} + if err := m.SetWidgetAction("navList1 item@3", &pages.NoClientAction{}); err != nil { + t.Fatal(err) + } + if !called { + t.Error("the action was not serialized") + } + if typ := bsonnav.DGetString(bsonnav.DGetDoc(m.widgetFinder(m.rawData, "navList1 item@3").widget, "Action"), "$Type"); typ != "Forms$ShowPageAction" { + t.Errorf("item 3 action = %s, want the new one", typ) + } + }) +} + +type actionStubDeps struct { + navStubDeps + onAction func() +} + +func (d *actionStubDeps) SerializeClientAction(pages.ClientAction) bson.D { + d.onAction() + return bson.D{{Key: "$Type", Value: "Forms$ShowPageAction"}} +} + +// Widgets next to an item, or in its place, would land in the list's Items. +func TestNavItemAddress_RefusesWidgetsAmongItems(t *testing.T) { + w := []pages.Widget{&pages.DynamicText{BaseWidget: pages.BaseWidget{Name: "x"}}} + m := makeThreeItemNavList() + if err := m.InsertWidget("navList1 item@1", "", toPos("after"), w); err == nil || !strings.Contains(err.Error(), "only items belong") { + t.Errorf("insert after an item {widget}: err = %v", err) + } + if err := m.ReplaceWidget("navList1 item@1", "", w); err == nil || !strings.Contains(err.Error(), "only items belong") { + t.Errorf("replace an item with a widget: err = %v", err) + } + if got := itemTexts(t, m); got != "t1,t2,t3" { + t.Errorf("a refused edit changed the items: %s", got) + } +} diff --git a/mdl/catalog/builder_contract.go b/mdl/catalog/builder_contract.go index 9f0508e8ee..202a26e29d 100644 --- a/mdl/catalog/builder_contract.go +++ b/mdl/catalog/builder_contract.go @@ -148,7 +148,7 @@ const contractEntityUsageSQL = ` SET UsedByExternalEntity = ( SELECT ee.QualifiedName FROM external_entities ee - JOIN odata_clients oc + JOIN consumed_odata_services oc ON oc.QualifiedName = ee.ServiceName WHERE oc.Id = contract_entities_data.ServiceId AND ee.RemoteName = contract_entities_data.EntityName diff --git a/mdl/catalog/catalog.go b/mdl/catalog/catalog.go index b3bf74c30c..0810dcc584 100644 --- a/mdl/catalog/catalog.go +++ b/mdl/catalog/catalog.go @@ -125,7 +125,7 @@ func (c *Catalog) Tables() []string { "CATALOG.ICON_COLLECTIONS", "CATALOG.MENUS", "CATALOG.SCHEDULED_EVENTS", - "CATALOG.QUEUES", + "CATALOG.TASK_QUEUES", "CATALOG.REGULAR_EXPRESSIONS", "CATALOG.DATA_TRANSFORMERS", "CATALOG.AGENTS", @@ -147,15 +147,15 @@ func (c *Catalog) Tables() []string { "CATALOG.SNAPSHOTS", "CATALOG.OBJECTS", "CATALOG.WORKFLOWS", - "CATALOG.ODATA_CLIENTS", - "CATALOG.ODATA_SERVICES", + "CATALOG.CONSUMED_ODATA_SERVICES", + "CATALOG.PUBLISHED_ODATA_SERVICES", "CATALOG.BUSINESS_EVENT_SERVICES", "CATALOG.DATABASE_CONNECTIONS", "CATALOG.JAR_DEPENDENCIES", "CATALOG.CONSTANTS", "CATALOG.CONSTANT_VALUES", - "CATALOG.REST_CLIENTS", - "CATALOG.REST_OPERATIONS", + "CATALOG.CONSUMED_REST_SERVICES", + "CATALOG.CONSUMED_REST_OPERATIONS", "CATALOG.PUBLISHED_REST_SERVICES", "CATALOG.PUBLISHED_REST_OPERATIONS", "CATALOG.EXTERNAL_ENTITIES", diff --git a/mdl/catalog/catalog_test.go b/mdl/catalog/catalog_test.go index 6b9a7cee73..2b8e3db24c 100644 --- a/mdl/catalog/catalog_test.go +++ b/mdl/catalog/catalog_test.go @@ -132,7 +132,8 @@ func TestTables_CoversAllViews(t *testing.T) { if err := rows.Scan(&name); err != nil { t.Fatalf("Scan failed: %v", err) } - if !listed[name] { + // A retired name kept as an alias is deliberately unlisted (#1395). + if !listed[name] && LegacyTableAliases[name] == "" { t.Errorf("catalog view %q is not listed in Tables() — add CATALOG.%s "+ "so SHOW CATALOG TABLES includes it", name, strings.ToUpper(name)) } diff --git a/mdl/catalog/studio_pro_names_test.go b/mdl/catalog/studio_pro_names_test.go new file mode 100644 index 0000000000..2a846db2b3 --- /dev/null +++ b/mdl/catalog/studio_pro_names_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "slices" + "strings" + "testing" +) + +// mendixlabs/mxcli#1395: "mxcli describe --help and the catalog.db still use +// the old names" after ako/mxcli#755 renamed the document types to Studio Pro's +// (R10). The catalog must expose the canonical table names and ObjectType +// values; the old table names stay queryable as aliases, so a saved query or a +// user's lint rule written against them keeps answering. +func TestCatalog_StudioProDocumentNames(t *testing.T) { + cat, err := New() + if err != nil { + t.Fatal(err) + } + defer cat.Close() + + db := cat.CatalogDB() + for _, stmt := range []string{ + `INSERT INTO rest_clients_data (Id, Name, QualifiedName, ModuleName) VALUES ('r1', 'Api', 'M.Api', 'M')`, + `INSERT INTO rest_operations_data (Id, ServiceId, ServiceQualifiedName, Name) VALUES ('o1', 'r1', 'M.Api', 'GetPets')`, + `INSERT INTO odata_clients_data (Id, Name, QualifiedName, ModuleName) VALUES ('c1', 'Crm', 'M.Crm', 'M')`, + `INSERT INTO odata_services_data (Id, Name, QualifiedName, ModuleName) VALUES ('s1', 'Pub', 'M.Pub', 'M')`, + `INSERT INTO queues_data (Id, Name, QualifiedName, ModuleName) VALUES ('q1', 'Mail', 'M.Mail', 'M')`, + } { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + + renamed := []struct{ canonical, old, name string }{ + {"consumed_rest_services", "rest_clients", "Api"}, + {"consumed_rest_operations", "rest_operations", "GetPets"}, + {"consumed_odata_services", "odata_clients", "Crm"}, + {"published_odata_services", "odata_services", "Pub"}, + {"task_queues", "queues", "Mail"}, + } + tables := cat.Tables() + for _, r := range renamed { + if !slices.Contains(tables, "CATALOG."+strings.ToUpper(r.canonical)) { + t.Errorf("Tables() does not list CATALOG.%s", strings.ToUpper(r.canonical)) + } + if slices.Contains(tables, "CATALOG."+strings.ToUpper(r.old)) { + t.Errorf("Tables() still lists the old name CATALOG.%s", strings.ToUpper(r.old)) + } + for _, view := range []string{r.canonical, r.old} { + res, err := cat.Query("SELECT Name FROM " + view) + if err != nil { + t.Errorf("SELECT FROM %s: %v", view, err) + continue + } + if len(res.Rows) != 1 || res.Rows[0][0] != r.name { + t.Errorf("SELECT Name FROM %s = %v, want [[%s]]", view, res.Rows, r.name) + } + } + } + + res, err := cat.Query("SELECT QualifiedName, ObjectType FROM objects ORDER BY QualifiedName") + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, row := range res.Rows { + got[row[0].(string)] = row[1].(string) + } + want := map[string]string{ + "M.Api": "CONSUMED_REST_SERVICE", + "M.Crm": "CONSUMED_ODATA_SERVICE", + "M.Pub": "PUBLISHED_ODATA_SERVICE", + "M.Mail": "TASK_QUEUE", + } + for qn, w := range want { + if got[qn] != w { + t.Errorf("objects.ObjectType for %s = %q, want %q", qn, got[qn], w) + } + } +} diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 31ee83b5ac..32b09e6cc3 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,14 @@ package catalog // // History: // +// 24 (Studio Pro document names): the views rest_clients, rest_operations, +// odata_clients, odata_services and queues are now consumed_rest_services, +// consumed_rest_operations, consumed_odata_services, +// published_odata_services and task_queues, with the old names kept as +// alias views; objects.ObjectType says CONSUMED_REST_SERVICE, +// CONSUMED_ODATA_SERVICE, PUBLISHED_ODATA_SERVICE and TASK_QUEUE +// (mendixlabs/mxcli#1395). Views are CREATE VIEW IF NOT EXISTS, so a +// cached catalog has neither the new names nor the new ObjectType values. // 23 (call and delete targets): activities_data gains QueueRef, the task // queue a call runs in, and its ActionRef / EntityRef now name a call's // target (microflow, nanoflow, Java or JavaScript action) and a delete's @@ -123,7 +131,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "23" +const CatalogSchemaVersion = "24" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -605,7 +613,8 @@ func (c *Catalog) createTables() error { ProjectId TEXT, SnapshotId TEXT )`, - viewWithFullSnapshot("queues"), + viewWithFullSnapshotOver("task_queues", "queues_data"), + legacyAliasView("queues", "task_queues"), // data_transformers `CREATE TABLE IF NOT EXISTS data_transformers_data ( @@ -789,7 +798,8 @@ func (c *Catalog) createTables() error { ProjectId TEXT, SnapshotId TEXT )`, - viewWithFullSnapshot("odata_clients"), + viewWithFullSnapshotOver("consumed_odata_services", "odata_clients_data"), + legacyAliasView("odata_clients", "consumed_odata_services"), // odata_services `CREATE TABLE IF NOT EXISTS odata_services_data ( @@ -805,7 +815,8 @@ func (c *Catalog) createTables() error { ProjectId TEXT, SnapshotId TEXT )`, - viewWithFullSnapshot("odata_services"), + viewWithFullSnapshotOver("published_odata_services", "odata_services_data"), + legacyAliasView("odata_services", "published_odata_services"), // workflows `CREATE TABLE IF NOT EXISTS workflows_data ( @@ -952,7 +963,8 @@ func (c *Catalog) createTables() error { ProjectId TEXT, SnapshotId TEXT )`, - viewWithFullSnapshot("rest_clients"), + viewWithFullSnapshotOver("consumed_rest_services", "rest_clients_data"), + legacyAliasView("rest_clients", "consumed_rest_services"), // rest_operations — partial denormalization (ProjectId, SnapshotId, SnapshotDate, SnapshotSource) `CREATE TABLE IF NOT EXISTS rest_operations_data ( @@ -970,7 +982,8 @@ func (c *Catalog) createTables() error { ProjectId TEXT, SnapshotId TEXT )`, - viewWithSnapshotDateSource("rest_operations"), + viewWithSnapshotDateSourceOver("consumed_rest_operations", "rest_operations_data"), + legacyAliasView("rest_operations", "consumed_rest_operations"), // published_rest_services `CREATE TABLE IF NOT EXISTS published_rest_services_data ( @@ -1330,9 +1343,9 @@ func (c *Catalog) createTables() error { ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource FROM scheduled_events UNION ALL - SELECT Id, 'QUEUE' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, + SELECT Id, 'TASK_QUEUE' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource - FROM queues + FROM task_queues UNION ALL SELECT Id, 'REGULAR_EXPRESSION' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource @@ -1358,13 +1371,13 @@ func (c *Catalog) createTables() error { ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource FROM consumed_mcp_services UNION ALL - SELECT Id, 'ODATA_CLIENT' as ObjectType, Name, QualifiedName, ModuleName, '' as Folder, '' as Description, + SELECT Id, 'CONSUMED_ODATA_SERVICE' as ObjectType, Name, QualifiedName, ModuleName, '' as Folder, '' as Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource - FROM odata_clients + FROM consumed_odata_services UNION ALL - SELECT Id, 'ODATA_SERVICE' as ObjectType, Name, QualifiedName, ModuleName, '' as Folder, '' as Description, + SELECT Id, 'PUBLISHED_ODATA_SERVICE' as ObjectType, Name, QualifiedName, ModuleName, '' as Folder, '' as Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource - FROM odata_services + FROM published_odata_services UNION ALL SELECT Id, 'WORKFLOW' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource @@ -1378,9 +1391,9 @@ func (c *Catalog) createTables() error { ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource FROM database_connections UNION ALL - SELECT Id, 'REST_CLIENT' as ObjectType, Name, QualifiedName, ModuleName, Folder, Documentation as Description, + SELECT Id, 'CONSUMED_REST_SERVICE' as ObjectType, Name, QualifiedName, ModuleName, Folder, Documentation as Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource - FROM rest_clients + FROM consumed_rest_services UNION ALL SELECT Id, 'PUBLISHED_REST_SERVICE' as ObjectType, Name, QualifiedName, ModuleName, Folder, Documentation as Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource @@ -1740,6 +1753,14 @@ func (c *Catalog) createTables() error { // SourceRevision on top of the underlying _data table. Used for tables whose // pre-refactor schema had all six denormalized columns. func viewWithFullSnapshot(name string) string { + return viewWithFullSnapshotOver(name, name+"_data") +} + +// viewWithFullSnapshotOver is viewWithFullSnapshot for a view whose name is +// not its storage table's: the document types renamed to Studio Pro's names +// (mendixlabs/mxcli#1395) kept their `_data` tables, which nothing outside the +// builders reads. +func viewWithFullSnapshotOver(name, data string) string { return `CREATE VIEW IF NOT EXISTS ` + name + ` AS SELECT t.*, s.ProjectName, @@ -1748,7 +1769,7 @@ func viewWithFullSnapshot(name string) string { s.SourceId, s.SourceBranch, s.SourceRevision - FROM ` + name + `_data t + FROM ` + data + ` t LEFT JOIN snapshots s ON s.SnapshotId = t.SnapshotId` } @@ -1769,10 +1790,33 @@ func viewWithProjectNameAndSnapshotDateSource(name string) string { // Used for tables whose pre-refactor schema had only those two denormalized // columns (no ProjectName, no Source*). func viewWithSnapshotDateSource(name string) string { + return viewWithSnapshotDateSourceOver(name, name+"_data") +} + +// viewWithSnapshotDateSourceOver is viewWithSnapshotDateSource over a storage +// table of another name; see viewWithFullSnapshotOver. +func viewWithSnapshotDateSourceOver(name, data string) string { return `CREATE VIEW IF NOT EXISTS ` + name + ` AS SELECT t.*, s.SnapshotDate, s.SnapshotSource - FROM ` + name + `_data t + FROM ` + data + ` t LEFT JOIN snapshots s ON s.SnapshotId = t.SnapshotId` } + +// LegacyTableAliases maps each catalog view name retired by the Studio Pro +// document names (mendixlabs/mxcli#1395) to its canonical view. The old names +// stay queryable so saved queries and user lint rules keep answering, but +// Tables() lists only the canonical ones. +var LegacyTableAliases = map[string]string{ + "rest_clients": "consumed_rest_services", + "rest_operations": "consumed_rest_operations", + "odata_clients": "consumed_odata_services", + "odata_services": "published_odata_services", + "queues": "task_queues", +} + +// legacyAliasView keeps a retired view name answering as its canonical view. +func legacyAliasView(old, canonical string) string { + return `CREATE VIEW IF NOT EXISTS ` + old + ` AS SELECT * FROM ` + canonical +} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 5b7c86d5a4..fa1d8c672f 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -584,6 +584,24 @@ func applyInsertWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op "Use one INSERT for the tab pages and another for the widgets") } + // Special path: adding items to a navigation list (#1388). An item lives in + // the list's Items and is not a widget, so the builder refuses one on its own + // ("item must be a direct child of navigationlist") and InsertWidget would + // put it in a widget list. The mutator checks what the target is. + if allNavigationListItems(op.Widgets) { + items, err := buildNavigationListItemsFromAST(ctx, op.Widgets, moduleName, moduleID, entityCtx, mutator) + if err != nil { + return mdlerrors.NewBackend("build navigation list items", err) + } + return mutator.InsertNavigationListItems(op.Target.Widget, backend.InsertPosition(op.Position), items) + } + if hasNavigationListItem(op.Widgets) { + return mdlerrors.NewValidation( + "mixing `item` blocks with ordinary widgets in one INSERT is not supported: " + + "items go in a navigation list's list of items, widgets inside an item. " + + "Use one INSERT for the items and another for the widgets") + } + // Build new widgets from AST widgets, err := buildWidgetsFromAST(ctx, op.Widgets, moduleName, moduleID, entityCtx, mutator) if err != nil { @@ -673,6 +691,22 @@ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op for n := range replaced { exclude = append(exclude, n) } + + // Special path: replacing one navigation list item with items (#1388). An + // item lives in the list's Items, so it is not a widget REPLACE; the mutator + // checks the target is an item. + if allNavigationListItems(op.NewWidgets) { + items, err := buildNavigationListItemsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, exclude...) + if err != nil { + return mdlerrors.NewBackend("build replacement navigation list items", err) + } + return mutator.ReplaceNavigationListItem(op.Target.Widget, items) + } + if hasNavigationListItem(op.NewWidgets) { + return mdlerrors.NewValidation( + "mixing `item` blocks with ordinary widgets in one REPLACE is not supported: " + + "items go in a navigation list's list of items, widgets inside an item") + } widgets, err := buildWidgetsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, exclude...) if err != nil { return mdlerrors.NewBackend("build replacement widgets", err) @@ -1032,6 +1066,46 @@ func buildTabPagesFromAST(ctx *ExecContext, nodes []*ast.WidgetV3, moduleName st return out, nil } +// buildNavigationListItemsFromAST builds the items an INSERT adds to a +// navigation list, with the same builder CREATE uses for a list's children. +func buildNavigationListItemsFromAST(ctx *ExecContext, nodes []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator, excludeFromScope ...string) ([]*pages.NavigationListItem, error) { + pb := newAlterPageBuilder(ctx, moduleName, moduleID, entityContext, mutator, excludeFromScope...) + out := make([]*pages.NavigationListItem, 0, len(nodes)) + for _, node := range nodes { + item, err := pb.buildNavigationListItemV3(node) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +} + +// allNavigationListItems reports whether every inserted node is a navigation +// list item. +func allNavigationListItems(widgets []*ast.WidgetV3) bool { + if len(widgets) == 0 { + return false + } + for _, w := range widgets { + if !strings.EqualFold(w.Type, "item") { + return false + } + } + return true +} + +// hasNavigationListItem reports whether ANY inserted node is a navigation list +// item, so a mixed insert is refused rather than half-applied. +func hasNavigationListItem(widgets []*ast.WidgetV3) bool { + for _, w := range widgets { + if strings.EqualFold(w.Type, "item") { + return true + } + } + return false +} + // allTabPages reports whether every inserted node is a tab page. func allTabPages(widgets []*ast.WidgetV3) bool { if len(widgets) == 0 { diff --git a/mdl/executor/cmd_alter_page_navlist_item_test.go b/mdl/executor/cmd_alter_page_navlist_item_test.go new file mode 100644 index 0000000000..616c976b50 --- /dev/null +++ b/mdl/executor/cmd_alter_page_navlist_item_test.go @@ -0,0 +1,170 @@ +// 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/sdk/pages" +) + +func navItemNode(textName, content string) *ast.WidgetV3 { + return &ast.WidgetV3{ + Type: "item", + Children: []*ast.WidgetV3{{ + Type: "dynamictext", Name: textName, Properties: map[string]any{"Content": content}, + }}, + } +} + +// mendixlabs/mxcli#1388: `insert into navList1 { item … }` failed with +// "failed to build widgets: failed to build widget : item must be a direct +// child of navigationlist" — the INSERT built each node as a widget, and a lone +// item is refused by the builder. It must build the item and hand it to +// InsertNavigationListItems with the target and position the statement named. +func TestAlterPageInsertNavigationListItem_RoutesToItems(t *testing.T) { + var gotTarget string + var gotPos backend.InsertPosition + var gotItems []*pages.NavigationListItem + mutator := &mock.MockPageMutator{ + InsertNavigationListItemsFunc: func(target string, pos backend.InsertPosition, items []*pages.NavigationListItem) error { + gotTarget, gotPos, gotItems = target, pos, items + return nil + }, + InsertWidgetFunc: func(string, string, backend.InsertPosition, []pages.Widget) error { + t.Error("a navigation list item went through InsertWidget — it would land in a widget list") + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "INTO", + Target: ast.WidgetRef{Widget: "navList1"}, + Widgets: []*ast.WidgetV3{navItemNode("t2", "Second")}, + }) + if err != nil { + t.Fatalf("insert into navList1 { item … }: %v", err) + } + if gotTarget != "navList1" || !strings.EqualFold(string(gotPos), "INTO") { + t.Errorf("InsertNavigationListItems(%q, %q), want (navList1, INTO)", gotTarget, gotPos) + } + if len(gotItems) != 1 { + t.Fatalf("got %d items, want 1", len(gotItems)) + } + it := gotItems[0] + if it.TypeName != "Forms$NavigationListItem" { + t.Errorf("item type = %s, want Forms$NavigationListItem", it.TypeName) + } + if len(it.Widgets) != 1 || it.Widgets[0].GetName() != "t2" { + t.Errorf("item widgets = %d, want the dynamic text t2", len(it.Widgets)) + } +} + +// An item and an ordinary widget go to different lists; one INSERT cannot mean +// both, so it is refused rather than half-applied. +func TestAlterPageInsertNavigationListItem_MixedRefused(t *testing.T) { + mutator := &mock.MockPageMutator{ + InsertNavigationListItemsFunc: func(string, backend.InsertPosition, []*pages.NavigationListItem) error { + t.Error("InsertNavigationListItems called for a mixed insert") + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "INTO", + Target: ast.WidgetRef{Widget: "navList1"}, + Widgets: []*ast.WidgetV3{ + navItemNode("t2", "Second"), + {Type: "dynamictext", Name: "plain", Properties: map[string]any{"Content": "x"}}, + }, + }) + assertError(t, err) + assertContainsStr(t, err.Error(), "mixing `item`") +} + +// ast.NavItemRef.Spell writes the address the visitor stores in Widget, and +// backend.ParseNavItemSelector is what the mutator reads it with. They live in +// packages that cannot import each other, so this pins them together. +func TestNavItemRef_SpellingIsTheBackendSelector(t *testing.T) { + for _, r := range []ast.NavItemRef{ + {List: "nav", Widget: "t1"}, + {List: "nav", Text: "it's"}, + {List: "nav", Ordinal: 2}, + {List: "nav", Text: "Home", Ordinal: 3}, + } { + sel, ok := backend.ParseNavItemSelector(r.Spell()) + if !ok || sel != (backend.NavItemSelector{List: r.List, Widget: r.Widget, Text: r.Text, Ordinal: r.Ordinal}) || sel.String() != r.Spell() { + t.Errorf("%q: parsed %+v, %v; spelled back %q", r.Spell(), sel, ok, sel.String()) + } + } +} + +func navItemTarget(r ast.NavItemRef) ast.WidgetRef { + return ast.WidgetRef{Widget: r.Spell(), Item: &r} +} + +// `replace nav item@2 with { item … }` replaces that item with the new ones — +// not a widget REPLACE, which would write an item into a widget list. +func TestAlterPageReplaceNavigationListItem_RoutesToItems(t *testing.T) { + var gotTarget string + var gotItems []*pages.NavigationListItem + mutator := &mock.MockPageMutator{ + ReplaceNavigationListItemFunc: func(target string, items []*pages.NavigationListItem) error { + gotTarget, gotItems = target, items + return nil + }, + ReplaceWidgetFunc: func(string, string, []pages.Widget) error { + t.Error("an item replacement went through ReplaceWidget") + return nil + }, + } + err := alterPageWith(t, mutator, &ast.ReplaceWidgetOp{ + Target: navItemTarget(ast.NavItemRef{List: "navList1", Ordinal: 2}), + NewWidgets: []*ast.WidgetV3{navItemNode("t9", "New")}, + }) + if err != nil { + t.Fatalf("replace navList1 item@2 with { item … }: %v", err) + } + if gotTarget != "navList1 item@2" || len(gotItems) != 1 || gotItems[0].Widgets[0].GetName() != "t9" { + t.Errorf("ReplaceNavigationListItem(%q, %d items), want (navList1 item@2, the t9 item)", gotTarget, len(gotItems)) + } +} + +// `insert after nav item(t1) { item … }` places the item next to that item. +func TestAlterPageInsertNavigationListItem_AfterAnItem(t *testing.T) { + var gotTarget string + var gotPos backend.InsertPosition + mutator := &mock.MockPageMutator{ + InsertNavigationListItemsFunc: func(target string, pos backend.InsertPosition, _ []*pages.NavigationListItem) error { + gotTarget, gotPos = target, pos + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "AFTER", + Target: navItemTarget(ast.NavItemRef{List: "navList1", Widget: "t1"}), + Widgets: []*ast.WidgetV3{navItemNode("t2", "Second")}, + }) + if err != nil { + t.Fatalf("insert after navList1 item(t1) { item … }: %v", err) + } + if gotTarget != "navList1 item(t1)" || !strings.EqualFold(string(gotPos), "AFTER") { + t.Errorf("InsertNavigationListItems(%q, %q), want (navList1 item(t1), AFTER)", gotTarget, gotPos) + } +} + +// `check` refuses an @n on a widget target with no project; the item's @n is +// part of the item address and must not trip that rule. +func TestValidateAlterPageAddresses_AcceptsNavItemAddress(t *testing.T) { + stmt := &ast.AlterPageStmt{ + PageName: ast.QualifiedName{Module: "M", Name: "S"}, + Operations: []ast.AlterPageOperation{ + &ast.DropWidgetOp{Targets: []ast.WidgetRef{navItemTarget(ast.NavItemRef{List: "nav", Text: "Home", Ordinal: 2})}}, + }, + } + if v := validateAlterPageAddresses(stmt); len(v) != 0 { + t.Errorf("violations = %+v, want none", v) + } +} diff --git a/mdl/executor/cmd_context.go b/mdl/executor/cmd_context.go index 39298c9e0b..79ba2ccd7e 100644 --- a/mdl/executor/cmd_context.go +++ b/mdl/executor/cmd_context.go @@ -475,7 +475,7 @@ func assembleJavaActionContext(ctx *ExecContext, out *strings.Builder, name stri func assembleODataClientContext(ctx *ExecContext, out *strings.Builder, name string) { out.WriteString("### Consumed OData Service\n\n") result, err := ctx.Catalog.Query(fmt.Sprintf( - "select Name, Version, ODataVersion, MetadataUrl from odata_clients where QualifiedName = '%s'", name)) + "select Name, Version, ODataVersion, MetadataUrl from consumed_odata_services where QualifiedName = '%s'", name)) if err == nil && result.Count > 0 { row := result.Rows[0] out.WriteString(fmt.Sprintf("- **Name**: %v\n", row[0])) @@ -606,7 +606,7 @@ func assembleWorkflowContext(ctx *ExecContext, out *strings.Builder, name string func assembleODataServiceContext(ctx *ExecContext, out *strings.Builder, name string) { out.WriteString("### Published OData Service\n\n") result, err := ctx.Catalog.Query(fmt.Sprintf( - "select Name, Path, Version, ODataVersion, EntitySetCount from odata_services where QualifiedName = '%s'", name)) + "select Name, Path, Version, ODataVersion, EntitySetCount from published_odata_services where QualifiedName = '%s'", name)) if err == nil && result.Count > 0 { row := result.Rows[0] out.WriteString(fmt.Sprintf("- **Name**: %v\n", row[0])) diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index ad55a2975f..374fc44b0d 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -211,7 +211,7 @@ Catalog Queries: Tables: modules, entities, attributes, microflows, nanoflows, pages, snippets, layouts, enumerations, JAVA_ACTIONS, ACTIVITIES*, widgets*, XPATH_EXPRESSIONS, REFS*, PROJECTS, SNAPSHOTS, - objects, ODATA_CLIENTS, ODATA_SERVICES, + objects, CONSUMED_ODATA_SERVICES, PUBLISHED_ODATA_SERVICES, BUSINESS_EVENT_SERVICES, STRINGS*, source** (* only populated with refresh catalog full) (** only populated with refresh catalog full source) diff --git a/mdl/executor/cmd_page_wireframe.go b/mdl/executor/cmd_page_wireframe.go index 87f97f8673..de37ccdd44 100644 --- a/mdl/executor/cmd_page_wireframe.go +++ b/mdl/executor/cmd_page_wireframe.go @@ -369,7 +369,6 @@ func rawWidgetToWireframe(w rawWidget, counter *wireframeCounter) wireframeNode case "NavigationListItem": node.Widget = "navigationlistitem" node.Action = w.Action - node.ButtonStyle = w.ButtonStyle for _, child := range w.Children { node.Children = append(node.Children, rawWidgetToWireframe(child, counter)) } diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index ee00277616..8d5b478570 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -1241,6 +1241,11 @@ func (pb *pageBuilder) buildNavigationListItemV3(w *ast.WidgetV3) (*pages.Naviga } } + // The item's Forms$Appearance — what describe prints for it. + item.Class = w.GetClass() + item.Style = w.GetStyle() + item.DynamicClasses = w.GetDynamicClasses() + // Set caption from Caption property if caption := w.GetCaption(); caption != "" { item.Caption = &model.Text{ diff --git a/mdl/executor/cmd_pages_describe_navlist_item_class_test.go b/mdl/executor/cmd_pages_describe_navlist_item_class_test.go new file mode 100644 index 0000000000..09224957ef --- /dev/null +++ b/mdl/executor/cmd_pages_describe_navlist_item_class_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// A navigation list item's Forms$Appearance carries Class, Style and +// DynamicClasses like any widget's (measured: every item in PedApp and the +// WidgetDemo showcase stores all three). Describe printed the class as +// `ButtonStyle: nav-orders` — a property an item does not have, which the item +// builder ignored — and the builder wrote an empty appearance whatever the item +// said, so a styled item lost its class on describe → exec. Found while adding +// item addresses for mendixlabs/mxcli#1388. +func TestNavigationListItemAppearance_RoundTrips(t *testing.T) { + var buf bytes.Buffer + ctx := (&Executor{}).newExecContext(context.Background()) + ctx.Output = &buf + stored := map[string]any{ + "$Type": "Forms$NavigationList", + "Name": "navList1", + "Items": []any{int32(2), map[string]any{ + "$Type": "Forms$NavigationListItem", + "Action": map[string]any{"$Type": "Forms$SignOutClientAction"}, + "Appearance": map[string]any{ + "$Type": "Forms$Appearance", + "Class": "nav-orders", + "Style": "color: red;", + "DynamicClasses": "if $currentUser = empty then 'anon' else ''", + }, + "Widgets": []any{int32(2)}, + }}, + } + outputWidgetMDLV3(ctx, parseRawWidget(ctx, stored)[0], 1) + got := buf.String() + if strings.Contains(got, "ButtonStyle") { + t.Errorf("describe prints the item's class as ButtonStyle:\n%s", got) + } + for _, want := range []string{"Class: 'nav-orders'", "Style: 'color: red;'", "DynamicClasses: if $currentUser = empty then 'anon' else ''"} { + if !strings.Contains(got, want) { + t.Errorf("describe output lacks %q:\n%s", want, got) + } + } + + prog, errs := visitor.Build("create snippet M.S {\n" + got + "}\n") + if len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, got) + } + itemNode := prog.Statements[0].(*ast.CreateSnippetStmtV3).Widgets[0].Children[0] + pb := &pageBuilder{ctx: ctx, widgetScope: map[string]model.ID{}} + item, err := pb.buildNavigationListItemV3(itemNode) + if err != nil { + t.Fatal(err) + } + if item.Class != "nav-orders" || item.Style != "color: red;" || item.DynamicClasses != "if $currentUser = empty then 'anon' else ''" { + t.Errorf("built item appearance = class %q style %q dynamic %q, want what describe printed", + item.Class, item.Style, item.DynamicClasses) + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 83cf2a5df0..ee8dbd87b0 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -954,8 +954,14 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if child.Action != "" { props = append(props, actionProp("Action", child.Action)) } - if child.ButtonStyle != "" && child.ButtonStyle != "Default" { - props = append(props, fmt.Sprintf("ButtonStyle: %s", child.ButtonStyle)) + if child.Class != "" { + props = append(props, fmt.Sprintf("Class: %s", mdlQuote(ctx, child.Class))) + } + if child.Style != "" { + props = append(props, fmt.Sprintf("Style: %s", mdlQuote(ctx, child.Style))) + } + if child.DynamicClasses != "" { + props = append(props, fmt.Sprintf("DynamicClasses: %s", child.DynamicClasses)) // an expression, printed as-is } formatWidgetProps(ctx.Output, prefix+" ", itemHeader, props, " {\n") for _, cw := range child.Children { diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index ec07fde58c..29da04068e 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -875,11 +875,13 @@ func parseNavigationListItems(ctx *ExecContext, w map[string]any) []rawWidget { // Extract action rw.Action = extractNavigationListItemAction(ctx, itemMap) - // Extract style from Appearance class + // The item's own Forms$Appearance: Class, Style, DynamicClasses. It was + // read into ButtonStyle, a property an item does not have, so describe + // printed `ButtonStyle: ` and exec dropped the class. if appearance, ok := itemMap["Appearance"].(map[string]any); ok { - if class, ok := appearance["Class"].(string); ok && class != "" { - rw.ButtonStyle = class - } + rw.Class, _ = appearance["Class"].(string) + rw.Style, _ = appearance["Style"].(string) + rw.DynamicClasses, _ = appearance["DynamicClasses"].(string) } result = append(result, rw) diff --git a/mdl/executor/cmd_structure.go b/mdl/executor/cmd_structure.go index 95f17dab13..d4ea739c83 100644 --- a/mdl/executor/cmd_structure.go +++ b/mdl/executor/cmd_structure.go @@ -976,7 +976,7 @@ func formatJATypeDisplay(typeStr string) string { // structureODataClients outputs OData clients for a module. func structureODataClients(ctx *ExecContext, moduleName string) error { result, err := structureQuery(ctx, fmt.Sprintf( - "select Name, ODataVersion from odata_clients where ModuleName = '%s' ORDER by Name", + "select Name, ODataVersion from consumed_odata_services where ModuleName = '%s' ORDER by Name", escapeSQLString(moduleName))) if err != nil { return err @@ -998,7 +998,7 @@ func structureODataClients(ctx *ExecContext, moduleName string) error { // structureODataServices outputs OData services for a module. func structureODataServices(ctx *ExecContext, moduleName string) error { result, err := structureQuery(ctx, fmt.Sprintf( - "select Name, Path, EntitySetCount from odata_services where ModuleName = '%s' ORDER by Name", + "select Name, Path, EntitySetCount from published_odata_services where ModuleName = '%s' ORDER by Name", escapeSQLString(moduleName))) if err != nil { return err diff --git a/mdl/executor/describe_auto.go b/mdl/executor/describe_auto.go index cd294650ee..d8f3693d27 100644 --- a/mdl/executor/describe_auto.go +++ b/mdl/executor/describe_auto.go @@ -17,29 +17,35 @@ import ( // kind directly and additionally includes JAVASCRIPT_ACTION / PUBLISHED_REST_SERVICE, // which the executor can describe but the CLI dispatch does not wire.) var objectTypeToDescribeKind = map[string]ast.DescribeObjectType{ - "MODULE": ast.DescribeModule, - "ENTITY": ast.DescribeEntity, - "EXTERNAL_ENTITY": ast.DescribeExternalEntity, - "ASSOCIATION": ast.DescribeAssociation, - "MICROFLOW": ast.DescribeMicroflow, - "NANOFLOW": ast.DescribeNanoflow, - "RULE": ast.DescribeRule, - "PAGE": ast.DescribePage, - "SNIPPET": ast.DescribeSnippet, - "BUILDING_BLOCK": ast.DescribeBuildingBlock, - "MENU": ast.DescribeMenu, - "QUEUE": ast.DescribeQueue, - "SCHEDULED_EVENT": ast.DescribeScheduledEvent, - "REGULAR_EXPRESSION": ast.DescribeRegularExpression, - "LAYOUT": ast.DescribeLayout, - "ENUMERATION": ast.DescribeEnumeration, - "CONSTANT": ast.DescribeConstant, - "JAVA_ACTION": ast.DescribeJavaAction, - "JAVASCRIPT_ACTION": ast.DescribeJavaScriptAction, - "WORKFLOW": ast.DescribeWorkflow, - "JSON_STRUCTURE": ast.DescribeJsonStructure, - "IMPORT_MAPPING": ast.DescribeImportMapping, - "EXPORT_MAPPING": ast.DescribeExportMapping, + "MODULE": ast.DescribeModule, + "ENTITY": ast.DescribeEntity, + "EXTERNAL_ENTITY": ast.DescribeExternalEntity, + "ASSOCIATION": ast.DescribeAssociation, + "MICROFLOW": ast.DescribeMicroflow, + "NANOFLOW": ast.DescribeNanoflow, + "RULE": ast.DescribeRule, + "PAGE": ast.DescribePage, + "SNIPPET": ast.DescribeSnippet, + "BUILDING_BLOCK": ast.DescribeBuildingBlock, + "MENU": ast.DescribeMenu, + "TASK_QUEUE": ast.DescribeQueue, + "QUEUE": ast.DescribeQueue, // pre-#1395 catalog + "SCHEDULED_EVENT": ast.DescribeScheduledEvent, + "REGULAR_EXPRESSION": ast.DescribeRegularExpression, + "LAYOUT": ast.DescribeLayout, + "ENUMERATION": ast.DescribeEnumeration, + "CONSTANT": ast.DescribeConstant, + "JAVA_ACTION": ast.DescribeJavaAction, + "JAVASCRIPT_ACTION": ast.DescribeJavaScriptAction, + "WORKFLOW": ast.DescribeWorkflow, + "JSON_STRUCTURE": ast.DescribeJsonStructure, + "IMPORT_MAPPING": ast.DescribeImportMapping, + "EXPORT_MAPPING": ast.DescribeExportMapping, + "CONSUMED_ODATA_SERVICE": ast.DescribeODataClient, + "PUBLISHED_ODATA_SERVICE": ast.DescribeODataService, + "CONSUMED_REST_SERVICE": ast.DescribeRestClient, + // The ObjectType values before mendixlabs/mxcli#1395, which a catalog + // cached by an older build still holds. "ODATA_CLIENT": ast.DescribeODataClient, "ODATA_SERVICE": ast.DescribeODataService, "REST_CLIENT": ast.DescribeRestClient, diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index d5b38a6ee6..b74c5901ef 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -425,8 +425,16 @@ alterFragment // columns over one attribute share the address and need `@n`. FIRST, so the // COLUMN keyword after the grid name is not left to the plain-name form. // `dg.Name`, the older address by a name mxcli derived, keeps working. +// +// A navigation list item has no name either (Studio Pro stores none), so it is +// addressed the same way (mendixlabs/mxcli#1388): `nav item(t1)` for the item +// holding the widget named t1, `nav item('Home')` for the one whose text is +// Home, and `nav item@2` for the second item. With a selector, @n chooses among +// the items that match it. alterTarget : identifierOrKeyword COLUMN LPAREN (attributePathV3 | STRING_LITERAL) RPAREN (AT NUMBER_LITERAL)? + | identifierOrKeyword ITEM LPAREN (identifierOrKeyword | STRING_LITERAL) RPAREN (AT NUMBER_LITERAL)? + | identifierOrKeyword ITEM AT NUMBER_LITERAL | identifierOrKeyword (DOT identifierOrKeyword)? (AT NUMBER_LITERAL)? | STRING_LITERAL (AT NUMBER_LITERAL)? ; diff --git a/mdl/linter/context.go b/mdl/linter/context.go index a3600273fd..a3681687f8 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -1202,7 +1202,7 @@ func (ctx *LintContext) Queues() iter.Seq[Queue] { return func(yield func(Queue) bool) { rows, err := ctx.db.Query(fmt.Sprintf(` SELECT q.Name, q.QualifiedName, q.ModuleName, q.Parallelism, q.ClusterWide - FROM queues q + FROM task_queues q LEFT JOIN modules m ON q.ModuleName = m.Name WHERE %s ORDER BY q.ModuleName, q.Name @@ -1292,7 +1292,7 @@ func (ctx *LintContext) RestClients() iter.Seq[RestClient] { rows, err := ctx.db.Query(fmt.Sprintf(` SELECT rc.Id, rc.Name, rc.QualifiedName, rc.ModuleName, rc.Folder, rc.BaseUrl, rc.AuthScheme, rc.OperationCount, rc.Documentation - FROM rest_clients rc + FROM consumed_rest_services rc LEFT JOIN modules m ON rc.ModuleName = m.Name WHERE %s ORDER BY rc.ModuleName, rc.Name @@ -1352,7 +1352,7 @@ func (ctx *LintContext) RestOperations() iter.Seq[RestOperation] { SELECT ro.Id, ro.ServiceId, ro.ServiceQualifiedName, ro.Name, ro.HttpMethod, ro.Path, ro.ParameterCount, ro.HasBody, ro.ResponseType, ro.Timeout, ro.ModuleName - FROM rest_operations ro + FROM consumed_rest_operations ro LEFT JOIN modules m ON ro.ModuleName = m.Name WHERE %s ORDER BY ro.ServiceQualifiedName, ro.Name diff --git a/mdl/visitor/visitor_alter_navitem_address_test.go b/mdl/visitor/visitor_alter_navitem_address_test.go new file mode 100644 index 0000000000..96da680e0d --- /dev/null +++ b/mdl/visitor/visitor_alter_navitem_address_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1388: a navigation list item has no name, so it is addressed +// as `nav item(widget)`, `nav item('text')` or `nav item@n`. The item's @n is +// its own, never the target's Ordinal (which a page would refuse), and Widget +// carries the address so every widget operation passes it through. +func TestAlterTarget_NavItemAddress(t *testing.T) { + stmt := buildAlterPage(t, `alter snippet M.S { + set (Action: show page M.Home) on nav item('Home'); + drop nav item@2, nav item(t1), nav item('it''s')@3; + insert after nav item(t1) { item (Action: show page M.Home) { dynamictext t2 (Content: 'x') } } + replace nav item@1 with { item (Action: show page M.Home) { dynamictext t3 (Content: 'y') } } + };`) + check := func(label string, got ast.WidgetRef, want ast.NavItemRef, spelled string) { + t.Helper() + if got.Item == nil || *got.Item != want { + t.Errorf("%s: Item = %+v, want %+v", label, got.Item, want) + } + if got.Widget != spelled || got.Ordinal != 0 || got.Column != "" { + t.Errorf("%s: Widget %q Ordinal %d Column %q, want %q, 0, \"\"", label, got.Widget, got.Ordinal, got.Column, spelled) + } + } + check("set", stmt.Operations[0].(*ast.SetPropertyOp).Target, ast.NavItemRef{List: "nav", Text: "Home"}, "nav item('Home')") + drop := stmt.Operations[1].(*ast.DropWidgetOp).Targets + if len(drop) != 3 { + t.Fatalf("drop has %d targets, want 3", len(drop)) + } + check("drop 1", drop[0], ast.NavItemRef{List: "nav", Ordinal: 2}, "nav item@2") + check("drop 2", drop[1], ast.NavItemRef{List: "nav", Widget: "t1"}, "nav item(t1)") + check("drop 3", drop[2], ast.NavItemRef{List: "nav", Text: "it's", Ordinal: 3}, "nav item('it''s')@3") + check("insert", stmt.Operations[2].(*ast.InsertWidgetOp).Target, ast.NavItemRef{List: "nav", Widget: "t1"}, "nav item(t1)") + check("replace", stmt.Operations[3].(*ast.ReplaceWidgetOp).Target, ast.NavItemRef{List: "nav", Ordinal: 1}, "nav item@1") +} diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index c4c8a15ff8..ce0d7d30c6 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -276,6 +276,22 @@ func (b *Builder) buildAlterTarget(ctx parser.IAlterTargetContext) ast.WidgetRef tc.GetText(), n.GetText())) } } + if tc.ITEM() != nil { + // `nav item(t1)` / `nav item('Home')` / `nav item@2` (#1388). The @n is + // the item's, so it moves off the target, where it would mean a choice + // among widgets. + ids := tc.AllIdentifierOrKeyword() + item := &ast.NavItemRef{List: identifierOrKeywordText(ids[0]), Ordinal: ref.Ordinal} + if sl := tc.STRING_LITERAL(); sl != nil { + item.Text = unquoteStringLit(sl) + } else if len(ids) == 2 { + item.Widget = identifierOrKeywordText(ids[1]) + } + ref.Ordinal = 0 + ref.Item = item + ref.Widget = item.Spell() + return ref + } if tc.COLUMN() != nil { // `grid column(Attr)` / `grid column('Caption')` (#749). ref.Widget = identifierOrKeywordText(tc.IdentifierOrKeyword(0)) diff --git a/mdl/visitor/visitor_alter_workflow.go b/mdl/visitor/visitor_alter_workflow.go index 8d1b1dc5c9..cd5b80172b 100644 --- a/mdl/visitor/visitor_alter_workflow.go +++ b/mdl/visitor/visitor_alter_workflow.go @@ -108,7 +108,7 @@ func (b *Builder) alterWorkflowActivityRef(ctx parser.IAlterTargetContext) (stri return "", 0, false } ref := b.buildAlterTarget(ctx) - if ref.IsColumnAddress() || ref.Column != "" { + if ref.IsColumnAddress() || ref.Column != "" || ref.Item != nil { b.addError(fmt.Errorf("alter workflow target %s: a workflow activity is addressed by its name "+ "(`ReviewOrder`) or its caption (`'Review the order'`), with @n to choose one of several "+ "matches — `describe workflow` prints every activity's name", ref.Name())) diff --git a/sdk/pages/pages_widgets_action.go b/sdk/pages/pages_widgets_action.go index d5083e32e4..f4bd2f7003 100644 --- a/sdk/pages/pages_widgets_action.go +++ b/sdk/pages/pages_widgets_action.go @@ -133,6 +133,11 @@ type NavigationListItem struct { Caption *model.Text `json:"caption,omitempty"` Action ClientAction `json:"action,omitempty"` Widgets []Widget `json:"widgets,omitempty"` // Nested widgets beyond the caption + // Class, Style and DynamicClasses are the item's Forms$Appearance — the + // same three fields a widget's appearance carries. + Class string `json:"class,omitempty"` + Style string `json:"style,omitempty"` + DynamicClasses string `json:"dynamicClasses,omitempty"` } // LinkButton represents a link-style button.