diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 7e3968e61..db9e64e5c 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -608,3 +608,7 @@ extracting `OffsetExpression`/`LimitExpression`. | `DELETE_BEHAVIOR PREVENT` (or `DELETE_IF_NO_REFERENCES`, or `DELETE_AND_REFERENCES`) reports `Modified association` and stores `DeleteMeButKeepReferences`, destroying whatever the association had — and `DESCRIBE ASSOCIATION` emits `delete_behavior DELETE_CASCADE`, which the parser then rejects | `buildDeleteBehavior` read only `db.CASCADE()`; the other four of the rule's five tokens fell through to the zero value `ast.DeleteKeepReferences`. A **legal** behaviour, so nothing below could tell it had been substituted. Underneath it, `ALTER ASSOCIATION SET` built the stored value as `DeleteBehaviorType(s.DeleteBehavior.String())`, and `String()` spells prevent `"DeleteIfNoReferences"` where Mendix writes `"DeleteMeIfNoReferences"` — so fixing the visitor alone put an **out-of-domain enum** on disk | `mdl/visitor/visitor_helpers.go` (`buildDeleteBehavior`), then `mdl/executor/cmd_associations.go` (`storageDeleteBehavior`) | Read **every** token the grammar rule admits — a `default:` arm returning a plausible value turns a parse gap into silent data loss. Route all storage conversions through one function; a `String()` returning storage-ish names is a trap, not an encoder. Emit from DESCRIBE only spellings the lexer has (`DELETE_AND_REFERENCES`, not `DELETE_CASCADE`) and prove it by feeding the output back through `visitor.Build`. **`mx check` is not the oracle here**: measured on mxbuild 11.6.6, a project carrying `DeleteIfNoReferences` builds with 0 errors exactly like a correct one — only Studio Pro refuses it, so assert the stored value is one of Mendix's three, not merely the one you asked for (upstream #901) | | `CREATE OR REPLACE WORKFLOW` silently deletes a boundary event (timer + its whole handler flow) or an event sub-process that the script does not restate — `exec` reports "Created workflow" and exit 0, and `mx check` afterwards reports 0 errors because the result is a valid workflow that simply no longer does what it did. `DESCRIBE WORKFLOW` does not show the construct either, so nothing reveals the loss | Two separate defects. (1) The **default (modelsdk) engine's workflow reader had no boundary-event support at all** — `grep BoundaryEvent mdl/backend/modelsdk/workflow_read.go` → 0, while `sdk/mpr/parser_workflow.go` → 17. Both engines *write* them, so a boundary event mxcli itself had just written read back as absent. (2) A rewrite rebuilds from the statement, so anything unstated is dropped, and no guard existed — unlike the queued-call and validation-rule paths | `mdl/backend/modelsdk/workflow_read.go` (`boundaryEventsFromGen`, wired into the 6 gen types with `BoundaryEventsItems`); `mdl/executor/validate_workflow_rewrite.go` (`checkNoDroppedWorkflowConstructs`, called from `cmd_workflows_write.go`); `mdl/executor/cmd_workflows.go` (clause order) | **Read the stored side from the RAW unit, not the semantic model** — the reader is what was blind, and a guard sharing its blind spot cannot see what it protects; `GetRawUnit` also covers constructs no engine models yet (event sub-processes exist in `modelsdk/gen` but have no semantic type, reader or writer anywhere). Match the `$Type` by **substring** so all three timer variants and any later one are caught. Authorable constructs get the queue treatment (restate them and the rewrite proceeds); unauthorable ones refuse outright. **Fixing a reader can expose a describer bug**: with boundary events finally readable, `describe → exec` stopped parsing, because the call-microflow describer emitted `boundary event` BEFORE `outcomes` and the grammar requires the reverse — pre-existing on legacy, invisible while the default engine emitted neither. Always re-run a describe→re-exec round trip after widening a reader. Issue #948 | | A workflow that calls a microflow existing nowhere passes `mxcli check --references` with "All references valid" and is written by `exec` with exit 0; only native `mx check` catches it (**CE1613** "The selected microflow ... no longer exists"). The identical mistake inside a plain microflow body IS caught | `validateWithContext`'s `CreateWorkflowStmt` case called only `validateWorkflowParameterMappings`, whose own comment defers a target that is not in the project to "the missing-reference check" — **which was never written**. `validateFlowBodyReferences` (which does catch it) is wired only to `CreateMicroflowStmt`/`CreateNanoflowStmt`. `AlterWorkflowStmt` had **no case at all** and fell to `default: skip validation` | `mdl/executor/validate_workflow_refs.go` (`validateWorkflowReferences`, `validateWorkflowStatementRefs`, `validateAlterWorkflowRefs`); `mdl/executor/validate.go` (both switch cases, `scriptContext.workflows`, `collectDefinitions`/`collectSingle`/`allNames`); `mdl/executor/helpers.go` (`buildWorkflowQualifiedNames`); exec-side guards in `cmd_workflows_write.go` + `cmd_alter_workflow.go` | **The gap is never one reference kind** — probe them all before fixing. Here *every* reference in a workflow was unvalidated: call microflow, call workflow, user task page, targeting microflow, context entity, and the workflow's own module (checked for a microflow, not for a workflow — and `exec` auto-creates a module, so `check` was the only guard against a typo silently making one). **`check` and `exec` run different passes**: `check --references` runs `validateProgram`, `exec` does not, so a validator wired only into the switch leaves exec writing the broken model — the guard must ALSO be called from the statement handler, before `findOrCreateModule` (same shape as #833). Pass `nil` for the scriptContext there: exec applies statements one at a time, so an earlier statement's output is already in the project. **The real risk is false positives** — verify by diffing error counts against a baseline binary over every workflow script in `mdl-examples/`, with the referenced modules actually present, and exempt `System.*` (`isBuiltinModuleEntity`) or every built-in target is reported missing. Issue #943 | +| `CREATE OR MODIFY ODATA CLIENT` reports `Unchanged OData client` after the contract file was refreshed from the running backend — `SHOW CONTRACT ENTITIES` keeps listing the old entity types, and the `CREATE OR MODIFY EXTERNAL ENTITIES` that follows imports the old shape with no warning. `DROP ODATA CLIENT` + recreate is the only way out, and it invalidates the client ID the existing external entities point at | The modify branch updated every property **except the cached contract**. Only the create path fetched `$metadata`; `svc.Metadata` / `svc.MetadataHash` kept the snapshot taken when the client was first created. The same branch also stored `stmt.MetadataUrl` raw, skipping the `NormalizeURL` the create path applies, so a relative `./contracts/x.xml` ended up as a URL neither Studio Pro nor `fetchODataMetadata` can open | `mdl/executor/cmd_odata.go` (`refreshCachedMetadata`, `normalizeMetadataURL`, `contractSummary`, called from the `stmt.CreateOrModify` branch of `createODataClient`); test `mdl/executor/cmd_odata_metadata_refresh_test.go`; repro `mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl` | **Refresh from the *effective* URL, not the statement's** — a `CREATE OR MODIFY` that omits `MetadataUrl` still has to converge the cache, or the idiomatic re-run silently keeps the stale contract. **A failed fetch must keep what is cached** (warning, not error): losing a contract that was merely unreachable is worse than serving a stale one, and there is nothing to fall back on. **Leave `svc` untouched when the hash matches**, or ADR-0008 elision breaks and every re-run churns the document — the unchanged-contract control is what proves this, and it passes both before and after the fix, so it is the half of the test that keeps the other half honest. `ALTER ODATA CLIENT SET MetadataUrl` has the same staleness and is deliberately **not** fixed here: it carries no design-time credentials, so a blind re-fetch of an authenticated URL would warn where it used to be silent. `mx check` is no oracle for any of this — the stale project builds with 0 errors, because a client caching an old contract is perfectly valid | +| A microflow rewritten by `CREATE OR MODIFY MICROFLOW` is drawn with its **StartEvent stranded** — every activity where the script asked, the start left behind, joined to its own first activity by a long mostly-empty line across the canvas. Reproduces on every rewrite, and copying a working script's `@position` pattern does not help | The fix for the OPPOSITE report. #884 was a describe→exec round-trip MOVING a hand-placed start (145;200 → 100;200), fixed by carrying the stored position over on every rewrite; that then **pinned** the start of every rewritten flow, so it no longer followed activities the same script had just moved. Both reports are real and neither is answerable without asking where the stored value came from | `mdl/executor/cmd_microflows_start_position.go` (`authoredStartPosition`, `derivedStartPosition`, `startAnnotationLines`), `mdl/executor/cmd_microflows_create.go` (`storedStartPosition`), `mdl/executor/cmd_microflows_builder_graph.go` (StartEvent construction) | Carry the stored position over **only when it is not where the layout would have put it** — a start at `first.X − spacing` on `first.Y` is mxcli's own arithmetic handed back and carries no intent, so re-derive it; anywhere else, a person put it there, so keep it. Add `@start(x, y)` on the first statement (the `@merge` precedent — it positions the other node with no statement of its own) so the position can be *stated* rather than inferred, and have DESCRIBE emit it **only for a non-derived start**, which is what makes the round-trip exact without pinning every described flow. **`mx check` is not the oracle, and neither is a green build**: the Mendix model carries no geometry rules, so a stranded start is a valid document — 0 errors on mxbuild 11.13.0 before and after. Verify by reading the coordinates back off the stored document. **Emit from BOTH describers**: `formatMicroflowActivities` and `formatMicroflowActivitiesWithSourceMap` are near-duplicates, and the first cut of this fix patched only the second, so `describe microflow` silently dropped the line. Controls: reverting the narrowing reproduces the stranded start, reverting the builder arm reproduces "@start does nothing", reverting either describer reproduces the lossy round-trip. Repro `mdl-examples/bug-tests/951-microflow-start-event-position.mdl`. Issue #951 | +| `SPLIT TYPE … END SPLIT` followed by any statement draws that statement **on top of the merge** in Studio Pro, joined by a zero-length sequence flow, and the merge itself sits far to the right of the branches it joins (branches at x=720, merge at x=1480). `CASE` and `IF/ELSE` with the same graph shape lay out correctly. `mx check` reports 0 errors either way | Two defects in `addStructuredInheritanceSplit`. (1) It ended with `fb.posX = mergeX` — the merge's own centre — where `addEnumSplit` steps to `mergeX + HorizontalSpacing/2` and `addIfStatement` to `mergeX + MergeSize + HorizontalSpacing/2`; three builders, three conventions, and the type split's was zero. (2) Branch width came from `measureStatements(appendInheritanceBodies(s))`, which concatenated every branch body into ONE list and measured it as a single left-to-right run — so the merge slid right by an activity-plus-spacing per **extra branch**, not per branch *width*. `layout.go`'s own `measureInheritanceSplitStatement` always took the max, so the builder disagreed with its measurer | `mdl/executor/cmd_microflows_builder_actions.go` (`addStructuredInheritanceSplit`; `appendInheritanceBodies` → `inheritanceBranchBodies`); tests `mdl/executor/cmd_microflows_builder_split_geometry_test.go`; repro `mdl-examples/bug-tests/953-split-type-merge-overlap.mdl` | **Geometry is invisible to every automatic check below Studio Pro** — the model is valid, so `mx check` passes, the build passes, and `describe` output looks right until you read the coordinates. `describe` printing `@position`/`@merge` IS the headless oracle: diff those, don't open the modeler. **The split builders had zero positional coverage** before this (grep `Position` in the enum-split and inheritance-split tests: nothing), which is the same gap behind the loop-box sizing bugs #790 and #884. **Pick the IF convention, not the CASE one**: the constants are centre-to-centre for a 40px edge gap, and `CASE`'s `+HorizontalSpacing/2` leaves a following activity's left edge exactly touching the merge (measured: merge 890, activity 970, both edges at 910) — a lesser pre-existing nit deliberately left alone, because changing it re-lays-out every enum split ever written. **`@merge(x,y)` was not a workaround**: `fb.posX = mergeX` read the overridden value, so moving the merge by hand moved the stacked element with it. A fix needs a control that the neighbouring construct did NOT move — `TestEnumSplitGeometryIsUnchanged` — or "the type split matches CASE now" can be achieved by breaking CASE. Issue #953 | +| A bundled skill renders with a stray `---` fence and a duplicated `name:` at the top of its body, and `mxcli init` ships it that way. `TestEmbeddedSkillsCarryAgentSkillsFrontmatter` passes — the frontmatter it checks is valid | The #906 migration **prepended** a `name`/`description` block to every skill, including the one file that already had one (`custom-widgets`, under its pre-rename name `mendix-custom-widgets`). Everything after the FIRST `---` block is body, so the old block became visible text. The test's `frontmatter.FindSubmatch` matches the first block and validates it correctly, which is precisely why it is blind to a second one | `.claude/skills/mendix/custom-widgets/SKILL.md` (two blocks merged into one, keeping the old block's trigger words — child slots, `.def.json`, engine internals); guard added to `cmd/mxcli/init_skills_standard_test.go` | **A first-block check cannot see a rename-and-prepend**, and that is the exact shape of a migration that renames things — so a bulk frontmatter edit needs a guard against a SECOND block, not a better check of the first. **Measure the pre-state before a bulk prepend**: 1 of 68 files already had frontmatter, which was known and written down, and the migration script still did not branch on it. **Do not attribute a defect to the change you are looking at** — this was reported against PR #222, whose diff touches only the `description:` inside the first block and shows the stray `---` as unchanged context; `git log -S 'name: mendix-custom-widgets'` settles it in one command. Two adjacent traps from the same report: `make test` did not depend on `sync-all` while `make build` did, so a bare `go build` left the generated `cmd/mxcli/skills` stale and six tests failed — and the failure message named the `go:embed` directive, which was fine, instead of the missing build step. A **differential across refs is only as sound as the procedure both sides share**: the same stale build made main fail 4 and the branch fail 6, which looked like a regression and was a test-count difference. mxcli-formula1 finding 68 | diff --git a/.claude/skills/mendix/README.md b/.claude/skills/mendix/README.md index 3d3972d9b..c35fb4c26 100644 --- a/.claude/skills/mendix/README.md +++ b/.claude/skills/mendix/README.md @@ -2,14 +2,20 @@ Skills for writing Mendix Definition Language (MDL) code correctly. +Each skill is a directory holding a `SKILL.md` with `name` and `description` +frontmatter, following the [Agent Skills](https://agentskills.io) standard — so +an assistant that reads skills finds the whole set on its own. **This index is a +convenience for humans, not the discovery mechanism**, and it does not list every +skill; the directory does. + ## Quick Reference (Cheat Sheets) Start here for quick syntax lookups: | Skill | Purpose | Use When | |-------|---------|----------| -| [cheatsheet-variables.md](cheatsheet-variables.md) | Variable declaration syntax | Declaring variables, fixing declaration errors | -| [cheatsheet-errors.md](cheatsheet-errors.md) | Common errors and fixes | Debugging MDL syntax errors | +| [cheatsheet-variables](cheatsheet-variables/SKILL.md) | Variable declaration syntax | Declaring variables, fixing declaration errors | +| [cheatsheet-errors](cheatsheet-errors/SKILL.md) | Common errors and fixes | Debugging MDL syntax errors | ## Syntax Reference (By Document Type) @@ -17,15 +23,15 @@ Detailed syntax for each MDL document type: | Skill | Purpose | Use When | |-------|---------|----------| -| [mdl-entities.md](mdl-entities.md) | Entity, attribute, association syntax | Creating domain models | -| [write-microflows.md](write-microflows.md) | Microflow syntax reference | Writing microflow logic | -| [write-nanoflows.md](write-nanoflows.md) | Nanoflow syntax reference | Writing client-side nanoflow logic | -| [write-rules.md](write-rules.md) | Rule syntax reference | Writing reusable decision logic a decision calls | -| [write-oql-queries.md](write-oql-queries.md) | OQL query syntax | Creating VIEW entities | -| [create-page.md](create-page.md) | Page and widget syntax | Creating pages | -| [fragments.md](fragments.md) | Fragment (reusable widget group) syntax | Reusing widget patterns across pages | -| [scheduled-events-and-queues.md](scheduled-events-and-queues.md) | Scheduled event (cron) and task queue syntax | Running a microflow on a schedule; bounding background concurrency | -| [regular-expressions.md](regular-expressions.md) | Named validation patterns | Adding an email/phone/identifier regex; changing a shared pattern | +| [mdl-entities](mdl-entities/SKILL.md) | Entity, attribute, association syntax | Creating domain models | +| [write-microflows](write-microflows/SKILL.md) | Microflow syntax reference | Writing microflow logic | +| [write-nanoflows](write-nanoflows/SKILL.md) | Nanoflow syntax reference | Writing client-side nanoflow logic | +| [write-rules](write-rules/SKILL.md) | Rule syntax reference | Writing reusable decision logic a decision calls | +| [write-oql-queries](write-oql-queries/SKILL.md) | OQL query syntax | Creating VIEW entities | +| [create-page](create-page/SKILL.md) | Page and widget syntax | Creating pages | +| [fragments](fragments/SKILL.md) | Fragment (reusable widget group) syntax | Reusing widget patterns across pages | +| [scheduled-events-and-queues](scheduled-events-and-queues/SKILL.md) | Scheduled event (cron) and task queue syntax | Running a microflow on a schedule; bounding background concurrency | +| [regular-expressions](regular-expressions/SKILL.md) | Named validation patterns | Adding an email/phone/identifier regex; changing a shared pattern | ## Patterns (By Use Case) @@ -33,9 +39,9 @@ Common implementation patterns: | Skill | Purpose | Use When | |-------|---------|----------| -| [patterns-crud.md](patterns-crud.md) | Create/Read/Update/Delete patterns | Building CRUD functionality | -| [patterns-data-processing.md](patterns-data-processing.md) | Loops, aggregates, batch processing | Processing lists of data | -| [validation-microflows.md](validation-microflows.md) | Validation feedback patterns | Building form validation | +| [patterns-crud](patterns-crud/SKILL.md) | Create/Read/Update/Delete patterns | Building CRUD functionality | +| [patterns-data-processing](patterns-data-processing/SKILL.md) | Loops, aggregates, batch processing | Processing lists of data | +| [validation-microflows](validation-microflows/SKILL.md) | Validation feedback patterns | Building form validation | ## Integration Skills @@ -43,14 +49,14 @@ External system integration: | Skill | Purpose | Use When | |-------|---------|----------| -| [database-connections.md](database-connections.md) | Mendix Database Connector | Connecting to Oracle, PostgreSQL, etc. via JDBC | -| [demo-data.md](demo-data.md) | Demo data & IMPORT | Seeding data, `import from` bulk import from external DB | -| [rest-client.md](rest-client.md) | REST API consumption | Calling external REST APIs via consumed REST client documents | -| [rest-call-from-json.md](rest-call-from-json.md) | REST CALL end-to-end | JSON Structure → Entities → Import Mapping → REST CALL microflow | -| [mock-rest-apis.md](mock-rest-apis.md) | Mock a REST dependency | Building or debugging a REST integration without the live API; forcing 404/500; running offline or in CI | -| [json-structures-and-mappings.md](json-structures-and-mappings.md) | JSON structures & mappings | CREATE/DESCRIBE JSON structures, import/export mappings, domain model patterns | -| [java-actions.md](java-actions.md) | Custom Java actions | Extending with Java code | -| [download-marketplace-content.md](download-marketplace-content.md) | Marketplace download & install | Adding a marketplace module/widget; downloading a `.mpk`; module-update caveat | +| [database-connections](database-connections/SKILL.md) | Mendix Database Connector | Connecting to Oracle, PostgreSQL, etc. via JDBC | +| [demo-data](demo-data/SKILL.md) | Demo data & IMPORT | Seeding data, `import from` bulk import from external DB | +| [rest-client](rest-client/SKILL.md) | REST API consumption | Calling external REST APIs via consumed REST client documents | +| [rest-call-from-json](rest-call-from-json/SKILL.md) | REST CALL end-to-end | JSON Structure → Entities → Import Mapping → REST CALL microflow | +| [mock-rest-apis](mock-rest-apis/SKILL.md) | Mock a REST dependency | Building or debugging a REST integration without the live API; forcing 404/500; running offline or in CI | +| [json-structures-and-mappings](json-structures-and-mappings/SKILL.md) | JSON structures & mappings | CREATE/DESCRIBE JSON structures, import/export mappings, domain model patterns | +| [java-actions](java-actions/SKILL.md) | Custom Java actions | Extending with Java code | +| [download-marketplace-content](download-marketplace-content/SKILL.md) | Marketplace download & install | Adding a marketplace module/widget; downloading a `.mpk`; module-update caveat | ## Page Patterns @@ -58,20 +64,20 @@ Page-specific patterns: | Skill | Purpose | Use When | |-------|---------|----------| -| [overview-pages.md](overview-pages.md) | List/grid pages | Building overview screens | -| [master-detail-pages.md](master-detail-pages.md) | Master-detail layouts | Building selection-based UIs | -| [bulk-widget-updates.md](bulk-widget-updates.md) | Bulk widget property updates | Changing widget settings across pages | +| [overview-pages](overview-pages/SKILL.md) | List/grid pages | Building overview screens | +| [master-detail-pages](master-detail-pages/SKILL.md) | Master-detail layouts | Building selection-based UIs | +| [bulk-widget-updates](bulk-widget-updates/SKILL.md) | Bulk widget property updates | Changing widget settings across pages | ## Specialized Skills | Skill | Purpose | Use When | |-------|---------|----------| -| [bootstrap-app.md](bootstrap-app.md) | Provision a new Mendix app in an empty repo | Starting from nothing: interview, `mxcli new`, hook + brief, commit, boot | -| [generate-domain-model.md](generate-domain-model.md) | Complete domain model generation | Generating full domain models | -| [create-custom-widget.md](create-custom-widget.md) | Custom pluggable widget AIGC | Creating custom React widgets from scratch | -| [migrate-design-prototype.md](migrate-design-prototype.md) | Turn a Claude Design prototype into a themed Mendix app | Reproducing a design handoff/prototype as an SCSS theme + styled pages | -| [debug-bson.md](debug-bson.md) | BSON debugging | Troubleshooting SDK issues | -| [analyze-runtime.md](analyze-runtime.md) | Analyze runtime behavior — logs, metrics, traces, catalog, and cross-source joins | Profiling a slow page/microflow, finding what hits the DB, correlating cost with model shape | +| [bootstrap-app](bootstrap-app/SKILL.md) | Provision a new Mendix app in an empty repo | Starting from nothing: interview, `mxcli new`, hook + brief, commit, boot | +| [generate-domain-model](generate-domain-model/SKILL.md) | Complete domain model generation | Generating full domain models | +| [create-custom-widget](create-custom-widget/SKILL.md) | Custom pluggable widget AIGC | Creating custom React widgets from scratch | +| [migrate-design-prototype](migrate-design-prototype/SKILL.md) | Turn a Claude Design prototype into a themed Mendix app | Reproducing a design handoff/prototype as an SCSS theme + styled pages | +| [debug-bson](debug-bson/SKILL.md) | BSON debugging | Troubleshooting SDK issues | +| [analyze-runtime](analyze-runtime/SKILL.md) | Analyze runtime behavior — logs, metrics, traces, catalog, and cross-source joins | Profiling a slow page/microflow, finding what hits the DB, correlating cost with model shape | --- @@ -83,33 +89,33 @@ Load skills based on the task: | User Request | Load These Skills | |--------------|-------------------| -| "Set this empty repo up as a Mendix app" | `bootstrap-app.md` | -| "Create entity/domain model" | `mdl-entities.md` | -| "Write microflow" | `write-microflows.md`, `cheatsheet-variables.md` | -| "Create validation" | `validation-microflows.md`, `patterns-crud.md` | -| "Add CRUD operations" | `patterns-crud.md` | -| "Process list of items" | `patterns-data-processing.md` | -| "Fix MDL error" | `cheatsheet-errors.md` | -| "Import data from database" | `demo-data.md` | -| "Call a REST API / integrate JSON endpoint" | `rest-call-from-json.md` | -| "Create JSON structure / import mapping" | `json-structures-and-mappings.md` | -| "Create export mapping" | `json-structures-and-mappings.md` | -| "Map JSON to entities" | `json-structures-and-mappings.md` | -| "Seed/populate test data" | `demo-data.md` | -| "Run a microflow nightly / hourly / on a schedule" | `scheduled-events-and-queues.md` | -| "Add a cron job / batch job / recurring task" | `scheduled-events-and-queues.md` | -| "Limit how many background tasks run at once" | `scheduled-events-and-queues.md` | -| "Add a validation pattern / email regex" | `regular-expressions.md` | -| "Update widget properties" | `bulk-widget-updates.md` | -| "Change widgets in bulk" | `bulk-widget-updates.md` | -| "Reuse widgets across pages" | `fragments.md` | -| "Define a fragment" | `fragments.md` | -| "Create custom widget" | `create-custom-widget.md` | -| "Build a pluggable widget" | `create-custom-widget.md` | -| "Turn a design prototype/handoff into a Mendix app" | `migrate-design-prototype.md`, `theme-styling.md`, `create-page.md` | -| "Build/apply a theme from a design" | `migrate-design-prototype.md`, `theme-styling.md` | -| "Why is this slow / profile the app / what hits the database" | `analyze-runtime.md`, `run-local.md` | -| "Trace / metrics / flame chart / correlate cost with model" | `analyze-runtime.md` | +| "Set this empty repo up as a Mendix app" | `bootstrap-app` | +| "Create entity/domain model" | `mdl-entities` | +| "Write microflow" | `write-microflows`, `cheatsheet-variables` | +| "Create validation" | `validation-microflows`, `patterns-crud` | +| "Add CRUD operations" | `patterns-crud` | +| "Process list of items" | `patterns-data-processing` | +| "Fix MDL error" | `cheatsheet-errors` | +| "Import data from database" | `demo-data` | +| "Call a REST API / integrate JSON endpoint" | `rest-call-from-json` | +| "Create JSON structure / import mapping" | `json-structures-and-mappings` | +| "Create export mapping" | `json-structures-and-mappings` | +| "Map JSON to entities" | `json-structures-and-mappings` | +| "Seed/populate test data" | `demo-data` | +| "Run a microflow nightly / hourly / on a schedule" | `scheduled-events-and-queues` | +| "Add a cron job / batch job / recurring task" | `scheduled-events-and-queues` | +| "Limit how many background tasks run at once" | `scheduled-events-and-queues` | +| "Add a validation pattern / email regex" | `regular-expressions` | +| "Update widget properties" | `bulk-widget-updates` | +| "Change widgets in bulk" | `bulk-widget-updates` | +| "Reuse widgets across pages" | `fragments` | +| "Define a fragment" | `fragments` | +| "Create custom widget" | `create-custom-widget` | +| "Build a pluggable widget" | `create-custom-widget` | +| "Turn a design prototype/handoff into a Mendix app" | `migrate-design-prototype`, `theme-styling`, `create-page` | +| "Build/apply a theme from a design" | `migrate-design-prototype`, `theme-styling` | +| "Why is this slow / profile the app / what hits the database" | `analyze-runtime`, `run-local` | +| "Trace / metrics / flame chart / correlate cost with model" | `analyze-runtime` | ### For Error Recovery @@ -117,11 +123,11 @@ When encountering errors: | Error Type | Load This Skill | |------------|-----------------| -| Variable not declared | `cheatsheet-variables.md` | -| Entity type syntax | `cheatsheet-errors.md` | -| Association path error | `cheatsheet-errors.md` | -| Microflow structure error | `write-microflows.md` | -| OQL syntax error | `write-oql-queries.md` | +| Variable not declared | `cheatsheet-variables` | +| Entity type syntax | `cheatsheet-errors` | +| Association path error | `cheatsheet-errors` | +| Microflow structure error | `write-microflows` | +| OQL syntax error | `write-oql-queries` | --- @@ -141,9 +147,9 @@ When encountering errors: ### REPL Integration ```bash -mdl> help variables # Load cheatsheet-variables.md -mdl> help errors # Load cheatsheet-errors.md -mdl> help crud # Load patterns-crud.md +mdl> help variables # Load cheatsheet-variables +mdl> help errors # Load cheatsheet-errors +mdl> help crud # Load patterns-crud ``` ### Check Command diff --git a/.claude/skills/mendix/agents.md b/.claude/skills/mendix/agents/SKILL.md similarity index 95% rename from .claude/skills/mendix/agents.md rename to .claude/skills/mendix/agents/SKILL.md index 6af50b85a..c18ab794a 100644 --- a/.claude/skills/mendix/agents.md +++ b/.claude/skills/mendix/agents/SKILL.md @@ -1,3 +1,8 @@ +--- +name: agents +description: "Author Mendix AI agent documents in MDL — Model, Knowledge Base, Consumed MCP Service and Agent, with variables, tools and multi-line prompts. Use when adding GenAI features to a Mendix app, wiring an LLM model or knowledge base, or listing/describing existing agent documents. Requires AgentEditorCommons and Mendix 11.9+." +--- + # Agents ## Overview diff --git a/.claude/skills/mendix/alter-page.md b/.claude/skills/mendix/alter-page/SKILL.md similarity index 97% rename from .claude/skills/mendix/alter-page.md rename to .claude/skills/mendix/alter-page/SKILL.md index 2ab9a2615..c41a91a08 100644 --- a/.claude/skills/mendix/alter-page.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -1,3 +1,8 @@ +--- +name: alter-page +description: "Modify an existing page or snippet's widget tree in place with ALTER PAGE / ALTER SNIPPET — SET, INSERT, DROP, REPLACE and SET Layout. Use when changing a caption, style or property, adding or removing a widget, or reordering a form, instead of rewriting the whole page with CREATE OR REPLACE." +--- + # ALTER PAGE / ALTER SNIPPET - Modify Existing Pages and Snippets ## Overview @@ -13,7 +18,7 @@ ALTER PAGE and ALTER SNIPPET modify an existing page or snippet's widget tree ** | Remove unused widgets | `alter page` with `drop` | | Replace a footer or section | `alter page` with `replace` | | Several related changes on the same page | `alter page` with multiple operations in one block | -| Same property across many pages (e.g., add `Class` to every Container) | `update widgets` — see `bulk-widget-updates.md` | +| Same property across many pages (e.g., add `Class` to every Container) | `update widgets` — see `bulk-widget-updates` | | Rebuild entire page from scratch | `create or replace page` | | Create a new page | `create page` | @@ -51,7 +56,7 @@ alter page MyMod.Product_Overview { }; ``` -For changes that should be applied across **many pages** (e.g., "add `Class='card'` to every Container in `MyMod`"), use `UPDATE WIDGETS` instead — see `bulk-widget-updates.md`. +For changes that should be applied across **many pages** (e.g., "add `Class='card'` to every Container in `MyMod`"), use `UPDATE WIDGETS` instead — see `bulk-widget-updates`. ## Operations @@ -514,6 +519,6 @@ between *different* microflows, not within the page itself. ## Related Skills -- [Create Page](./create-page.md) - Full page creation syntax -- [Overview Pages](./overview-pages.md) - CRUD page patterns -- [Master-Detail Pages](./master-detail-pages.md) - Selection binding pattern +- [Create Page](../create-page/SKILL.md) - Full page creation syntax +- [Overview Pages](../overview-pages/SKILL.md) - CRUD page patterns +- [Master-Detail Pages](../master-detail-pages/SKILL.md) - Selection binding pattern diff --git a/.claude/skills/mendix/analyze-runtime.md b/.claude/skills/mendix/analyze-runtime/SKILL.md similarity index 91% rename from .claude/skills/mendix/analyze-runtime.md rename to .claude/skills/mendix/analyze-runtime/SKILL.md index 1d41fd3dc..d8033e9dd 100644 --- a/.claude/skills/mendix/analyze-runtime.md +++ b/.claude/skills/mendix/analyze-runtime/SKILL.md @@ -1,3 +1,8 @@ +--- +name: analyze-runtime +description: "Find out what a running Mendix app actually does — logs, Prometheus metrics, OpenTelemetry traces and the model catalog, joined across sources. Use when profiling a slow page or microflow, finding what hits the database, chasing an error that shows only a generic dialog, or correlating runtime cost with model shape." +--- + # Analyze an App's Runtime Behavior — Logs, Metrics, Traces, Catalog ## Overview @@ -22,7 +27,7 @@ sources that no single tool answers alone. - A server-side error shows only a generic dialog in the browser. - You're profiling and need a flame chart, or want cost correlated with model shape. -Prerequisite: run the app with the fast local loop — see `run-local.md`. Everything +Prerequisite: run the app with the fast local loop — see `run-local`. Everything below assumes `mxcli run --local` (add the flags noted per signal). ## 1. Logs — the first stop for errors @@ -40,7 +45,7 @@ tail -f .mxcli/runtime.log Gotchas: - **Nanoflow `LOG` lands under the `Client_Nanoflow` node**, not the node name you declared — a filter built around microflow node names silently drops it. `LOG DEBUG` - from a nanoflow is dropped server-side (browser console only). See `write-nanoflows.md`. + from a nanoflow is dropped server-side (browser console only). See `write-nanoflows`. - A spike in "Executing N database synchronization command(s)" on an *unchanged* model is a red flag (see the `create or modify` data-loss class of bug). @@ -108,7 +113,7 @@ That same probe showed Mendix rejecting `$filter` on a property not declared `Filterable`, with a **400 "Property 'rowKey' is non-filterable"**, before the read microflow ran. So the platform does enforce the filterability you declare in `expose (…)`; what it does *not* do is apply `$top`/`$skip`/`$orderby` for a -read-microflow resource (see `odata-data-sharing.md`). +read-microflow resource (see `odata-data-sharing`). ## 2. Metrics — throughput and database pressure @@ -163,8 +168,8 @@ mxcli -p app.mpr -c "SELECT MicroflowQualifiedName, COUNT(*) activities ``` `CATALOG.ACTIVITIES.Id` is the model GUID the debugger breaks on (see -`debug-microflows.md`), with the action name and its sequence — a named, ordered -activity list per microflow. See `catalog-search.md` / `graph-analysis.md` for the +`debug-microflows`), with the action name and its sequence — a named, ordered +activity list per microflow. See `catalog-search` / `graph-analysis` for the richer queries. ## 5. The app warehouse — join the signals (external DuckDB) @@ -215,7 +220,7 @@ export traces (and logs) via OTLP with `--trace-otlp` so the backend joins them. ## See Also -- `run-local.md` — the local loop these flags hang off (`--metrics` / `--trace` / `--trace-otlp` / `--runtime-setting` reference). -- `debug-microflows.md` — interactive breakpoints/stepping when a trace isn't enough. -- `catalog-search.md`, `graph-analysis.md` — catalog query patterns and dependency analysis. -- `verify-with-oql.md` — query the running app's data directly. +- `run-local` — the local loop these flags hang off (`--metrics` / `--trace` / `--trace-otlp` / `--runtime-setting` reference). +- `debug-microflows` — interactive breakpoints/stepping when a trace isn't enough. +- `catalog-search`, `graph-analysis` — catalog query patterns and dependency analysis. +- `verify-with-oql` — query the running app's data directly. diff --git a/.claude/skills/mendix/assess-migration.md b/.claude/skills/mendix/assess-migration/SKILL.md similarity index 95% rename from .claude/skills/mendix/assess-migration.md rename to .claude/skills/mendix/assess-migration/SKILL.md index 08b364127..2560e6031 100644 --- a/.claude/skills/mendix/assess-migration.md +++ b/.claude/skills/mendix/assess-migration/SKILL.md @@ -1,3 +1,8 @@ +--- +name: assess-migration +description: "Investigate an existing non-Mendix application (Java, .NET, Python, Node, PHP, …) and produce a structured migration assessment for Mendix. Use when asked to analyse a legacy codebase for migration, build a migration inventory, or size the effort before any MDL is written." +--- + # Migration Assessment: Investigating Non-Mendix Projects This skill guides the investigation of existing non-Mendix applications to produce a structured migration assessment for Mendix. @@ -337,7 +342,7 @@ Combine all findings into a structured report: ## Related Skills -- [/migrate-oracle-forms](./migrate-oracle-forms.md) - Oracle Forms-specific migration -- [/generate-domain-model](./generate-domain-model.md) - Creating entities and associations in MDL -- [/write-microflows](./write-microflows.md) - Implementing business logic in MDL -- [/organize-project](./organize-project.md) - Folder structure for the migrated project +- [/migrate-oracle-forms](../migrate-oracle-forms/SKILL.md) - Oracle Forms-specific migration +- [/generate-domain-model](../generate-domain-model/SKILL.md) - Creating entities and associations in MDL +- [/write-microflows](../write-microflows/SKILL.md) - Implementing business logic in MDL +- [/organize-project](../organize-project/SKILL.md) - Folder structure for the migrated project diff --git a/.claude/skills/mendix/assess-quality.md b/.claude/skills/mendix/assess-quality/SKILL.md similarity index 96% rename from .claude/skills/mendix/assess-quality.md rename to .claude/skills/mendix/assess-quality/SKILL.md index df881c1c6..774cf4116 100644 --- a/.claude/skills/mendix/assess-quality.md +++ b/.claude/skills/mendix/assess-quality/SKILL.md @@ -1,3 +1,8 @@ +--- +name: assess-quality +description: "Audit a Mendix project against best practices for naming, security, performance, maintainability and architecture, and report a scored result. Use when asked to evaluate project quality, run a pre-go-live health check, or get oriented in an unfamiliar app." +--- + # Assess Mendix Project Quality This skill guides a comprehensive quality assessment of a Mendix project, evaluating it against established best practices across naming, security, performance, maintainability, architecture, and more. @@ -59,7 +64,7 @@ ORDER by activity_count desc mxcli models the project as a dependency graph and exposes topological analyses on top of it — god nodes, coupling, cohesion, communities, centrality, cycles, and layering. These answer questions catalog metrics can't: what's central, what's tangled, what belongs together. -**Read [`graph-analysis.md`](./graph-analysis.md) before running graph analysis.** It covers setup (two-step refresh required), all eight use cases (impact assessment, dead code, cohesion, communities, bridges, hotspots, cycles, monolith splits), column schemas, false-positive caveats for dead assets, and a quick-reference table mapping questions to tables. +**Read [`graph-analysis`](../graph-analysis/SKILL.md) before running graph analysis.** It covers setup (two-step refresh required), all eight use cases (impact assessment, dead code, cohesion, communities, bridges, hotspots, cycles, monolith splits), column schemas, false-positive caveats for dead assets, and a quick-reference table mapping questions to tables. ### Step 3: Manual Review Against Guidelines diff --git a/.claude/skills/mendix/atlas-design.md b/.claude/skills/mendix/atlas-design/SKILL.md similarity index 53% rename from .claude/skills/mendix/atlas-design.md rename to .claude/skills/mendix/atlas-design/SKILL.md index 5aab6983a..1686dfa35 100644 --- a/.claude/skills/mendix/atlas-design.md +++ b/.claude/skills/mendix/atlas-design/SKILL.md @@ -1,5 +1,24 @@ +--- +name: atlas-design +description: "Make a Mendix app look designed rather than default-Atlas: layout, spacing, typography, colour and design properties that reach a finished standard. Use when asked to make an app look professional, branded or less bland, when styling pages, or when matching a design mock." +--- + # Atlas Design — Make a Mendix App Look Designed, Not Bland +## Reference files + +`SKILL.md` covers the thesis, the layer architecture, the workflow and the +gotchas. The inventories are next door: + +- [`reference/building-blocks.md`](reference/building-blocks.md) — what Atlas + ships out of the box (layouts, page templates, building blocks, widgets) and the + appearance vocabulary: the classes and design properties available on each. + **Look here before writing custom SCSS** — most of what people hand-roll already + exists as a class. +- [`reference/dark-mode-and-charts.md`](reference/dark-mode-and-charts.md) — a + dataviz-grade theme for the Mendix chart widgets, and the optional per-widget + overrides that make dark mode look deliberate rather than inverted. + ## When to Use This Skill Use this skill when: @@ -9,9 +28,9 @@ Use this skill when: - You are re-branding an existing app to a new identity (palette, type, corners) This is the **taste + workflow** layer. It sits on top of the styling mechanics -(`theme-styling.md`), the widget syntax (`create-page.md`), the composition -primitives (`fragments.md`), and the design-handoff pipeline -(`migrate-design-prototype.md`). It does **not** re-teach SCSS compilation or +(`theme-styling`), the widget syntax (`create-page`), the composition +primitives (`fragments`), and the design-handoff pipeline +(`migrate-design-prototype`). It does **not** re-teach SCSS compilation or `Class:`/`DesignProperties:` syntax — those skills own that. It adds **which** tokens/classes to use, **when**, and the **discover → inspect → use** method built on the Atlas building blocks every Mendix project already ships. @@ -21,17 +40,16 @@ built on the Atlas building blocks every Mendix project already ships. 1. [The thesis: be Atlas-first](#the-thesis-be-atlas-first) 2. [The 4-layer architecture](#the-4-layer-architecture) 3. [The workflow: discover → inspect → use](#the-workflow-discover--inspect--use) -4. [Atlas building blocks — the out-of-the-box inventory](#atlas-building-blocks--the-out-of-the-box-inventory) -5. [Atlas appearance vocabulary — classes & design properties](#atlas-appearance-vocabulary--classes--design-properties) -6. [Brand re-tune (Layer 1) — where most of the win is](#brand-re-tune-layer-1--where-most-of-the-win-is) -7. [Layer 1 in practice — start from the shipped theme](#layer-1-in-practice--start-from-the-shipped-theme) -8. [Charts — a dataviz-grade theme for the Mendix chart widgets](#charts--a-dataviz-grade-theme-for-the-mendix-chart-widgets) -9. [Dark mode — Mendix 11 makes this cheap](#dark-mode--mendix-11-makes-this-cheap) -10. [Optional dark-mode Atlas-widget overrides](#optional-dark-mode-atlas-widget-overrides) -11. [Verify at runtime — this is mandatory](#verify-at-runtime--this-is-mandatory) -12. [Gotchas catalog](#gotchas-catalog) -13. [Validation checklist](#validation-checklist) -14. [Related skills](#related-skills) +4. [Brand re-tune (Layer 1) — where most of the win is](#brand-re-tune-layer-1--where-most-of-the-win-is) +5. [Layer 1 in practice — start from the shipped theme](#layer-1-in-practice--start-from-the-shipped-theme) +6. [Dark mode — Mendix 11 makes this cheap](#dark-mode--mendix-11-makes-this-cheap) +7. [Verify at runtime — this is mandatory](#verify-at-runtime--this-is-mandatory) +8. [Gotchas catalog](#gotchas-catalog) +9. [Validation checklist](#validation-checklist) +10. [Related skills](#related-skills) + +Inventories and the two long theming sections live beside this file — see +[Reference files](#reference-files) above. --- @@ -79,7 +97,7 @@ Layer 0 ATLAS Atlas classes / design properties / building blocks — str *last* — after Atlas Core and after every module theme source — so your rules win without `!important`. Use `themesource//web/main.scss` only when the styling belongs to that module: a theme source folder whose name does not match a real - module is **silently not compiled**. See `theme-styling.md`. + module is **silently not compiled**. See `theme-styling`. - **Layer 3 — Verify.** Non-negotiable. `mx check` misses client-side crashes; you must screenshot a *running* build. @@ -163,269 +181,6 @@ one-line `use building block`. --- -## Atlas building blocks — the out-of-the-box inventory - -Every Mendix project ships **`Atlas_Web_Content`**, a library of **39 building -blocks**: pre-composed widget shapes that Mendix itself uses. They are the canonical -reference for "what a well-made X looks like in Atlas." - -### The inventory (real names, grouped by category) - -| Category | Blocks | -|---|---| -| **Cards** | `Card`, `Card_Action`, `Card_ActionWithImage`, `Card_Background`, `Card_WithImage` | -| **Headers** | `Heroheader`, `Heroheader_Background`, `Heroheader_WithAction`, `Pageheader`, `Pageheader_WithBack`, `Pageheader_WithControls`, `Pageheader_WithSearch`, `PageheaderImage`, `PageheaderImage_WithBack`, `PageheaderImage_WithControls` | -| **Forms** | `Form_Horizontal`, `Form_Horizontal_WithTitle`, `Form_Horizontal_WithAction`, `Form_Vertical`, `Form_Vertical_WithTitle`, `Form_Vertical_WithAction` | -| **Lists** | `List_Cards`, `List_WithImage`, `ListItem_SingleLine`, `ListItem_DoubleLine`, `ListItem_WithImage` | -| **Master Detail** | `Master_Detail` | -| **Timeline** | `Timeline`, `Timeline_WithImage` | -| **Wizards** | `Wizard_Arrow`, `Wizard_Arrow_Step`, `Wizard_Circle`, `Wizard_Circle_Step` | -| **Notifications** | `Alert`, `Alert_WithAction`, `AlertIcon`, `AlertIcon_WithAction` | -| **Breadcrumbs** | `Breadcrumb`, `Breadcrumb_Underline` | - -All are `Platform: Web`, all live in module `Atlas_Web_Content`, referenced as -`Atlas_Web_Content.`. - -> Your project may ship more blocks from installed modules (e.g. a feedback widget). -> Always `show building blocks` on the actual project rather than trusting this list — -> it is the standard Atlas baseline, not an exhaustive per-project inventory. - -### Capability reality: discover, inspect, and instantiate - -| Capability | State | -|---|---| -| **Discover** — `SHOW BUILDING BLOCKS`, `CATALOG.building_blocks` | ✅ shipped | -| **Inspect** — `DESCRIBE BUILDING BLOCK Mod.Name` (full widget tree) | ✅ shipped | -| **Instantiate** — `use building block Mod.Name [as prefix_]` onto a page | ✅ v1 (deep-copy; configure afterwards with `alter page`; legacy engine today) | -| **Author** — `CREATE BUILDING BLOCK` | ❌ not yet (proposed) | - -The one-line `use building block` (above) is the normal path — deep-copy the block, -then configure the copy. **Mirroring** — reproducing a block's widget tree by hand — -is the fallback for hand-tuning or the modelsdk engine; the how-to is below. - -### How to mirror a block - -1. **Inspect it.** `describe building block Atlas_Web_Content.`. -2. **Read both channels.** Atlas blocks style with `Class:` strings *and* typed - `DesignProperties:` — copy both. -3. **Reproduce the tree** on your page, binding real data where the block has - placeholder text (`'Card title'` → your attribute/content). -4. **DRY it** — if the shape repeats, put it in a `define fragment` and `use` it. - -### Worked example — `Card` - -`describe building block Atlas_Web_Content.Card` yields the tree shown above. Mirror -it onto a page, binding real content: - -```mdl -create page MyModule.CardDemo -( - title: 'Card demo', - layout: Atlas_Core.Atlas_Default -) -{ - container myCard (designproperties: ['Card style': on]) { - dynamictext cardTitle - ( - content: 'Customers', - rendermode: H4, - class: 'card-title', - designproperties: ['Spacing': ['margin-bottom': 'L']] - ) - } -}; -``` - -Reusable version — put the card **shell** in a fragment with a `slot`, then fill -the slot with each card's own content. This is the key idiom: one card wrapper, -arbitrary bodies, no copy-paste of the wrapper markup. - -```mdl -define fragment SectionCard as { - container card1 (designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large']]) { - container cardBody (class: 'card-body') { - slot content -- each page's widgets land here - } - } -}; - -create page MyModule.Dashboard -( - title: 'Dashboard', - layout: Atlas_Core.Atlas_Default -) -{ - container page1 (class: 'flex-column') { - use fragment SectionCard { - dynamictext custTitle (content: 'Customers', rendermode: H4, class: 'card-title') - dynamictext custBody (content: 'Recent customer activity') - } - use fragment SectionCard { - dynamictext ordTitle (content: 'Orders', rendermode: H4, class: 'card-title') - datagrid ordGrid (datasource: database MyModule.Order) { } - } - } -}; -``` - -The `slot` marker is resolved at expansion — `describe page` shows the fully -wrapped tree (`card1 > cardBody > custTitle, custBody`), and `mx check` is clean. -The slot name is optional (defaults to `content`); a fragment supports one slot. -Use `as prefix_` when the wrapper's *own* widget names would collide across uses -(the payload keeps the names you give it). For a fixed, content-invariant group -(a footer, a button pair) a plain slotless fragment is still the right tool. - -**Binding data and behaviour (experimental).** A slot varies *what widgets* go -inside; typed **parameters** vary *which entity* and *which microflow*. Declare a -`datasource` and/or `action` parameter and the card becomes a real component: - -```mdl -define fragment EntityCard($data: datasource, $onOpen: action) as { - container card1 (designproperties: ['Card style': on]) { - listview lv (datasource: $data) { - slot content - actionbutton open (caption: 'Open', action: $onOpen, buttonstyle: primary) - } - } -}; -use fragment EntityCard ($data: database Sales.Order, $onOpen: microflow Sales.Open) { - dynamictext cardTitle (content: 'Orders', rendermode: H4, class: 'card-title') -} -``` - -Atlas **building blocks** can't declare params, but `use building block` takes -rebind overrides that rewrite the block's outermost datasource / first button: - -```mdl -use building block Atlas_Web_Content.List_Cards - (datasource: database Sales.Order, action: microflow Sales.Open) as orders_; -``` - -For a binding the override rule can't reach, copy the block in (`as prefix_`) and -`alter page … set datasource/action on prefix_widget`. - -### Worked example — `Pageheader` - -`describe building block Atlas_Web_Content.Pageheader`: - -``` -{ - container container1 (Class: 'pageheader', DesignProperties: ['Item gap': 'None']) { - dynamictext text40 (Content: 'Page header title', RenderMode: H1, Class: 'pageheader-title') - dynamictext text39 (Content: 'Supporting text', RenderMode: Paragraph, Class: 'pageheader-subtitle', - DesignProperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']]) - } -} -``` - -Mirror: - -```mdl -create page MyModule.CustomersHeaderDemo -( - title: 'Customers', - layout: Atlas_Core.Atlas_Default -) -{ - container pageHeader (class: 'pageheader', designproperties: ['Item gap': 'None']) { - dynamictext headerTitle (content: 'Customers', rendermode: H1, class: 'pageheader-title') - dynamictext headerSubtitle - ( - content: 'All active accounts', - rendermode: Paragraph, - class: 'pageheader-subtitle', - designproperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']] - ) - } -}; -``` - -### Block → screen map (which block to reach for) - -| You want | Mirror this block | -|---|---| -| A titled surface panel | `Card` / `Card_Action` (with a trailing action) / `Card_WithImage` | -| A page title + subtitle band | `Pageheader` (+ `_WithBack` / `_WithControls` / `_WithSearch`) | -| A big splash header | `Heroheader` (+ `_Background` / `_WithAction`) | -| A vertical / horizontal form | `Form_Vertical*` / `Form_Horizontal*` | -| A card/list feed | `List_Cards`, `List_WithImage`, `ListItem_*` | -| A master list + detail pane | `Master_Detail` | -| An activity/history feed | `Timeline` / `Timeline_WithImage` | -| A multi-step flow | `Wizard_Arrow` / `Wizard_Circle` (+ their `_Step`) | -| An inline notice | `Alert`, `AlertIcon` (+ `_WithAction`) | -| A path/breadcrumb trail | `Breadcrumb` / `Breadcrumb_Underline` | - ---- - -## Atlas appearance vocabulary — classes & design properties - -Atlas exposes its whole appearance system through the styling channels mxcli can -write today: raw `class:` strings and typed `designproperties:`. **Reach for these -before writing custom CSS.** - -### The cheat-sheet - -Apply via `class:` on any widget (space-join several: `class:'card flex-column'`). - -| Concern | Atlas classes | -|---|---| -| **Cards** | `card`, `cards` (+ Card-style variants) — real CSS, `.card` is ~19 rules | -| **Backgrounds** | `background-{default,main,primary,secondary,success,warning,danger}` | -| **Buttons** | `btn-{primary,secondary,success,warning,danger}`, `btn-{lg,sm,bordered,block,icon-right,icon-top}` | -| **Flex / align** | `flex-{row,column,nowrap,items-grow,items-shrink}`, `align-x-{left,center,right,between,around,evenly}`, `align-y-*` | -| **Spacing utils** | `spacing-{outer,inner}-{top,right,bottom,left}` (+ `-medium` / `-large` / `-none` sizes) | -| **Borders / overflow** | `div-border-toggle-{all,top,…,none}`, `div-overflow-{auto,hidden,visible}` (+ border radius/color/style/width) | -| **Elevation** | `Shadow` toggle | -| **Data grids** | `datagrid-{bordered,hover,striped,lined,lg,sm}` | -| **Group boxes** | `groupbox-{primary,danger,secondary,callout}` | - -Source: `atlas_core/web/design-properties.json` (verified in-project). To see what a -specific widget offers, run `show design properties` / `describe styling` -(`theme-styling.md`). - -### When to reach for each - -- **`card` / `Card style`** — any titled surface panel. This is the workhorse; a - dashboard is mostly cards on a `background-main` page. -- **`background-primary` / `background-success` / …** — coloured section/hero/status - surfaces. These resolve to your **retuned brand tokens** (Layer 1), so a hero band - set to `background-primary` turns *your* brand colour automatically. -- **`btn-*`** — prefer `buttonstyle: primary` on `actionbutton` for the semantic - style; add `btn-lg` / `btn-bordered` / `btn-block` as classes for size and shape. -- **`flex-row` / `flex-column` + `align-x-*` / `align-y-*`** — layout inside a - container without a `layoutgrid`. `flex-row` + `align-x-between` is the standard - "title on the left, action on the right" header row. -- **`spacing-inner-*` / `spacing-outer-*`** — padding/margin without inline `style:`. -- **`datagrid-*`** — reach for these on data grids before overriding grid CSS. -- **`groupbox-*`** — callouts / grouped sections with a semantic tint. - -### Typed design properties — the alternative channel - -Atlas building blocks use **both** channels side by side. The typed channel is what -Studio Pro's Appearance tab reads, so mirror it when you want the block to round-trip -cleanly into Studio Pro. Common mappings: - -| Class-style | Typed design-property equivalent | -|---|---| -| `class:'card'` | `designproperties: ['Card style': on]` | -| `class:'background-primary'` | `designproperties: ['Background color': 'Brand Primary']` | -| `class:'flex-column'` | `designproperties: ['Flex container': 'Vertical (column)']` | -| `class:'flex-row'` | `designproperties: ['Flex container': 'Horizontal (row)']` | -| `class:'align-x-center'` | `designproperties: ['Align items X': 'Center']` | -| `class:'Shadow'` | `designproperties: ['Shadow': 'None' / 'Small' / …]` | -| spacing utilities | `designproperties: ['Spacing': ['margin-bottom': 'L', 'padding-top': 'S']]` | - -**Both channels render identically at runtime** — raw `class:` is sufficient for the -visual result today. The typed channel matters for Studio Pro round-trip and is the -more idiomatic form to mirror from a `describe building block`. Notes: -- Design-property **keys are case-sensitive** — match the `describe` output exactly. -- Compound properties (Spacing, Border) take a **nested list**: - `['Spacing': ['margin-top': 'Large', 'margin-bottom': 'None']]`. -- **Never** put inline `style:` on a `dynamictext` — it crashes MxBuild. Use `class:` - or wrap in a styled `container`. (`theme-styling.md`.) - ---- - ## Brand re-tune (Layer 1) — where most of the win is Retune the palette in `theme/web/custom-variables.scss` — the file @@ -500,68 +255,12 @@ per-widget CSS. moment anything flips the palette — the failure is total and silent. If you genuinely need a token the theme does not expose, add it to the palette -block and reference it from your own Layer-2 rules. See `theme-styling.md` for +block and reference it from your own Layer-2 rules. See `theme-styling` for the compile order and for why `theme/web/main.scss` is the only correct home for app-level rules. --- -## Charts — a dataviz-grade theme for the Mendix chart widgets - -Out of the box the chart widgets (Column / Bar / Area / Pie / Line) render **raw -Plotly defaults**: one flat colour, a floating mode-bar, wide margins, heavy -gridlines, a white paper background. That is the single biggest "not a real product" -tell. Three Plotly hooks — barely used by generated apps — turn them into designed -charts. All three are **plain JSON strings** (no Mendix expression quoting). - -| Property | Plotly layer | Use it for | -|---|---|---| -| `customLayout` | `layout` | transparent `paper_bgcolor` + `plot_bgcolor`, system font, `#8a94a6` ticks, tight `margin`, faint `gridcolor`, `zeroline:false` / `showline:false`, dark `hoverlabel` | -| `customConfigurations` | `config` | `{"displayModeBar":false,"responsive":true}` — removes the floating toolbar | -| `customSeriesOptions` (per series; chart-level on Pie) | trace | brand colour, `marker.cornerradius` (rounded bars), `line.shape:"spline"` + translucent `fillcolor` (area), Pie colour array + white inside labels | - -**The key trick — transparent background = theme-agnostic charts.** Set -`paper_bgcolor` and `plot_bgcolor` to `rgba(0,0,0,0)`; the plot inherits whatever -panel it sits on, so **one config is correct in both light and dark** with zero -per-theme CSS. Pair it with a neutral tick colour (`#8a94a6`) that reads on either -background. Always kill the white paper **and** the mode-bar — the two ugliest -defaults. - -Ready-made `customLayout` (transparent, themed): -```json -{ - "paper_bgcolor": "rgba(0,0,0,0)", - "plot_bgcolor": "rgba(0,0,0,0)", - "font": { "family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "color": "#8a94a6" }, - "margin": { "t": 8, "r": 8, "b": 32, "l": 40 }, - "xaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, - "yaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, - "hoverlabel": { "bgcolor": "#1a2129", "font": { "color": "#ffffff" } } -} -``` - -`customConfigurations` (kill the mode-bar): `{ "displayModeBar": false, "responsive": true }` - -`customSeriesOptions` per type: -```jsonc -// Column / Bar — brand colour + rounded corners -{ "marker": { "color": "#2b5170", "cornerradius": 6 } } -// Area — spline curve + translucent fill -{ "line": { "shape": "spline", "color": "#2b5170" }, "fill": "tozeroy", "fillcolor": "rgba(43,81,112,0.15)" } -// Pie (chart-level) — colour array + white inside labels -{ "marker": { "colors": ["#2b5170", "#4a7a5c", "#c9a227", "#a13a2c"] }, "insidetextfont": { "color": "#ffffff" } } -``` - -Swap the hex values for your brand palette (the same values you set in the Layer-1 -scaffold). The generic `dataviz` skill is the HTML/React analogue of this — same -"kill the defaults, one theme-agnostic config, brand the series" philosophy. - -**Chart gotchas** are in the [gotchas catalog](#gotchas-catalog). All chart types -(incl. Line/Bubble/Heatmap/TimeSeries) are MDL-authorable today — see -`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets.md`. - ---- - ## Dark mode — Mendix 11 makes this cheap Older guidance here said to commit to a single theme, because a @@ -603,73 +302,6 @@ Atlas corners a token flip misses. --- -## Optional dark-mode Atlas-widget overrides - -Paste into `main.scss` (Layer 2), after the `@import`s. Replace the token -placeholders with your dark palette. Popovers/modals render at ``, so the -popover + modal block must **not** be scoped to your app class — keep it global. - -```scss -// --- Dark palette tokens (TODO: set these) ---------------------------------- -$dk-surface: #1a2129; // panel / row background -$dk-surface-2: #232c37; // header / chip background -$dk-ink: #e6ebf1; // primary text -$dk-ink-mut: #9aa6b4; // muted text -$dk-border: #2f3a47; // hairline - -// Wrap in the media query for a dual-theme app; DELETE the @media line (and its -// closing brace) for a committed dark-only app to make these unconditional. -@media (prefers-color-scheme: dark) { - - // Form controls: text input / textarea / combobox field - .form-control, - .mx-textarea textarea, - .form-control input { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; - } - - // Datagrid: rows, headers, filter chips - .mx-datagrid table, .mx-datagrid tr, .mx-datagrid th, .mx-datagrid td { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; - } - .filter-selector-button { - background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; - } - - // Datagrid dropdown filter: kill the hardcoded white scroll-fade gradient - .widget-dropdown-filter-menu { - background-image: none; background-color: $dk-surface; - } - .widget-dropdown-filter-menu * { color: $dk-ink; } - - // Accordion / Fieldset - .mx-groupbox, .mx-groupbox-header, fieldset, legend { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; - } - - // TreeNode: expanded child rows carry a WHITE card bg — let the panel show through - .mx-treenode, .mx-treenode .mx-treenode-content { - background: transparent; color: $dk-ink; - } -} - -// Popovers / modals render at — theme these GLOBALLY (unscoped). -// Combobox / tooltip / dropdown-filter popovers and edit popups (.mx-window / -// .modal-content) live outside your app class, so a scoped selector misses them. -.mx-window-content, .modal-content, .mx-window-header, .mx-tooltip, .mx-combobox-menu { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; -} -.mx-window-content .form-control, .modal-content .form-control { - background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; -} -.mx-window .btn-default, .modal-content .btn-default { - background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; -} -// Charts: DON'T style them here — use the transparent customLayout (above). -``` - ---- - ## Verify at runtime — this is mandatory **Runtime verification is not optional.** `mx check` (and `mxcli check --references`) @@ -700,7 +332,7 @@ mxcli run --local -p app.mpr --watch --screenshot **From an egress-only environment (Claude Code web):** `--hub ` reverse-tunnels the local app out over a single 443 connection to a relay, giving a public URL you can -open in a real browser. `--hub` implies `--local`. See `run-local.md` for the flags. +open in a real browser. `--hub` implies `--local`. See `run-local` for the flags. **What a screenshot can't catch — drive the interaction.** A single screenshot is a static frame; the Slider `findDOMNode` throw fires on drag, a filter popover's white @@ -798,9 +430,9 @@ row before opening files. ## Related skills -- `theme-styling.md` — SCSS compilation chain, hot-reload, styling caveats -- `migrate-design-prototype.md` — turning a Claude Design handoff into a theme + pages -- `create-page.md` — page/widget syntax -- `alter-page.md` — in-place widget edits -- `fragments.md` — reusable widget groups (how the mirror recipes stay DRY) -- `run-local.md` — the warm dev loop and screenshot flags +- `theme-styling` — SCSS compilation chain, hot-reload, styling caveats +- `migrate-design-prototype` — turning a Claude Design handoff into a theme + pages +- `create-page` — page/widget syntax +- `alter-page` — in-place widget edits +- `fragments` — reusable widget groups (how the mirror recipes stay DRY) +- `run-local` — the warm dev loop and screenshot flags diff --git a/.claude/skills/mendix/atlas-design/reference/building-blocks.md b/.claude/skills/mendix/atlas-design/reference/building-blocks.md new file mode 100644 index 000000000..f90311557 --- /dev/null +++ b/.claude/skills/mendix/atlas-design/reference/building-blocks.md @@ -0,0 +1,265 @@ +# Atlas building blocks and appearance vocabulary + +Supporting reference for [atlas-design](../SKILL.md). + +## Atlas building blocks — the out-of-the-box inventory + +Every Mendix project ships **`Atlas_Web_Content`**, a library of **39 building +blocks**: pre-composed widget shapes that Mendix itself uses. They are the canonical +reference for "what a well-made X looks like in Atlas." + +### The inventory (real names, grouped by category) + +| Category | Blocks | +|---|---| +| **Cards** | `Card`, `Card_Action`, `Card_ActionWithImage`, `Card_Background`, `Card_WithImage` | +| **Headers** | `Heroheader`, `Heroheader_Background`, `Heroheader_WithAction`, `Pageheader`, `Pageheader_WithBack`, `Pageheader_WithControls`, `Pageheader_WithSearch`, `PageheaderImage`, `PageheaderImage_WithBack`, `PageheaderImage_WithControls` | +| **Forms** | `Form_Horizontal`, `Form_Horizontal_WithTitle`, `Form_Horizontal_WithAction`, `Form_Vertical`, `Form_Vertical_WithTitle`, `Form_Vertical_WithAction` | +| **Lists** | `List_Cards`, `List_WithImage`, `ListItem_SingleLine`, `ListItem_DoubleLine`, `ListItem_WithImage` | +| **Master Detail** | `Master_Detail` | +| **Timeline** | `Timeline`, `Timeline_WithImage` | +| **Wizards** | `Wizard_Arrow`, `Wizard_Arrow_Step`, `Wizard_Circle`, `Wizard_Circle_Step` | +| **Notifications** | `Alert`, `Alert_WithAction`, `AlertIcon`, `AlertIcon_WithAction` | +| **Breadcrumbs** | `Breadcrumb`, `Breadcrumb_Underline` | + +All are `Platform: Web`, all live in module `Atlas_Web_Content`, referenced as +`Atlas_Web_Content.`. + +> Your project may ship more blocks from installed modules (e.g. a feedback widget). +> Always `show building blocks` on the actual project rather than trusting this list — +> it is the standard Atlas baseline, not an exhaustive per-project inventory. + +### Capability reality: discover, inspect, and instantiate + +| Capability | State | +|---|---| +| **Discover** — `SHOW BUILDING BLOCKS`, `CATALOG.building_blocks` | ✅ shipped | +| **Inspect** — `DESCRIBE BUILDING BLOCK Mod.Name` (full widget tree) | ✅ shipped | +| **Instantiate** — `use building block Mod.Name [as prefix_]` onto a page | ✅ v1 (deep-copy; configure afterwards with `alter page`; legacy engine today) | +| **Author** — `CREATE BUILDING BLOCK` | ❌ not yet (proposed) | + +The one-line `use building block` (above) is the normal path — deep-copy the block, +then configure the copy. **Mirroring** — reproducing a block's widget tree by hand — +is the fallback for hand-tuning or the modelsdk engine; the how-to is below. + +### How to mirror a block + +1. **Inspect it.** `describe building block Atlas_Web_Content.`. +2. **Read both channels.** Atlas blocks style with `Class:` strings *and* typed + `DesignProperties:` — copy both. +3. **Reproduce the tree** on your page, binding real data where the block has + placeholder text (`'Card title'` → your attribute/content). +4. **DRY it** — if the shape repeats, put it in a `define fragment` and `use` it. + +### Worked example — `Card` + +`describe building block Atlas_Web_Content.Card` yields the tree shown above. Mirror +it onto a page, binding real content: + +```mdl +create page MyModule.CardDemo +( + title: 'Card demo', + layout: Atlas_Core.Atlas_Default +) +{ + container myCard (designproperties: ['Card style': on]) { + dynamictext cardTitle + ( + content: 'Customers', + rendermode: H4, + class: 'card-title', + designproperties: ['Spacing': ['margin-bottom': 'L']] + ) + } +}; +``` + +Reusable version — put the card **shell** in a fragment with a `slot`, then fill +the slot with each card's own content. This is the key idiom: one card wrapper, +arbitrary bodies, no copy-paste of the wrapper markup. + +```mdl +define fragment SectionCard as { + container card1 (designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large']]) { + container cardBody (class: 'card-body') { + slot content -- each page's widgets land here + } + } +}; + +create page MyModule.Dashboard +( + title: 'Dashboard', + layout: Atlas_Core.Atlas_Default +) +{ + container page1 (class: 'flex-column') { + use fragment SectionCard { + dynamictext custTitle (content: 'Customers', rendermode: H4, class: 'card-title') + dynamictext custBody (content: 'Recent customer activity') + } + use fragment SectionCard { + dynamictext ordTitle (content: 'Orders', rendermode: H4, class: 'card-title') + datagrid ordGrid (datasource: database MyModule.Order) { } + } + } +}; +``` + +The `slot` marker is resolved at expansion — `describe page` shows the fully +wrapped tree (`card1 > cardBody > custTitle, custBody`), and `mx check` is clean. +The slot name is optional (defaults to `content`); a fragment supports one slot. +Use `as prefix_` when the wrapper's *own* widget names would collide across uses +(the payload keeps the names you give it). For a fixed, content-invariant group +(a footer, a button pair) a plain slotless fragment is still the right tool. + +**Binding data and behaviour (experimental).** A slot varies *what widgets* go +inside; typed **parameters** vary *which entity* and *which microflow*. Declare a +`datasource` and/or `action` parameter and the card becomes a real component: + +```mdl +define fragment EntityCard($data: datasource, $onOpen: action) as { + container card1 (designproperties: ['Card style': on]) { + listview lv (datasource: $data) { + slot content + actionbutton open (caption: 'Open', action: $onOpen, buttonstyle: primary) + } + } +}; +use fragment EntityCard ($data: database Sales.Order, $onOpen: microflow Sales.Open) { + dynamictext cardTitle (content: 'Orders', rendermode: H4, class: 'card-title') +} +``` + +Atlas **building blocks** can't declare params, but `use building block` takes +rebind overrides that rewrite the block's outermost datasource / first button: + +```mdl +use building block Atlas_Web_Content.List_Cards + (datasource: database Sales.Order, action: microflow Sales.Open) as orders_; +``` + +For a binding the override rule can't reach, copy the block in (`as prefix_`) and +`alter page … set datasource/action on prefix_widget`. + +### Worked example — `Pageheader` + +`describe building block Atlas_Web_Content.Pageheader`: + +``` +{ + container container1 (Class: 'pageheader', DesignProperties: ['Item gap': 'None']) { + dynamictext text40 (Content: 'Page header title', RenderMode: H1, Class: 'pageheader-title') + dynamictext text39 (Content: 'Supporting text', RenderMode: Paragraph, Class: 'pageheader-subtitle', + DesignProperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']]) + } +} +``` + +Mirror: + +```mdl +create page MyModule.CustomersHeaderDemo +( + title: 'Customers', + layout: Atlas_Core.Atlas_Default +) +{ + container pageHeader (class: 'pageheader', designproperties: ['Item gap': 'None']) { + dynamictext headerTitle (content: 'Customers', rendermode: H1, class: 'pageheader-title') + dynamictext headerSubtitle + ( + content: 'All active accounts', + rendermode: Paragraph, + class: 'pageheader-subtitle', + designproperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']] + ) + } +}; +``` + +### Block → screen map (which block to reach for) + +| You want | Mirror this block | +|---|---| +| A titled surface panel | `Card` / `Card_Action` (with a trailing action) / `Card_WithImage` | +| A page title + subtitle band | `Pageheader` (+ `_WithBack` / `_WithControls` / `_WithSearch`) | +| A big splash header | `Heroheader` (+ `_Background` / `_WithAction`) | +| A vertical / horizontal form | `Form_Vertical*` / `Form_Horizontal*` | +| A card/list feed | `List_Cards`, `List_WithImage`, `ListItem_*` | +| A master list + detail pane | `Master_Detail` | +| An activity/history feed | `Timeline` / `Timeline_WithImage` | +| A multi-step flow | `Wizard_Arrow` / `Wizard_Circle` (+ their `_Step`) | +| An inline notice | `Alert`, `AlertIcon` (+ `_WithAction`) | +| A path/breadcrumb trail | `Breadcrumb` / `Breadcrumb_Underline` | + +--- +## Atlas appearance vocabulary — classes & design properties + +Atlas exposes its whole appearance system through the styling channels mxcli can +write today: raw `class:` strings and typed `designproperties:`. **Reach for these +before writing custom CSS.** + +### The cheat-sheet + +Apply via `class:` on any widget (space-join several: `class:'card flex-column'`). + +| Concern | Atlas classes | +|---|---| +| **Cards** | `card`, `cards` (+ Card-style variants) — real CSS, `.card` is ~19 rules | +| **Backgrounds** | `background-{default,main,primary,secondary,success,warning,danger}` | +| **Buttons** | `btn-{primary,secondary,success,warning,danger}`, `btn-{lg,sm,bordered,block,icon-right,icon-top}` | +| **Flex / align** | `flex-{row,column,nowrap,items-grow,items-shrink}`, `align-x-{left,center,right,between,around,evenly}`, `align-y-*` | +| **Spacing utils** | `spacing-{outer,inner}-{top,right,bottom,left}` (+ `-medium` / `-large` / `-none` sizes) | +| **Borders / overflow** | `div-border-toggle-{all,top,…,none}`, `div-overflow-{auto,hidden,visible}` (+ border radius/color/style/width) | +| **Elevation** | `Shadow` toggle | +| **Data grids** | `datagrid-{bordered,hover,striped,lined,lg,sm}` | +| **Group boxes** | `groupbox-{primary,danger,secondary,callout}` | + +Source: `atlas_core/web/design-properties.json` (verified in-project). To see what a +specific widget offers, run `show design properties` / `describe styling` +(`theme-styling`). + +### When to reach for each + +- **`card` / `Card style`** — any titled surface panel. This is the workhorse; a + dashboard is mostly cards on a `background-main` page. +- **`background-primary` / `background-success` / …** — coloured section/hero/status + surfaces. These resolve to your **retuned brand tokens** (Layer 1), so a hero band + set to `background-primary` turns *your* brand colour automatically. +- **`btn-*`** — prefer `buttonstyle: primary` on `actionbutton` for the semantic + style; add `btn-lg` / `btn-bordered` / `btn-block` as classes for size and shape. +- **`flex-row` / `flex-column` + `align-x-*` / `align-y-*`** — layout inside a + container without a `layoutgrid`. `flex-row` + `align-x-between` is the standard + "title on the left, action on the right" header row. +- **`spacing-inner-*` / `spacing-outer-*`** — padding/margin without inline `style:`. +- **`datagrid-*`** — reach for these on data grids before overriding grid CSS. +- **`groupbox-*`** — callouts / grouped sections with a semantic tint. + +### Typed design properties — the alternative channel + +Atlas building blocks use **both** channels side by side. The typed channel is what +Studio Pro's Appearance tab reads, so mirror it when you want the block to round-trip +cleanly into Studio Pro. Common mappings: + +| Class-style | Typed design-property equivalent | +|---|---| +| `class:'card'` | `designproperties: ['Card style': on]` | +| `class:'background-primary'` | `designproperties: ['Background color': 'Brand Primary']` | +| `class:'flex-column'` | `designproperties: ['Flex container': 'Vertical (column)']` | +| `class:'flex-row'` | `designproperties: ['Flex container': 'Horizontal (row)']` | +| `class:'align-x-center'` | `designproperties: ['Align items X': 'Center']` | +| `class:'Shadow'` | `designproperties: ['Shadow': 'None' / 'Small' / …]` | +| spacing utilities | `designproperties: ['Spacing': ['margin-bottom': 'L', 'padding-top': 'S']]` | + +**Both channels render identically at runtime** — raw `class:` is sufficient for the +visual result today. The typed channel matters for Studio Pro round-trip and is the +more idiomatic form to mirror from a `describe building block`. Notes: +- Design-property **keys are case-sensitive** — match the `describe` output exactly. +- Compound properties (Spacing, Border) take a **nested list**: + `['Spacing': ['margin-top': 'Large', 'margin-bottom': 'None']]`. +- **Never** put inline `style:` on a `dynamictext` — it crashes MxBuild. Use `class:` + or wrap in a styled `container`. (`theme-styling`.) + +--- diff --git a/.claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md b/.claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md new file mode 100644 index 000000000..5eda3d309 --- /dev/null +++ b/.claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md @@ -0,0 +1,125 @@ +# Charts, dark mode and widget overrides + +Supporting reference for [atlas-design](../SKILL.md). + +## Charts — a dataviz-grade theme for the Mendix chart widgets + +Out of the box the chart widgets (Column / Bar / Area / Pie / Line) render **raw +Plotly defaults**: one flat colour, a floating mode-bar, wide margins, heavy +gridlines, a white paper background. That is the single biggest "not a real product" +tell. Three Plotly hooks — barely used by generated apps — turn them into designed +charts. All three are **plain JSON strings** (no Mendix expression quoting). + +| Property | Plotly layer | Use it for | +|---|---|---| +| `customLayout` | `layout` | transparent `paper_bgcolor` + `plot_bgcolor`, system font, `#8a94a6` ticks, tight `margin`, faint `gridcolor`, `zeroline:false` / `showline:false`, dark `hoverlabel` | +| `customConfigurations` | `config` | `{"displayModeBar":false,"responsive":true}` — removes the floating toolbar | +| `customSeriesOptions` (per series; chart-level on Pie) | trace | brand colour, `marker.cornerradius` (rounded bars), `line.shape:"spline"` + translucent `fillcolor` (area), Pie colour array + white inside labels | + +**The key trick — transparent background = theme-agnostic charts.** Set +`paper_bgcolor` and `plot_bgcolor` to `rgba(0,0,0,0)`; the plot inherits whatever +panel it sits on, so **one config is correct in both light and dark** with zero +per-theme CSS. Pair it with a neutral tick colour (`#8a94a6`) that reads on either +background. Always kill the white paper **and** the mode-bar — the two ugliest +defaults. + +Ready-made `customLayout` (transparent, themed): +```json +{ + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(0,0,0,0)", + "font": { "family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "color": "#8a94a6" }, + "margin": { "t": 8, "r": 8, "b": 32, "l": 40 }, + "xaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, + "yaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, + "hoverlabel": { "bgcolor": "#1a2129", "font": { "color": "#ffffff" } } +} +``` + +`customConfigurations` (kill the mode-bar): `{ "displayModeBar": false, "responsive": true }` + +`customSeriesOptions` per type: +```jsonc +// Column / Bar — brand colour + rounded corners +{ "marker": { "color": "#2b5170", "cornerradius": 6 } } +// Area — spline curve + translucent fill +{ "line": { "shape": "spline", "color": "#2b5170" }, "fill": "tozeroy", "fillcolor": "rgba(43,81,112,0.15)" } +// Pie (chart-level) — colour array + white inside labels +{ "marker": { "colors": ["#2b5170", "#4a7a5c", "#c9a227", "#a13a2c"] }, "insidetextfont": { "color": "#ffffff" } } +``` + +Swap the hex values for your brand palette (the same values you set in the Layer-1 +scaffold). The generic `dataviz` skill is the HTML/React analogue of this — same +"kill the defaults, one theme-agnostic config, brand the series" philosophy. + +**Chart gotchas** are in the [gotchas catalog](#gotchas-catalog). All chart types +(incl. Line/Bubble/Heatmap/TimeSeries) are MDL-authorable today — see +`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets`. + +--- +## Optional dark-mode Atlas-widget overrides + +Paste into `main.scss` (Layer 2), after the `@import`s. Replace the token +placeholders with your dark palette. Popovers/modals render at ``, so the +popover + modal block must **not** be scoped to your app class — keep it global. + +```scss +// --- Dark palette tokens (TODO: set these) ---------------------------------- +$dk-surface: #1a2129; // panel / row background +$dk-surface-2: #232c37; // header / chip background +$dk-ink: #e6ebf1; // primary text +$dk-ink-mut: #9aa6b4; // muted text +$dk-border: #2f3a47; // hairline + +// Wrap in the media query for a dual-theme app; DELETE the @media line (and its +// closing brace) for a committed dark-only app to make these unconditional. +@media (prefers-color-scheme: dark) { + + // Form controls: text input / textarea / combobox field + .form-control, + .mx-textarea textarea, + .form-control input { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + + // Datagrid: rows, headers, filter chips + .mx-datagrid table, .mx-datagrid tr, .mx-datagrid th, .mx-datagrid td { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + .filter-selector-button { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; + } + + // Datagrid dropdown filter: kill the hardcoded white scroll-fade gradient + .widget-dropdown-filter-menu { + background-image: none; background-color: $dk-surface; + } + .widget-dropdown-filter-menu * { color: $dk-ink; } + + // Accordion / Fieldset + .mx-groupbox, .mx-groupbox-header, fieldset, legend { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + + // TreeNode: expanded child rows carry a WHITE card bg — let the panel show through + .mx-treenode, .mx-treenode .mx-treenode-content { + background: transparent; color: $dk-ink; + } +} + +// Popovers / modals render at — theme these GLOBALLY (unscoped). +// Combobox / tooltip / dropdown-filter popovers and edit popups (.mx-window / +// .modal-content) live outside your app class, so a scoped selector misses them. +.mx-window-content, .modal-content, .mx-window-header, .mx-tooltip, .mx-combobox-menu { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; +} +.mx-window-content .form-control, .modal-content .form-control { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; +} +.mx-window .btn-default, .modal-content .btn-default { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; +} +// Charts: DON'T style them here — use the transparent customLayout (above). +``` + +--- diff --git a/.claude/skills/mendix/bootstrap-app.md b/.claude/skills/mendix/bootstrap-app/SKILL.md similarity index 95% rename from .claude/skills/mendix/bootstrap-app.md rename to .claude/skills/mendix/bootstrap-app/SKILL.md index e31b2f7c1..0b16c85aa 100644 --- a/.claude/skills/mendix/bootstrap-app.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -1,3 +1,8 @@ +--- +name: bootstrap-app +description: "Provision a Mendix project in a repo that has none — interview, `mxcli new`, session hook, project brief, first commit and boot. Use when the repository is empty or has no .mpr yet, typically from the empty-repo seed prompt." +--- + # Bootstrap a Mendix App in an Empty Repo ## When to Use This Skill @@ -13,9 +18,9 @@ mxcli rather than by re-pasting a longer prompt. If an `.mpr` already exists, this is the wrong skill: run `mxcli init` in the app folder and go straight to the work. -Related skills: `run-local.md` (the warm dev loop this ends in), `mdl-entities.md` and -`create-page.md` (building the model you propose at the end), -`migrate-design-prototype.md` (when a design was handed to you). +Related skills: `run-local` (the warm dev loop this ends in), `mdl-entities` and +`create-page` (building the model you propose at the end), +`migrate-design-prototype` (when a design was handed to you). --- @@ -208,7 +213,7 @@ named after it. From the brief, propose in chat: Show it as **MDL the user can read**, and wait for their go-ahead before executing it. If a design was handed to you, it is the source of truth for the model and the pages — -see `migrate-design-prototype.md`. +see `migrate-design-prototype`. --- @@ -229,5 +234,5 @@ resolve: --app-port 8180 --admin-port 8190 --serve-port 6643) ``` -See `run-local.md` for the warm loop, `--watch`, `--ensure-db`, and the screenshot +See `run-local` for the warm loop, `--watch`, `--ensure-db`, and the screenshot flags. diff --git a/.claude/skills/mendix/browse-integrations.md b/.claude/skills/mendix/browse-integrations/SKILL.md similarity index 93% rename from .claude/skills/mendix/browse-integrations.md rename to .claude/skills/mendix/browse-integrations/SKILL.md index ee771a4a8..c248e7426 100644 --- a/.claude/skills/mendix/browse-integrations.md +++ b/.claude/skills/mendix/browse-integrations/SKILL.md @@ -1,8 +1,13 @@ +--- +name: browse-integrations +description: "Discover the external services a project consumes and browse their cached contracts through the MDL CATALOG — OData entities and actions, REST clients and operations, AsyncAPI channels and messages. Use when asked what integrations exist, what an OData service offers, or what a business-event contract contains." +--- + # Browse Integration Services and Contracts This skill covers discovering external services, browsing cached contracts, and querying integration assets via the **MDL CATALOG** (local project metadata). -**⚠️ NOTE:** This covers the **MDL CATALOG keyword** (`SELECT ... FROM CATALOG.entities`), NOT the **Mendix Catalog CLI** (`mxcli catalog search`). See `.claude/skills/mendix/catalog-search.md` for the external service registry. +**⚠️ NOTE:** This covers the **MDL CATALOG keyword** (`SELECT ... FROM CATALOG.entities`), NOT the **Mendix Catalog CLI** (`mxcli catalog search`). See `.claude/skills/mendix/catalog-search` for the external service registry. ## When to Use This Skill diff --git a/.claude/skills/mendix/bulk-widget-updates.md b/.claude/skills/mendix/bulk-widget-updates/SKILL.md similarity index 94% rename from .claude/skills/mendix/bulk-widget-updates.md rename to .claude/skills/mendix/bulk-widget-updates/SKILL.md index 51979dc0e..8c8c95a7c 100644 --- a/.claude/skills/mendix/bulk-widget-updates.md +++ b/.claude/skills/mendix/bulk-widget-updates/SKILL.md @@ -1,3 +1,8 @@ +--- +name: bulk-widget-updates +description: "Discover and bulk-change widget properties across many pages with SHOW WIDGETS and UPDATE WIDGETS. Use when the same widget setting has to change on many pages at once. Experimental — always DRY RUN first and back the project up." +--- + # Bulk Widget Property Updates > **EXPERIMENTAL**: These commands are an untested proof-of-concept. @@ -9,7 +14,7 @@ Use `show widgets` and `update widgets` to discover and modify widget properties | Scope | Tool | |-------|------| -| One page, one or several related changes | `alter page` — see `alter-page.md` | +| One page, one or several related changes | `alter page` — see `alter-page` | | Many pages matched by `WHERE` filter (across one or more modules) | `update widgets` (this skill) | `alter page` is the right call for targeted, page-specific edits — it can combine `set`/`insert`/`drop`/`replace` in a single block. `update widgets` is for cross-page patterns like "set `Class` on every Container in `MyMod`" where naming each page explicitly would be tedious or error-prone. diff --git a/.claude/skills/mendix/business-events.md b/.claude/skills/mendix/business-events/SKILL.md similarity index 94% rename from .claude/skills/mendix/business-events.md rename to .claude/skills/mendix/business-events/SKILL.md index 81822a75a..1925c3e9f 100644 --- a/.claude/skills/mendix/business-events.md +++ b/.claude/skills/mendix/business-events/SKILL.md @@ -1,3 +1,8 @@ +--- +name: business-events +description: "Define event-driven APIs over Kafka with Mendix business event services — publish and subscribe contracts, CREATE/DROP/DESCRIBE. Use when building publish/subscribe integration between apps, or inspecting the business event services a project already has." +--- + # Business Events ## When to Use This Skill diff --git a/.claude/skills/mendix/catalog-search.md b/.claude/skills/mendix/catalog-search/SKILL.md similarity index 91% rename from .claude/skills/mendix/catalog-search.md rename to .claude/skills/mendix/catalog-search/SKILL.md index a1e2936bb..beabb4262 100644 --- a/.claude/skills/mendix/catalog-search.md +++ b/.claude/skills/mendix/catalog-search/SKILL.md @@ -1,8 +1,13 @@ +--- +name: catalog-search +description: "Search the Mendix Catalog platform service registry (catalog.mendix.com) from the CLI to find services published across an organisation. Use when looking for an existing published service to consume. This is the external registry, not the MDL CATALOG keyword — for that see browse-integrations." +--- + # Catalog Search (Mendix Platform Service Registry) Search and discover services registered in **Mendix Catalog** (catalog.mendix.com) programmatically. -**⚠️ NOTE:** This is the **external Mendix Catalog service** (CLI: `mxcli catalog search`), NOT the **MDL CATALOG keyword** which queries local project metadata tables (`SELECT ... FROM CATALOG.entities`). See `.claude/skills/mendix/browse-integrations.md` for MDL CATALOG queries. +**⚠️ NOTE:** This is the **external Mendix Catalog service** (CLI: `mxcli catalog search`), NOT the **MDL CATALOG keyword** which queries local project metadata tables (`SELECT ... FROM CATALOG.entities`). See `.claude/skills/mendix/browse-integrations` for MDL CATALOG queries. ## Authentication Required @@ -160,10 +165,10 @@ See GitHub issue #213 for architecture discussion. - **Requires**: `REFRESH CATALOG` command (no auth needed) - **Data source**: Your local .mpr file -See `.claude/skills/mendix/browse-integrations.md` for MDL CATALOG usage. +See `.claude/skills/mendix/browse-integrations` for MDL CATALOG usage. ## Related - Platform authentication: `.claude/skills/mendix/platform-auth.md` -- OData client creation: `.claude/skills/mendix/odata-data-sharing.md` -- MDL CATALOG queries: `.claude/skills/mendix/browse-integrations.md` +- OData client creation: `.claude/skills/mendix/odata-data-sharing` +- MDL CATALOG queries: `.claude/skills/mendix/browse-integrations` diff --git a/.claude/skills/mendix/cheatsheet-errors.md b/.claude/skills/mendix/cheatsheet-errors/SKILL.md similarity index 96% rename from .claude/skills/mendix/cheatsheet-errors.md rename to .claude/skills/mendix/cheatsheet-errors/SKILL.md index b23b1875b..73ba39538 100644 --- a/.claude/skills/mendix/cheatsheet-errors.md +++ b/.claude/skills/mendix/cheatsheet-errors/SKILL.md @@ -1,3 +1,8 @@ +--- +name: cheatsheet-errors +description: "Quick fixes for the MDL syntax errors that come up most: undeclared variables, wrong retrieve source, list operations, expression mistakes. Use when `mxcli check` reports an error and you want the fix rather than the reference." +--- + # MDL Common Errors Cheatsheet Quick fixes for common MDL syntax errors. diff --git a/.claude/skills/mendix/cheatsheet-variables.md b/.claude/skills/mendix/cheatsheet-variables/SKILL.md similarity index 94% rename from .claude/skills/mendix/cheatsheet-variables.md rename to .claude/skills/mendix/cheatsheet-variables/SKILL.md index 33e7579cb..d3558a53b 100644 --- a/.claude/skills/mendix/cheatsheet-variables.md +++ b/.claude/skills/mendix/cheatsheet-variables/SKILL.md @@ -1,3 +1,8 @@ +--- +name: cheatsheet-variables +description: "One-page reference for declaring and assigning variables in MDL microflows — every type, with the exact syntax. Use when writing a `declare` and unsure of the spelling, or when a declaration error needs fixing fast." +--- + # MDL Variable Cheatsheet Quick reference for variable declarations in MDL microflows. diff --git a/.claude/skills/mendix/check-syntax.md b/.claude/skills/mendix/check-syntax/SKILL.md similarity index 95% rename from .claude/skills/mendix/check-syntax.md rename to .claude/skills/mendix/check-syntax/SKILL.md index 7f1ca8a49..fa0ed1f64 100644 --- a/.claude/skills/mendix/check-syntax.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -1,3 +1,8 @@ +--- +name: check-syntax +description: "Validate MDL with `mxcli check` before presenting or executing it, including reference resolution against a project. Use ALWAYS before showing MDL to a user, running `mxcli exec`, or committing a .mdl file — exec refuses exactly what check rejects." +--- + # MDL Syntax Validation Skill This skill ensures MDL scripts are validated before presenting them to users or executing them. @@ -188,8 +193,8 @@ not close it. 1. **Read the skill files:** ```bash - cat .claude/skills/write-microflows.md - cat .claude/skills/overview-pages.md + cat .claude/skills/write-microflows/SKILL.md + cat .claude/skills/overview-pages/SKILL.md ``` 2. **Check help for specific syntax:** @@ -257,7 +262,7 @@ Organize scripts in dependency order: ```mdl -- check-skip: illustrative ordering example; the PHASE 5 page block uses -- shorthand pseudo-syntax (layout/title/parameter/widgets) for brevity, not --- runnable MDL. See create-page.md for the real page syntax. +-- runnable MDL. See create-page for the real page syntax. -- ============================================ -- PHASE 1: Enumerations (no dependencies) -- ============================================ @@ -332,7 +337,7 @@ document is created. that embed the snippet), create a minimal placeholder first, then create the referencing documents, then fill in the placeholder with `CREATE OR MODIFY` — which preserves the original UUID so all existing bindings remain valid - (see [Resolve Forward References](./resolve-forward-references.md)) + (see [Resolve Forward References](../resolve-forward-references/SKILL.md)) Declaration order that avoids most forward references: ``` @@ -415,7 +420,7 @@ After `./mxcli exec script.mdl -p app.mpr` succeeds: ## Related Skills -- [/write-microflows](./write-microflows.md) - Detailed microflow syntax -- [/overview-pages](./overview-pages.md) - Page building syntax -- [/resolve-forward-references](./resolve-forward-references.md) - Placeholder pattern and declaration ordering -- [/migrate-oracle-forms](./migrate-oracle-forms.md) - Migration-specific guidance +- [/write-microflows](../write-microflows/SKILL.md) - Detailed microflow syntax +- [/overview-pages](../overview-pages/SKILL.md) - Page building syntax +- [/resolve-forward-references](../resolve-forward-references/SKILL.md) - Placeholder pattern and declaration ordering +- [/migrate-oracle-forms](../migrate-oracle-forms/SKILL.md) - Migration-specific guidance diff --git a/.claude/skills/mendix/connect-rapidminer-graph.md b/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md similarity index 94% rename from .claude/skills/mendix/connect-rapidminer-graph.md rename to .claude/skills/mendix/connect-rapidminer-graph/SKILL.md index 59ef7d5f5..c2ce1aece 100644 --- a/.claude/skills/mendix/connect-rapidminer-graph.md +++ b/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md @@ -1,3 +1,8 @@ +--- +name: connect-rapidminer-graph +description: "Fetch data from a RapidMiner graph mart or any SPARQL 1.1 HTTP endpoint (AnzoGraph and friends) and surface it as Mendix entities. Use when a graph database with a SPARQL endpoint has to feed a Mendix app read-only." +--- + # Connecting Mendix to RapidMiner / AnzoGraph via SPARQL Use this skill when you need to fetch data from a RapidMiner graph mart (or any SPARQL 1.1 HTTP endpoint like AnzoGraph) and surface it in a Mendix app. @@ -302,7 +307,7 @@ For demos, literal credentials inline in the microflow are the simplest and most ## Related skills -- [rest-client.md](./rest-client.md) — REST Client + SEND REST REQUEST pattern (preferred when Basic Auth is not needed or uses simple passwords) -- [json-structures-and-mappings.md](./json-structures-and-mappings.md) — JSON structure / import mapping details -- [rest-call-from-json.md](./rest-call-from-json.md) — inline REST CALL + mapping pipeline -- [write-microflows.md](./write-microflows.md) — microflow syntax reference +- [rest-client](../rest-client/SKILL.md) — REST Client + SEND REST REQUEST pattern (preferred when Basic Auth is not needed or uses simple passwords) +- [json-structures-and-mappings](../json-structures-and-mappings/SKILL.md) — JSON structure / import mapping details +- [rest-call-from-json](../rest-call-from-json/SKILL.md) — inline REST CALL + mapping pipeline +- [write-microflows](../write-microflows/SKILL.md) — microflow syntax reference diff --git a/.claude/skills/mendix/create-custom-widget.md b/.claude/skills/mendix/create-custom-widget/SKILL.md similarity index 98% rename from .claude/skills/mendix/create-custom-widget.md rename to .claude/skills/mendix/create-custom-widget/SKILL.md index 360c825a0..bedb94fb4 100644 --- a/.claude/skills/mendix/create-custom-widget.md +++ b/.claude/skills/mendix/create-custom-widget/SKILL.md @@ -1,3 +1,8 @@ +--- +name: create-custom-widget +description: "Build a Mendix pluggable widget from scratch with React and TypeScript and package it as an .mpk. Use when no marketplace or built-in widget covers what is needed and a custom React component has to be written." +--- + # Create Custom Pluggable Widget Build a Mendix pluggable widget from scratch using React + TypeScript. Produces a `.mpk` file ready for Studio Pro. diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md new file mode 100644 index 000000000..c84df99aa --- /dev/null +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -0,0 +1,452 @@ +--- +name: create-page +description: "CREATE PAGE syntax reference — parameters, variables, layouts, and the full widget vocabulary. Use before writing any CREATE PAGE statement, or when a widget's property spelling needs checking." +--- + +# CREATE PAGE - MDL Syntax Guide + +## Reference files + +`SKILL.md` covers page structure, the syntax, and what does not work. The bulk is +next door: + +- [`reference/widgets.md`](reference/widgets.md) — the widget catalogue: every + supported widget with its properties and the exact MDL spelling. **Look a widget + up here before writing it**; guessing a property name is the most common way a + page fails to build. +- [`reference/examples.md`](reference/examples.md) — complete pages, end to end, + to copy and adapt rather than assemble from parts. + +For the widgets *this project actually has* — including any marketplace or custom +ones — read the generated `widgets` skill instead +(`.ai-context/skills/widgets/SKILL.md`). + +## Overview +Guide for writing CREATE PAGE statements in Mendix Definition Language (MDL). + +## Syntax + +```sql +create [or replace] page Module.PageName +( + [params: { $ParamName: Module.EntityType | PrimitiveType, ... },] + [variables: { $varName: DataType = 'defaultExpression', ... },] + title: 'Page Title', + layout: Module.LayoutName, + [url: 'page-url',] + [folder: 'FolderPath',] + [PopupWidth: 800, PopupHeight: 480, PopupResizable: true,] + [Class: 'css-class', Style: 'css: rule'] +) +{ + -- Widget definitions using explicit properties +} +``` + +**Pop-up dimensions** (`PopupWidth` / `PopupHeight` / `PopupResizable`) apply when the +page is opened in a pop-up. They are optional — omitting them uses the Mendix defaults +(600 × 600, not resizable). Unlike the other header keywords, these property names are +**case-sensitive** and must be written exactly as shown. They can also be changed later +with `alter page … { set PopupWidth = …; }` (see the alter-page skill). + +**Page CSS class / style** (`Class` / `Style`) set the page's Appearance — a CSS class +and inline style applied to the whole page (e.g. `Class: 'container-fluid bg-light'`). +Both are optional and can be changed later with `alter page … { set Class = '…'; }`. + +**Page Variables**: Local variables at the page level for use in expressions (e.g., column visibility). +- DataType: `boolean`, `string`, `integer`, `decimal`, `datetime` +- Default value: Mendix expression in single quotes +- Referenced in expressions as `$varName` +- Use for DataGrid2 column `visible:` (which hides/shows entire column, NOT per-row) + +### Key Syntax Elements + +| Element | Syntax | Example | +|---------|--------|---------| +| Properties | `(key: value, ...)` | `(title: 'Edit', layout: Atlas_Core.Atlas_Default)` | +| Widget name | Required after type | `textbox txtName (...)` | +| Attribute binding | `attribute: AttrName` | `textbox txt (label: 'Name', attribute: Name)` | +| Variable binding | `datasource: $Var` | `dataview dv (datasource: $Product) { ... }` | +| Action binding | `action: type` | `actionbutton btn (caption: 'Save', action: save_changes)` | +| Database source | `datasource: database entity` | `datagrid dg (datasource: database Module.Entity)` | +| Selection binding | `datasource: selection widget` | `dataview dv (datasource: selection galleryList)` | +| CSS class | `class: 'classes'` | `container c (class: 'card mx-spacing-top-large')` | +| Inline style | `style: 'css'` | `container c (style: 'padding: 16px;')` | +| Design properties | `designproperties: [...]` | `container c (designproperties: ['Spacing top': 'Large', 'full width': on])` | + +### FOLDER Option + +Place pages in folders for better organization: + +```sql +create page MyModule.CustomerEdit +( + title: 'Edit Customer', + layout: Atlas_Core.PopupLayout, + folder: 'Customers' +) +{ + -- widgets +} + +-- Nested folders (created automatically if they don't exist) +create page MyModule.OrderDetail +( + title: 'Order Details', + layout: Atlas_Core.Atlas_Default, + folder: 'Orders/Details' +) +{ + -- widgets +} +``` + +### Styling: Class, Style, and DesignProperties + +Three styling mechanisms can be applied to any widget: + +**CSS Class** — Atlas UI utility classes or custom CSS classes: +```sql +container c (class: 'card mx-spacing-top-large') { ... } +actionbutton btn (caption: 'Save', class: 'btn-lg') +``` + +**Inline Style** — One-off CSS styles (use sparingly): +```sql +container c (style: 'background-color: #f8f9fa; padding: 16px;') { ... } +``` + +> **Warning:** Do NOT use `style` directly on DYNAMICTEXT widgets — it crashes MxBuild with a NullReferenceException. Wrap the DYNAMICTEXT in a styled CONTAINER instead. + +**Design Properties** — Atlas UI structured properties (spacing, colors, toggles): +```sql +-- Option property: 'Key': 'Value' +container c (designproperties: ['Spacing top': 'Large', 'Background color': 'Brand Primary']) { ... } + +-- Toggle property: 'Key': ON (enabled) or OFF (disabled/omitted) +container c (designproperties: ['Full width': on]) { ... } + +-- Multiple types combined +actionbutton btn (caption: 'Save', designproperties: ['Size': 'Large', 'Full width': on]) +``` + +**Dynamic Classes** — a Mendix expression evaluated at runtime that returns a +class list (applied on top of the static `class`). Root attributes in +`$currentObject` and escape single quotes by doubling them (`''`): +```sql +dynamictext ovChip ( + content: 'chip', + class: 'ss-chip', + dynamicclasses: 'if $currentObject/VesselClass = Mod.BoatClass.Astute then ''ss-chip--astute'' else ''''' +) +``` + +**All can be combined on a single widget:** +```sql +container ctnHero ( + class: 'card', + style: 'border-left: 4px solid #264AE5;', + dynamicclasses: 'if $currentObject/Featured then ''is-featured'' else ''''', + designproperties: ['Spacing top': 'Large', 'Full width': on] +) { + dynamictext txtTitle (content: 'Styled Container', rendermode: H3) +} +``` + +> `mxcli check` **warns** (MDL-WIDGET07) when a built-in widget carries an +> unrecognized property (a typo, or a property mxcli doesn't persist) — it would +> otherwise be silently dropped on write. It is a warning, not an error, so the +> check still passes; fix the spelling or remove the property. + +## Basic Examples + +### Simple Page with Title + +```sql +create page MyModule.HomePage +( + title: 'Home Page', + layout: Atlas_Core.Atlas_Default +) +{ + dynamictext welcomeText (content: 'Welcome to My App', rendermode: H1) +} +``` + +### Page with Multiple Widgets + +```sql +create page MyModule.CustomerPage +( + title: 'Customer Details', + layout: Atlas_Core.Atlas_Default +) +{ + layoutgrid mainGrid { + row row1 { + column col1 (desktopwidth: 12) { + dynamictext heading (content: 'Customer Information', rendermode: H2) + } + } + row row2 { + column col2a (desktopwidth: 6) { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + } + column col2b (desktopwidth: 6) { + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } + } + } +} +``` + +### Layout Placeholders (multiple content areas) + +By default all top-level widgets bind to the layout's **Main** placeholder. When a +layout has more than one placeholder (e.g. Main + a sidebar/topbar), use a +`placeholder { … }` block to assign widgets to a specific placeholder. Bare +widgets still bind to Main. + +```sql +-- Atlas_Core.Atlas_SideBar has two placeholders: Main and Topbar +create page MyModule.Dashboard (title: 'Dashboard', layout: Atlas_Core.Atlas_SideBar) +{ + placeholder Main { + dynamictext lblMain (content: 'Main content area') + } + placeholder Topbar { + dynamictext lblTop (content: 'Top bar content') + } +} +``` + +Notes: +- The placeholder name must match a placeholder defined in the layout (e.g. `Main`, + `Right`, `Topbar`, `Content` — depends on the layout). An unknown name fails `mx check`. +- Keyword-like names (`Right`, `Left`, `Content`) are accepted. +- `describe page` emits `placeholder` blocks for multi-placeholder pages so they round-trip. + +## Modifying Existing Pages + +To make targeted changes to an existing page (change a label, add a field, remove a widget), use `alter page` instead of `create or replace page`. ALTER PAGE modifies the widget tree in-place, preserving properties that MDL doesn't model. + +```sql +-- Change a button caption and add a field +alter page Module.Customer_Edit { + set caption = 'Save & Close' on btnSave; + insert after txtEmail { + textbox txtPhone (label: 'Phone', attribute: Phone) + } +}; +``` + +See the dedicated skill file: [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) + +## Conditional Visibility and Editability + +Any widget (including CONTAINER) can have conditional visibility. Input widgets can also have conditional editability. Use bracket syntax `[expression]`: + +```sql +-- Conditionally visible widget (boolean attribute) +textbox txtName (label: 'Name', attribute: Name, visible: [IsActive]) + +-- Conditionally visible container +container ctnDetails (visible: [Name != '']) { dynamictext t (content: '...') } + +-- Conditionally editable input (boolean) +textbox txtStatus (label: 'Status', attribute: status, editable: [CanEdit]) + +-- Enum comparison: use the QUALIFIED enum value. Attributes are rooted for you, +-- but a bare enum VALUE would be treated as an attribute — always qualify it. +textbox txtNotes (label: 'Notes', attribute: Notes, + visible: [Status = MES.EquipmentStatus.Running]) + +-- Combined +textbox txtEmail (label: 'Email', attribute: Email, + visible: [ShowEmail], + editable: [CanEdit]) + +-- Static values still work +textbox txtReadOnly (label: 'Read Only', attribute: Name, editable: Never) +textbox txtHidden (label: 'Hidden', attribute: Name, visible: false) + +-- A quoted-string expression is also accepted (CREATE and ALTER). Unlike the +-- bracket form, it is NOT auto-rooted — write $currentObject/ yourself. +dynamictext ovChip (content: 'chip', visible: '$currentObject/Name != empty') + +-- Function calls work in the bracket form, including functions whose name is +-- also an MDL keyword (trim, length, find). Arguments are rooted like any other +-- reference. +dynamictext tTrim (content: 'x', visible: [trim($currentObject/Slug) != '']) +textbox txtSlug (label: 'Slug', attribute: Slug, editable: [length(Slug) > 0]) +``` + +> **`visible:`/`editable:` is a Mendix *expression*, not XPath** — a different +> function set from a datasource `where` clause, even though both use `[ ... ]`: +> +> | | `visible:` / `editable:` (client expression) | `where [ … ]` (XPath) | +> |---|---|---| +> | String tests | `trim()`, `length()`, `toUpperCase()`, `find()`, `contains()` | `contains()`, `starts-with()`, `ends-with()`, `string-length()` | +> | `length()` | character count | number of elements in a list | +> | Emptiness | `$currentObject/X != ''` / `!= empty` | `[X = empty]` or `[X = NULL]` — a **keyword**, never `empty(…)` | +> | Aggregates | not available | `count()`/`avg()`/`min()`/`max()`/`sum()` are Java-API-only | +> +> mxcli's grammar accepts any function name in both and lets MxBuild adjudicate, +> so a wrong-context call surfaces as **CE0117** "Error(s) in expression" at +> build rather than as a parse error. See the Mendix reference guide: +> [XPath constraint functions](https://docs.mendix.com/refguide/xpath-constraint-functions/), +> [XPath keywords](https://docs.mendix.com/refguide/xpath-keywords-and-system-variables/). + +> **An unparseable conditional is an error, not a silent drop.** If the +> expression inside `visible: [ ... ]` / `editable: [ ... ]` can't be parsed, the +> property has nowhere to go and would vanish on write — leaving the widget +> unconditionally visible/editable, which looks identical to a specificity bug in +> the running app. `mxcli check` reports this as **MDL-WIDGET19** and fails the +> command instead. Until v0.16.x, `trim(…)` and `length(…)` hit exactly this path +> and disappeared without a word (issue #852). + +> **Attribute rooting is automatic** — a bare attribute in a widget +> visibility/editability expression (`[Name != '']`, `[IsActive]`) is rooted in the +> widget data context as `$currentObject/Name != ''` for you, so it no longer +> triggers CE0117. Paths you write with an explicit `$currentObject/…` or `$Param/…` +> prefix pass through unchanged. +> +> **Enum comparison differs by context:** +> - **Widget visibility/editability expression** (per-object): qualified enum value — +> `[Status = MES.EquipmentStatus.Running]` (the `Status` attribute is rooted for you; +> the *value* must stay qualified, or it would be mistaken for an attribute). +> - **XPath datasource constraint** (`where […]`): the string key works — +> `where [Status = 'Running']` (see [xpath-constraints](../xpath-constraints/SKILL.md)). +> - **Microflow expression**: qualified value — +> `$obj/Status = MES.EquipmentStatus.Running` (see [write-microflows](../write-microflows/SKILL.md)). +> +> Widget-level visibility does **not** apply to **DataGrid2 column** `visible:` (next +> section), which hides/shows the whole column and must use page variables. + +## Known Limitations + +The following features are NOT implemented in mxcli and require manual configuration in Studio Pro: + +| Feature | Workaround | +|---------|------------| +| Nested dataviews filtering by parent | Use microflow datasource or configure in Studio Pro | +| Complex conditional visibility | Configure visibility rules in Studio Pro | +| Widget-level security | Configure access rules in Studio Pro | + +### Runtime Pitfalls + +> **Empty CONTAINER crashes at runtime.** A CONTAINER with no child widgets compiles and builds successfully but crashes when the page loads with "Did not expect an argument to be undefined". Always include at least one child widget: +> ```sql +> -- Wrong: crashes at runtime +> CONTAINER spacer1 (Style: 'height: 6px;') +> +> -- Correct: include a child (even a space) +> CONTAINER spacer1 (Style: 'height: 6px;') { +> DYNAMICTEXT spacerText (Content: ' ', RenderMode: Paragraph) +> } +> ``` + +> **`content: ''` (empty string) fails MxBuild.** An empty Content on DYNAMICTEXT causes a misleading error: "Place holder index 1 is greater than 0, the number of parameter(s)." Use a single space instead: +> ```sql +> -- Wrong: MxBuild error +> DYNAMICTEXT spacer (Content: '') +> +> -- Correct: use a space +> DYNAMICTEXT spacer (Content: ' ') +> ``` + +### Binding across modules and to audit members + +An attribute path may cross module boundaries, including into the platform's +`System` module — the association does not need to live in the same module as +the entity it targets: + +```sql +create association IT.Issue_Assignee from IT.Issue to System.User; + +DATAVIEW dv (DataSource: $Issue) { + DYNAMICTEXT txtAssignee (Attribute: Issue_Assignee/Name) -- into System + DYNAMICTEXT txtApprover (Attribute: Issue_Approver/Name) -- into another module +} +``` + +A bare association name is qualified with the module of the entity the widget +sits on. On a ComboBox that matters: its `DataSource:` is the *option list*, but +`Association:` names a reference on the containing entity, so +`Association: Issue_Assignee` resolves against the dataview's entity, not the +option list's module. + +Audit members declared with the `Auto*` pseudo-types bind under the name you +declared: + +```sql +create or modify persistent entity IT.Issue ( CreatedDate: AutoCreatedDate ); +DYNAMICTEXT txtCreated (Attribute: CreatedDate) -- also accepts createdDate +``` + +**Script Execution Note:** Script execution stops on the first error. If a page fails to create (e.g., invalid widget syntax), earlier statements in the script will have already been committed. Plan scripts with uncertain syntax in phases. + +## Tips + +1. **OR REPLACE**: Use to recreate existing pages +2. **Widget Names**: Required - use descriptive camelCase names +3. **Layout Requirement**: Layout must exist in the project +4. **Nesting**: Use `{ }` blocks for all widget children +5. **Properties**: Use `(key: value)` syntax for all widget properties +6. **Bindings**: Use `attribute:` for attributes, `datasource:` for data, `action:` for buttons + +## Related Commands + +- `alter page Module.PageName { ... }` - Modify page widgets in-place (SET, INSERT, DROP, REPLACE) +- `alter snippet Module.SnippetName { ... }` - Modify snippet widgets in-place +- `describe page Module.PageName` - View page source in MDL format (shows Class, Style, DesignProperties) +- `describe snippet Module.SnippetName` - View snippet source in MDL format +- `show pages [in module]` - List all pages +- `show widgets [where ...] [in module]` - Discover widgets across pages/snippets +- `update widgets set ... where ... [dry run]` - Bulk update widget properties (see below) +- `drop page Module.PageName` - Delete a page + +### Bulk Widget Updates + +Use `update widgets` to change properties across many widgets at once: + +```sql +-- Preview changes first (always use DRY RUN) +update widgets set 'Class' = 'card' where widgettype like '%Container%' in MyModule dry run; + +-- Apply changes +update widgets set 'showLabel' = false where widgettype like '%combobox%'; + +-- Multiple properties +update widgets set 'Class' = 'btn-lg', 'Style' = 'margin-top: 8px;' where widgettype like '%ActionButton%'; +``` + +## PLUGGABLEWIDGET Escape Hatch + +All shorthand widgets (IMAGE, COMBOBOX, GALLERY, DATAGRID, etc.) are pluggable widgets under the hood. When the shorthand doesn't expose a property you need, use `pluggablewidget 'widget.id' name (properties)` for full access to all widget properties. + +```sql +-- Shorthand (common properties only) +image imgLogo (width: 48, height: 48) + +-- Full PLUGGABLEWIDGET syntax (all properties available) +pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( + datasource: imageUrl, imageUrl: 'img/logo.svg', + widthUnit: pixels, width: 48, heightUnit: pixels, height: 48 +) +``` + +The project's own widgets are documented as a skill: read +`.ai-context/skills/widgets/SKILL.md` (also at `.claude/skills/widgets/SKILL.md`) +for the index, then the per-widget file for the one you are placing — it carries +the full property table with enumeration values, nested object properties, child +slots and object lists. + +`mxcli widget docs -p app.mpr` regenerates it (so does `refresh catalog`), and +`mxcli widget describe -p app.mpr` reads the same data live from the +`.mpk` when a widget has been upgraded since. + +## See Also + +- [Overview Pages](../overview-pages/SKILL.md) - CRUD page patterns +- [Master-Detail Pages](../master-detail-pages/SKILL.md) - Selection binding pattern diff --git a/.claude/skills/mendix/create-page/reference/examples.md b/.claude/skills/mendix/create-page/reference/examples.md new file mode 100644 index 000000000..95f2475fa --- /dev/null +++ b/.claude/skills/mendix/create-page/reference/examples.md @@ -0,0 +1,108 @@ +# Complete page examples + +Supporting reference for [create-page](../SKILL.md). + +## Complete Examples + +### Customer Edit Page + +```sql +create or replace page CRM.CustomerEdit +( + params: { $Customer: CRM.Customer }, + title: 'Edit Customer', + layout: Atlas_Core.PopupLayout +) +{ + -- Wrap the form's DataView in a layout grid. Label width and input-control + -- width are expressed in Bootstrap grid columns and only render correctly + -- inside a layoutgrid → row → column. A DataView with input fields placed + -- directly on the page (no grid) is flagged by lint rule MPR010 / mxcli check. + layoutgrid mainGrid { + row row1 { + column col1 (desktopwidth: autofill) { + dataview dvCustomer (datasource: $Customer) { + textbox txtName (label: 'Name', attribute: Name) + textbox txtEmail (label: 'Email', attribute: Email) + textbox txtPhone (label: 'Phone', attribute: Phone) + checkbox cbActive (label: 'Active', attribute: IsActive) + + footer footer1 { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } + } + } + } + } +} +``` + +### Order Overview Page + +```sql +create page Orders.OrderOverview +( + title: 'Orders', + layout: Atlas_Core.Atlas_Default +) +{ + layoutgrid mainGrid { + row row1 { + column col1 (desktopwidth: 12) { + dynamictext heading (content: 'Order Overview', rendermode: H2) + } + } + row row2 { + column col2 (desktopwidth: 12) { + datagrid dgOrders (datasource: database from Orders.Order sort by OrderDate desc) { + column colNumber (attribute: OrderNumber, caption: 'Order #') + column colDate (attribute: OrderDate, caption: 'Date') + column colTotal (attribute: TotalAmount, caption: 'Total') + } + } + } + } +} +``` + +### Master-Detail Page + +```sql +create page CRM.Customer_MasterDetail +( + title: 'Customer Management', + layout: Atlas_Core.Atlas_Default +) +{ + layoutgrid mainGrid { + row row1 { + -- Master list (left column) + column colMaster (desktopwidth: 4) { + dynamictext heading (content: 'Customers', rendermode: H3) + gallery customerList (datasource: database from CRM.Customer sort by Name asc, selection: single) { + template template1 { + dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) + dynamictext email (content: '{1}', contentparams: [{1} = Email]) + } + } + } + + -- Detail form (right column) + column colDetail (desktopwidth: 8) { + dataview customerDetail (datasource: selection customerList) { + dynamictext detailHeading (content: 'Customer Details', rendermode: H3) + textbox txtName (label: 'Name', attribute: Name) + textbox txtEmail (label: 'Email', attribute: Email) + textbox txtPhone (label: 'Phone', attribute: Phone) + + footer footer1 { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } + } + } + } + } +} +``` diff --git a/.claude/skills/mendix/create-page.md b/.claude/skills/mendix/create-page/reference/widgets.md similarity index 62% rename from .claude/skills/mendix/create-page.md rename to .claude/skills/mendix/create-page/reference/widgets.md index 34870f292..681eebda4 100644 --- a/.claude/skills/mendix/create-page.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -1,209 +1,6 @@ -# CREATE PAGE - MDL Syntax Guide +# Widget catalogue -## Overview -Guide for writing CREATE PAGE statements in Mendix Definition Language (MDL). - -## Syntax - -```sql -create [or replace] page Module.PageName -( - [params: { $ParamName: Module.EntityType | PrimitiveType, ... },] - [variables: { $varName: DataType = 'defaultExpression', ... },] - title: 'Page Title', - layout: Module.LayoutName, - [url: 'page-url',] - [folder: 'FolderPath',] - [PopupWidth: 800, PopupHeight: 480, PopupResizable: true,] - [Class: 'css-class', Style: 'css: rule'] -) -{ - -- Widget definitions using explicit properties -} -``` - -**Pop-up dimensions** (`PopupWidth` / `PopupHeight` / `PopupResizable`) apply when the -page is opened in a pop-up. They are optional — omitting them uses the Mendix defaults -(600 × 600, not resizable). Unlike the other header keywords, these property names are -**case-sensitive** and must be written exactly as shown. They can also be changed later -with `alter page … { set PopupWidth = …; }` (see the alter-page skill). - -**Page CSS class / style** (`Class` / `Style`) set the page's Appearance — a CSS class -and inline style applied to the whole page (e.g. `Class: 'container-fluid bg-light'`). -Both are optional and can be changed later with `alter page … { set Class = '…'; }`. - -**Page Variables**: Local variables at the page level for use in expressions (e.g., column visibility). -- DataType: `boolean`, `string`, `integer`, `decimal`, `datetime` -- Default value: Mendix expression in single quotes -- Referenced in expressions as `$varName` -- Use for DataGrid2 column `visible:` (which hides/shows entire column, NOT per-row) - -### Key Syntax Elements - -| Element | Syntax | Example | -|---------|--------|---------| -| Properties | `(key: value, ...)` | `(title: 'Edit', layout: Atlas_Core.Atlas_Default)` | -| Widget name | Required after type | `textbox txtName (...)` | -| Attribute binding | `attribute: AttrName` | `textbox txt (label: 'Name', attribute: Name)` | -| Variable binding | `datasource: $Var` | `dataview dv (datasource: $Product) { ... }` | -| Action binding | `action: type` | `actionbutton btn (caption: 'Save', action: save_changes)` | -| Database source | `datasource: database entity` | `datagrid dg (datasource: database Module.Entity)` | -| Selection binding | `datasource: selection widget` | `dataview dv (datasource: selection galleryList)` | -| CSS class | `class: 'classes'` | `container c (class: 'card mx-spacing-top-large')` | -| Inline style | `style: 'css'` | `container c (style: 'padding: 16px;')` | -| Design properties | `designproperties: [...]` | `container c (designproperties: ['Spacing top': 'Large', 'full width': on])` | - -### FOLDER Option - -Place pages in folders for better organization: - -```sql -create page MyModule.CustomerEdit -( - title: 'Edit Customer', - layout: Atlas_Core.PopupLayout, - folder: 'Customers' -) -{ - -- widgets -} - --- Nested folders (created automatically if they don't exist) -create page MyModule.OrderDetail -( - title: 'Order Details', - layout: Atlas_Core.Atlas_Default, - folder: 'Orders/Details' -) -{ - -- widgets -} -``` - -### Styling: Class, Style, and DesignProperties - -Three styling mechanisms can be applied to any widget: - -**CSS Class** — Atlas UI utility classes or custom CSS classes: -```sql -container c (class: 'card mx-spacing-top-large') { ... } -actionbutton btn (caption: 'Save', class: 'btn-lg') -``` - -**Inline Style** — One-off CSS styles (use sparingly): -```sql -container c (style: 'background-color: #f8f9fa; padding: 16px;') { ... } -``` - -> **Warning:** Do NOT use `style` directly on DYNAMICTEXT widgets — it crashes MxBuild with a NullReferenceException. Wrap the DYNAMICTEXT in a styled CONTAINER instead. - -**Design Properties** — Atlas UI structured properties (spacing, colors, toggles): -```sql --- Option property: 'Key': 'Value' -container c (designproperties: ['Spacing top': 'Large', 'Background color': 'Brand Primary']) { ... } - --- Toggle property: 'Key': ON (enabled) or OFF (disabled/omitted) -container c (designproperties: ['Full width': on]) { ... } - --- Multiple types combined -actionbutton btn (caption: 'Save', designproperties: ['Size': 'Large', 'Full width': on]) -``` - -**Dynamic Classes** — a Mendix expression evaluated at runtime that returns a -class list (applied on top of the static `class`). Root attributes in -`$currentObject` and escape single quotes by doubling them (`''`): -```sql -dynamictext ovChip ( - content: 'chip', - class: 'ss-chip', - dynamicclasses: 'if $currentObject/VesselClass = Mod.BoatClass.Astute then ''ss-chip--astute'' else ''''' -) -``` - -**All can be combined on a single widget:** -```sql -container ctnHero ( - class: 'card', - style: 'border-left: 4px solid #264AE5;', - dynamicclasses: 'if $currentObject/Featured then ''is-featured'' else ''''', - designproperties: ['Spacing top': 'Large', 'Full width': on] -) { - dynamictext txtTitle (content: 'Styled Container', rendermode: H3) -} -``` - -> `mxcli check` **warns** (MDL-WIDGET07) when a built-in widget carries an -> unrecognized property (a typo, or a property mxcli doesn't persist) — it would -> otherwise be silently dropped on write. It is a warning, not an error, so the -> check still passes; fix the spelling or remove the property. - -## Basic Examples - -### Simple Page with Title - -```sql -create page MyModule.HomePage -( - title: 'Home Page', - layout: Atlas_Core.Atlas_Default -) -{ - dynamictext welcomeText (content: 'Welcome to My App', rendermode: H1) -} -``` - -### Page with Multiple Widgets - -```sql -create page MyModule.CustomerPage -( - title: 'Customer Details', - layout: Atlas_Core.Atlas_Default -) -{ - layoutgrid mainGrid { - row row1 { - column col1 (desktopwidth: 12) { - dynamictext heading (content: 'Customer Information', rendermode: H2) - } - } - row row2 { - column col2a (desktopwidth: 6) { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - } - column col2b (desktopwidth: 6) { - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } - } - } -} -``` - -### Layout Placeholders (multiple content areas) - -By default all top-level widgets bind to the layout's **Main** placeholder. When a -layout has more than one placeholder (e.g. Main + a sidebar/topbar), use a -`placeholder { … }` block to assign widgets to a specific placeholder. Bare -widgets still bind to Main. - -```sql --- Atlas_Core.Atlas_SideBar has two placeholders: Main and Topbar -create page MyModule.Dashboard (title: 'Dashboard', layout: Atlas_Core.Atlas_SideBar) -{ - placeholder Main { - dynamictext lblMain (content: 'Main content area') - } - placeholder Topbar { - dynamictext lblTop (content: 'Top bar content') - } -} -``` - -Notes: -- The placeholder name must match a placeholder defined in the layout (e.g. `Main`, - `Right`, `Topbar`, `Content` — depends on the layout). An unknown name fails `mx check`. -- Keyword-like names (`Right`, `Left`, `Content`) are accepted. -- `describe page` emits `placeholder` blocks for multi-placeholder pages so they round-trip. +Supporting reference for [create-page](../SKILL.md). ## Supported Widgets @@ -1065,326 +862,4 @@ Notes: - **Slider / RangeSlider**: set `showTooltip: false`. The tooltip calls React's removed `findDOMNode` on Mendix 11, throwing "Could not render widget" on drag — a runtime crash `mx check` cannot catch (only a running build does). See - `atlas-design.md` for the full runtime-verification rule. - -## Complete Examples - -### Customer Edit Page - -```sql -create or replace page CRM.CustomerEdit -( - params: { $Customer: CRM.Customer }, - title: 'Edit Customer', - layout: Atlas_Core.PopupLayout -) -{ - -- Wrap the form's DataView in a layout grid. Label width and input-control - -- width are expressed in Bootstrap grid columns and only render correctly - -- inside a layoutgrid → row → column. A DataView with input fields placed - -- directly on the page (no grid) is flagged by lint rule MPR010 / mxcli check. - layoutgrid mainGrid { - row row1 { - column col1 (desktopwidth: autofill) { - dataview dvCustomer (datasource: $Customer) { - textbox txtName (label: 'Name', attribute: Name) - textbox txtEmail (label: 'Email', attribute: Email) - textbox txtPhone (label: 'Phone', attribute: Phone) - checkbox cbActive (label: 'Active', attribute: IsActive) - - footer footer1 { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } - } - } - } - } -} -``` - -### Order Overview Page - -```sql -create page Orders.OrderOverview -( - title: 'Orders', - layout: Atlas_Core.Atlas_Default -) -{ - layoutgrid mainGrid { - row row1 { - column col1 (desktopwidth: 12) { - dynamictext heading (content: 'Order Overview', rendermode: H2) - } - } - row row2 { - column col2 (desktopwidth: 12) { - datagrid dgOrders (datasource: database from Orders.Order sort by OrderDate desc) { - column colNumber (attribute: OrderNumber, caption: 'Order #') - column colDate (attribute: OrderDate, caption: 'Date') - column colTotal (attribute: TotalAmount, caption: 'Total') - } - } - } - } -} -``` - -### Master-Detail Page - -```sql -create page CRM.Customer_MasterDetail -( - title: 'Customer Management', - layout: Atlas_Core.Atlas_Default -) -{ - layoutgrid mainGrid { - row row1 { - -- Master list (left column) - column colMaster (desktopwidth: 4) { - dynamictext heading (content: 'Customers', rendermode: H3) - gallery customerList (datasource: database from CRM.Customer sort by Name asc, selection: single) { - template template1 { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) - } - } - } - - -- Detail form (right column) - column colDetail (desktopwidth: 8) { - dataview customerDetail (datasource: selection customerList) { - dynamictext detailHeading (content: 'Customer Details', rendermode: H3) - textbox txtName (label: 'Name', attribute: Name) - textbox txtEmail (label: 'Email', attribute: Email) - textbox txtPhone (label: 'Phone', attribute: Phone) - - footer footer1 { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } - } - } - } - } -} -``` - -## Modifying Existing Pages - -To make targeted changes to an existing page (change a label, add a field, remove a widget), use `alter page` instead of `create or replace page`. ALTER PAGE modifies the widget tree in-place, preserving properties that MDL doesn't model. - -```sql --- Change a button caption and add a field -alter page Module.Customer_Edit { - set caption = 'Save & Close' on btnSave; - insert after txtEmail { - textbox txtPhone (label: 'Phone', attribute: Phone) - } -}; -``` - -See the dedicated skill file: [ALTER PAGE/SNIPPET](./alter-page.md) - -## Conditional Visibility and Editability - -Any widget (including CONTAINER) can have conditional visibility. Input widgets can also have conditional editability. Use bracket syntax `[expression]`: - -```sql --- Conditionally visible widget (boolean attribute) -textbox txtName (label: 'Name', attribute: Name, visible: [IsActive]) - --- Conditionally visible container -container ctnDetails (visible: [Name != '']) { dynamictext t (content: '...') } - --- Conditionally editable input (boolean) -textbox txtStatus (label: 'Status', attribute: status, editable: [CanEdit]) - --- Enum comparison: use the QUALIFIED enum value. Attributes are rooted for you, --- but a bare enum VALUE would be treated as an attribute — always qualify it. -textbox txtNotes (label: 'Notes', attribute: Notes, - visible: [Status = MES.EquipmentStatus.Running]) - --- Combined -textbox txtEmail (label: 'Email', attribute: Email, - visible: [ShowEmail], - editable: [CanEdit]) - --- Static values still work -textbox txtReadOnly (label: 'Read Only', attribute: Name, editable: Never) -textbox txtHidden (label: 'Hidden', attribute: Name, visible: false) - --- A quoted-string expression is also accepted (CREATE and ALTER). Unlike the --- bracket form, it is NOT auto-rooted — write $currentObject/ yourself. -dynamictext ovChip (content: 'chip', visible: '$currentObject/Name != empty') - --- Function calls work in the bracket form, including functions whose name is --- also an MDL keyword (trim, length, find). Arguments are rooted like any other --- reference. -dynamictext tTrim (content: 'x', visible: [trim($currentObject/Slug) != '']) -textbox txtSlug (label: 'Slug', attribute: Slug, editable: [length(Slug) > 0]) -``` - -> **`visible:`/`editable:` is a Mendix *expression*, not XPath** — a different -> function set from a datasource `where` clause, even though both use `[ ... ]`: -> -> | | `visible:` / `editable:` (client expression) | `where [ … ]` (XPath) | -> |---|---|---| -> | String tests | `trim()`, `length()`, `toUpperCase()`, `find()`, `contains()` | `contains()`, `starts-with()`, `ends-with()`, `string-length()` | -> | `length()` | character count | number of elements in a list | -> | Emptiness | `$currentObject/X != ''` / `!= empty` | `[X = empty]` or `[X = NULL]` — a **keyword**, never `empty(…)` | -> | Aggregates | not available | `count()`/`avg()`/`min()`/`max()`/`sum()` are Java-API-only | -> -> mxcli's grammar accepts any function name in both and lets MxBuild adjudicate, -> so a wrong-context call surfaces as **CE0117** "Error(s) in expression" at -> build rather than as a parse error. See the Mendix reference guide: -> [XPath constraint functions](https://docs.mendix.com/refguide/xpath-constraint-functions/), -> [XPath keywords](https://docs.mendix.com/refguide/xpath-keywords-and-system-variables/). - -> **An unparseable conditional is an error, not a silent drop.** If the -> expression inside `visible: [ ... ]` / `editable: [ ... ]` can't be parsed, the -> property has nowhere to go and would vanish on write — leaving the widget -> unconditionally visible/editable, which looks identical to a specificity bug in -> the running app. `mxcli check` reports this as **MDL-WIDGET19** and fails the -> command instead. Until v0.16.x, `trim(…)` and `length(…)` hit exactly this path -> and disappeared without a word (issue #852). - -> **Attribute rooting is automatic** — a bare attribute in a widget -> visibility/editability expression (`[Name != '']`, `[IsActive]`) is rooted in the -> widget data context as `$currentObject/Name != ''` for you, so it no longer -> triggers CE0117. Paths you write with an explicit `$currentObject/…` or `$Param/…` -> prefix pass through unchanged. -> -> **Enum comparison differs by context:** -> - **Widget visibility/editability expression** (per-object): qualified enum value — -> `[Status = MES.EquipmentStatus.Running]` (the `Status` attribute is rooted for you; -> the *value* must stay qualified, or it would be mistaken for an attribute). -> - **XPath datasource constraint** (`where […]`): the string key works — -> `where [Status = 'Running']` (see [xpath-constraints.md](./xpath-constraints.md)). -> - **Microflow expression**: qualified value — -> `$obj/Status = MES.EquipmentStatus.Running` (see [write-microflows.md](./write-microflows.md)). -> -> Widget-level visibility does **not** apply to **DataGrid2 column** `visible:` (next -> section), which hides/shows the whole column and must use page variables. - -## Known Limitations - -The following features are NOT implemented in mxcli and require manual configuration in Studio Pro: - -| Feature | Workaround | -|---------|------------| -| Nested dataviews filtering by parent | Use microflow datasource or configure in Studio Pro | -| Complex conditional visibility | Configure visibility rules in Studio Pro | -| Widget-level security | Configure access rules in Studio Pro | - -### Runtime Pitfalls - -> **Empty CONTAINER crashes at runtime.** A CONTAINER with no child widgets compiles and builds successfully but crashes when the page loads with "Did not expect an argument to be undefined". Always include at least one child widget: -> ```sql -> -- Wrong: crashes at runtime -> CONTAINER spacer1 (Style: 'height: 6px;') -> -> -- Correct: include a child (even a space) -> CONTAINER spacer1 (Style: 'height: 6px;') { -> DYNAMICTEXT spacerText (Content: ' ', RenderMode: Paragraph) -> } -> ``` - -> **`content: ''` (empty string) fails MxBuild.** An empty Content on DYNAMICTEXT causes a misleading error: "Place holder index 1 is greater than 0, the number of parameter(s)." Use a single space instead: -> ```sql -> -- Wrong: MxBuild error -> DYNAMICTEXT spacer (Content: '') -> -> -- Correct: use a space -> DYNAMICTEXT spacer (Content: ' ') -> ``` - -### Binding across modules and to audit members - -An attribute path may cross module boundaries, including into the platform's -`System` module — the association does not need to live in the same module as -the entity it targets: - -```sql -create association IT.Issue_Assignee from IT.Issue to System.User; - -DATAVIEW dv (DataSource: $Issue) { - DYNAMICTEXT txtAssignee (Attribute: Issue_Assignee/Name) -- into System - DYNAMICTEXT txtApprover (Attribute: Issue_Approver/Name) -- into another module -} -``` - -A bare association name is qualified with the module of the entity the widget -sits on. On a ComboBox that matters: its `DataSource:` is the *option list*, but -`Association:` names a reference on the containing entity, so -`Association: Issue_Assignee` resolves against the dataview's entity, not the -option list's module. - -Audit members declared with the `Auto*` pseudo-types bind under the name you -declared: - -```sql -create or modify persistent entity IT.Issue ( CreatedDate: AutoCreatedDate ); -DYNAMICTEXT txtCreated (Attribute: CreatedDate) -- also accepts createdDate -``` - -**Script Execution Note:** Script execution stops on the first error. If a page fails to create (e.g., invalid widget syntax), earlier statements in the script will have already been committed. Plan scripts with uncertain syntax in phases. - -## Tips - -1. **OR REPLACE**: Use to recreate existing pages -2. **Widget Names**: Required - use descriptive camelCase names -3. **Layout Requirement**: Layout must exist in the project -4. **Nesting**: Use `{ }` blocks for all widget children -5. **Properties**: Use `(key: value)` syntax for all widget properties -6. **Bindings**: Use `attribute:` for attributes, `datasource:` for data, `action:` for buttons - -## Related Commands - -- `alter page Module.PageName { ... }` - Modify page widgets in-place (SET, INSERT, DROP, REPLACE) -- `alter snippet Module.SnippetName { ... }` - Modify snippet widgets in-place -- `describe page Module.PageName` - View page source in MDL format (shows Class, Style, DesignProperties) -- `describe snippet Module.SnippetName` - View snippet source in MDL format -- `show pages [in module]` - List all pages -- `show widgets [where ...] [in module]` - Discover widgets across pages/snippets -- `update widgets set ... where ... [dry run]` - Bulk update widget properties (see below) -- `drop page Module.PageName` - Delete a page - -### Bulk Widget Updates - -Use `update widgets` to change properties across many widgets at once: - -```sql --- Preview changes first (always use DRY RUN) -update widgets set 'Class' = 'card' where widgettype like '%Container%' in MyModule dry run; - --- Apply changes -update widgets set 'showLabel' = false where widgettype like '%combobox%'; - --- Multiple properties -update widgets set 'Class' = 'btn-lg', 'Style' = 'margin-top: 8px;' where widgettype like '%ActionButton%'; -``` - -## PLUGGABLEWIDGET Escape Hatch - -All shorthand widgets (IMAGE, COMBOBOX, GALLERY, DATAGRID, etc.) are pluggable widgets under the hood. When the shorthand doesn't expose a property you need, use `pluggablewidget 'widget.id' name (properties)` for full access to all widget properties. - -```sql --- Shorthand (common properties only) -image imgLogo (width: 48, height: 48) - --- Full PLUGGABLEWIDGET syntax (all properties available) -pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( - datasource: imageUrl, imageUrl: 'img/logo.svg', - widthUnit: pixels, width: 48, heightUnit: pixels, height: 48 -) -``` - -Run `mxcli widget docs -p app.mpr` to generate complete property documentation for all pluggable widgets in the project. Output is saved to `.ai-context/skills/widgets/`. - -## See Also - -- [Overview Pages](./overview-pages.md) - CRUD page patterns -- [Master-Detail Pages](./master-detail-pages.md) - Selection binding pattern + `atlas-design` for the full runtime-verification rule. diff --git a/.claude/skills/mendix/custom-widgets.md b/.claude/skills/mendix/custom-widgets/SKILL.md similarity index 97% rename from .claude/skills/mendix/custom-widgets.md rename to .claude/skills/mendix/custom-widgets/SKILL.md index 454aceac6..fc87acb84 100644 --- a/.claude/skills/mendix/custom-widgets.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -1,6 +1,6 @@ --- -name: mendix-custom-widgets -description: Use when writing MDL for GALLERY, COMBOBOX, or third-party pluggable widgets in CREATE PAGE / ALTER PAGE statements. Covers built-in widget syntax, child slots (TEMPLATE/FILTER), adding new custom widgets via .def.json, and engine internals. +name: custom-widgets +description: "MDL syntax for pluggable widgets in CREATE PAGE / ALTER PAGE — GALLERY, COMBOBOX, DataGrid2 and third-party widgets: datasource and column forms, child slots (TEMPLATE/FILTER), adding a widget via .def.json, and the engine internals. Use when placing a pluggable widget on a page, or when `mxcli widget describe` output needs interpreting. For the widgets THIS project actually has, read the generated `widgets` skill." --- # Custom & Pluggable Widgets in MDL diff --git a/.claude/skills/mendix/database-connections.md b/.claude/skills/mendix/database-connections/SKILL.md similarity index 97% rename from .claude/skills/mendix/database-connections.md rename to .claude/skills/mendix/database-connections/SKILL.md index 4333c47b8..aa68ec641 100644 --- a/.claude/skills/mendix/database-connections.md +++ b/.claude/skills/mendix/database-connections/SKILL.md @@ -1,3 +1,8 @@ +--- +name: database-connections +description: "Connect a Mendix app to an external database over JDBC with the External Database Connector, and define the queries microflows run against it. Use when the app must read or write Oracle, PostgreSQL, MySQL or SQL Server directly from a microflow." +--- + # Skill: Create External Database Connections ## Purpose @@ -502,7 +507,7 @@ import from source query 'SELECT name, email FROM employees' map (name as Name, email as Email); ``` -See [demo-data.md](./demo-data.md) for details on the Mendix ID system and manual insertion. +See [demo-data](../demo-data/SKILL.md) for details on the Mendix ID system and manual insertion. ## References diff --git a/.claude/skills/mendix/debug-bson.md b/.claude/skills/mendix/debug-bson/SKILL.md similarity index 98% rename from .claude/skills/mendix/debug-bson.md rename to .claude/skills/mendix/debug-bson/SKILL.md index 4de1be2a0..81ce8f252 100644 --- a/.claude/skills/mendix/debug-bson.md +++ b/.claude/skills/mendix/debug-bson/SKILL.md @@ -1,3 +1,8 @@ +--- +name: debug-bson +description: "Diagnose BSON serialization problems in the MPR file — missing properties, wrong storage names, widget definitions Studio Pro rejects. Use when something created through MDL does not appear correctly in Studio Pro, or when a CE error points at a document mxcli wrote." +--- + # BSON Serialization Debugging Skill This skill provides guidance for debugging BSON serialization issues when implementing or fixing Mendix SDK writers. @@ -566,7 +571,7 @@ This applies to any executor function that reads column headers, button captions - [BSON Mapping Specification](../../docs/05-mdl-specification/10-bson-mapping.md) - [Page Widget Serialization](../../sdk/mpr/writer_widgets.go) -- [Create Page Skill](./create-page.md) +- [Create Page Skill](../create-page/SKILL.md) - [Widget Templates README](../../sdk/widgets/templates/README.md) ## Quick Reference diff --git a/.claude/skills/mendix/debug-microflows.md b/.claude/skills/mendix/debug-microflows/SKILL.md similarity index 93% rename from .claude/skills/mendix/debug-microflows.md rename to .claude/skills/mendix/debug-microflows/SKILL.md index 9f286fd13..17b33a592 100644 --- a/.claude/skills/mendix/debug-microflows.md +++ b/.claude/skills/mendix/debug-microflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: debug-microflows +description: "Drive the Mendix runtime's microflow and nanoflow debugger from the command line with `mxcli debug` — breakpoints by name, variable inspection, step and continue. Use when a microflow produces the wrong result and reading it is not enough." +--- + # Debug Microflows — `mxcli debug` ## Overview @@ -19,7 +24,7 @@ GUIDs (from the `.mpr`). You never deal with raw GUIDs. - You want to confirm a microflow takes the branch/value you expect. For a server **stack trace / `LOG` output** (not stepping), you usually just want -the runtime log — see `run-local.md` (`--runtime-log`). Use the debugger when you +the runtime log — see `run-local` (`--runtime-log`). Use the debugger when you need to **pause and inspect** live execution. ## Prerequisites @@ -103,7 +108,7 @@ gets a **new** `debug_id` after every step — the old one is invalidated. Becau the fresh id each time. Reusing a `--flow ` copied from an earlier `paused` will fail on the second nanoflow step with "could not find … in debug with id". -For **nanoflow log output**, see `write-nanoflows.md` — the runtime rewrites the log +For **nanoflow log output**, see `write-nanoflows` — the runtime rewrites the log node to `Client_Nanoflow`, so grep `runtime.log` for `Client_Nanoflow`, not your node name. diff --git a/.claude/skills/mendix/demo-data.md b/.claude/skills/mendix/demo-data/SKILL.md similarity index 95% rename from .claude/skills/mendix/demo-data.md rename to .claude/skills/mendix/demo-data/SKILL.md index 7377f0e3b..faead4d4b 100644 --- a/.claude/skills/mendix/demo-data.md +++ b/.claude/skills/mendix/demo-data/SKILL.md @@ -1,3 +1,8 @@ +--- +name: demo-data +description: "Seed a Mendix app's PostgreSQL database directly, bypassing the runtime — Mendix's internal ID system, association storage, and bulk `import from` an external database. Use when populating test or demo data, when data is needed before the UI exists, or for imports too large for the runtime." +--- + # Skill: Connect to Application Database and Generate Demo Data ## Purpose @@ -132,7 +137,7 @@ from "tasklist$task"; > app) returns `0` for freshly seeded data — this looks like a seeding failure but > isn't. Run `mxcli docker reload` (or restart the app) after seeding. To confirm > rows landed *before* a reload, query Postgres directly. See -> [verify-with-oql.md](./verify-with-oql.md). +> [verify-with-oql](../verify-with-oql/SKILL.md). --- @@ -466,6 +471,6 @@ Use manual INSERT (described above) when you need: ## Related Skills -- [database-connections.md](./database-connections.md) — Connecting to *external* databases from Mendix microflows -- [project-settings.md](./project-settings.md) — Reading and changing project configuration with `alter settings` -- [generate-domain-model.md](./generate-domain-model.md) — Creating entities before inserting data +- [database-connections](../database-connections/SKILL.md) — Connecting to *external* databases from Mendix microflows +- [project-settings](../project-settings/SKILL.md) — Reading and changing project configuration with `alter settings` +- [generate-domain-model](../generate-domain-model/SKILL.md) — Creating entities before inserting data diff --git a/.claude/skills/mendix/docker-workflow.md b/.claude/skills/mendix/docker-workflow/SKILL.md similarity index 98% rename from .claude/skills/mendix/docker-workflow.md rename to .claude/skills/mendix/docker-workflow/SKILL.md index 4c3e8f572..6a7904910 100644 --- a/.claude/skills/mendix/docker-workflow.md +++ b/.claude/skills/mendix/docker-workflow/SKILL.md @@ -1,3 +1,8 @@ +--- +name: docker-workflow +description: "Build, run and validate a Mendix app in Docker from inside a devcontainer. Use when asked to run the app in Docker or produce a deployable image. For the faster Docker-free loop, prefer run-local." +--- + # Docker Build & Run Skill This skill guides you through building, running, and testing a Mendix application using Docker inside a devcontainer. @@ -85,7 +90,7 @@ mxcli init /path/to/my-app The `mx create-project` command creates an MPR v2 project with the standard Mendix module structure. You can then use the Docker workflow to build and run it. -**Caveat:** Blank projects have no demo users — login will fail until you configure security via MDL or Studio Pro. See `manage-security.md` for setting up demo users. +**Caveat:** Blank projects have no demo users — login will fail until you configure security via MDL or Studio Pro. See `manage-security` for setting up demo users. ## Step-by-Step Workflow diff --git a/.claude/skills/mendix/download-marketplace-content.md b/.claude/skills/mendix/download-marketplace-content/SKILL.md similarity index 98% rename from .claude/skills/mendix/download-marketplace-content.md rename to .claude/skills/mendix/download-marketplace-content/SKILL.md index 5fb771cbe..5c7432314 100644 --- a/.claude/skills/mendix/download-marketplace-content.md +++ b/.claude/skills/mendix/download-marketplace-content/SKILL.md @@ -1,3 +1,8 @@ +--- +name: download-marketplace-content +description: "The full lifecycle of Mendix Marketplace modules and widgets from the CLI — search, download, install, detect local edits, and update. Use when adding a marketplace module or widget, upgrading one, or asking what an upgrade would overwrite." +--- + # Download, Install and Update Marketplace Content This skill covers the full lifecycle of Mendix Marketplace content (modules and widgets) @@ -359,7 +364,7 @@ and DataWidgets 3.5.0 → 3.11.3 (49 files) both reach **0 errors**. ## Worked example: the agent-editor stack on a vanilla app Run end-to-end on 2026-08-12 against a fresh `mxcli new … --version 11.12.1` app. The -modules in `.claude/skills/mendix/agents.md` must all be present before any `create agent` +modules in `.claude/skills/mendix/agents` must all be present before any `create agent` statement will build, and two of the dependencies are neither listed there nor modules. | Step | Content | Id | `--version` | Units | Errors after check | diff --git a/.claude/skills/mendix/fragments.md b/.claude/skills/mendix/fragments/SKILL.md similarity index 96% rename from .claude/skills/mendix/fragments.md rename to .claude/skills/mendix/fragments/SKILL.md index da48a6afa..a0b8a3352 100644 --- a/.claude/skills/mendix/fragments.md +++ b/.claude/skills/mendix/fragments/SKILL.md @@ -1,3 +1,8 @@ +--- +name: fragments +description: "Define reusable widget groups with DEFINE FRAGMENT and place them with USE FRAGMENT. Use when the same widget pattern repeats across pages and should be written once." +--- + # Mendix Fragments Skill ## When to Use This Skill @@ -291,6 +296,6 @@ create page Module.MyPage (...) { ## Related Documentation - `mxcli syntax fragment` — CLI help topic -- `create-page.md` — Page/widget syntax reference -- `overview-pages.md` — CRUD page patterns +- `create-page` — Page/widget syntax reference +- `overview-pages` — CRUD page patterns - Proposal: `docs/11-proposals/proposal_page_composition.md` diff --git a/.claude/skills/mendix/generate-domain-model/SKILL.md b/.claude/skills/mendix/generate-domain-model/SKILL.md new file mode 100644 index 000000000..4644a92b4 --- /dev/null +++ b/.claude/skills/mendix/generate-domain-model/SKILL.md @@ -0,0 +1,234 @@ +--- +name: generate-domain-model +description: "Generate a complete Mendix domain model in MDL — entities, attributes, associations, enumerations — and validate it. Use when asked for a domain model for a business area (e-commerce, HR, CRM, …) rather than a single entity." +--- + +# Creating Mendix Domain Model MDL Scripts + +Use this skill to generate Mendix domain model scripts in MDL (Mendix Definition Language) format and validate them with the linter. + +## Reference files + +`SKILL.md` covers the process and the decisions — what to model, in what order, +and how to check it. The lookup material is next door: + +- [`reference/syntax.md`](reference/syntax.md) — the complete syntax for entities, + attributes, associations and enumerations, every attribute type, plus reserved + keywords and entity positioning. **Check a spelling here rather than guessing**; + a reserved word used as an attribute name fails in Studio Pro, not in the parser. +- [`reference/patterns.md`](reference/patterns.md) — the recurring domain shapes + (header/detail, categorisation, audit, soft delete, many-to-many with payload) + and a full worked e-commerce model. + +## When to Use This Skill + +- User asks to create a domain model for a specific use case +- User wants to generate entities, associations, and enumerations +- User requests a complete e-commerce, HR, CRM, or other business domain model +- User needs validation of generated MDL scripts + +## Modelling for the client: widgets bind members, not expressions + +A page widget binds an **attribute or association path** — never an expression. There +is no `substring(attr, i, 1)` or computed binding in a widget. So a value that must be +rendered per-part (each character of a code, each cell of a grid, each of N pencil +marks) has to be stored as **separate attributes**, not packed into one string and +indexed client-side. Model the wide form when the UI needs to address the parts +individually (e.g. `N1`…`N9` booleans rather than a packed `Notes` string); reach for +a computed/derived value only where a microflow or view-entity OQL produces it into a +real attribute. (Same reason the bucket-class idiom exists — see +`migrate-design-prototype`.) + +## Documentation Best Practices + +### Entity Documentation + +```sql +/** + * Brief one-line summary + * + * Detailed multi-line description explaining: + * - What the entity represents + * - Key business rules + * - Relationships to other entities + * + * @since 1.0.0 + * @see Module.RelatedEntity + */ +``` + +### Attribute Documentation + +```sql +/** Brief description of what this attribute stores */ +attributename: type, +``` + +### Association Documentation + +```sql +/** + * Relationship description + * + * Explains the business meaning of this association. + * + * @since 1.0.0 + */ +``` + +## Step-by-Step Process + +### 1. Analyze Requirements + +When user requests a domain model: +1. Identify core entities (nouns) +2. Identify enumerations (status, types, categories) +3. Identify relationships (associations) +4. Identify attributes for each entity +5. Check for reserved keyword conflicts + +### 2. Generate MDL Script + +Create script with this structure: +```sql +-- ============================================================================ +-- Domain Model Name +-- ============================================================================ +-- Description of the domain +-- ============================================================================ + +-- MARK: ENUMERATIONS + +create enumeration Module.Enum1 (...); +create enumeration Module.Enum2 (...); + +-- MARK: CORE ENTITIES + +-- MARK: - Entity Group 1 + +create persistent entity Module.Entity1 (...); +create persistent entity Module.Entity2 (...); + +-- MARK: - Entity Group 2 + +create persistent entity Module.Entity3 (...); + +-- MARK: VIEW ENTITIES + +create view entity Module.View1 as ...; + +-- MARK: ASSOCIATIONS + +-- MARK: - Entity Group 1 Associations + +create association Module.Assoc1 ...; +create association Module.Assoc2 ...; +``` + +### 3. Validate with Linter + +Run the linter to check for issues: + +```bash +# Standalone test +node dist/test-linter-standalone.js + +# or create a custom test file +``` + +The linter will detect: +- ✅ Reserved keywords (CE7247) +- ✅ Duplicate names (CE0065) +- ✅ OQL syntax errors (CE0174) + +### 4. Review and Fix Issues + +**Common Issues**: + +1. **Reserved Keyword Error**: + ``` + error: Reserved keyword 'CreatedDate' used as attribute name + 💡 rename to 'CreationDate' + ``` + Fix: Rename to suggested alternative + +2. **Duplicate Name Error**: + ``` + error: Duplicate name 'Status' in module 'Shop' + 💡 rename one of the enumeration, entity to avoid conflict + ``` + Fix: Rename entity to `OrderStatus` or similar + +3. **OQL Syntax Error**: + ``` + error: ORDER by requires limit or offset + 💡 add limit clause to query + ``` + Fix: Add `limit 100` to view entity query + +### 5. Generate Complete Script + +Ensure: +- ✅ All entities have JavaDoc documentation +- ✅ All attributes have inline comments +- ✅ All associations have descriptions +- ✅ Position annotations for all entities +- ✅ No reserved keywords +- ✅ No duplicate names +- ✅ Valid OQL queries + +## Testing the Script + +1. **Save to file**: Save as `examples/my-domain-model.mdl` + +2. **Run standalone linter**: + ```bash + node dist/test-linter-standalone.js + ``` + +3. **Execute in REPL**: + ```sql + mendix> connect to FILESYSTEM 'path/to/project.mpr'; + mendix> execute script 'examples/my-domain-model.mdl'; + ``` + +4. **Check Studio Pro**: Open project and verify entities appear correctly + +## Checklist + +Before finalizing an MDL script: + +- [ ] All entities have JavaDoc documentation +- [ ] All attributes have inline comments +- [ ] All associations have descriptions +- [ ] Position annotations on all entities +- [ ] MARK comments for files 300+ lines (at least 3 sections) +- [ ] All identifiers quoted with double quotes +- [ ] No duplicate names (run linter) +- [ ] Valid OQL queries in view entities (run linter) +- [ ] Consistent naming conventions (PascalCase) +- [ ] Appropriate data types and lengths +- [ ] Required fields marked with NOT NULL +- [ ] Validation error messages added for NOT NULL and UNIQUE constraints +- [ ] IDs marked with NOT NULL UNIQUE +- [ ] Email/unique fields marked with UNIQUE + +## References + +- **Reserved Keywords**: `packages/mendix-repl/docs/reference/reserved-keywords.md` +- **Linter Proposal**: `packages/mendix-repl/docs/proposals/mdl-linter-proposal.md` +- **Example Scripts**: + - `packages/mendix-repl/examples/shop-domain-model.mdl` + - `packages/mendix-repl/examples/pet-store-domain-model.mdl` +- **Linter Test**: `packages/mendix-repl/src/test-linter-standalone.ts` + +## Tips for AI Assistants + +1. **Always quote all identifiers** with double quotes to avoid MDL parser keyword conflicts — but note quoting does **not** exempt platform-reserved member names (`Type`, `CreatedDate`, `ChangedDate`, `Owner`, `ChangedBy`, `ID`, …); rename those +2. **Use descriptive names** (ServiceType, CustomerOrder) +3. **Run linter** on generated scripts before presenting to user +4. **Fix all errors** reported by linter before finalizing +5. **Follow examples** in shop-domain-model.mdl and pet-store-domain-model.mdl +6. **Document thoroughly** - Studio Pro users benefit from good documentation +7. **Position thoughtfully** - Related entities should be visually grouped +8. **Test incrementally** - Generate in sections and validate each part diff --git a/.claude/skills/mendix/generate-domain-model/reference/patterns.md b/.claude/skills/mendix/generate-domain-model/reference/patterns.md new file mode 100644 index 000000000..2cb262a72 --- /dev/null +++ b/.claude/skills/mendix/generate-domain-model/reference/patterns.md @@ -0,0 +1,311 @@ +# Domain model patterns and a worked example + +Supporting reference for [generate-domain-model](../SKILL.md). + +## Example: E-Commerce Domain Model + +```sql +-- ============================================================================ +-- E-Commerce Domain Model +-- ============================================================================ + +create module ECommerce; + +-- Enumerations +-- ============================================================================ + +/** + * Order status enumeration + * + * @since 1.0.0 + */ +create enumeration ECommerce.OrderStatus ( + Draft 'Draft', + Submitted 'Submitted', + Paid 'Paid', + Shipped 'Shipped', + Delivered 'Delivered', + Cancelled 'Cancelled' +); + +-- Entities +-- ============================================================================ + +-- Customer Management +-- ---------------------------------------------------------------------------- + +/** + * Customer entity + * + * Stores customer information for e-commerce platform. + * + * @since 1.0.0 + * @see ECommerce.SalesOrder + */ +@position(50, 50) +create persistent entity ECommerce.Customer ( + /** Unique customer identifier */ + CustomerId: long not null error 'Customer ID is required' unique error 'Customer ID must be unique', + /** Customer full name */ + FullName: string(200) not null error 'Full name is required', + /** Email address */ + Email: string(200) not null error 'Email is required' unique error 'Email must be unique', + /** Registration date */ + RegistrationDate: datetime not null error 'Registration date is required' +); + +/** + * Product entity + * + * Catalog of products available for purchase. + * + * @since 1.0.0 + */ +@position(50, 250) +create persistent entity ECommerce.Product ( + /** Unique product identifier */ + ProductId: long not null error 'Product ID is required' unique error 'Product ID must be unique', + /** Product name */ + ProductName: string(200) not null error 'Product name is required', + /** Product SKU */ + SKU: string(50) not null error 'SKU is required' unique error 'SKU must be unique', + /** Unit price */ + Price: decimal not null error 'Price is required', + /** Stock quantity */ + StockQuantity: integer not null error 'Stock quantity is required' +); + +/** + * Sales order entity + * + * Customer orders for products. + * + * @since 1.0.0 + */ +@position(300, 150) +create persistent entity ECommerce.SalesOrder ( + /** Unique order identifier */ + OrderId: long not null error 'Order ID is required' unique error 'Order ID must be unique', + /** Order number */ + OrderNumber: string(50) not null error 'Order number is required' unique error 'Order number must be unique', + /** Order date */ + OrderDate: datetime not null error 'Order date is required', + /** Total amount */ + TotalAmount: decimal not null error 'Total amount is required', + /** Order status */ + status: enumeration(ECommerce.OrderStatus) not null error 'Status is required' +); + +-- Associations +-- ============================================================================ + +/** + * Customer orders + * + * Links customers to their orders. + * + * @since 1.0.0 + */ +create association ECommerce.Customer_Orders +from ECommerce.Customer to ECommerce.SalesOrder +type ReferenceSet +owner both; +``` +## Common Patterns + +### One-to-Many Relationship +```sql +-- Parent entity +create persistent entity Module.Parent (Id: long not null unique); + +-- Child entity +create persistent entity Module.Child ( + Id: long not null unique, + ChildData: string(200) +); + +-- Association (Parent has many Children) +create association Module.Parent_Children +from Module.Parent to Module.Child +type ReferenceSet +owner both; +``` + +### Many-to-Many Relationship +```sql +-- Entity A +create persistent entity Module.EntityA (Id: long not null unique); + +-- Entity B +create persistent entity Module.EntityB (Id: long not null unique); + +-- Bidirectional association +create association Module.EntityA_EntityB +from Module.EntityA to Module.EntityB +type ReferenceSet +owner both; +``` + +### Hierarchical Relationship (Self-Reference) + +**IMPORTANT: Self-referencing associations must use `owner default`** (one-to-many). Using `owner both` is not supported for self-references. + +```sql +/** + * Category with parent-child hierarchy + */ +create persistent entity Module.Category ( + Id: long not null unique, + CategoryName: string(200) not null +); + +/** + * Parent category link (self-reference) + */ +create association Module.Category_ParentCategory +from Module.Category to Module.Category +type reference +owner default; +``` + +### ALTER ENTITY (Incremental Modifications) + +Use `alter entity` to make targeted changes to existing entities without redefining the entire entity: + +```sql +-- Add a new attribute +alter entity Module.Customer + add attribute PhoneNumber: string(20); + +-- Add multiple attributes at once +alter entity Module.Order + add attribute VATRate: decimal + add attribute VATAmount: decimal; + +-- Rename an attribute (preserves data). Every stored reference follows it: +-- microflow create/change members, page attribute widgets, validation rules, +-- access rules -- and XPath constraints too ([CreatedDate > ...]), including +-- ones that reach the entity through an association. Microflow expressions +-- ($Order/CreatedDate) are NOT rewritten -- mxbuild reports those as CE0117, +-- so build afterwards. +alter entity Module.Order + rename attribute CreatedDate to OrderDate; + +-- Drop an attribute +alter entity Module.Product + drop attribute LegacyCode; + +-- Modify attribute type +alter entity Module.Customer + modify attribute Address: string(500); + +-- Modify attribute constraints. MODIFY applies the constraints you specify and +-- preserves the ones you don't: +-- NULLABLE -> make a required attribute optional (removes NOT NULL) +-- NOT NULL -> make an optional attribute required +-- UNIQUE -> add a uniqueness constraint +-- DEFAULT x -> set/replace the default +alter entity Module.Customer + modify attribute Email: string(200) nullable; -- Email is now optional +alter entity Module.Customer + modify attribute Code: string(20) not null unique; + +-- Set entity documentation +alter entity Module.Customer + set documentation 'Core customer entity for CRM module'; + +-- Add an index +alter entity Module.Customer + add index idx_email (Email asc); + +-- Reposition entity on domain model canvas +alter entity Module.Customer + set position (100, 200); +``` + +**Supported operations:** ADD ATTRIBUTE, RENAME ATTRIBUTE, MODIFY ATTRIBUTE (type + `NULLABLE`/`NOT NULL`/`UNIQUE`/`DEFAULT` constraints), DROP ATTRIBUTE, SET DOCUMENTATION, SET COMMENT, ADD INDEX, DROP INDEX, SET POSITION. + +> **`MODIFY ATTRIBUTE` always takes a type** — restate it even when you only want +> to change a constraint. Its type slot accepts a bare qualified name, so a +> clause written in the type position is read as a type name: +> `MODIFY ATTRIBUTE X SET DEFAULT 0` treats `SET` as the type. mxcli now refuses +> that; before it did, the statement rewrote the attribute to an enumeration and +> produced a project Mendix could not open (#910). +> +> To clear a default value use **`DROP DEFAULT ON ATTRIBUTE `**. + +### Entity Positioning Guidelines + +When creating or repositioning entities, follow these layout rules for readable domain models: + +- **Horizontal spacing:** 350px between columns (x = 50, 400, 750, 1100, ...) +- **Vertical spacing:** calculate per-column based on the entity above: `y = previous_y + 50 + (previous_entity_attribute_count * 20)` +- Entity header is ~40px, each attribute adds ~20px of height, plus ~50px padding +- **Position column-by-column**, not in rigid rows — avoids wasting space when entities have different attribute counts +- **Place related entities** in the same column or adjacent columns so associations are short + +Example layout for entities with varying attribute counts: + +``` +column 1 (x=50): column 2 (x=400): + entity A (4 attrs) entity C (14 attrs) + y=50 y=50 + + entity B (10 attrs) entity D (3 attrs) + y=180 (50+50+4*20) y=380 (50+50+14*20) +``` + +```sql +-- Position entities after creation +alter entity Module.EntityA set position (50, 50); +alter entity Module.EntityB set position (50, 180); +alter entity Module.EntityC set position (400, 50); +alter entity Module.EntityD set position (400, 380); +``` + +### Entity Migration with CREATE OR MODIFY + +Use `create or modify` to update existing entities without losing data. The REPL computes differences and applies incremental changes. + +```sql +/** + * Customer entity migration - rename CustomerName to FullName + */ +create or modify persistent entity Module.Customer ( + /** Unique identifier (unchanged) */ + CustomerId: long not null unique, + + /** Renamed from CustomerName - data preserved */ + @RenamedFrom('CustomerName') + FullName: string(200) not null, + + /** New field */ + Email: string(255) unique, + + /** Type widened from String(100) to String(200) */ + Address: string(200) +); +``` + +**Key features:** +- `@RenamedFrom('oldName')` - renames attribute, preserves data +- Auto-removes attributes not in new definition +- Allows compatible type changes (e.g., String length increase) +- Preserves entity UUID (no data loss) + +### Status-Driven Entity +```sql +-- Status enumeration +create enumeration Module.TaskStatus ( + Todo 'To Do', + InProgress 'In Progress', + Done 'Done' +); + +-- Entity with status +create persistent entity Module.Task ( + Id: long not null unique, + TaskName: string(200) not null, + status: enumeration(Module.TaskStatus) not null +); +``` diff --git a/.claude/skills/mendix/generate-domain-model.md b/.claude/skills/mendix/generate-domain-model/reference/syntax.md similarity index 57% rename from .claude/skills/mendix/generate-domain-model.md rename to .claude/skills/mendix/generate-domain-model/reference/syntax.md index 85c8c870e..a5f09dd8c 100644 --- a/.claude/skills/mendix/generate-domain-model.md +++ b/.claude/skills/mendix/generate-domain-model/reference/syntax.md @@ -1,13 +1,6 @@ -# Creating Mendix Domain Model MDL Scripts +# Domain model syntax reference -Use this skill to generate Mendix domain model scripts in MDL (Mendix Definition Language) format and validate them with the linter. - -## When to Use This Skill - -- User asks to create a domain model for a specific use case -- User wants to generate entities, associations, and enumerations -- User requests a complete e-commerce, HR, CRM, or other business domain model -- User needs validation of generated MDL scripts +Supporting reference for [generate-domain-model](../SKILL.md). ## MDL Syntax Reference @@ -245,7 +238,7 @@ must cover them. Grant an inherited member exactly like one of the entity's own — and `read *` / `write *` cover them too. Skipping them is Mendix CE0066 "Entity access is out of date". The one exception is entities extending `System.User`, whose inherited platform members Mendix manages and which must not be granted. See -`manage-security.md`. +`manage-security`. #### System Attributes (Auditing) @@ -453,7 +446,7 @@ type reference; > datagrid/gallery) over the reverse then fails MxBuild **CE8812** "A grid > association path must result in a list." (a DataView over the reverse is fine — > it's a single object). With `default` ownership, the reverse is the expected -> to-many collection and a list widget works. See master-detail-pages.md for the +> to-many collection and a list widget works. See master-detail-pages for the > widget patterns. **Delete Behaviors**: @@ -574,7 +567,6 @@ status: enumeration(Module.Status) not null error 'Status is required', -- Enum with default value (use fully qualified Module.Enum.Value) Priority: enumeration(Module.Priority) default Module.Priority.Normal ``` - ## Reserved Keywords **Best practice: Always quote all identifiers** (entity names, attribute names) with double quotes. This escapes every **MDL parser** keyword conflict — quotes are stripped automatically by the parser. So `"create"`, `"status"`, `"end"` become valid attribute names. @@ -604,7 +596,6 @@ create persistent entity Module.Item ( IsPublished: boolean default true ); ``` - ## Entity Positioning Use `@position(x, y)` to control layout in Studio Pro: @@ -623,518 +614,3 @@ create persistent entity Module.Address (...); @position(50, 250) -- Below: Dependent entity create persistent entity Module.Order (...); ``` - -## Modelling for the client: widgets bind members, not expressions - -A page widget binds an **attribute or association path** — never an expression. There -is no `substring(attr, i, 1)` or computed binding in a widget. So a value that must be -rendered per-part (each character of a code, each cell of a grid, each of N pencil -marks) has to be stored as **separate attributes**, not packed into one string and -indexed client-side. Model the wide form when the UI needs to address the parts -individually (e.g. `N1`…`N9` booleans rather than a packed `Notes` string); reach for -a computed/derived value only where a microflow or view-entity OQL produces it into a -real attribute. (Same reason the bucket-class idiom exists — see -`migrate-design-prototype.md`.) - -## Documentation Best Practices - -### Entity Documentation - -```sql -/** - * Brief one-line summary - * - * Detailed multi-line description explaining: - * - What the entity represents - * - Key business rules - * - Relationships to other entities - * - * @since 1.0.0 - * @see Module.RelatedEntity - */ -``` - -### Attribute Documentation - -```sql -/** Brief description of what this attribute stores */ -attributename: type, -``` - -### Association Documentation - -```sql -/** - * Relationship description - * - * Explains the business meaning of this association. - * - * @since 1.0.0 - */ -``` - -## Step-by-Step Process - -### 1. Analyze Requirements - -When user requests a domain model: -1. Identify core entities (nouns) -2. Identify enumerations (status, types, categories) -3. Identify relationships (associations) -4. Identify attributes for each entity -5. Check for reserved keyword conflicts - -### 2. Generate MDL Script - -Create script with this structure: -```sql --- ============================================================================ --- Domain Model Name --- ============================================================================ --- Description of the domain --- ============================================================================ - --- MARK: ENUMERATIONS - -create enumeration Module.Enum1 (...); -create enumeration Module.Enum2 (...); - --- MARK: CORE ENTITIES - --- MARK: - Entity Group 1 - -create persistent entity Module.Entity1 (...); -create persistent entity Module.Entity2 (...); - --- MARK: - Entity Group 2 - -create persistent entity Module.Entity3 (...); - --- MARK: VIEW ENTITIES - -create view entity Module.View1 as ...; - --- MARK: ASSOCIATIONS - --- MARK: - Entity Group 1 Associations - -create association Module.Assoc1 ...; -create association Module.Assoc2 ...; -``` - -### 3. Validate with Linter - -Run the linter to check for issues: - -```bash -# Standalone test -node dist/test-linter-standalone.js - -# or create a custom test file -``` - -The linter will detect: -- ✅ Reserved keywords (CE7247) -- ✅ Duplicate names (CE0065) -- ✅ OQL syntax errors (CE0174) - -### 4. Review and Fix Issues - -**Common Issues**: - -1. **Reserved Keyword Error**: - ``` - error: Reserved keyword 'CreatedDate' used as attribute name - 💡 rename to 'CreationDate' - ``` - Fix: Rename to suggested alternative - -2. **Duplicate Name Error**: - ``` - error: Duplicate name 'Status' in module 'Shop' - 💡 rename one of the enumeration, entity to avoid conflict - ``` - Fix: Rename entity to `OrderStatus` or similar - -3. **OQL Syntax Error**: - ``` - error: ORDER by requires limit or offset - 💡 add limit clause to query - ``` - Fix: Add `limit 100` to view entity query - -### 5. Generate Complete Script - -Ensure: -- ✅ All entities have JavaDoc documentation -- ✅ All attributes have inline comments -- ✅ All associations have descriptions -- ✅ Position annotations for all entities -- ✅ No reserved keywords -- ✅ No duplicate names -- ✅ Valid OQL queries - -## Example: E-Commerce Domain Model - -```sql --- ============================================================================ --- E-Commerce Domain Model --- ============================================================================ - -create module ECommerce; - --- Enumerations --- ============================================================================ - -/** - * Order status enumeration - * - * @since 1.0.0 - */ -create enumeration ECommerce.OrderStatus ( - Draft 'Draft', - Submitted 'Submitted', - Paid 'Paid', - Shipped 'Shipped', - Delivered 'Delivered', - Cancelled 'Cancelled' -); - --- Entities --- ============================================================================ - --- Customer Management --- ---------------------------------------------------------------------------- - -/** - * Customer entity - * - * Stores customer information for e-commerce platform. - * - * @since 1.0.0 - * @see ECommerce.SalesOrder - */ -@position(50, 50) -create persistent entity ECommerce.Customer ( - /** Unique customer identifier */ - CustomerId: long not null error 'Customer ID is required' unique error 'Customer ID must be unique', - /** Customer full name */ - FullName: string(200) not null error 'Full name is required', - /** Email address */ - Email: string(200) not null error 'Email is required' unique error 'Email must be unique', - /** Registration date */ - RegistrationDate: datetime not null error 'Registration date is required' -); - -/** - * Product entity - * - * Catalog of products available for purchase. - * - * @since 1.0.0 - */ -@position(50, 250) -create persistent entity ECommerce.Product ( - /** Unique product identifier */ - ProductId: long not null error 'Product ID is required' unique error 'Product ID must be unique', - /** Product name */ - ProductName: string(200) not null error 'Product name is required', - /** Product SKU */ - SKU: string(50) not null error 'SKU is required' unique error 'SKU must be unique', - /** Unit price */ - Price: decimal not null error 'Price is required', - /** Stock quantity */ - StockQuantity: integer not null error 'Stock quantity is required' -); - -/** - * Sales order entity - * - * Customer orders for products. - * - * @since 1.0.0 - */ -@position(300, 150) -create persistent entity ECommerce.SalesOrder ( - /** Unique order identifier */ - OrderId: long not null error 'Order ID is required' unique error 'Order ID must be unique', - /** Order number */ - OrderNumber: string(50) not null error 'Order number is required' unique error 'Order number must be unique', - /** Order date */ - OrderDate: datetime not null error 'Order date is required', - /** Total amount */ - TotalAmount: decimal not null error 'Total amount is required', - /** Order status */ - status: enumeration(ECommerce.OrderStatus) not null error 'Status is required' -); - --- Associations --- ============================================================================ - -/** - * Customer orders - * - * Links customers to their orders. - * - * @since 1.0.0 - */ -create association ECommerce.Customer_Orders -from ECommerce.Customer to ECommerce.SalesOrder -type ReferenceSet -owner both; -``` - -## Testing the Script - -1. **Save to file**: Save as `examples/my-domain-model.mdl` - -2. **Run standalone linter**: - ```bash - node dist/test-linter-standalone.js - ``` - -3. **Execute in REPL**: - ```sql - mendix> connect to FILESYSTEM 'path/to/project.mpr'; - mendix> execute script 'examples/my-domain-model.mdl'; - ``` - -4. **Check Studio Pro**: Open project and verify entities appear correctly - -## Common Patterns - -### One-to-Many Relationship -```sql --- Parent entity -create persistent entity Module.Parent (Id: long not null unique); - --- Child entity -create persistent entity Module.Child ( - Id: long not null unique, - ChildData: string(200) -); - --- Association (Parent has many Children) -create association Module.Parent_Children -from Module.Parent to Module.Child -type ReferenceSet -owner both; -``` - -### Many-to-Many Relationship -```sql --- Entity A -create persistent entity Module.EntityA (Id: long not null unique); - --- Entity B -create persistent entity Module.EntityB (Id: long not null unique); - --- Bidirectional association -create association Module.EntityA_EntityB -from Module.EntityA to Module.EntityB -type ReferenceSet -owner both; -``` - -### Hierarchical Relationship (Self-Reference) - -**IMPORTANT: Self-referencing associations must use `owner default`** (one-to-many). Using `owner both` is not supported for self-references. - -```sql -/** - * Category with parent-child hierarchy - */ -create persistent entity Module.Category ( - Id: long not null unique, - CategoryName: string(200) not null -); - -/** - * Parent category link (self-reference) - */ -create association Module.Category_ParentCategory -from Module.Category to Module.Category -type reference -owner default; -``` - -### ALTER ENTITY (Incremental Modifications) - -Use `alter entity` to make targeted changes to existing entities without redefining the entire entity: - -```sql --- Add a new attribute -alter entity Module.Customer - add attribute PhoneNumber: string(20); - --- Add multiple attributes at once -alter entity Module.Order - add attribute VATRate: decimal - add attribute VATAmount: decimal; - --- Rename an attribute (preserves data). Every stored reference follows it: --- microflow create/change members, page attribute widgets, validation rules, --- access rules -- and XPath constraints too ([CreatedDate > ...]), including --- ones that reach the entity through an association. Microflow expressions --- ($Order/CreatedDate) are NOT rewritten -- mxbuild reports those as CE0117, --- so build afterwards. -alter entity Module.Order - rename attribute CreatedDate to OrderDate; - --- Drop an attribute -alter entity Module.Product - drop attribute LegacyCode; - --- Modify attribute type -alter entity Module.Customer - modify attribute Address: string(500); - --- Modify attribute constraints. MODIFY applies the constraints you specify and --- preserves the ones you don't: --- NULLABLE -> make a required attribute optional (removes NOT NULL) --- NOT NULL -> make an optional attribute required --- UNIQUE -> add a uniqueness constraint --- DEFAULT x -> set/replace the default -alter entity Module.Customer - modify attribute Email: string(200) nullable; -- Email is now optional -alter entity Module.Customer - modify attribute Code: string(20) not null unique; - --- Set entity documentation -alter entity Module.Customer - set documentation 'Core customer entity for CRM module'; - --- Add an index -alter entity Module.Customer - add index idx_email (Email asc); - --- Reposition entity on domain model canvas -alter entity Module.Customer - set position (100, 200); -``` - -**Supported operations:** ADD ATTRIBUTE, RENAME ATTRIBUTE, MODIFY ATTRIBUTE (type + `NULLABLE`/`NOT NULL`/`UNIQUE`/`DEFAULT` constraints), DROP ATTRIBUTE, SET DOCUMENTATION, SET COMMENT, ADD INDEX, DROP INDEX, SET POSITION. - -> **`MODIFY ATTRIBUTE` always takes a type** — restate it even when you only want -> to change a constraint. Its type slot accepts a bare qualified name, so a -> clause written in the type position is read as a type name: -> `MODIFY ATTRIBUTE X SET DEFAULT 0` treats `SET` as the type. mxcli now refuses -> that; before it did, the statement rewrote the attribute to an enumeration and -> produced a project Mendix could not open (#910). -> -> To clear a default value use **`DROP DEFAULT ON ATTRIBUTE `**. - -### Entity Positioning Guidelines - -When creating or repositioning entities, follow these layout rules for readable domain models: - -- **Horizontal spacing:** 350px between columns (x = 50, 400, 750, 1100, ...) -- **Vertical spacing:** calculate per-column based on the entity above: `y = previous_y + 50 + (previous_entity_attribute_count * 20)` -- Entity header is ~40px, each attribute adds ~20px of height, plus ~50px padding -- **Position column-by-column**, not in rigid rows — avoids wasting space when entities have different attribute counts -- **Place related entities** in the same column or adjacent columns so associations are short - -Example layout for entities with varying attribute counts: - -``` -column 1 (x=50): column 2 (x=400): - entity A (4 attrs) entity C (14 attrs) - y=50 y=50 - - entity B (10 attrs) entity D (3 attrs) - y=180 (50+50+4*20) y=380 (50+50+14*20) -``` - -```sql --- Position entities after creation -alter entity Module.EntityA set position (50, 50); -alter entity Module.EntityB set position (50, 180); -alter entity Module.EntityC set position (400, 50); -alter entity Module.EntityD set position (400, 380); -``` - -### Entity Migration with CREATE OR MODIFY - -Use `create or modify` to update existing entities without losing data. The REPL computes differences and applies incremental changes. - -```sql -/** - * Customer entity migration - rename CustomerName to FullName - */ -create or modify persistent entity Module.Customer ( - /** Unique identifier (unchanged) */ - CustomerId: long not null unique, - - /** Renamed from CustomerName - data preserved */ - @RenamedFrom('CustomerName') - FullName: string(200) not null, - - /** New field */ - Email: string(255) unique, - - /** Type widened from String(100) to String(200) */ - Address: string(200) -); -``` - -**Key features:** -- `@RenamedFrom('oldName')` - renames attribute, preserves data -- Auto-removes attributes not in new definition -- Allows compatible type changes (e.g., String length increase) -- Preserves entity UUID (no data loss) - -### Status-Driven Entity -```sql --- Status enumeration -create enumeration Module.TaskStatus ( - Todo 'To Do', - InProgress 'In Progress', - Done 'Done' -); - --- Entity with status -create persistent entity Module.Task ( - Id: long not null unique, - TaskName: string(200) not null, - status: enumeration(Module.TaskStatus) not null -); -``` - -## Checklist - -Before finalizing an MDL script: - -- [ ] All entities have JavaDoc documentation -- [ ] All attributes have inline comments -- [ ] All associations have descriptions -- [ ] Position annotations on all entities -- [ ] MARK comments for files 300+ lines (at least 3 sections) -- [ ] All identifiers quoted with double quotes -- [ ] No duplicate names (run linter) -- [ ] Valid OQL queries in view entities (run linter) -- [ ] Consistent naming conventions (PascalCase) -- [ ] Appropriate data types and lengths -- [ ] Required fields marked with NOT NULL -- [ ] Validation error messages added for NOT NULL and UNIQUE constraints -- [ ] IDs marked with NOT NULL UNIQUE -- [ ] Email/unique fields marked with UNIQUE - -## References - -- **Reserved Keywords**: `packages/mendix-repl/docs/reference/reserved-keywords.md` -- **Linter Proposal**: `packages/mendix-repl/docs/proposals/mdl-linter-proposal.md` -- **Example Scripts**: - - `packages/mendix-repl/examples/shop-domain-model.mdl` - - `packages/mendix-repl/examples/pet-store-domain-model.mdl` -- **Linter Test**: `packages/mendix-repl/src/test-linter-standalone.ts` - -## Tips for AI Assistants - -1. **Always quote all identifiers** with double quotes to avoid MDL parser keyword conflicts — but note quoting does **not** exempt platform-reserved member names (`Type`, `CreatedDate`, `ChangedDate`, `Owner`, `ChangedBy`, `ID`, …); rename those -2. **Use descriptive names** (ServiceType, CustomerOrder) -3. **Run linter** on generated scripts before presenting to user -4. **Fix all errors** reported by linter before finalizing -5. **Follow examples** in shop-domain-model.mdl and pet-store-domain-model.mdl -6. **Document thoroughly** - Studio Pro users benefit from good documentation -7. **Position thoughtfully** - Related entities should be visually grouped -8. **Test incrementally** - Generate in sections and validate each part diff --git a/.claude/skills/mendix/graph-analysis.md b/.claude/skills/mendix/graph-analysis/SKILL.md similarity index 94% rename from .claude/skills/mendix/graph-analysis.md rename to .claude/skills/mendix/graph-analysis/SKILL.md index 40c0fa7f1..7ce2494ea 100644 --- a/.claude/skills/mendix/graph-analysis.md +++ b/.claude/skills/mendix/graph-analysis/SKILL.md @@ -1,3 +1,8 @@ +--- +name: graph-analysis +description: "Query the project as a dependency graph to assess the blast radius of a change, find dead or risky elements, and evaluate module health. Use when planning a refactor, judging what a change will break, or looking for unused documents." +--- + # Graph Analysis: Impact Assessment & Architecture Evaluation Use this skill when assessing the impact of a change, identifying risky elements, evaluating module health, or planning a structural refactor. The graph tables model the entire project as a dependency graph and expose topology metrics on top of it. @@ -298,7 +303,7 @@ Elements at the same layer can be parallelised safely. A microflow calling somet ## Related Skills -- [assess-quality.md](./assess-quality.md) — Full quality assessment including lint, report scores, and manual review guidelines -- [organize-project.md](./organize-project.md) — MOVE command and folder structure -- [write-lint-rules.md](./write-lint-rules.md) — Encoding a layering policy as an enforced Starlark rule -- [odata-data-sharing.md](./odata-data-sharing.md) — Publishing entities for cross-app access +- [assess-quality](../assess-quality/SKILL.md) — Full quality assessment including lint, report scores, and manual review guidelines +- [organize-project](../organize-project/SKILL.md) — MOVE command and folder structure +- [write-lint-rules](../write-lint-rules/SKILL.md) — Encoding a layering policy as an enforced Starlark rule +- [odata-data-sharing](../odata-data-sharing/SKILL.md) — Publishing entities for cross-app access diff --git a/.claude/skills/mendix/java-actions.md b/.claude/skills/mendix/java-actions.md deleted file mode 100644 index ef78d855e..000000000 --- a/.claude/skills/mendix/java-actions.md +++ /dev/null @@ -1,1204 +0,0 @@ -# Mendix Java Actions Skill - -This skill provides comprehensive guidance for creating and using custom Java actions in Mendix projects. - -## When to Use This Skill - -Use this skill when: -- You need to extend Mendix with custom Java logic -- Building integrations with external Java libraries -- Implementing complex algorithms not feasible in microflows -- Calling Java actions from MDL microflows -- Debugging Java action calls - -## Overview - -Java actions allow you to extend Mendix with custom Java code. The workflow is: -1. **Define** the Java action in Studio Pro (parameters, return type) -2. **Implement** the Java code in Eclipse/IDE -3. **Call** the Java action from microflows using MDL - -## Part 1: Creating Java Actions in Studio Pro - -### Step 1: Create the Java Action - -In Studio Pro: -1. Right-click module in Project Explorer → **Add other** → **Java action** -2. Name using convention: `JA_ActionName` (e.g., `JA_CalculateTax`, `JA_SendEmail`) -3. Define parameters and return type - -### Step 2: Define Parameters - -| Parameter Type | Mendix Type | Java Type | -|----------------|-------------|-----------| -| String | String | `java.lang.String` | -| Integer | Integer/Long | `java.lang.Long` | -| Decimal | Decimal | `java.math.BigDecimal` | -| Boolean | Boolean | `java.lang.Boolean` | -| DateTime | Date and time | `java.util.Date` | -| Object | Entity | `IMendixObject` | -| List | List of Entity | `java.util.List` | -| StringTemplate(Sql) | SQL template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | -| StringTemplate(Text) | Text template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | - -**Note:** `stringtemplate(sql)` and `stringtemplate(text)` are specialized types for parameterized SQL/OQL queries and text templates respectively. - -### Step 3: Export for Eclipse - -1. Menu → **App** → **Deploy for Eclipse** -2. Open project in Eclipse -3. Find Java action in `javasource//actions/` - -## Part 2: Writing Java Action Code - -### Basic Structure - -```java -package mymodule.actions; - -import com.mendix.systemwideinterfaces.core.IContext; -import com.mendix.webui.CustomJavaAction; -import com.mendix.core.Core; -import com.mendix.systemwideinterfaces.core.IMendixObject; - -public class JA_CalculateTax extends CustomJavaAction -{ - private java.math.BigDecimal amount; - private java.math.BigDecimal taxRate; - - public JA_CalculateTax(IContext context, java.math.BigDecimal amount, java.math.BigDecimal taxRate) - { - super(context); - this.amount = amount; - this.taxRate = taxRate; - } - - @java.lang.Override - public java.math.BigDecimal executeAction() throws Exception - { - // begin user CODE - if (this.amount == null || this.taxRate == null) { - return java.math.BigDecimal.ZERO; - } - - return this.amount.multiply(this.taxRate); - // end user CODE - } -} -``` - -**CRITICAL**: Only code between `// begin user CODE` and `// end user CODE` is preserved. Everything else is regenerated by Studio Pro. - -### Working with Mendix Objects - -```java -@java.lang.Override -public IMendixObject executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - // create a new object - IMendixObject order = Core.instantiate(context, "Sales.Order"); - order.setValue(context, "OrderNumber", "ORD-" + System.currentTimeMillis()); - order.setValue(context, "OrderDate", new java.util.Date()); - order.setValue(context, "status", "Draft"); - order.setValue(context, "TotalAmount", java.math.BigDecimal.ZERO); - - // commit to database - Core.commit(context, order); - - return order; - // end user CODE -} -``` - -### Core API Reference - -| Method | Description | -|--------|-------------| -| `Core.instantiate(context, "Module.Entity")` | Create new object | -| `Core.commit(context, object)` | Save to database | -| `Core.commitWithoutEvents(context, object)` | Save without triggering events | -| `Core.delete(context, object)` | Delete object | -| `Core.rollback(context, object)` | Discard uncommitted changes | -| `Core.retrieveId(context, id)` | Retrieve by GUID | -| `Core.createXPathQuery(xpath).execute(context)` | Query with XPath | -| `Core.microflowCall(name).execute(context)` | Call microflow | - -### Reading and Writing Attributes - -```java -// Reading values -string name = (string) order.getValue(context, "Name"); -java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); -boolean isActive = (boolean) order.getValue(context, "IsActive"); -java.util.Date orderDate = (java.util.Date) order.getValue(context, "OrderDate"); - -// Writing values -order.setValue(context, "Name", "New Order"); -order.setValue(context, "Amount", new java.math.BigDecimal("100.00")); -order.setValue(context, "IsActive", true); -order.setValue(context, "ProcessedDate", new java.util.Date()); -``` - -### Working with Associations - -```java -// set association (reference) -IMendixObject customer = Core.createXPathQuery("//Sales.Customer[CustomerCode = 'CUST001']") - .execute(context).get(0); -order.setValue(context, "Sales.Order_Customer", customer.getId()); - -// get associated object -IMendixIdentifier customerId = order.getValue(context, "Sales.Order_Customer"); -if (customerId != null) { - IMendixObject relatedCustomer = Core.retrieveId(context, customerId); - string customerName = (string) relatedCustomer.getValue(context, "Name"); -} -``` - -### Working with Lists - -```java -// retrieve list -list orders = Core.createXPathQuery("//Sales.Order[status = 'Pending']") - .execute(context); - -// Process list -java.math.BigDecimal total = java.math.BigDecimal.ZERO; -for (IMendixObject order : orders) { - java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); - if (amount != null) { - total = total.add(amount); - } -} - -// create list to return -list results = new java.util.ArrayList<>(); -results.add(order1); -results.add(order2); -return results; -``` - -### Error Handling - -```java -@java.lang.Override -public boolean executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - try { - // business logic here - IMendixObject order = Core.instantiate(context, "Sales.Order"); - order.setValue(context, "OrderNumber", generateOrderNumber()); - Core.commit(context, order); - return true; - - } catch (Exception e) { - // log error - Core.getLogger("MyModule").error("Failed to create order: " + e.getMessage(), e); - - // Optionally throw to show error to user - throw new com.mendix.systemwideinterfaces.MendixRuntimeException( - "Could not create order: " + e.getMessage()); - } - // end user CODE -} -``` - -### Logging - -```java -import com.mendix.logging.ILogNode; - -// get logger -ILogNode logger = Core.getLogger("MyModule.MyAction"); - -// log at different levels -logger.trace("Detailed trace message"); -logger.debug("debug information"); -logger.info("Processing started for order: " + orderNumber); -logger.warn("Unusual condition detected"); -logger.error("error processing order", exception); -logger.critical("critical system failure"); -``` - -## Part 2.5: Creating Java Actions in MDL - -MDL supports defining Java actions with inline Java code using `create java action`. - -### Basic Syntax - -```mdl -create java action Module.ActionName(param1: type, param2: type) returns ReturnType -as $$ -// java code here -return result; -$$; -``` - -**`AS $$ ... $$` is mandatory.** The body cannot be omitted even for placeholder or stub actions. Omitting it causes a parse error: `no viable alternative at input '...'`. Use a minimal body if the real implementation is not yet written: - -```mdl -create java action Module.Stub() returns boolean -as $$ -return false; -$$; -``` - -### Type Parameters (Generics) - -Type parameters let Java actions accept any entity type dynamically. Use `entity ` in a parameter type to declare the type parameter inline. That parameter becomes the **entity type selector** (receives the entity type name, e.g., `'Module.Entity'`). Bare `pEntity` parameters become **parameterized entity** params (receive entity instances, e.g., `$Variable`). - -```mdl --- ENTITY declares the type parameter; bare pEntity references it -create java action Module.Validate( - EntityType: entity not null, - InputObject: pEntity not null -) returns boolean -as $$ -return InputObject != null; -$$; -``` - -Multiple type parameters use separate `entity <...>` declarations: - -```mdl -create java action Module.Transform( - SourceType: entity not null, - TargetType: entity not null, - source: pSource not null, - Target: pTarget not null -) returns boolean -as $$ -return true; -$$; -``` - -Type parameter names can be mixed with regular parameter types: - -```mdl -create java action Module.CopyAttributes( - EntityType: entity not null, - source: pEntity not null, - Target: pEntity not null, - AttributeNames: string not null -) returns boolean -as $$ -return true; -$$; -``` - -When **calling** these actions from microflows, the entity type selector receives the entity type name as a string literal, while instance params receive variables: - -```mdl -$Result = call java action Module.CopyAttributes( - EntityType = 'Module.ProcessResult', - source = $source, - Target = $Target, - AttributeNames = 'Name,Status' -); -``` - -### EXPOSED AS (Toolbox Visibility) - -The `exposed as 'caption' in 'Category'` clause makes the Java action appear as a toolbox item in Studio Pro's microflow editor: - -```mdl -create java action Module.FormatCurrency( - Amount: decimal not null, - CurrencyCode: string not null -) returns string -exposed as 'Format Currency' in 'Formatting' -as $$ -return String.format("%.2f %s", Amount, CurrencyCode); -$$; -``` - -Type parameters and EXPOSED AS can be combined: - -```mdl -create java action Module.DeepClone( - EntityType: entity not null, - Original: pEntity not null -) returns boolean -exposed as 'Deep Clone Object' in 'Object Utils' -as $$ -return true; -$$; -``` - -### Supported Parameter Types - -| MDL Type | Description | -|----------|-------------| -| `string` | Text value | -| `integer` | Whole number | -| `long` | Large whole number | -| `decimal` | Decimal number | -| `boolean` | True/false | -| `datetime` | Date and time | -| `Module.Entity` | Entity reference | -| `list of Module.Entity` | List of entities | -| `stringtemplate(sql)` | SQL/OQL query template with parameters | -| `stringtemplate(text)` | Text template with parameters | -| `entity ` | Type parameter declaration (entity type selector) | -| `enum Module.EnumName` | Enumeration type | -| `enumeration(Module.EnumName)` | Enumeration type (alternative syntax) | -| `pEntity` (type param ref) | Type parameter reference (entity instance) | - -### Examples - -#### Simple Action (No Parameters) - -```mdl -/** Returns the current timestamp. */ -create java action MyModule.GetCurrentTimestamp() returns datetime -as $$ -return new java.util.Date(); -$$; -``` - -#### Action with Primitive Parameters - -```mdl -/** Calculates tax amount. */ -create java action Finance.CalculateTax(Amount: decimal, TaxRate: decimal) returns decimal -as $$ -if (Amount == null || TaxRate == null) { - return java.math.BigDecimal.ZERO; -} -return Amount.multiply(TaxRate).divide(java.math.BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); -$$; -``` - -#### Action with StringTemplate (SQL/OQL) - -```mdl -/** Executes an OQL statement with parameterized query. */ -create java action Database.ExecuteOQLStatement(OqlStatement: stringtemplate(sql) not null) returns boolean -as $$ -// execute the parameterized OQL statement -// The stringtemplate handles parameter substitution safely -return true; -$$; -``` - -#### Action with NOT NULL Parameter - -```mdl -/** Validates an email address - email is required. */ -create java action Validation.ValidateEmail(EmailAddress: string not null) returns boolean -as $$ -string emailRegex = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; -return EmailAddress.matches(emailRegex); -$$; -``` - -#### Action with Type Parameter (Generic) - -```mdl -/** Validates any entity - checks that required fields are filled. */ -create java action Validation.ValidateEntity( - EntityType: entity not null, - InputObject: pEntity not null -) returns boolean -as $$ -return InputObject.getMembers().values().stream() - .allMatch(m -> !m.isRequired() || m.getValue(getContext()) != null); -$$; -``` - -#### Action with Type Parameter + EXPOSED AS - -```mdl -/** Deep clones any entity (toolbox-visible). */ -create java action Utils.DeepClone( - EntityType: entity not null, - Original: pEntity not null -) returns boolean -exposed as 'Deep Clone Object' in 'Object Utils' -as $$ -return true; -$$; -``` - -## Part 3: Calling Java Actions from MDL - -### Basic Syntax - -```mdl --- Call Java action (no return value) -call java action Module.JA_ActionName( - ParamName1 = value1, - ParamName2 = value2 -); - --- Call Java action with return value -$Result = call java action Module.JA_ActionName( - ParamName1 = value1, - ParamName2 = value2 -); -``` - -### Avoiding Duplicate Variables (CE0111) - -`$Var = call java action ...` **creates a new variable**. Do NOT `declare` a variable with the same name first: - -```mdl --- WRONG: DECLARE + CALL both create $Success → CE0111 -declare $success boolean = false; -$success = call java action Module.DoWork(); - --- CORRECT: Use a separate name when you need a default -declare $success boolean = false; -$WorkResult = call java action Module.DoWork(); -set $success = $WorkResult; - --- CORRECT: Simple pass-through (no default needed) -$success = call java action Module.DoWork(); -return $success; -``` - -When calling Java actions in **multiple branches**, use unique result variable names: - -```mdl -declare $success boolean = false; -if $Priority = 'HIGH' then - $UrgentResult = call java action Module.SendUrgent(Msg = $Email); - set $success = $UrgentResult; -else - $NormalResult = call java action Module.SendNormal(Msg = $Email); - set $success = $NormalResult; -end if; -``` - -### Expression Escaping in String Arguments - -Single quotes within string literal arguments must be doubled (`''`): - -```mdl --- OQL with embedded quotes — use '' to escape -$count = call java action Module.ExecuteOQL( - Statement = 'SELECT * FROM Module.Entity WHERE Status = ''Active''' -); -``` - -### Complete Examples - -#### Example 1: Simple Calculation - -```mdl -/** - * Calculate tax using custom Java action - */ -create microflow Tax.ACT_CalculateOrderTax($order: Tax.Order) -returns decimal as $taxAmount -begin - declare $subtotal decimal = $order/Subtotal; - declare $taxRate decimal = 0.21; - - -- Call Java action for complex calculation - $taxAmount = call java action Tax.JA_CalculateTax( - Amount = $subtotal, - TaxRate = $taxRate - ); - - change $order (TaxAmount = $taxAmount); - commit $order; - - return $taxAmount; -end; -``` - -#### Example 2: External API Integration - -```mdl -/** - * Send notification via external service using Java action - */ -create microflow Notifications.ACT_SendOrderConfirmation($order: Sales.Order) -returns boolean as $success -begin - declare $customerEmail string = $order/Sales.Order_Customer/Email; - declare $orderNumber string = $order/OrderNumber; - - -- Call Java action that integrates with external email service - $success = call java action Notifications.JA_SendEmail( - ToAddress = $customerEmail, - Subject = 'Order Confirmation: ' + $orderNumber, - body = 'Your order has been confirmed.', - TemplateName = 'OrderConfirmation' - ); - - if $success then - change $order (NotificationSent = true); - commit $order; - else - log warning node 'Notifications' 'Failed to send email for order: ' + $orderNumber; - end if; - - return $success; -end; -``` - -#### Example 3: OQL Bulk Operations (Mendix 11.6+) - -```mdl -/** - * Bulk update using OQL via Java action - */ -create microflow Finance.ACT_ArchiveOldTransactions() -returns integer as $rowsAffected -begin - -- Use built-in OQL execution Java action - $rowsAffected = call java action CustomActivities.ExecuteOQLStatement( - OqlStatement = 'UPDATE Finance.Transaction SET Status = ''ARCHIVED'' WHERE TransactionDate < ''2024-01-01'' AND Status = ''COMPLETED''' - ); - - log info node 'Finance' 'Archived ' + toString($rowsAffected) + ' transactions'; - - return $rowsAffected; -end; -``` - -#### Example 4: OQL with Parameters - -```mdl -/** - * Parameterized OQL update via Java action - */ -create microflow Finance.ACT_UpdateTransactionStatus( - $oldStatus: string, - $newStatus: string, - $cutoffDate: datetime -) -returns integer as $rowsUpdated -begin - $rowsUpdated = call java action CustomActivities.ExecuteOQLStatementPars( - OqlStatement = 'UPDATE Finance.Transaction SET Status = {1} WHERE Status = {2} AND TransactionDate < {3}' with ( - {1} = $newStatus, - {2} = $oldStatus, - {3} = $cutoffDate as datetime - ) - ); - - return $rowsUpdated; -end; -``` - -#### Example 5: Returning Objects - -```mdl -/** - * Create complex object structure using Java action - */ -create microflow Import.ACT_ParseCSVFile($fileDocument: System.FileDocument) -returns list of Import.ImportRecord as $records -begin - -- Java action parses CSV and returns list of objects - $records = call java action Import.JA_ParseCSV( - FileDocument = $fileDocument, - HasHeader = true, - Delimiter = ',' - ); - - if $records = empty then - log warning node 'Import' 'No records parsed from file'; - else - log info node 'Import' 'Parsed ' + toString(length($records)) + ' records'; - end if; - - return $records; -end; -``` - -## Part 4: Common Java Action Patterns - -### Pattern 1: Validation Helper - -**Java Action Definition:** -- Name: `JA_ValidateEmail` -- Parameter: `EmailAddress` (String) -- Return: Boolean - -```java -@java.lang.Override -public java.lang.Boolean executeAction() throws Exception -{ - // begin user CODE - if (this.EmailAddress == null || this.EmailAddress.trim().isEmpty()) { - return false; - } - - string emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"; - return this.EmailAddress.matches(emailRegex); - // end user CODE -} -``` - -**MDL Usage:** -```mdl -create microflow Customer.VAL_CustomerEmail($customer: Customer.Customer) -returns boolean as $isValid -begin - $isValid = call java action Customer.JA_ValidateEmail( - EmailAddress = $customer/Email - ); - - if not($isValid) then - validation feedback $customer/Email - message 'Please enter a valid email address'; - end if; - - return $isValid; -end; -``` - -### Pattern 2: External API Call - -**Java Action Definition:** -- Name: `JA_FetchExchangeRate` -- Parameters: `FromCurrency` (String), `ToCurrency` (String) -- Return: Decimal - -```java -@java.lang.Override -public java.math.BigDecimal executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - try { - // build api url - string url = "https://api.exchangerate.host/convert?from=" - + this.FromCurrency + "&to=" + this.ToCurrency; - - // Make HTTP request (using your preferred HTTP client) - java.net.HttpURLConnection conn = - (java.net.HttpURLConnection) new java.net.URL(url).openConnection(); - conn.setRequestMethod("get"); - - // Parse response - java.io.BufferedReader reader = new java.io.BufferedReader( - new java.io.InputStreamReader(conn.getInputStream())); - StringBuilder response = new StringBuilder(); - string line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - reader.close(); - - // Parse json and extract rate (simplified) - // in production, use a proper json library - string json = response.toString(); - int rateIndex = json.indexOf("\"result\":"); - if (rateIndex > 0) { - string rateStr = json.substring(rateIndex + 9, json.indexOf(",", rateIndex)); - return new java.math.BigDecimal(rateStr.trim()); - } - - return java.math.BigDecimal.ONE; - - } catch (Exception e) { - Core.getLogger("ExchangeRate").error("Failed to fetch rate", e); - throw new com.mendix.systemwideinterfaces.MendixRuntimeException( - "Could not fetch exchange rate: " + e.getMessage()); - } - // end user CODE -} -``` - -**MDL Usage:** -```mdl -create microflow Finance.ACT_ConvertCurrency( - $amount: decimal, - $fromCurrency: string, - $toCurrency: string -) -returns decimal as $convertedAmount -begin - declare $rate decimal; - - $rate = call java action Finance.JA_FetchExchangeRate( - FromCurrency = $fromCurrency, - ToCurrency = $toCurrency - ); - - set $convertedAmount = $amount * $rate; - return $convertedAmount; -end; -``` - -### Pattern 3: File Processing - -**Java Action Definition:** -- Name: `JA_GeneratePDF` -- Parameters: `Order` (Sales.Order entity), `TemplateName` (String) -- Return: System.FileDocument - -```java -@java.lang.Override -public IMendixObject executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - // get order data - string orderNumber = (string) this.Order.getValue(context, "OrderNumber"); - java.math.BigDecimal total = (java.math.BigDecimal) this.Order.getValue(context, "TotalAmount"); - - // generate PDF content (using iText or similar library) - byte[] pdfContent = generatePdfBytes(orderNumber, total); - - // create FileDocument - IMendixObject fileDoc = Core.instantiate(context, "System.FileDocument"); - fileDoc.setValue(context, "Name", "Order_" + orderNumber + ".pdf"); - Core.storeFileDocumentContent(context, fileDoc, - new java.io.ByteArrayInputStream(pdfContent)); - - Core.commit(context, fileDoc); - - return fileDoc; - // end user CODE -} -``` - -**MDL Usage:** -```mdl -create microflow Sales.ACT_GenerateOrderPDF($order: Sales.Order) -returns System.FileDocument as $pdfFile -begin - $pdfFile = call java action Sales.JA_GeneratePDF( - Order = $order, - TemplateName = 'OrderConfirmation' - ); - - log info node 'Sales' 'Generated PDF for order: ' + $order/OrderNumber; - - return $pdfFile; -end; -``` - -## Part 5: Best Practices - -### Naming Conventions - -| Element | Convention | Example | -|---------|------------|---------| -| Java Action | `JA_` prefix + PascalCase | `JA_CalculateTax`, `JA_SendEmail` | -| Module | Business domain name | `Finance`, `Integration`, `Utils` | -| Parameters | PascalCase, descriptive | `OrderAmount`, `CustomerEmail` | - -### Code Organization (Recommended) - -**Keep Java action code minimal** - only handle parameter extraction and delegation. Put the actual implementation in separate classes under `.impl`. - -**Why?** -- Code between `begin user CODE` and `end user CODE` is preserved, but it's limited space -- Implementation classes are fully under your control (not regenerated) -- Easier to unit test implementation logic separately -- Better code organization and reusability - -**Package Structure:** -``` -javasource/ -├── mymodule/ -│ ├── actions/ -│ │ └── JA_ProcessOrder.java # Generated action (minimal code) -│ └── impl/ -│ ├── processorder/ -│ │ ├── OrderProcessor.java # Main implementation -│ │ ├── OrderValidator.java # validation logic -│ │ └── OrderNotifier.java # notification logic -│ └── shared/ -│ └── EmailService.java # Shared utilities -``` - -**Example - Java Action (Thin Wrapper):** -```java -// in javasource/mymodule/actions/JA_ProcessOrder.java -@java.lang.Override -public java.lang.Boolean executeAction() throws Exception -{ - // begin user CODE - // Delegate to implementation class - keep this minimal! - return new mymodule.impl.processorder.OrderProcessor(getContext()) - .process(this.Order, this.SendNotification); - // end user CODE -} -``` - -**Example - Implementation Class (Testable Design):** - -The key to testability is separating **pure business logic** from **Mendix API calls**. Use interfaces for data access so you can mock them in tests. - -```java -// in javasource/mymodule/impl/processorder/OrderProcessor.java -package mymodule.impl.processorder; - -import java.math.BigDecimal; -import java.util.Date; - -/** - * Pure business logic - NO Mendix dependencies! - * Can be tested with plain JUnit without running Mendix. - */ -public class OrderProcessor { - - public ProcessResult process(OrderData order, boolean sendNotification) { - // Validate - pure java logic - if (order.getOrderNumber() == null || order.getOrderNumber().isEmpty()) { - return ProcessResult.failure("Order number is required"); - } - if (order.getTotalAmount() == null || order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { - return ProcessResult.failure("Order amount must be positive"); - } - - // Calculate - pure java logic - BigDecimal tax = calculateTax(order.getTotalAmount(), order.getTaxRate()); - BigDecimal finalAmount = order.getTotalAmount().add(tax); - - // return result (actual persistence happens in adapter) - return ProcessResult.success(finalAmount, tax, new date()); - } - - public BigDecimal calculateTax(BigDecimal amount, BigDecimal rate) { - if (amount == null || rate == null) { - return BigDecimal.ZERO; - } - return amount.multiply(rate).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); - } -} -``` - -```java -// in javasource/mymodule/impl/processorder/OrderData.java -package mymodule.impl.processorder; - -import java.math.BigDecimal; - -/** - * Plain Java data object - no Mendix dependencies. - */ -public class OrderData { - private string orderNumber; - private BigDecimal totalAmount; - private BigDecimal taxRate; - - // Constructor, getters, setters... - public OrderData(string orderNumber, BigDecimal totalAmount, BigDecimal taxRate) { - this.orderNumber = orderNumber; - this.totalAmount = totalAmount; - this.taxRate = taxRate; - } - - public string getOrderNumber() { return orderNumber; } - public BigDecimal getTotalAmount() { return totalAmount; } - public BigDecimal getTaxRate() { return taxRate; } -} -``` - -```java -// in javasource/mymodule/impl/processorder/MendixOrderAdapter.java -package mymodule.impl.processorder; - -import com.mendix.systemwideinterfaces.core.IContext; -import com.mendix.systemwideinterfaces.core.IMendixObject; -import com.mendix.core.Core; -import java.math.BigDecimal; - -/** - * Adapter: converts between Mendix objects and pure Java objects. - * This is the ONLY class that touches Mendix APIs. - */ -public class MendixOrderAdapter { - private final IContext context; - - public MendixOrderAdapter(IContext context) { - this.context = context; - } - - public OrderData toOrderData(IMendixObject mendixOrder) { - return new OrderData( - (string) mendixOrder.getValue(context, "OrderNumber"), - (BigDecimal) mendixOrder.getValue(context, "TotalAmount"), - (BigDecimal) mendixOrder.getValue(context, "TaxRate") - ); - } - - public void applyResult(IMendixObject mendixOrder, ProcessResult result) throws Exception { - mendixOrder.setValue(context, "status", "Processed"); - mendixOrder.setValue(context, "FinalAmount", result.getFinalAmount()); - mendixOrder.setValue(context, "TaxAmount", result.getTaxAmount()); - mendixOrder.setValue(context, "ProcessedDate", result.getProcessedDate()); - Core.commit(context, mendixOrder); - } -} -``` - -**Example - Java Action (Wiring Only):** -```java -// in javasource/mymodule/actions/JA_ProcessOrder.java -@java.lang.Override -public java.lang.Boolean executeAction() throws Exception -{ - // begin user CODE - // Wire up adapter and processor - MendixOrderAdapter adapter = new MendixOrderAdapter(getContext()); - OrderProcessor processor = new OrderProcessor(); - - // Convert Mendix object to plain java object - OrderData orderData = adapter.toOrderData(this.Order); - - // Process (pure java - no Mendix dependencies) - ProcessResult result = processor.process(orderData, this.SendNotification); - - if (result.isSuccess()) { - // apply result back to Mendix object - adapter.applyResult(this.Order, result); - return true; - } else { - Core.getLogger("MyModule").warn("Order processing failed: " + result.getMessage()); - return false; - } - // end user CODE -} -``` - -**Example - Unit Test (No Mendix Runtime Required):** -```java -// in javasource/mymodule/impl/processorder/OrderProcessorTest.java -package mymodule.impl.processorder; - -import org.junit.Test; -import static org.junit.Assert.*; -import java.math.BigDecimal; - -public class OrderProcessorTest { - - @Test - public void testProcessValidOrder() { - // Arrange - plain java objects, no mocking needed! - OrderProcessor processor = new OrderProcessor(); - OrderData order = new OrderData("ORD-001", new BigDecimal("100.00"), new BigDecimal("21")); - - // Act - ProcessResult result = processor.process(order, false); - - // Assert - assertTrue(result.isSuccess()); - assertEquals(new BigDecimal("21.00"), result.getTaxAmount()); - assertEquals(new BigDecimal("121.00"), result.getFinalAmount()); - } - - @Test - public void testProcessInvalidOrder_MissingOrderNumber() { - OrderProcessor processor = new OrderProcessor(); - OrderData order = new OrderData(null, new BigDecimal("100.00"), new BigDecimal("21")); - - ProcessResult result = processor.process(order, false); - - assertFalse(result.isSuccess()); - assertEquals("Order number is required", result.getMessage()); - } - - @Test - public void testCalculateTax() { - OrderProcessor processor = new OrderProcessor(); - - BigDecimal tax = processor.calculateTax(new BigDecimal("200.00"), new BigDecimal("10")); - - assertEquals(new BigDecimal("20.00"), tax); - } -} -``` - -**Run tests with Maven or standalone:** -```bash -# from javasource directory -javac -cp .:junit-4.13.jar mymodule/impl/processorder/*.java -java -cp .:junit-4.13.jar org.junit.runner.JUnitCore mymodule.impl.processorder.OrderProcessorTest -``` - -**Benefits:** -- **Testable without Mendix** - Run JUnit tests locally or in CI without Mendix runtime -- **Fast feedback** - Unit tests run in milliseconds, not minutes -- **Clear separation** - Business logic is pure Java; Mendix integration is isolated in adapters -- **Reusable** - `OrderProcessor` can be used in other contexts (batch jobs, REST APIs) -- **Maintainable** - Changes to business logic don't require Mendix knowledge - -### Error Handling Best Practices - -1. **Always wrap in try-catch**: -```java -try { - // business logic -} catch (Exception e) { - Core.getLogger("MyModule").error("operation failed", e); - throw new MendixRuntimeException("user-friendly message: " + e.getMessage()); -} -``` - -2. **Validate inputs early**: -```java -if (this.requiredParam == null) { - throw new IllegalArgumentException("RequiredParam is required"); -} -``` - -3. **Use appropriate log levels**: -- `trace`: Detailed debugging -- `debug`: Development information -- `info`: Normal operations -- `warn`: Potential issues -- `error`: Recoverable errors -- `critical`: System failures - -### Performance Considerations - -1. **Batch operations** when possible: -```java -// Instead of committing one by one -list toCommit = new ArrayList<>(); -for (IMendixObject obj : objects) { - obj.setValue(context, "status", "Processed"); - toCommit.add(obj); -} -Core.commit(context, toCommit); // single batch commit -``` - -2. **Use pagination** for large datasets: -```java -int offset = 0; -int batchSize = 1000; -list batch; -do { - batch = Core.createXPathQuery(xpath).setAmount(batchSize).setOffset(offset).execute(context); - // Process batch - offset += batchSize; -} while (batch.size() == batchSize); -``` - -3. **Cache expensive lookups**: -```java -private static map cache = new ConcurrentHashMap<>(); -``` - -## Validation Checklist - -Before deploying Java actions, verify: - -- [ ] Java action has `JA_` prefix naming convention -- [ ] All parameters are defined with correct types -- [ ] Return type matches what you return in Java -- [ ] Code is only between `begin user CODE` / `end user CODE` markers -- [ ] Proper null checks for all parameters -- [ ] Exception handling with logging -- [ ] No hardcoded credentials or sensitive data -- [ ] Entity and attribute names match model exactly -- [ ] Unit tests cover main scenarios - -## Common Errors - -| Error | Cause | Fix | -|-------|-------|-----| -| `ClassNotFoundException` | Missing library | Add JAR to `userlib/` folder | -| `NullPointerException` | Null parameter | Add null checks | -| `Could not find entity` | Wrong entity name | Use exact qualified name | -| `attribute not found` | Wrong attribute name | Check model for exact name | -| `ClassCastException` | Wrong type cast | Check parameter types | -| `no viable alternative at input '...'` (parse error) | `AS $$ ... $$` body is missing — it is **mandatory** even for void/stub actions | Add `as $$ return null; $$;` (or appropriate stub) | - -## Related Documentation - -- [Mendix Java Actions Reference](https://docs.mendix.com/refguide/java-actions/) -- [Build Microflow Actions with Java](https://docs.mendix.com/howto/extensibility/howto-connector-kit/) -- [Java Programming in Mendix](https://docs.mendix.com/refguide/java-programming/) -- [Write Microflows Skill](./write-microflows.md) -- [Validation Microflows Skill](./validation-microflows.md) - -## Quick Reference - -### Java Action Definition Syntax -```mdl --- Basic Java action -create java action Module.Name(Param: type not null) returns boolean -as $$ -return true; -$$; - --- With type parameters (generics) --- ENTITY = entity type selector, bare pEntity = entity instances -create java action Module.Name( - EntityType: entity not null, - Obj: pEntity not null -) returns boolean -as $$ -return Obj != null; -$$; - --- With EXPOSED AS (toolbox visibility) -create java action Module.Name(Amount: decimal) returns string -exposed as 'Format Amount' in 'Formatting' -as $$ -return Amount.toString(); -$$; - --- Combined type parameters + EXPOSED AS -create java action Module.Name( - EntityType: entity not null, - Obj: pEntity not null -) returns boolean -exposed as 'Validate Object' in 'Validation' -as $$ -return Obj != null; -$$; -``` - -### Java Action Call Syntax -```mdl --- Without return value -call java action Module.JA_ActionName(Param1 = value1, Param2 = value2); - --- With return value -$Result = call java action Module.JA_ActionName(Param1 = value1); - --- With OQL parameters (Mendix 11.6+) -$Rows = call java action Module.JA_ExecuteOQL( - Statement = 'UPDATE Module.Entity SET Attr = {1} WHERE Id = {2}' with ( - {1} = $value1, - {2} = $value2 as integer - ) -); -``` - -### Core API Quick Reference -```java -// context -IContext context = getContext(); - -// create -IMendixObject obj = Core.instantiate(context, "Module.Entity"); - -// read -object value = obj.getValue(context, "attributename"); - -// update -obj.setValue(context, "attributename", newValue); - -// Save -Core.commit(context, obj); - -// delete -Core.delete(context, obj); - -// query -list results = Core.createXPathQuery("//Module.Entity[attr = 'value']").execute(context); - -// log -Core.getLogger("ModuleName").info("message"); -``` diff --git a/.claude/skills/mendix/java-actions/SKILL.md b/.claude/skills/mendix/java-actions/SKILL.md new file mode 100644 index 000000000..e9aa991e6 --- /dev/null +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -0,0 +1,541 @@ +--- +name: java-actions +description: "Create and call custom Java actions — extending Mendix with server-side Java, and invoking those actions from microflows in MDL. Use when logic needs a Java library, an algorithm microflows cannot express, or an integration only available in Java." +--- + +# Mendix Java Actions Skill + +This skill provides comprehensive guidance for creating and using custom Java actions in Mendix projects. + +## Reference files + +`SKILL.md` covers declaring a Java action in MDL and calling it from a microflow — +the parts that touch the model. The Java side is next door: + +- [`reference/writing-java.md`](reference/writing-java.md) — creating the action + in Studio Pro, writing the `executeAction` body, the Core API surface you have, + and the recurring patterns (HTTP calls, file handling, batch work). +- [`reference/best-practices.md`](reference/best-practices.md) — error handling, + transactions and rollback, logging, threading, security, and performance. Read + it before shipping a Java action that touches data or an external system. + +## When to Use This Skill + +Use this skill when: +- You need to extend Mendix with custom Java logic +- Building integrations with external Java libraries +- Implementing complex algorithms not feasible in microflows +- Calling Java actions from MDL microflows +- Debugging Java action calls + +## Overview + +Java actions allow you to extend Mendix with custom Java code. The workflow is: +1. **Define** the Java action in Studio Pro (parameters, return type) +2. **Implement** the Java code in Eclipse/IDE +3. **Call** the Java action from microflows using MDL + +## Part 2.5: Creating Java Actions in MDL + +MDL supports defining Java actions with inline Java code using `create java action`. + +### Basic Syntax + +```mdl +create java action Module.ActionName(param1: type, param2: type) returns ReturnType +as $$ +// java code here +return result; +$$; +``` + +**`AS $$ ... $$` is mandatory.** The body cannot be omitted even for placeholder or stub actions. Omitting it causes a parse error: `no viable alternative at input '...'`. Use a minimal body if the real implementation is not yet written: + +```mdl +create java action Module.Stub() returns boolean +as $$ +return false; +$$; +``` + +### Type Parameters (Generics) + +Type parameters let Java actions accept any entity type dynamically. Use `entity ` in a parameter type to declare the type parameter inline. That parameter becomes the **entity type selector** (receives the entity type name, e.g., `'Module.Entity'`). Bare `pEntity` parameters become **parameterized entity** params (receive entity instances, e.g., `$Variable`). + +```mdl +-- ENTITY declares the type parameter; bare pEntity references it +create java action Module.Validate( + EntityType: entity not null, + InputObject: pEntity not null +) returns boolean +as $$ +return InputObject != null; +$$; +``` + +Multiple type parameters use separate `entity <...>` declarations: + +```mdl +create java action Module.Transform( + SourceType: entity not null, + TargetType: entity not null, + source: pSource not null, + Target: pTarget not null +) returns boolean +as $$ +return true; +$$; +``` + +Type parameter names can be mixed with regular parameter types: + +```mdl +create java action Module.CopyAttributes( + EntityType: entity not null, + source: pEntity not null, + Target: pEntity not null, + AttributeNames: string not null +) returns boolean +as $$ +return true; +$$; +``` + +When **calling** these actions from microflows, the entity type selector receives the entity type name as a string literal, while instance params receive variables: + +```mdl +$Result = call java action Module.CopyAttributes( + EntityType = 'Module.ProcessResult', + source = $source, + Target = $Target, + AttributeNames = 'Name,Status' +); +``` + +### EXPOSED AS (Toolbox Visibility) + +The `exposed as 'caption' in 'Category'` clause makes the Java action appear as a toolbox item in Studio Pro's microflow editor: + +```mdl +create java action Module.FormatCurrency( + Amount: decimal not null, + CurrencyCode: string not null +) returns string +exposed as 'Format Currency' in 'Formatting' +as $$ +return String.format("%.2f %s", Amount, CurrencyCode); +$$; +``` + +Type parameters and EXPOSED AS can be combined: + +```mdl +create java action Module.DeepClone( + EntityType: entity not null, + Original: pEntity not null +) returns boolean +exposed as 'Deep Clone Object' in 'Object Utils' +as $$ +return true; +$$; +``` + +### Supported Parameter Types + +| MDL Type | Description | +|----------|-------------| +| `string` | Text value | +| `integer` | Whole number | +| `long` | Large whole number | +| `decimal` | Decimal number | +| `boolean` | True/false | +| `datetime` | Date and time | +| `Module.Entity` | Entity reference | +| `list of Module.Entity` | List of entities | +| `stringtemplate(sql)` | SQL/OQL query template with parameters | +| `stringtemplate(text)` | Text template with parameters | +| `entity ` | Type parameter declaration (entity type selector) | +| `enum Module.EnumName` | Enumeration type | +| `enumeration(Module.EnumName)` | Enumeration type (alternative syntax) | +| `pEntity` (type param ref) | Type parameter reference (entity instance) | + +### Examples + +#### Simple Action (No Parameters) + +```mdl +/** Returns the current timestamp. */ +create java action MyModule.GetCurrentTimestamp() returns datetime +as $$ +return new java.util.Date(); +$$; +``` + +#### Action with Primitive Parameters + +```mdl +/** Calculates tax amount. */ +create java action Finance.CalculateTax(Amount: decimal, TaxRate: decimal) returns decimal +as $$ +if (Amount == null || TaxRate == null) { + return java.math.BigDecimal.ZERO; +} +return Amount.multiply(TaxRate).divide(java.math.BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); +$$; +``` + +#### Action with StringTemplate (SQL/OQL) + +```mdl +/** Executes an OQL statement with parameterized query. */ +create java action Database.ExecuteOQLStatement(OqlStatement: stringtemplate(sql) not null) returns boolean +as $$ +// execute the parameterized OQL statement +// The stringtemplate handles parameter substitution safely +return true; +$$; +``` + +#### Action with NOT NULL Parameter + +```mdl +/** Validates an email address - email is required. */ +create java action Validation.ValidateEmail(EmailAddress: string not null) returns boolean +as $$ +string emailRegex = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; +return EmailAddress.matches(emailRegex); +$$; +``` + +#### Action with Type Parameter (Generic) + +```mdl +/** Validates any entity - checks that required fields are filled. */ +create java action Validation.ValidateEntity( + EntityType: entity not null, + InputObject: pEntity not null +) returns boolean +as $$ +return InputObject.getMembers().values().stream() + .allMatch(m -> !m.isRequired() || m.getValue(getContext()) != null); +$$; +``` + +#### Action with Type Parameter + EXPOSED AS + +```mdl +/** Deep clones any entity (toolbox-visible). */ +create java action Utils.DeepClone( + EntityType: entity not null, + Original: pEntity not null +) returns boolean +exposed as 'Deep Clone Object' in 'Object Utils' +as $$ +return true; +$$; +``` + +## Part 3: Calling Java Actions from MDL + +### Basic Syntax + +```mdl +-- Call Java action (no return value) +call java action Module.JA_ActionName( + ParamName1 = value1, + ParamName2 = value2 +); + +-- Call Java action with return value +$Result = call java action Module.JA_ActionName( + ParamName1 = value1, + ParamName2 = value2 +); +``` + +### Avoiding Duplicate Variables (CE0111) + +`$Var = call java action ...` **creates a new variable**. Do NOT `declare` a variable with the same name first: + +```mdl +-- WRONG: DECLARE + CALL both create $Success → CE0111 +declare $success boolean = false; +$success = call java action Module.DoWork(); + +-- CORRECT: Use a separate name when you need a default +declare $success boolean = false; +$WorkResult = call java action Module.DoWork(); +set $success = $WorkResult; + +-- CORRECT: Simple pass-through (no default needed) +$success = call java action Module.DoWork(); +return $success; +``` + +When calling Java actions in **multiple branches**, use unique result variable names: + +```mdl +declare $success boolean = false; +if $Priority = 'HIGH' then + $UrgentResult = call java action Module.SendUrgent(Msg = $Email); + set $success = $UrgentResult; +else + $NormalResult = call java action Module.SendNormal(Msg = $Email); + set $success = $NormalResult; +end if; +``` + +### Expression Escaping in String Arguments + +Single quotes within string literal arguments must be doubled (`''`): + +```mdl +-- OQL with embedded quotes — use '' to escape +$count = call java action Module.ExecuteOQL( + Statement = 'SELECT * FROM Module.Entity WHERE Status = ''Active''' +); +``` + +### Complete Examples + +#### Example 1: Simple Calculation + +```mdl +/** + * Calculate tax using custom Java action + */ +create microflow Tax.ACT_CalculateOrderTax($order: Tax.Order) +returns decimal as $taxAmount +begin + declare $subtotal decimal = $order/Subtotal; + declare $taxRate decimal = 0.21; + + -- Call Java action for complex calculation + $taxAmount = call java action Tax.JA_CalculateTax( + Amount = $subtotal, + TaxRate = $taxRate + ); + + change $order (TaxAmount = $taxAmount); + commit $order; + + return $taxAmount; +end; +``` + +#### Example 2: External API Integration + +```mdl +/** + * Send notification via external service using Java action + */ +create microflow Notifications.ACT_SendOrderConfirmation($order: Sales.Order) +returns boolean as $success +begin + declare $customerEmail string = $order/Sales.Order_Customer/Email; + declare $orderNumber string = $order/OrderNumber; + + -- Call Java action that integrates with external email service + $success = call java action Notifications.JA_SendEmail( + ToAddress = $customerEmail, + Subject = 'Order Confirmation: ' + $orderNumber, + body = 'Your order has been confirmed.', + TemplateName = 'OrderConfirmation' + ); + + if $success then + change $order (NotificationSent = true); + commit $order; + else + log warning node 'Notifications' 'Failed to send email for order: ' + $orderNumber; + end if; + + return $success; +end; +``` + +#### Example 3: OQL Bulk Operations (Mendix 11.6+) + +```mdl +/** + * Bulk update using OQL via Java action + */ +create microflow Finance.ACT_ArchiveOldTransactions() +returns integer as $rowsAffected +begin + -- Use built-in OQL execution Java action + $rowsAffected = call java action CustomActivities.ExecuteOQLStatement( + OqlStatement = 'UPDATE Finance.Transaction SET Status = ''ARCHIVED'' WHERE TransactionDate < ''2024-01-01'' AND Status = ''COMPLETED''' + ); + + log info node 'Finance' 'Archived ' + toString($rowsAffected) + ' transactions'; + + return $rowsAffected; +end; +``` + +#### Example 4: OQL with Parameters + +```mdl +/** + * Parameterized OQL update via Java action + */ +create microflow Finance.ACT_UpdateTransactionStatus( + $oldStatus: string, + $newStatus: string, + $cutoffDate: datetime +) +returns integer as $rowsUpdated +begin + $rowsUpdated = call java action CustomActivities.ExecuteOQLStatementPars( + OqlStatement = 'UPDATE Finance.Transaction SET Status = {1} WHERE Status = {2} AND TransactionDate < {3}' with ( + {1} = $newStatus, + {2} = $oldStatus, + {3} = $cutoffDate as datetime + ) + ); + + return $rowsUpdated; +end; +``` + +#### Example 5: Returning Objects + +```mdl +/** + * Create complex object structure using Java action + */ +create microflow Import.ACT_ParseCSVFile($fileDocument: System.FileDocument) +returns list of Import.ImportRecord as $records +begin + -- Java action parses CSV and returns list of objects + $records = call java action Import.JA_ParseCSV( + FileDocument = $fileDocument, + HasHeader = true, + Delimiter = ',' + ); + + if $records = empty then + log warning node 'Import' 'No records parsed from file'; + else + log info node 'Import' 'Parsed ' + toString(length($records)) + ' records'; + end if; + + return $records; +end; +``` + +## Validation Checklist + +Before deploying Java actions, verify: + +- [ ] Java action has `JA_` prefix naming convention +- [ ] All parameters are defined with correct types +- [ ] Return type matches what you return in Java +- [ ] Code is only between `begin user CODE` / `end user CODE` markers +- [ ] Proper null checks for all parameters +- [ ] Exception handling with logging +- [ ] No hardcoded credentials or sensitive data +- [ ] Entity and attribute names match model exactly +- [ ] Unit tests cover main scenarios + +## Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `ClassNotFoundException` | Missing library | Add JAR to `userlib/` folder | +| `NullPointerException` | Null parameter | Add null checks | +| `Could not find entity` | Wrong entity name | Use exact qualified name | +| `attribute not found` | Wrong attribute name | Check model for exact name | +| `ClassCastException` | Wrong type cast | Check parameter types | +| `no viable alternative at input '...'` (parse error) | `AS $$ ... $$` body is missing — it is **mandatory** even for void/stub actions | Add `as $$ return null; $$;` (or appropriate stub) | + +## Related Documentation + +- [Mendix Java Actions Reference](https://docs.mendix.com/refguide/java-actions/) +- [Build Microflow Actions with Java](https://docs.mendix.com/howto/extensibility/howto-connector-kit/) +- [Java Programming in Mendix](https://docs.mendix.com/refguide/java-programming/) +- [Write Microflows Skill](../write-microflows/SKILL.md) +- [Validation Microflows Skill](../validation-microflows/SKILL.md) + +## Quick Reference + +### Java Action Definition Syntax +```mdl +-- Basic Java action +create java action Module.Name(Param: type not null) returns boolean +as $$ +return true; +$$; + +-- With type parameters (generics) +-- ENTITY = entity type selector, bare pEntity = entity instances +create java action Module.Name( + EntityType: entity not null, + Obj: pEntity not null +) returns boolean +as $$ +return Obj != null; +$$; + +-- With EXPOSED AS (toolbox visibility) +create java action Module.Name(Amount: decimal) returns string +exposed as 'Format Amount' in 'Formatting' +as $$ +return Amount.toString(); +$$; + +-- Combined type parameters + EXPOSED AS +create java action Module.Name( + EntityType: entity not null, + Obj: pEntity not null +) returns boolean +exposed as 'Validate Object' in 'Validation' +as $$ +return Obj != null; +$$; +``` + +### Java Action Call Syntax +```mdl +-- Without return value +call java action Module.JA_ActionName(Param1 = value1, Param2 = value2); + +-- With return value +$Result = call java action Module.JA_ActionName(Param1 = value1); + +-- With OQL parameters (Mendix 11.6+) +$Rows = call java action Module.JA_ExecuteOQL( + Statement = 'UPDATE Module.Entity SET Attr = {1} WHERE Id = {2}' with ( + {1} = $value1, + {2} = $value2 as integer + ) +); +``` + +### Core API Quick Reference +```java +// context +IContext context = getContext(); + +// create +IMendixObject obj = Core.instantiate(context, "Module.Entity"); + +// read +object value = obj.getValue(context, "attributename"); + +// update +obj.setValue(context, "attributename", newValue); + +// Save +Core.commit(context, obj); + +// delete +Core.delete(context, obj); + +// query +list results = Core.createXPathQuery("//Module.Entity[attr = 'value']").execute(context); + +// log +Core.getLogger("ModuleName").info("message"); +``` diff --git a/.claude/skills/mendix/java-actions/reference/best-practices.md b/.claude/skills/mendix/java-actions/reference/best-practices.md new file mode 100644 index 000000000..cf348467f --- /dev/null +++ b/.claude/skills/mendix/java-actions/reference/best-practices.md @@ -0,0 +1,308 @@ +# Java action best practices + +Supporting reference for [java-actions](../SKILL.md). + +## Part 5: Best Practices + +### Naming Conventions + +| Element | Convention | Example | +|---------|------------|---------| +| Java Action | `JA_` prefix + PascalCase | `JA_CalculateTax`, `JA_SendEmail` | +| Module | Business domain name | `Finance`, `Integration`, `Utils` | +| Parameters | PascalCase, descriptive | `OrderAmount`, `CustomerEmail` | + +### Code Organization (Recommended) + +**Keep Java action code minimal** - only handle parameter extraction and delegation. Put the actual implementation in separate classes under `.impl`. + +**Why?** +- Code between `begin user CODE` and `end user CODE` is preserved, but it's limited space +- Implementation classes are fully under your control (not regenerated) +- Easier to unit test implementation logic separately +- Better code organization and reusability + +**Package Structure:** +``` +javasource/ +├── mymodule/ +│ ├── actions/ +│ │ └── JA_ProcessOrder.java # Generated action (minimal code) +│ └── impl/ +│ ├── processorder/ +│ │ ├── OrderProcessor.java # Main implementation +│ │ ├── OrderValidator.java # validation logic +│ │ └── OrderNotifier.java # notification logic +│ └── shared/ +│ └── EmailService.java # Shared utilities +``` + +**Example - Java Action (Thin Wrapper):** +```java +// in javasource/mymodule/actions/JA_ProcessOrder.java +@java.lang.Override +public java.lang.Boolean executeAction() throws Exception +{ + // begin user CODE + // Delegate to implementation class - keep this minimal! + return new mymodule.impl.processorder.OrderProcessor(getContext()) + .process(this.Order, this.SendNotification); + // end user CODE +} +``` + +**Example - Implementation Class (Testable Design):** + +The key to testability is separating **pure business logic** from **Mendix API calls**. Use interfaces for data access so you can mock them in tests. + +```java +// in javasource/mymodule/impl/processorder/OrderProcessor.java +package mymodule.impl.processorder; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * Pure business logic - NO Mendix dependencies! + * Can be tested with plain JUnit without running Mendix. + */ +public class OrderProcessor { + + public ProcessResult process(OrderData order, boolean sendNotification) { + // Validate - pure java logic + if (order.getOrderNumber() == null || order.getOrderNumber().isEmpty()) { + return ProcessResult.failure("Order number is required"); + } + if (order.getTotalAmount() == null || order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { + return ProcessResult.failure("Order amount must be positive"); + } + + // Calculate - pure java logic + BigDecimal tax = calculateTax(order.getTotalAmount(), order.getTaxRate()); + BigDecimal finalAmount = order.getTotalAmount().add(tax); + + // return result (actual persistence happens in adapter) + return ProcessResult.success(finalAmount, tax, new date()); + } + + public BigDecimal calculateTax(BigDecimal amount, BigDecimal rate) { + if (amount == null || rate == null) { + return BigDecimal.ZERO; + } + return amount.multiply(rate).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); + } +} +``` + +```java +// in javasource/mymodule/impl/processorder/OrderData.java +package mymodule.impl.processorder; + +import java.math.BigDecimal; + +/** + * Plain Java data object - no Mendix dependencies. + */ +public class OrderData { + private string orderNumber; + private BigDecimal totalAmount; + private BigDecimal taxRate; + + // Constructor, getters, setters... + public OrderData(string orderNumber, BigDecimal totalAmount, BigDecimal taxRate) { + this.orderNumber = orderNumber; + this.totalAmount = totalAmount; + this.taxRate = taxRate; + } + + public string getOrderNumber() { return orderNumber; } + public BigDecimal getTotalAmount() { return totalAmount; } + public BigDecimal getTaxRate() { return taxRate; } +} +``` + +```java +// in javasource/mymodule/impl/processorder/MendixOrderAdapter.java +package mymodule.impl.processorder; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.IMendixObject; +import com.mendix.core.Core; +import java.math.BigDecimal; + +/** + * Adapter: converts between Mendix objects and pure Java objects. + * This is the ONLY class that touches Mendix APIs. + */ +public class MendixOrderAdapter { + private final IContext context; + + public MendixOrderAdapter(IContext context) { + this.context = context; + } + + public OrderData toOrderData(IMendixObject mendixOrder) { + return new OrderData( + (string) mendixOrder.getValue(context, "OrderNumber"), + (BigDecimal) mendixOrder.getValue(context, "TotalAmount"), + (BigDecimal) mendixOrder.getValue(context, "TaxRate") + ); + } + + public void applyResult(IMendixObject mendixOrder, ProcessResult result) throws Exception { + mendixOrder.setValue(context, "status", "Processed"); + mendixOrder.setValue(context, "FinalAmount", result.getFinalAmount()); + mendixOrder.setValue(context, "TaxAmount", result.getTaxAmount()); + mendixOrder.setValue(context, "ProcessedDate", result.getProcessedDate()); + Core.commit(context, mendixOrder); + } +} +``` + +**Example - Java Action (Wiring Only):** +```java +// in javasource/mymodule/actions/JA_ProcessOrder.java +@java.lang.Override +public java.lang.Boolean executeAction() throws Exception +{ + // begin user CODE + // Wire up adapter and processor + MendixOrderAdapter adapter = new MendixOrderAdapter(getContext()); + OrderProcessor processor = new OrderProcessor(); + + // Convert Mendix object to plain java object + OrderData orderData = adapter.toOrderData(this.Order); + + // Process (pure java - no Mendix dependencies) + ProcessResult result = processor.process(orderData, this.SendNotification); + + if (result.isSuccess()) { + // apply result back to Mendix object + adapter.applyResult(this.Order, result); + return true; + } else { + Core.getLogger("MyModule").warn("Order processing failed: " + result.getMessage()); + return false; + } + // end user CODE +} +``` + +**Example - Unit Test (No Mendix Runtime Required):** +```java +// in javasource/mymodule/impl/processorder/OrderProcessorTest.java +package mymodule.impl.processorder; + +import org.junit.Test; +import static org.junit.Assert.*; +import java.math.BigDecimal; + +public class OrderProcessorTest { + + @Test + public void testProcessValidOrder() { + // Arrange - plain java objects, no mocking needed! + OrderProcessor processor = new OrderProcessor(); + OrderData order = new OrderData("ORD-001", new BigDecimal("100.00"), new BigDecimal("21")); + + // Act + ProcessResult result = processor.process(order, false); + + // Assert + assertTrue(result.isSuccess()); + assertEquals(new BigDecimal("21.00"), result.getTaxAmount()); + assertEquals(new BigDecimal("121.00"), result.getFinalAmount()); + } + + @Test + public void testProcessInvalidOrder_MissingOrderNumber() { + OrderProcessor processor = new OrderProcessor(); + OrderData order = new OrderData(null, new BigDecimal("100.00"), new BigDecimal("21")); + + ProcessResult result = processor.process(order, false); + + assertFalse(result.isSuccess()); + assertEquals("Order number is required", result.getMessage()); + } + + @Test + public void testCalculateTax() { + OrderProcessor processor = new OrderProcessor(); + + BigDecimal tax = processor.calculateTax(new BigDecimal("200.00"), new BigDecimal("10")); + + assertEquals(new BigDecimal("20.00"), tax); + } +} +``` + +**Run tests with Maven or standalone:** +```bash +# from javasource directory +javac -cp .:junit-4.13.jar mymodule/impl/processorder/*.java +java -cp .:junit-4.13.jar org.junit.runner.JUnitCore mymodule.impl.processorder.OrderProcessorTest +``` + +**Benefits:** +- **Testable without Mendix** - Run JUnit tests locally or in CI without Mendix runtime +- **Fast feedback** - Unit tests run in milliseconds, not minutes +- **Clear separation** - Business logic is pure Java; Mendix integration is isolated in adapters +- **Reusable** - `OrderProcessor` can be used in other contexts (batch jobs, REST APIs) +- **Maintainable** - Changes to business logic don't require Mendix knowledge + +### Error Handling Best Practices + +1. **Always wrap in try-catch**: +```java +try { + // business logic +} catch (Exception e) { + Core.getLogger("MyModule").error("operation failed", e); + throw new MendixRuntimeException("user-friendly message: " + e.getMessage()); +} +``` + +2. **Validate inputs early**: +```java +if (this.requiredParam == null) { + throw new IllegalArgumentException("RequiredParam is required"); +} +``` + +3. **Use appropriate log levels**: +- `trace`: Detailed debugging +- `debug`: Development information +- `info`: Normal operations +- `warn`: Potential issues +- `error`: Recoverable errors +- `critical`: System failures + +### Performance Considerations + +1. **Batch operations** when possible: +```java +// Instead of committing one by one +list toCommit = new ArrayList<>(); +for (IMendixObject obj : objects) { + obj.setValue(context, "status", "Processed"); + toCommit.add(obj); +} +Core.commit(context, toCommit); // single batch commit +``` + +2. **Use pagination** for large datasets: +```java +int offset = 0; +int batchSize = 1000; +list batch; +do { + batch = Core.createXPathQuery(xpath).setAmount(batchSize).setOffset(offset).execute(context); + // Process batch + offset += batchSize; +} while (batch.size() == batchSize); +``` + +3. **Cache expensive lookups**: +```java +private static map cache = new ConcurrentHashMap<>(); +``` diff --git a/.claude/skills/mendix/java-actions/reference/writing-java.md b/.claude/skills/mendix/java-actions/reference/writing-java.md new file mode 100644 index 000000000..a10d92944 --- /dev/null +++ b/.claude/skills/mendix/java-actions/reference/writing-java.md @@ -0,0 +1,376 @@ +# Writing the Java side + +Supporting reference for [java-actions](../SKILL.md). + +## Part 1: Creating Java Actions in Studio Pro + +### Step 1: Create the Java Action + +In Studio Pro: +1. Right-click module in Project Explorer → **Add other** → **Java action** +2. Name using convention: `JA_ActionName` (e.g., `JA_CalculateTax`, `JA_SendEmail`) +3. Define parameters and return type + +### Step 2: Define Parameters + +| Parameter Type | Mendix Type | Java Type | +|----------------|-------------|-----------| +| String | String | `java.lang.String` | +| Integer | Integer/Long | `java.lang.Long` | +| Decimal | Decimal | `java.math.BigDecimal` | +| Boolean | Boolean | `java.lang.Boolean` | +| DateTime | Date and time | `java.util.Date` | +| Object | Entity | `IMendixObject` | +| List | List of Entity | `java.util.List` | +| StringTemplate(Sql) | SQL template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | +| StringTemplate(Text) | Text template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | + +**Note:** `stringtemplate(sql)` and `stringtemplate(text)` are specialized types for parameterized SQL/OQL queries and text templates respectively. + +### Step 3: Export for Eclipse + +1. Menu → **App** → **Deploy for Eclipse** +2. Open project in Eclipse +3. Find Java action in `javasource//actions/` +## Part 2: Writing Java Action Code + +### Basic Structure + +```java +package mymodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.webui.CustomJavaAction; +import com.mendix.core.Core; +import com.mendix.systemwideinterfaces.core.IMendixObject; + +public class JA_CalculateTax extends CustomJavaAction +{ + private java.math.BigDecimal amount; + private java.math.BigDecimal taxRate; + + public JA_CalculateTax(IContext context, java.math.BigDecimal amount, java.math.BigDecimal taxRate) + { + super(context); + this.amount = amount; + this.taxRate = taxRate; + } + + @java.lang.Override + public java.math.BigDecimal executeAction() throws Exception + { + // begin user CODE + if (this.amount == null || this.taxRate == null) { + return java.math.BigDecimal.ZERO; + } + + return this.amount.multiply(this.taxRate); + // end user CODE + } +} +``` + +**CRITICAL**: Only code between `// begin user CODE` and `// end user CODE` is preserved. Everything else is regenerated by Studio Pro. + +### Working with Mendix Objects + +```java +@java.lang.Override +public IMendixObject executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + // create a new object + IMendixObject order = Core.instantiate(context, "Sales.Order"); + order.setValue(context, "OrderNumber", "ORD-" + System.currentTimeMillis()); + order.setValue(context, "OrderDate", new java.util.Date()); + order.setValue(context, "status", "Draft"); + order.setValue(context, "TotalAmount", java.math.BigDecimal.ZERO); + + // commit to database + Core.commit(context, order); + + return order; + // end user CODE +} +``` + +### Core API Reference + +| Method | Description | +|--------|-------------| +| `Core.instantiate(context, "Module.Entity")` | Create new object | +| `Core.commit(context, object)` | Save to database | +| `Core.commitWithoutEvents(context, object)` | Save without triggering events | +| `Core.delete(context, object)` | Delete object | +| `Core.rollback(context, object)` | Discard uncommitted changes | +| `Core.retrieveId(context, id)` | Retrieve by GUID | +| `Core.createXPathQuery(xpath).execute(context)` | Query with XPath | +| `Core.microflowCall(name).execute(context)` | Call microflow | + +### Reading and Writing Attributes + +```java +// Reading values +string name = (string) order.getValue(context, "Name"); +java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); +boolean isActive = (boolean) order.getValue(context, "IsActive"); +java.util.Date orderDate = (java.util.Date) order.getValue(context, "OrderDate"); + +// Writing values +order.setValue(context, "Name", "New Order"); +order.setValue(context, "Amount", new java.math.BigDecimal("100.00")); +order.setValue(context, "IsActive", true); +order.setValue(context, "ProcessedDate", new java.util.Date()); +``` + +### Working with Associations + +```java +// set association (reference) +IMendixObject customer = Core.createXPathQuery("//Sales.Customer[CustomerCode = 'CUST001']") + .execute(context).get(0); +order.setValue(context, "Sales.Order_Customer", customer.getId()); + +// get associated object +IMendixIdentifier customerId = order.getValue(context, "Sales.Order_Customer"); +if (customerId != null) { + IMendixObject relatedCustomer = Core.retrieveId(context, customerId); + string customerName = (string) relatedCustomer.getValue(context, "Name"); +} +``` + +### Working with Lists + +```java +// retrieve list +list orders = Core.createXPathQuery("//Sales.Order[status = 'Pending']") + .execute(context); + +// Process list +java.math.BigDecimal total = java.math.BigDecimal.ZERO; +for (IMendixObject order : orders) { + java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); + if (amount != null) { + total = total.add(amount); + } +} + +// create list to return +list results = new java.util.ArrayList<>(); +results.add(order1); +results.add(order2); +return results; +``` + +### Error Handling + +```java +@java.lang.Override +public boolean executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + try { + // business logic here + IMendixObject order = Core.instantiate(context, "Sales.Order"); + order.setValue(context, "OrderNumber", generateOrderNumber()); + Core.commit(context, order); + return true; + + } catch (Exception e) { + // log error + Core.getLogger("MyModule").error("Failed to create order: " + e.getMessage(), e); + + // Optionally throw to show error to user + throw new com.mendix.systemwideinterfaces.MendixRuntimeException( + "Could not create order: " + e.getMessage()); + } + // end user CODE +} +``` + +### Logging + +```java +import com.mendix.logging.ILogNode; + +// get logger +ILogNode logger = Core.getLogger("MyModule.MyAction"); + +// log at different levels +logger.trace("Detailed trace message"); +logger.debug("debug information"); +logger.info("Processing started for order: " + orderNumber); +logger.warn("Unusual condition detected"); +logger.error("error processing order", exception); +logger.critical("critical system failure"); +``` +## Part 4: Common Java Action Patterns + +### Pattern 1: Validation Helper + +**Java Action Definition:** +- Name: `JA_ValidateEmail` +- Parameter: `EmailAddress` (String) +- Return: Boolean + +```java +@java.lang.Override +public java.lang.Boolean executeAction() throws Exception +{ + // begin user CODE + if (this.EmailAddress == null || this.EmailAddress.trim().isEmpty()) { + return false; + } + + string emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"; + return this.EmailAddress.matches(emailRegex); + // end user CODE +} +``` + +**MDL Usage:** +```mdl +create microflow Customer.VAL_CustomerEmail($customer: Customer.Customer) +returns boolean as $isValid +begin + $isValid = call java action Customer.JA_ValidateEmail( + EmailAddress = $customer/Email + ); + + if not($isValid) then + validation feedback $customer/Email + message 'Please enter a valid email address'; + end if; + + return $isValid; +end; +``` + +### Pattern 2: External API Call + +**Java Action Definition:** +- Name: `JA_FetchExchangeRate` +- Parameters: `FromCurrency` (String), `ToCurrency` (String) +- Return: Decimal + +```java +@java.lang.Override +public java.math.BigDecimal executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + try { + // build api url + string url = "https://api.exchangerate.host/convert?from=" + + this.FromCurrency + "&to=" + this.ToCurrency; + + // Make HTTP request (using your preferred HTTP client) + java.net.HttpURLConnection conn = + (java.net.HttpURLConnection) new java.net.URL(url).openConnection(); + conn.setRequestMethod("get"); + + // Parse response + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream())); + StringBuilder response = new StringBuilder(); + string line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + + // Parse json and extract rate (simplified) + // in production, use a proper json library + string json = response.toString(); + int rateIndex = json.indexOf("\"result\":"); + if (rateIndex > 0) { + string rateStr = json.substring(rateIndex + 9, json.indexOf(",", rateIndex)); + return new java.math.BigDecimal(rateStr.trim()); + } + + return java.math.BigDecimal.ONE; + + } catch (Exception e) { + Core.getLogger("ExchangeRate").error("Failed to fetch rate", e); + throw new com.mendix.systemwideinterfaces.MendixRuntimeException( + "Could not fetch exchange rate: " + e.getMessage()); + } + // end user CODE +} +``` + +**MDL Usage:** +```mdl +create microflow Finance.ACT_ConvertCurrency( + $amount: decimal, + $fromCurrency: string, + $toCurrency: string +) +returns decimal as $convertedAmount +begin + declare $rate decimal; + + $rate = call java action Finance.JA_FetchExchangeRate( + FromCurrency = $fromCurrency, + ToCurrency = $toCurrency + ); + + set $convertedAmount = $amount * $rate; + return $convertedAmount; +end; +``` + +### Pattern 3: File Processing + +**Java Action Definition:** +- Name: `JA_GeneratePDF` +- Parameters: `Order` (Sales.Order entity), `TemplateName` (String) +- Return: System.FileDocument + +```java +@java.lang.Override +public IMendixObject executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + // get order data + string orderNumber = (string) this.Order.getValue(context, "OrderNumber"); + java.math.BigDecimal total = (java.math.BigDecimal) this.Order.getValue(context, "TotalAmount"); + + // generate PDF content (using iText or similar library) + byte[] pdfContent = generatePdfBytes(orderNumber, total); + + // create FileDocument + IMendixObject fileDoc = Core.instantiate(context, "System.FileDocument"); + fileDoc.setValue(context, "Name", "Order_" + orderNumber + ".pdf"); + Core.storeFileDocumentContent(context, fileDoc, + new java.io.ByteArrayInputStream(pdfContent)); + + Core.commit(context, fileDoc); + + return fileDoc; + // end user CODE +} +``` + +**MDL Usage:** +```mdl +create microflow Sales.ACT_GenerateOrderPDF($order: Sales.Order) +returns System.FileDocument as $pdfFile +begin + $pdfFile = call java action Sales.JA_GeneratePDF( + Order = $order, + TemplateName = 'OrderConfirmation' + ); + + log info node 'Sales' 'Generated PDF for order: ' + $order/OrderNumber; + + return $pdfFile; +end; +``` diff --git a/.claude/skills/mendix/java-dependencies.md b/.claude/skills/mendix/java-dependencies/SKILL.md similarity index 88% rename from .claude/skills/mendix/java-dependencies.md rename to .claude/skills/mendix/java-dependencies/SKILL.md index d50e13f0b..74755513c 100644 --- a/.claude/skills/mendix/java-dependencies.md +++ b/.claude/skills/mendix/java-dependencies/SKILL.md @@ -1,3 +1,8 @@ +--- +name: java-dependencies +description: "Add, update, remove and audit a module's Maven/JAR dependencies in MDL — Studio Pro's Module Settings → Java dependencies tab. Use when a Java action needs an external library, when bumping a library version, or when auditing which modules pull what." +--- + # Managing Maven / JAR Dependencies This skill explains how to add, update, and remove Maven/JAR dependencies in a @@ -125,5 +130,5 @@ The version is stored separately and shown in `LIST JAR DEPENDENCIES` output. ## Related Skills -- [java-actions.md](./java-actions.md) — authoring Java actions -- [project-settings.md](./project-settings.md) — other module/project settings +- [java-actions](../java-actions/SKILL.md) — authoring Java actions +- [project-settings](../project-settings/SKILL.md) — other module/project settings diff --git a/.claude/skills/mendix/javascript-actions.md b/.claude/skills/mendix/javascript-actions/SKILL.md similarity index 92% rename from .claude/skills/mendix/javascript-actions.md rename to .claude/skills/mendix/javascript-actions/SKILL.md index b058e49e9..0751d0435 100644 --- a/.claude/skills/mendix/javascript-actions.md +++ b/.claude/skills/mendix/javascript-actions/SKILL.md @@ -1,3 +1,8 @@ +--- +name: javascript-actions +description: "Create, call and drop JavaScript actions — client-side logic called from nanoflows, not microflows. Use when behaviour must run in the browser or on the device: DOM, fetch, geolocation, or wrapping a marketplace JS action." +--- + # Mendix JavaScript Actions Skill Guidance for creating, calling, and dropping **JavaScript actions** in Mendix @@ -11,7 +16,7 @@ called from **nanoflows** (not microflows — those use Java actions). - Calling an existing JavaScript action from a nanoflow - Wrapping a marketplace/native capability exposed as a JS action -For **server-side** custom code, use Java actions instead (see `java-actions.md`). +For **server-side** custom code, use Java actions instead (see `java-actions`). ## Overview @@ -182,6 +187,6 @@ DESCRIBE JAVASCRIPT ACTION Module.Name; -- re-executable MDL (signature + body ## Related Documentation -- `java-actions.md` — server-side custom code (microflows) -- `write-nanoflows.md` — nanoflow syntax and restrictions +- `java-actions` — server-side custom code (microflows) +- `write-nanoflows` — nanoflow syntax and restrictions - `mxcli syntax javascript-action` — quick syntax reference diff --git a/.claude/skills/mendix/json-structures-and-mappings.md b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md similarity index 98% rename from .claude/skills/mendix/json-structures-and-mappings.md rename to .claude/skills/mendix/json-structures-and-mappings/SKILL.md index 3e9a379c9..ae406bb99 100644 --- a/.claude/skills/mendix/json-structures-and-mappings.md +++ b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md @@ -1,3 +1,8 @@ +--- +name: json-structures-and-mappings +description: "Create and manage JSON structures, import mappings and export mappings in MDL, plus the domain-model shapes they map onto. Use when turning a JSON payload into entities, mapping a REST or queue response, or exporting objects as JSON." +--- + # JSON Structures, Import Mappings & Export Mappings This skill covers creating and managing JSON structures, import mappings, and export mappings in Mendix using MDL. @@ -619,7 +624,7 @@ leaves placement alone — it never returns a document to the module root — so adding a folder to an existing script is safe and removing one is a no-op. `describe` emits the clause, so a description replays into the same folder. -See `organize-project.md` for `move` and the full folder story. +See `organize-project` for `move` and the full folder story. ## Common Mistakes diff --git a/.claude/skills/mendix/live-edit-with-studio-pro.md b/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md similarity index 94% rename from .claude/skills/mendix/live-edit-with-studio-pro.md rename to .claude/skills/mendix/live-edit-with-studio-pro/SKILL.md index df3a04fa1..7b84fb2dc 100644 --- a/.claude/skills/mendix/live-edit-with-studio-pro.md +++ b/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md @@ -1,3 +1,8 @@ +--- +name: live-edit-with-studio-pro +description: "Change the model through mxcli while Studio Pro has the project open, with edits appearing live over MCP. Use when Claude Code and Studio Pro run on the same machine and a save-and-reopen cycle is unwanted." +--- + # Live-Editing an Open Studio Pro Project with mxcli (MCP) Use mxcli to change the model **while Studio Pro has the project open**, with edits diff --git a/.claude/skills/mendix/manage-navigation.md b/.claude/skills/mendix/manage-navigation/SKILL.md similarity index 97% rename from .claude/skills/mendix/manage-navigation.md rename to .claude/skills/mendix/manage-navigation/SKILL.md index 563833b7d..46f27514c 100644 --- a/.claude/skills/mendix/manage-navigation.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -1,3 +1,8 @@ +--- +name: manage-navigation +description: "Inspect and change navigation profiles in MDL — home pages, menus, login and not-found pages, and role-based routing. Use when asked to change where the app opens, restructure the menu, or route roles to different home pages." +--- + # Navigation Management Skill This skill covers inspecting and modifying Mendix navigation profiles via MDL: home pages, menu items, login pages, role-based routing, and navigation catalog queries. diff --git a/.claude/skills/mendix/manage-security.md b/.claude/skills/mendix/manage-security/SKILL.md similarity index 97% rename from .claude/skills/mendix/manage-security.md rename to .claude/skills/mendix/manage-security/SKILL.md index 6e111a6ac..43aaa9375 100644 --- a/.claude/skills/mendix/manage-security.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -1,3 +1,8 @@ +--- +name: manage-security +description: "Configure Mendix security in MDL — module and user roles, access to microflows, pages and entities, project security level, demo users, and guest access. Use when setting up or changing who can see or do what in an app." +--- + # Security Management Skill This skill covers Mendix security configuration via MDL: module roles, user roles, access control (microflows, pages, entities), project security settings, and demo users. @@ -327,7 +332,7 @@ Security data is available in Starlark lint rules (`.star` files): | `role_mappings()` | list of role_mapping | User role → module role mappings | | `project_security()` | struct or None | Security level, guest access, password policy | -See `write-lint-rules.md` for object property details. +See `write-lint-rules` for object property details. ## Common Workflow: Setting Up Module Security diff --git a/.claude/skills/mendix/master-detail-pages.md b/.claude/skills/mendix/master-detail-pages/SKILL.md similarity index 93% rename from .claude/skills/mendix/master-detail-pages.md rename to .claude/skills/mendix/master-detail-pages/SKILL.md index b6a7ed9eb..2e958c1ae 100644 --- a/.claude/skills/mendix/master-detail-pages.md +++ b/.claude/skills/mendix/master-detail-pages/SKILL.md @@ -1,3 +1,8 @@ +--- +name: master-detail-pages +description: "The master-detail page pattern in MDL: a selectable list beside a detail form driven by SELECTION. Use when building a selection-based screen rather than a plain list or form." +--- + # Master-Detail Pages ## Overview @@ -174,9 +179,9 @@ template template1 { ## Related Skills -- [Overview Pages](./overview-pages.md) - CRUD page patterns -- [Create Page](./create-page.md) - Basic page syntax -- [ALTER PAGE/SNIPPET](./alter-page.md) - Modify existing pages in-place (SET, INSERT, DROP, REPLACE) +- [Overview Pages](../overview-pages/SKILL.md) - CRUD page patterns +- [Create Page](../create-page/SKILL.md) - Basic page syntax +- [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) - Modify existing pages in-place (SET, INSERT, DROP, REPLACE) ## Implementation Notes diff --git a/.claude/skills/mendix/mdl-entities.md b/.claude/skills/mendix/mdl-entities/SKILL.md similarity index 97% rename from .claude/skills/mendix/mdl-entities.md rename to .claude/skills/mendix/mdl-entities/SKILL.md index fd0ad395b..9c2d8ca63 100644 --- a/.claude/skills/mendix/mdl-entities.md +++ b/.claude/skills/mendix/mdl-entities/SKILL.md @@ -1,3 +1,8 @@ +--- +name: mdl-entities +description: "Complete syntax reference for entities, attributes and associations — every entity kind, attribute type, and association form. Use when writing or altering a domain model and the exact spelling matters." +--- + # MDL Entity Syntax Reference Complete syntax reference for creating entities, attributes, and associations. diff --git a/.claude/skills/mendix/migrate-design-prototype.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md similarity index 96% rename from .claude/skills/mendix/migrate-design-prototype.md rename to .claude/skills/mendix/migrate-design-prototype/SKILL.md index cdb88eb32..750a13846 100644 --- a/.claude/skills/mendix/migrate-design-prototype.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -1,3 +1,8 @@ +--- +name: migrate-design-prototype +description: "Reproduce a Claude Design prototype or design handoff (HTML/CSS, .dc.html export, tokens, screenshots) inside a Mendix app as an SCSS theme plus styled pages. Use when given a design artefact and asked to make the app look like it." +--- + # Migrate a Claude Design Prototype into a Mendix App (Theme + Pages) ## When to Use This Skill @@ -13,10 +18,10 @@ It covers the two halves of the job: 2. **Apply it in pages** — attach the theme's classes to widgets with MDL (`Class:` / `DynamicClasses:` on `create page` / `alter page`). -Related skills: **`atlas-design.md` (read first — the Atlas-first taste + workflow layer)**, -`theme-styling.md` (SCSS compilation chain, hot-reload, styling caveats), -`create-page.md` (widget syntax), `alter-page.md` (in-place widget edits), -`bulk-widget-updates.md` (apply a class across many widgets). +Related skills: **`atlas-design` (read first — the Atlas-first taste + workflow layer)**, +`theme-styling` (SCSS compilation chain, hot-reload, styling caveats), +`create-page` (widget syntax), `alter-page` (in-place widget edits), +`bulk-widget-updates` (apply a class across many widgets). --- @@ -42,7 +47,7 @@ screenshots ──③ reference ──► widgets get Class: / Dyna screen, open the matching screenshot/handoff for that screen and match it — colours, spacing, font, component shapes. Do not invent styling the prototype doesn't show. -**Atlas-first (read `atlas-design.md`).** Reproduce the prototype with what Atlas already +**Atlas-first (read `atlas-design`).** Reproduce the prototype with what Atlas already gives you *before* hand-writing custom SCSS. In order of preference: 1. **An Atlas building block** — `use building block Atlas_Web_Content.Card` / `Pageheader` @@ -61,7 +66,7 @@ gives you *before* hand-writing custom SCSS. In order of preference: class (below) only when Atlas genuinely can't express the shape (bespoke chrome, fractional-track grids, pixel-exact rows). Hand-rolling `.panel`/`.stat`/`.card` SCSS that just re-implements what `class:'card'` already does is the single most common - mistake — see `atlas-design.md`. + mistake — see `atlas-design`. The rest of this skill (custom SCSS components, `.ss-*` classes, ListView row reshaping) is **layer 4** — the identity layer you drop to when the first three don't reach the design. @@ -147,7 +152,7 @@ is there a building block (`show building blocks`) or an Atlas class / design pr (`card`, `btn-*`, `spacing-*`, `flex-*`+`align-*`, `['Card style': on]`) that gets you most of the way? If so, use it and add a thin `.ss-*` class only for the brand delta (colour, radius, font). Re-implementing `card`/`panel`/`btn` from scratch is the mistake -`atlas-design.md` exists to prevent. +`atlas-design` exists to prevent. When Atlas can't express the shape, write **one reusable class** driven by the tokens from step ①. Keep classes small and composable so a widget can stack several @@ -290,7 +295,7 @@ runtime (verified on the dashboard). **Real Mendix Charts** (BarChart/LineChart/PieChart/HeatMap/…) are fully authorable too — each `series`/`line` binds its own OQL-view datasource + X/Y attributes; Pie/HeatMap bind at the widget level (`ValueAttribute:`, Pie needs `SeriesName:`). -See **[Custom & Pluggable Widgets → Charts](custom-widgets.md)** for the chart-type +See **[Custom & Pluggable Widgets → Charts](../custom-widgets/SKILL.md)** for the chart-type → id table, per-chart required-property gotchas (TimeSeries needs a datetime X, Bubble needs a size attribute), and the **CE0463 → `mxcli docker check`/`build`** step (these normalize widgets *and* preserve MPRv2 storage — never run bare @@ -334,7 +339,7 @@ mxcli -p baedemo.mpr -c "SHOW NAVIGATION MENU Responsive" # the menu tree Add or reorder items with `CREATE OR REPLACE NAVIGATION …` (full-replacement — dump the current profile first with `DESCRIBE NAVIGATION `, edit, re-apply). See -`manage-navigation.md` for the item syntax, home/login pages, and role-based homes. +`manage-navigation` for the item syntax, home/login pages, and role-based homes. ### Style the shell to match the design @@ -477,7 +482,7 @@ steps is usually plenty). ### Adding classes to an existing page -Use `alter page` to attach a class without rewriting the page (see `alter-page.md`): +Use `alter page` to attach a class without rewriting the page (see `alter-page`): ```sql alter page ResourceScheduling.Approvals { @@ -485,14 +490,14 @@ alter page ResourceScheduling.Approvals { } ``` -To apply the same class across many widgets/pages at once, see `bulk-widget-updates.md` +To apply the same class across many widgets/pages at once, see `bulk-widget-updates` (`update widgets ... dry run` first). --- ## ④ Build & Preview -SCSS is **not** live — you must compile before the theme shows. See `theme-styling.md`. +SCSS is **not** live — you must compile before the theme shows. See `theme-styling`. ```bash mxcli docker build -p baedemo.mpr # compiles SCSS into the deployment package (~55s) @@ -507,7 +512,7 @@ mxcli docker reload -p baedemo.mpr --css # pushes compiled CSS to browsers (in ## ⑤ Verify Against the Prototype Put the running screen next to the handoff screenshot and reconcile the diff — spacing, -colours, font, component shapes. The project already has Playwright wired in (`test-app.md`) +colours, font, component shapes. The project already has Playwright wired in (`test-app`) for screenshotting the running app. Iterate ②–④ per screen until it matches. --- @@ -564,9 +569,9 @@ settles it in one pass — see `.claude/skills/verify-in-runtime.md`. - **For bespoke tables, prefer a styled `listview`** over the `datagrid` pluggable widget — you control the full row markup, which a pixel-faithful design usually needs. - **Prefer typed `designproperties:` / Atlas classes over custom SCSS for anything Atlas - covers** (spacing, alignment, card/background, flex layout) — see `atlas-design.md`. Keys + covers** (spacing, alignment, card/background, flex layout) — see `atlas-design`. Keys and values are case-sensitive; `mxcli check -p` validates them (MDL-WIDGET11/12) and lists - the allowed values. `theme-styling.md` has the compilation/reload mechanics. + the allowed values. `theme-styling` has the compilation/reload mechanics. - **`alter styling` can't find widgets in MDL-builder-created pages** — apply classes via `Class:`/`DynamicClasses:` in `create page` / `alter page` instead. @@ -583,7 +588,7 @@ the fast index so a design migration doesn't rediscover them. `visible`, `dynamicclasses`, and inline-bracket XPath `where` — mxcli now **strips** stray identifier quotes, so a quoted attribute no longer breaks the build or makes an XPath `where` silently return 0 rows; still prefer the unquoted form there for readability. See - `write-microflows.md`. + `write-microflows`. - **Show a related (to-one) object's attributes** — two ways, both persist now: - A nested **"data from context" DataView** for a full read view of the referenced object; its children bind to the related entity: @@ -608,11 +613,11 @@ the fast index so a design migration doesn't rediscover them. SVG** container (or `HTMLElement`) for sparklines/trends — no datasource — and `ProgressCircle` (`type: expression`, `expressionCurrentValue: '$currentObject/Rate'`, min `'0'` / max `'100'`, `labelType: percentage`) for a single-value gauge. -- **Dashboard aggregates → OQL view entities** (`write-oql-queries.md`). A grouped enum column in +- **Dashboard aggregates → OQL view entities** (`write-oql-queries`). A grouped enum column in the view must be typed `enumeration(Module.Enum)`, not `string`, or you get CE6770. Test with `mxcli oql` first. - **Seed demo data via an after-startup microflow** guarded to run once (skip if data exists); it - must `return true`. Direct-SQL seeding doesn't apply to the default **HSQLDB**. See `demo-data.md`. + must `return true`. Direct-SQL seeding doesn't apply to the default **HSQLDB**. See `demo-data`. - **New entities / view entities need a runtime restart** (`docker up --detach --wait`) to register — a hot `reload` won't see them. Hot `reload` also occasionally crashes the runtime; if the app stops responding, `docker up --detach --wait` recovers it with data intact. diff --git a/.claude/skills/mendix/migrate-k2-nintex.md b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md similarity index 95% rename from .claude/skills/mendix/migrate-k2-nintex.md rename to .claude/skills/mendix/migrate-k2-nintex/SKILL.md index 36609ae33..aee2db8e0 100644 --- a/.claude/skills/mendix/migrate-k2-nintex.md +++ b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md @@ -1,3 +1,8 @@ +--- +name: migrate-k2-nintex +description: "Assess and migrate K2 / Nintex K2 applications to Mendix — SmartObjects to domain models, SmartForms to pages, K2 workflows to microflows and Mendix workflows. Use when analysing or converting a K2 application." +--- + # K2/Nintex to Mendix Migration Skill This skill provides comprehensive guidance for assessing and migrating K2 (now Nintex K2) applications to Mendix using MDL (Mendix Definition Language). @@ -463,9 +468,9 @@ If you have a `.kspx` file, it can potentially be parsed (it's a ZIP-based forma ## Related Skills -- [/assess-migration](./assess-migration.md) - General migration assessment framework -- [/generate-domain-model](./generate-domain-model.md) - Creating entities and associations in MDL -- [/write-microflows](./write-microflows.md) - Implementing business logic in MDL -- [/create-page](./create-page.md) - Building pages in MDL -- [/manage-security](./manage-security.md) - Setting up roles and access rules -- [/organize-project](./organize-project.md) - Folder structure for the migrated project +- [/assess-migration](../assess-migration/SKILL.md) - General migration assessment framework +- [/generate-domain-model](../generate-domain-model/SKILL.md) - Creating entities and associations in MDL +- [/write-microflows](../write-microflows/SKILL.md) - Implementing business logic in MDL +- [/create-page](../create-page/SKILL.md) - Building pages in MDL +- [/manage-security](../manage-security/SKILL.md) - Setting up roles and access rules +- [/organize-project](../organize-project/SKILL.md) - Folder structure for the migrated project diff --git a/.claude/skills/mendix/migrate-oracle-forms.md b/.claude/skills/mendix/migrate-oracle-forms/SKILL.md similarity index 95% rename from .claude/skills/mendix/migrate-oracle-forms.md rename to .claude/skills/mendix/migrate-oracle-forms/SKILL.md index e64d7b838..554c42976 100644 --- a/.claude/skills/mendix/migrate-oracle-forms.md +++ b/.claude/skills/mendix/migrate-oracle-forms/SKILL.md @@ -1,3 +1,8 @@ +--- +name: migrate-oracle-forms +description: "Assess and migrate Oracle Forms applications to Mendix — .fmb forms to pages, PL/SQL to microflows, and a staged migration strategy. Use when analysing or converting an Oracle Forms system." +--- + # Oracle Forms to Mendix Migration Skill This skill provides comprehensive guidance for migrating Oracle Forms applications to Mendix using MDL (Mendix Definition Language). @@ -63,7 +68,7 @@ MDL scripts execute statements sequentially. Items created in one statement can ```mdl -- check-skip: the PHASE 3 page block uses shorthand pseudo-syntax -- (layout/title/parameter/widgets, dataview source, INPUT ...) to sketch the --- migrated UI concept; it is not runnable MDL. See create-page.md / overview-pages.md. +-- migrated UI concept; it is not runnable MDL. See create-page / overview-pages. -- ============================================ -- PHASE 1: Domain Model (Entities & Associations) -- ============================================ @@ -316,7 +321,7 @@ datagrid source $OrderList ( **Oracle Forms Master-Detail → Mendix:** ```mdl -- check-skip: shorthand pseudo-syntax sketch of the migrated page, not runnable --- MDL. See create-page.md / overview-pages.md for real page syntax. +-- MDL. See create-page / overview-pages for real page syntax. create page MyModule.CustomerOrders layout Atlas_Default title 'Customer Orders' @@ -399,7 +404,7 @@ After migration: ## Related Skills -- [/write-microflows](./write-microflows.md) - Detailed microflow syntax +- [/write-microflows](../write-microflows/SKILL.md) - Detailed microflow syntax - [/create-crud](./create-crud.md) - Generate CRUD operations -- [/overview-pages](./overview-pages.md) - Page building patterns -- [/master-detail-pages](./master-detail-pages.md) - Master-detail layouts +- [/overview-pages](../overview-pages/SKILL.md) - Page building patterns +- [/master-detail-pages](../master-detail-pages/SKILL.md) - Master-detail layouts diff --git a/.claude/skills/mendix/mock-rest-apis.md b/.claude/skills/mendix/mock-rest-apis/SKILL.md similarity index 90% rename from .claude/skills/mendix/mock-rest-apis.md rename to .claude/skills/mendix/mock-rest-apis/SKILL.md index 0f99d25ec..6d67bb73f 100644 --- a/.claude/skills/mendix/mock-rest-apis.md +++ b/.claude/skills/mendix/mock-rest-apis/SKILL.md @@ -1,3 +1,8 @@ +--- +name: mock-rest-apis +description: "Stand up an HTTP endpoint you control instead of a live third-party API, and point the Mendix app at it — Prism from an OpenAPI contract, a constant swap, or a forward proxy. Use when building or debugging a REST integration without the real API, forcing a 404/500 through an error handler, or running offline or in CI." +--- + # Mock REST APIs Skill Use this skill when a REST integration needs an endpoint you control instead of a @@ -14,7 +19,7 @@ the BSON. A mock removes the variables that are not the bug. - Building a REST client or `REST CALL` microflow before (or without) real credentials - Reproducing a payload-shaped bug **deterministically** — a shape small enough to read, that behaves the same on every run - Exercising error paths: 404, 500, a timeout, a 401 from missing auth -- Verifying the app (`test-app.md`) or running a suite (`test-microflows.md`) offline or in CI +- Verifying the app (`test-app`) or running a suite (`test-microflows`) offline or in CI - Redirecting the outbound calls of an app whose model you must not edit ## Two separate problems @@ -157,7 +162,7 @@ Two things to know before committing to this route: For **consumed OData** services specifically there is a fourth route that needs no JVM flags: `System.ConsumedODataConfiguration` carries `ProxyConfiguration`, -`ProxyHost` and `ProxyPort` as data (see `system-module.md`), so the proxy can be +`ProxyHost` and `ProxyPort` as data (see `system-module`), so the proxy can be set per service at runtime. ## Verify the mock before blaming Mendix @@ -187,8 +192,8 @@ Then, and only then, run the microflow and check the payload actually reached it ## Related Skills -- `rest-client.md` — the three ways to call a REST API; where the contract goes once you have one -- `rest-call-from-json.md` — JSON structure → entities → import mapping → `REST CALL` -- `test-app.md` — browser verification; a REST app's prerequisite is a reachable endpoint -- `test-microflows.md` — running a suite; `--constant` points it at the mock -- `run-local.md` — `mxcli run --local`, the warm loop the mock plugs into +- `rest-client` — the three ways to call a REST API; where the contract goes once you have one +- `rest-call-from-json` — JSON structure → entities → import mapping → `REST CALL` +- `test-app` — browser verification; a REST app's prerequisite is a reachable endpoint +- `test-microflows` — running a suite; `--constant` points it at the mock +- `run-local` — `mxcli run --local`, the warm loop the mock plugs into diff --git a/.claude/skills/mendix/odata-data-sharing/SKILL.md b/.claude/skills/mendix/odata-data-sharing/SKILL.md new file mode 100644 index 000000000..c1b5a7a88 --- /dev/null +++ b/.claude/skills/mendix/odata-data-sharing/SKILL.md @@ -0,0 +1,304 @@ +--- +name: odata-data-sharing +description: "Share data between Mendix apps over OData — published services, view entities as an abstraction layer, consumed clients and external entities. Use when exposing data from one app to another, consuming another app's OData service, or refreshing a cached contract." +--- + +# OData Data Sharing Between Mendix Apps + +This skill covers how to use OData services to share data between Mendix applications, with emphasis on using view entities as an abstraction layer to decouple the API contract from the internal domain model. + +## Reference files + +`SKILL.md` covers the architecture, the contract rules, and the conventions. The +long build-outs are next door: + +- [`reference/walkthroughs.md`](reference/walkthroughs.md) — four complete + builds, start to finish: a read-only API behind view entities, a + non-persistable published entity, a read-write API with microflow handlers, + publishing a microflow as an OData action, and the GraphQL variant. +- [`reference/errors-and-auth.md`](reference/errors-and-auth.md) — which HTTP + status each capability can actually return, the authentication methods and what + Basic auth costs, and the configuration microflow for custom headers. + +## When to Use This Skill + +- User asks to expose data from one Mendix app to another +- User wants to set up inter-app communication via OData +- User needs to create an API layer that abstracts internal entities +- User asks about external entities, consumed/published OData services +- User wants to decouple modules or apps for independent deployment +- User asks about the view entity pattern for OData services +- User asks about local metadata files or offline OData development + +## MetadataUrl Formats + +`CREATE ODATA CLIENT` supports three formats for the `MetadataUrl` parameter: + +| Format | Example | Stored In Model | +|--------|---------|-----------------| +| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Unchanged | +| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Unchanged | +| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | **Normalized to absolute `file://`** | + +**Path Normalization:** +- Relative paths (with or without `./`) are **automatically converted** to absolute `file://` URLs in the Mendix model +- This ensures Studio Pro can properly detect local file vs HTTP metadata sources (radio button in UI) +- Example: `./metadata/service.xml` → `file:///absolute/path/to/project/metadata/service.xml` + +**Path Resolution (before normalization):** +- With project loaded (`-p` flag or REPL): relative paths are resolved against the `.mpr` file's directory +- Without project: relative paths are resolved against the current working directory + +## Refreshing the Cached Contract + +mxcli caches the `$metadata` document in the client so the model rebuilds without +the service running. That cache is a snapshot, and a consumed service that gains +entity sets makes it stale — the file on disk has five, the client still answers +three. + +`CREATE OR MODIFY ODATA CLIENT` re-reads the contract every time it runs, so +refreshing is a re-run of the statement you already have: + +```mdl +-- after refreshing ./contracts/live-now-metadata.xml from the running backend +CREATE OR MODIFY ODATA CLIENT F1Now.NowApi ( + ODataVersion: OData4, + MetadataUrl: './contracts/live-now-metadata.xml', + Timeout: 300, + ServiceUrl: '@F1Now.ApiLocation' +); +``` + +Read the verb it prints — it tells you which happened: + +| Output | Meaning | +|--------|---------| +| `Modified OData client: …` + `Refreshed $metadata: 5 entity types, 0 actions` | The contract changed and the client now carries the new one | +| `Unchanged OData client: …` | The contract is identical, so nothing was written | +| `Warning: could not refresh $metadata: …` | The contract could not be read; the **previously cached one is kept**, so re-run once it is reachable | + +Then re-import: `CREATE OR MODIFY EXTERNAL ENTITIES FROM F1Now.NowApi` maps the +new entity sets. Do **not** `DROP ODATA CLIENT` and recreate it to force a +refresh — that invalidates the client ID the existing external entities point at. + +Note that `ALTER ODATA CLIENT SET MetadataUrl = …` does *not* re-fetch. Use +`CREATE OR MODIFY` when the contract is what changed. + +**Use Cases for Local Metadata:** +- **Offline development** — no network access required +- **Testing and CI/CD** — reproducible builds with metadata snapshots +- **Version control** — commit metadata files alongside code +- **Pre-production** — test against upcoming API changes before deployment +- **Firewall-friendly** — works in locked-down corporate environments + +## ServiceUrl Must Be a Constant + +**IMPORTANT:** The `ServiceUrl` parameter **must always be a constant reference** (prefixed with `@`). Direct URLs are not allowed. + +**Correct:** +```sql +CREATE CONSTANT ProductClient.ProductDataApiLocation + TYPE String + DEFAULT 'http://localhost:8080/odata/productdataapi/v1/'; + +CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'https://api.example.com/$metadata', + ServiceUrl: '@ProductClient.ProductDataApiLocation' -- ✅ Constant reference +); +``` + +**Incorrect:** +```sql +CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'https://api.example.com/$metadata', + ServiceUrl: 'https://api.example.com/odata' -- ❌ Direct URL not allowed +); +``` + +This enforces Mendix best practice of externalizing configuration values for different environments. + +## Architecture Overview + +OData data sharing follows a **producer/consumer** pattern with three layers: + +``` +┌─────────────────────────────────────────────┐ +│ PRODUCER APP │ +│ │ +│ persistent entities ──▶ view entities │ +│ (Shop.Customer, (Api.CustomerVE) │ +│ Shop.Address) │ +│ ▼ │ +│ odata service │ +│ (Api.CustomerApi) │ +└──────────────────────┬──────────────────────┘ + │ HTTP/OData4 +┌──────────────────────▼──────────────────────┐ +│ CONSUMER APP │ +│ │ +│ odata client │ +│ (Client.CustomerApiClient) │ +│ ▼ │ +│ external entities │ +│ (Client.CustomersEE) │ +│ ▼ │ +│ pages & microflows │ +└─────────────────────────────────────────────┘ +``` + +### Why View Entities? + +Publishing persistent entities directly exposes your internal schema. When you change a column name, add a table, or restructure associations, every consumer app breaks. **View entities** solve this: + +1. **Stable API contract** -- the view's shape stays the same even when the underlying tables change +2. **Flattened data** -- joins across multiple tables into a single flat resource (e.g., Customer + BillingAddress + DeliveryAddress into one `CustomerAddressVE`) +3. **Computed fields** -- add calculated columns like `FullAddress` or `ActivePrice` using OQL expressions +4. **Filtered datasets** -- restrict what's visible (e.g., only active products, cheap products) +5. **Aggregations** -- expose pre-aggregated metrics (e.g., orders per day, sum of line items) + +## API Versioning + +When your API contract changes, create a new version rather than breaking existing consumers: + +```sql +-- v1: Original API (keep running for existing consumers) +create odata service ProductApi.ProductDataApi ( + path: 'odata/productdataapi/v1/', + version: '1.0.0', + ... +); + +-- v2: New version with additional fields +create odata service ProductApi.ProductDataApi_v2 ( + path: 'odata/productdataapi/v2/', + version: '2.0.0', + ODataVersion: OData4, + ServiceName: 'ProductDataApi', + Summary: 'Product API v2 - includes weight and tags', + ... +) +authentication basic +{ + publish entity ProductApi.ProductWithPriceAndTagsVE as 'Product' ( + ReadMode: ReadFromDatabase, + InsertMode: microflow ProductApi.InsertProductV2, + UpdateMode: microflow ProductApi.UpdateProductV2, + DeleteMode: microflow ProductApi.DeleteProductV2 + ) + expose (...); +}; +``` + +## Folder Organization + +Use the `Folder` property to organize OData documents within modules. + +**MetadataUrl accepts three formats:** +1. **HTTP(S) URL** — fetches from remote service (production) +2. **file:///absolute/path** — reads from local absolute path +3. **./path or path/file.xml** — reads from local relative path (resolved against .mpr directory) + +```sql +-- Format 1: HTTP(S) URL +create odata client ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'https://api.example.com/odata/v4/$metadata', + Folder: 'Integration/ProductAPI' +); + +-- Format 2: Absolute file:// URI +create odata client ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml', + Folder: 'Integration/ProductAPI' +); + +-- Format 3a: Relative path with ./ +create odata client ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: './metadata/productdataapi.xml', + Folder: 'Integration/ProductAPI' +); + +-- Format 3b: Relative path without ./ +create odata client ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'metadata/productdataapi.xml', + Folder: 'Integration/ProductAPI' +); + +create odata service ProductApi.ProductDataApi ( + path: 'odata/productdataapi/v1/', + version: '1.0.0', + ODataVersion: OData4, + folder: 'Integration/APIs' +) +authentication basic +{ ... }; +``` + +Folders are created automatically if they don't exist. Use `/` for nested folders. + +## Module Organization Conventions + +Follow this naming convention for clean separation: + +| Module | Purpose | Contains | +|--------|---------|----------| +| `Shop` | Core domain | Persistent entities, business logic | +| `ShopApi` or `ShopViews` | API layer (producer) | View entities, OData service, CUD microflows | +| `ShopClient` or `ShopViewsClient` | API consumer | OData client, external entities, client constants | + +This keeps the API contract separate from the domain logic, and the consumer separate from the producer. + +## Checklist + +Before publishing: +- [ ] View entities expose only the fields consumers need (no internal IDs unless needed for writes) +- [ ] View entity has at least one `key` field for OData identity +- [ ] Module role created and granted on view entities (READ, optionally WRITE) +- [ ] OData service has AUTHENTICATION set (Basic, Session, or Microflow) +- [ ] GRANT ACCESS ON ODATA SERVICE to the API module role +- [ ] CUD microflows (if writable) accept `($ViewEntity, $HttpRequest)` parameters +- [ ] CUD microflows granted EXECUTE to the API module role + +Before consuming: +- [ ] Location constant created for environment-specific URLs +- [ ] OData client `MetadataUrl` points to either: + - HTTP(S) URL: `https://api.example.com/$metadata` + - Local file (absolute): `file:///path/to/metadata.xml` + - Local file (relative): `./metadata/service.xml` (resolved against `.mpr` directory) +- [ ] OData client uses `ServiceUrl: '@Module.Constant'` for runtime endpoint +- [ ] External entities match the published exposed names and types +- [ ] Module role created and granted on external entities (READ, optionally CREATE/WRITE/DELETE) + +## Exploration Commands + +Use these commands to inspect existing OData setup in a project: + +```sql +-- List all published and consumed services +show odata services; +show odata clients; + +-- Inspect a specific service +describe odata service ShopViews.ShopViewsApi; +describe odata client ShopViewsClient.ShopViewsApiClient; + +-- See external entities and view entities +show entities in ShopViewsClient; +show external entities; +show external actions; + +-- Browse available assets from cached $metadata contract +show contract entities from ShopViewsClient.ShopViewsApiClient; +show contract actions from ShopViewsClient.ShopViewsApiClient; +describe contract entity ShopViewsClient.ShopViewsApiClient.Product; +describe contract entity ShopViewsClient.ShopViewsApiClient.Product format mdl; + +-- Check security setup +show access on odata service ShopViews.ShopViewsApi; +show module roles in ShopViews; +``` diff --git a/.claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md b/.claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md new file mode 100644 index 000000000..a3ba7e581 --- /dev/null +++ b/.claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md @@ -0,0 +1,259 @@ +# Status codes, capabilities and authentication + +Supporting reference for [odata-data-sharing](../SKILL.md). + +## HTTP Status Codes and Errors: What Each Capability Can Do + +**The read path and the write path have different powers, and the difference is +the single most expensive thing to get wrong here.** Read this before designing +any microflow-backed resource. + +| Capability | Can set the HTTP status code? | How | +|---|---|---| +| OData **action** (published microflow) | **Yes** | add a `System.HttpResponse` parameter | +| Entity **Insertable / Updatable / Deletable** microflow | **Yes** | add a `System.HttpResponse` parameter | +| Entity **Readable** microflow | **No** | not offered — the read capability has no documented `HttpResponse` parameter | + +Sources: [published-odata-microflow §4](https://docs.mendix.com/refguide/published-odata-microflow/#4-customizing-the-outgoing-http-response), +[published-odata-entity, custom HTTP response](https://docs.mendix.com/refguide/published-odata-entity/#custom-http-response). +The custom-response section names Insertable, Updatable and Deletable; the +Readable section never references it. + +### Writing a status code (action / insert / update / delete) + +```sql +create microflow Api.InsertRow ( + $Row: Api.Row, + $HttpResponse: System.HttpResponse +) +begin + if $Row/RowKey = empty then + change $HttpResponse (StatusCode = 400, Content = '{"error":"rowKey is required"}'); + return; + end if; + ... +end; +``` + +Three rules the platform imposes: + +- **`ReasonPhrase` is ignored.** Setting it is dead code; put the explanation in + `Content`. +- **`204` always produces an empty body.** Setting `Content` alongside it is + discarded. +- **Changing status or content makes the whole response come from + `HttpResponse`** — headers included. Changing *only* headers merges them with + the defaults instead. +- `Transfer-Encoding` and `Date` cannot be changed. + +### The read path cannot refuse, so it must not over-promise + +A read microflow has no way to answer `400`. Its only exits are to throw (a +blunt `500`) or to return data. That has two consequences, and both are design +obligations rather than nice-to-haves: + +**1. Declare capabilities you do not implement as `No`.** Mendix applies *no* +query options to a read-microflow resource — it hands over the request and +returns whatever comes back — so `TopSupported` / `SkipSupported` / `Countable` +are claims about your microflow, not about the platform. A resource that +advertises `TopSupported: Yes` and ignores `$top` returns the entire collection +with a `200`, and the client believes it received a page. + +```sql +publish entity Api.Row as 'Rows' ( + ReadMode: microflow Api.Read_Rows, + -- Only claim what Read_Rows actually parses out of the URI: + TopSupported: No, + SkipSupported: No, + Countable: No +) +``` + +Declaring `No` is the read path's substitute for the `400` it cannot send. It is +also safe to declare: mxcli reads these capabilities off the contract, so a +consuming app generated from this service says `No` too. (Until it learned to, +the generator always said `Yes`, and the consumer failed to build with **CE6630** +"'Rows' is marked supports $top=False in the OData service, but True in the app" +— if you hit that against an older mxcli, that is the cause.) + +**The capabilities vocabulary has two annotation shapes**, and it is worth knowing +which you are looking at when reading a `$metadata` by hand: + +| Capability | Shape | +|---|---| +| `TopSupported`, `SkipSupported` | standalone: `` | +| `Insertable`, `Updatable`, `Deletable`, `Countable` | a `` with a `Bool` property value | +| `Filterable`, `Sortable` | **either** — a record listing `NonFilterableProperties` when *some* attributes are filterable, or a bare `Bool="false"` on the record when *none* are | + +That last row is the trap. Mendix picks the shape by arithmetic (there is no list +to write when nothing is filterable), so both appear in one document on different +entity sets, and a service where an entity exposes only a KEY emits the whole-set +form. + +`mxcli check` enforces this as **MDL-ODATA03**, and it looks for the option +*names* in the microflow body, not just for a `System.HttpRequest` parameter — +adding the parameter to answer the KEY (below) does not silence the paging +warning. A microflow that hands the request to a Java action, a JavaScript +action, or a microflow outside the script is left alone, since mxcli cannot read +what those do. + +**2. Answer a lookup by your own KEY.** A client holding a row re-reads it by +key, unprompted, and Mendix's own OData client sends the `$filter` spelling: + +``` +?$filter=rowKey eq '1036-c' ← what the runtime actually sends +/Rows('1036-c') ← bare path key +/Rows(rowKey='1036-c') ← named path key +``` + +If the microflow parses only its collection filter, the key request falls through +to the collection default and the client adopts the **first row** as the identity +of the object it is displaying. There is no error: the request is well-formed, +the response is a valid collection, the count is right, the status is `200`. Two +different objects are then on screen at once, and nothing distinguishes them +until one travels to another page. + +So: `expose ( … (KEY) )` is a promise the *service* makes on the *microflow's* +behalf. Branch key → id → filter → default. + +**Not declaring the KEY is not a way out.** Mendix requires a published entity to +have one — `CE6585 "Published entity 'X' must have a key defined."` — so the only +correct resolution is to answer the lookup. (Query *options* you may decline; +the key you may not.) + +The request itself always arrives on `System.HttpRequest`: + +```sql +create microflow Api.Read_Rows ( + $Request: System.HttpRequest, + $Response: System.ODataResponse -- required while Countable is Yes +) +returns List of Api.Row +begin + log info 'URI=' + $Request/Uri; -- the whole query string, URL-encoded + ... +end; +``` + +To watch what clients actually send, raise the **`OData Publish`** log node (note +the space; it exists only when the project publishes a service): + +```bash +mxcli log set "OData Publish" TRACE +``` +``` +TRACE - OData Publish: Incoming request from 127.0.0.1: GET .../Rows?$top=5&$filter=rowKey eq 'abc' +DEBUG - OData Publish: Responding to client with status code 400. +``` + +That is the fastest way to see a client re-reading a row by key, and it needs no +change to the model. `ODataConsume` is the client side, a different node. + +Mendix validates field names before the microflow runs (`$filter=secretColumn eq 'x'` +is a `400` from the platform), and it enforces `Filterable`: filtering on a property +you did not declare filterable is rejected with `400 "Property 'x' is +non-filterable"` before the microflow runs. So the microflow only ever sees names +that exist in the published metadata. That is defence in depth, not a substitute for a +whitelist — it constrains the *name*, not what you do with it. +## Authentication Methods, and the Cost of Basic Auth + +A published service names one or more methods in the `authentication` clause: + +```sql +authentication basic, session +authentication microflow ProductApi.Authenticate +``` + +| Method | How the caller proves itself | Cost per request | +|---|---|---| +| `basic` | `Authorization: Basic …` on every request | **a full password hash** | +| `session` | an existing session + `X-Csrf-Token` | none, but only reachable from same-origin JavaScript in the same app | +| `guest` | anonymous | none | +| `microflow` | your microflow decides | whatever the microflow does | + +**Basic auth hashes on every call, including failures.** A consumed OData +service holds no session, so every request is a fresh login. Measured on a real +app, that was 60–80% of a page turn — and a *wrong* password costs the same, +because the hash runs either way: + +``` +GET /odata/f1/Drivers?$top=20 + basic auth 200 ~360 ms + wrong password 401 ~346 ms <- hashes either way + session cookie 200 ~19 ms + no credentials 401 ~42 ms +``` + +Do not read the 19 ms as the fix: `session` is only open to same-origin +JavaScript, and a Mendix OData *client* sends basic auth and keeps no cookie. + +### Custom authentication + +A microflow taking `List of System.HttpHeader` and returning a `System.User`. +Return empty to deny. No password hash anywhere, so the ~42 ms floor. + +```sql +CREATE MICROFLOW ProductApi.Authenticate ($Headers: List of System.HttpHeader) + RETURNS System.User AS $User +BEGIN + -- e.g. compare a shared secret from $Headers, then retrieve the service account + retrieve $Users from System.User; + $User = head($Users); + RETURN $User; +END; + +create odata service ProductApi.Api ( ... ) +authentication microflow ProductApi.Authenticate +{ ... }; +``` + +**The client needs no change.** The microflow can read the `Authorization: +Basic …` header itself and compare it against a constant, so the consumer keeps +its existing username/password configuration and the server simply stops +calling BCrypt. + +Two build rules to know before you reach for it: + +- **The microflow is mandatory.** `authentication microflow` with no name parses + but fails the build with **CE0333** "Please select a microflow to use for + authentication". `mxcli check` flags this as `MDL-ODATA04`. +- **App security must be on.** With security off, Mendix reports **CE6600** + "App security is off, but custom authentication is enabled for this service". + Set it with `alter project security level prototype` (or `production`). + +If custom authentication is more than you need, `ALTER SETTINGS MODEL +BcryptCost = 8` shrinks the hash instead of removing it. Each step down halves +the cost; the default is 12. That is a judgement about the accounts in *that* +app — appropriate for machine accounts with generated passwords, not for a +database of human passwords. +## Advanced: Configuration Microflow for Custom Headers + +When the consumer needs to pass custom headers (e.g., for audit trails or user context), use a configuration microflow: + +```sql +/** + * Adds current user name as custom header for audit logging. + */ +create microflow ProductClient.SetClientHeaders ( + $httpResponse: System.HttpResponse +) +returns list of System.HttpHeader as $HttpHeaderList +begin + $HttpHeaderList = create list of System.HttpHeader; + $NewHttpHeader = create System.HttpHeader ( + key = 'X-Audit-User', + value = $currentUser/Name + ); + add $NewHttpHeader to $HttpHeaderList; + return $HttpHeaderList; +end; +``` + +Reference it in the client: + +```sql +create odata client ProductClient.ProductDataApiClient ( + ... + ConfigurationMicroflow: microflow ProductClient.SetClientHeaders +); +``` diff --git a/.claude/skills/mendix/odata-data-sharing.md b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md similarity index 60% rename from .claude/skills/mendix/odata-data-sharing.md rename to .claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md index 08d37083d..73f370eb1 100644 --- a/.claude/skills/mendix/odata-data-sharing.md +++ b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md @@ -1,109 +1,6 @@ -# OData Data Sharing Between Mendix Apps +# Step-by-step build walkthroughs -This skill covers how to use OData services to share data between Mendix applications, with emphasis on using view entities as an abstraction layer to decouple the API contract from the internal domain model. - -## When to Use This Skill - -- User asks to expose data from one Mendix app to another -- User wants to set up inter-app communication via OData -- User needs to create an API layer that abstracts internal entities -- User asks about external entities, consumed/published OData services -- User wants to decouple modules or apps for independent deployment -- User asks about the view entity pattern for OData services -- User asks about local metadata files or offline OData development - -## MetadataUrl Formats - -`CREATE ODATA CLIENT` supports three formats for the `MetadataUrl` parameter: - -| Format | Example | Stored In Model | -|--------|---------|-----------------| -| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Unchanged | -| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Unchanged | -| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | **Normalized to absolute `file://`** | - -**Path Normalization:** -- Relative paths (with or without `./`) are **automatically converted** to absolute `file://` URLs in the Mendix model -- This ensures Studio Pro can properly detect local file vs HTTP metadata sources (radio button in UI) -- Example: `./metadata/service.xml` → `file:///absolute/path/to/project/metadata/service.xml` - -**Path Resolution (before normalization):** -- With project loaded (`-p` flag or REPL): relative paths are resolved against the `.mpr` file's directory -- Without project: relative paths are resolved against the current working directory - -**Use Cases for Local Metadata:** -- **Offline development** — no network access required -- **Testing and CI/CD** — reproducible builds with metadata snapshots -- **Version control** — commit metadata files alongside code -- **Pre-production** — test against upcoming API changes before deployment -- **Firewall-friendly** — works in locked-down corporate environments - -## ServiceUrl Must Be a Constant - -**IMPORTANT:** The `ServiceUrl` parameter **must always be a constant reference** (prefixed with `@`). Direct URLs are not allowed. - -**Correct:** -```sql -CREATE CONSTANT ProductClient.ProductDataApiLocation - TYPE String - DEFAULT 'http://localhost:8080/odata/productdataapi/v1/'; - -CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'https://api.example.com/$metadata', - ServiceUrl: '@ProductClient.ProductDataApiLocation' -- ✅ Constant reference -); -``` - -**Incorrect:** -```sql -CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'https://api.example.com/$metadata', - ServiceUrl: 'https://api.example.com/odata' -- ❌ Direct URL not allowed -); -``` - -This enforces Mendix best practice of externalizing configuration values for different environments. - -## Architecture Overview - -OData data sharing follows a **producer/consumer** pattern with three layers: - -``` -┌─────────────────────────────────────────────┐ -│ PRODUCER APP │ -│ │ -│ persistent entities ──▶ view entities │ -│ (Shop.Customer, (Api.CustomerVE) │ -│ Shop.Address) │ -│ ▼ │ -│ odata service │ -│ (Api.CustomerApi) │ -└──────────────────────┬──────────────────────┘ - │ HTTP/OData4 -┌──────────────────────▼──────────────────────┐ -│ CONSUMER APP │ -│ │ -│ odata client │ -│ (Client.CustomerApiClient) │ -│ ▼ │ -│ external entities │ -│ (Client.CustomersEE) │ -│ ▼ │ -│ pages & microflows │ -└─────────────────────────────────────────────┘ -``` - -### Why View Entities? - -Publishing persistent entities directly exposes your internal schema. When you change a column name, add a table, or restructure associations, every consumer app breaks. **View entities** solve this: - -1. **Stable API contract** -- the view's shape stays the same even when the underlying tables change -2. **Flattened data** -- joins across multiple tables into a single flat resource (e.g., Customer + BillingAddress + DeliveryAddress into one `CustomerAddressVE`) -3. **Computed fields** -- add calculated columns like `FullAddress` or `ActivePrice` using OQL expressions -4. **Filtered datasets** -- restrict what's visible (e.g., only active products, cheap products) -5. **Aggregations** -- expose pre-aggregated metrics (e.g., orders per day, sum of line items) +Supporting reference for [odata-data-sharing](../SKILL.md). ## Step-by-Step: Read-Only API with View Abstraction @@ -415,7 +312,6 @@ from odata client ShopClient.ShopApiClient alter entity ShopClient.Product set allow_create_change_locally = true; alter entity ShopClient.Product set allow_create_change_locally = false; ``` - ## Publishing a Non-Persistable Entity (no copy of the data) A published entity does **not** have to be persistable. Back it with a read @@ -678,7 +574,6 @@ with a single slash (CE6552). A path with **no slash at all** is the trap: mxbui throws `System.ArgumentOutOfRangeException` out of its own validator, with no error code, no element name and no line, which reads as a corrupt project. Use `'odata/thing/'`. `mxcli check` catches all three (MDL-ODATA05). - ## Also Publishing as GraphQL `SupportsGraphQL: Yes` makes the same service answer GraphQL as well as OData. @@ -768,161 +663,6 @@ is enabled. GraphQL here is not as complete as the OData surface — it is a second way to read the same published resources, which some widgets and clients prefer. - -## HTTP Status Codes and Errors: What Each Capability Can Do - -**The read path and the write path have different powers, and the difference is -the single most expensive thing to get wrong here.** Read this before designing -any microflow-backed resource. - -| Capability | Can set the HTTP status code? | How | -|---|---|---| -| OData **action** (published microflow) | **Yes** | add a `System.HttpResponse` parameter | -| Entity **Insertable / Updatable / Deletable** microflow | **Yes** | add a `System.HttpResponse` parameter | -| Entity **Readable** microflow | **No** | not offered — the read capability has no documented `HttpResponse` parameter | - -Sources: [published-odata-microflow §4](https://docs.mendix.com/refguide/published-odata-microflow/#4-customizing-the-outgoing-http-response), -[published-odata-entity, custom HTTP response](https://docs.mendix.com/refguide/published-odata-entity/#custom-http-response). -The custom-response section names Insertable, Updatable and Deletable; the -Readable section never references it. - -### Writing a status code (action / insert / update / delete) - -```sql -create microflow Api.InsertRow ( - $Row: Api.Row, - $HttpResponse: System.HttpResponse -) -begin - if $Row/RowKey = empty then - change $HttpResponse (StatusCode = 400, Content = '{"error":"rowKey is required"}'); - return; - end if; - ... -end; -``` - -Three rules the platform imposes: - -- **`ReasonPhrase` is ignored.** Setting it is dead code; put the explanation in - `Content`. -- **`204` always produces an empty body.** Setting `Content` alongside it is - discarded. -- **Changing status or content makes the whole response come from - `HttpResponse`** — headers included. Changing *only* headers merges them with - the defaults instead. -- `Transfer-Encoding` and `Date` cannot be changed. - -### The read path cannot refuse, so it must not over-promise - -A read microflow has no way to answer `400`. Its only exits are to throw (a -blunt `500`) or to return data. That has two consequences, and both are design -obligations rather than nice-to-haves: - -**1. Declare capabilities you do not implement as `No`.** Mendix applies *no* -query options to a read-microflow resource — it hands over the request and -returns whatever comes back — so `TopSupported` / `SkipSupported` / `Countable` -are claims about your microflow, not about the platform. A resource that -advertises `TopSupported: Yes` and ignores `$top` returns the entire collection -with a `200`, and the client believes it received a page. - -```sql -publish entity Api.Row as 'Rows' ( - ReadMode: microflow Api.Read_Rows, - -- Only claim what Read_Rows actually parses out of the URI: - TopSupported: No, - SkipSupported: No, - Countable: No -) -``` - -Declaring `No` is the read path's substitute for the `400` it cannot send. It is -also safe to declare: mxcli reads these capabilities off the contract, so a -consuming app generated from this service says `No` too. (Until it learned to, -the generator always said `Yes`, and the consumer failed to build with **CE6630** -"'Rows' is marked supports $top=False in the OData service, but True in the app" -— if you hit that against an older mxcli, that is the cause.) - -**The capabilities vocabulary has two annotation shapes**, and it is worth knowing -which you are looking at when reading a `$metadata` by hand: - -| Capability | Shape | -|---|---| -| `TopSupported`, `SkipSupported` | standalone: `` | -| `Insertable`, `Updatable`, `Deletable`, `Countable` | a `` with a `Bool` property value | -| `Filterable`, `Sortable` | **either** — a record listing `NonFilterableProperties` when *some* attributes are filterable, or a bare `Bool="false"` on the record when *none* are | - -That last row is the trap. Mendix picks the shape by arithmetic (there is no list -to write when nothing is filterable), so both appear in one document on different -entity sets, and a service where an entity exposes only a KEY emits the whole-set -form. - -`mxcli check` enforces this as **MDL-ODATA03**, and it looks for the option -*names* in the microflow body, not just for a `System.HttpRequest` parameter — -adding the parameter to answer the KEY (below) does not silence the paging -warning. A microflow that hands the request to a Java action, a JavaScript -action, or a microflow outside the script is left alone, since mxcli cannot read -what those do. - -**2. Answer a lookup by your own KEY.** A client holding a row re-reads it by -key, unprompted, and Mendix's own OData client sends the `$filter` spelling: - -``` -?$filter=rowKey eq '1036-c' ← what the runtime actually sends -/Rows('1036-c') ← bare path key -/Rows(rowKey='1036-c') ← named path key -``` - -If the microflow parses only its collection filter, the key request falls through -to the collection default and the client adopts the **first row** as the identity -of the object it is displaying. There is no error: the request is well-formed, -the response is a valid collection, the count is right, the status is `200`. Two -different objects are then on screen at once, and nothing distinguishes them -until one travels to another page. - -So: `expose ( … (KEY) )` is a promise the *service* makes on the *microflow's* -behalf. Branch key → id → filter → default. - -**Not declaring the KEY is not a way out.** Mendix requires a published entity to -have one — `CE6585 "Published entity 'X' must have a key defined."` — so the only -correct resolution is to answer the lookup. (Query *options* you may decline; -the key you may not.) - -The request itself always arrives on `System.HttpRequest`: - -```sql -create microflow Api.Read_Rows ( - $Request: System.HttpRequest, - $Response: System.ODataResponse -- required while Countable is Yes -) -returns List of Api.Row -begin - log info 'URI=' + $Request/Uri; -- the whole query string, URL-encoded - ... -end; -``` - -To watch what clients actually send, raise the **`OData Publish`** log node (note -the space; it exists only when the project publishes a service): - -```bash -mxcli log set "OData Publish" TRACE -``` -``` -TRACE - OData Publish: Incoming request from 127.0.0.1: GET .../Rows?$top=5&$filter=rowKey eq 'abc' -DEBUG - OData Publish: Responding to client with status code 400. -``` - -That is the fastest way to see a client re-reading a row by key, and it needs no -change to the model. `ODataConsume` is the client side, a different node. - -Mendix validates field names before the microflow runs (`$filter=secretColumn eq 'x'` -is a `400` from the platform), and it enforces `Filterable`: filtering on a property -you did not declare filterable is rejected with `400 "Property 'x' is -non-filterable"` before the microflow runs. So the microflow only ever sees names -that exist in the published metadata. That is defence in depth, not a substitute for a -whitelist — it constrains the *name*, not what you do with it. - ## OData Actions: Publishing a Microflow An entity set is a *read* surface. To let a client **invoke** something — @@ -972,79 +712,6 @@ an attribute and echo it back on each row — `SELECT f.*, {driverId} AS driver_ first request dies with *"No JDBC driver found in app for URL"* — the Connector resolves drivers from the app's classpath, not the runtime's. Use `included = true` and `mxcli sync-java-deps`. - -## Authentication Methods, and the Cost of Basic Auth - -A published service names one or more methods in the `authentication` clause: - -```sql -authentication basic, session -authentication microflow ProductApi.Authenticate -``` - -| Method | How the caller proves itself | Cost per request | -|---|---|---| -| `basic` | `Authorization: Basic …` on every request | **a full password hash** | -| `session` | an existing session + `X-Csrf-Token` | none, but only reachable from same-origin JavaScript in the same app | -| `guest` | anonymous | none | -| `microflow` | your microflow decides | whatever the microflow does | - -**Basic auth hashes on every call, including failures.** A consumed OData -service holds no session, so every request is a fresh login. Measured on a real -app, that was 60–80% of a page turn — and a *wrong* password costs the same, -because the hash runs either way: - -``` -GET /odata/f1/Drivers?$top=20 - basic auth 200 ~360 ms - wrong password 401 ~346 ms <- hashes either way - session cookie 200 ~19 ms - no credentials 401 ~42 ms -``` - -Do not read the 19 ms as the fix: `session` is only open to same-origin -JavaScript, and a Mendix OData *client* sends basic auth and keeps no cookie. - -### Custom authentication - -A microflow taking `List of System.HttpHeader` and returning a `System.User`. -Return empty to deny. No password hash anywhere, so the ~42 ms floor. - -```sql -CREATE MICROFLOW ProductApi.Authenticate ($Headers: List of System.HttpHeader) - RETURNS System.User AS $User -BEGIN - -- e.g. compare a shared secret from $Headers, then retrieve the service account - retrieve $Users from System.User; - $User = head($Users); - RETURN $User; -END; - -create odata service ProductApi.Api ( ... ) -authentication microflow ProductApi.Authenticate -{ ... }; -``` - -**The client needs no change.** The microflow can read the `Authorization: -Basic …` header itself and compare it against a constant, so the consumer keeps -its existing username/password configuration and the server simply stops -calling BCrypt. - -Two build rules to know before you reach for it: - -- **The microflow is mandatory.** `authentication microflow` with no name parses - but fails the build with **CE0333** "Please select a microflow to use for - authentication". `mxcli check` flags this as `MDL-ODATA04`. -- **App security must be on.** With security off, Mendix reports **CE6600** - "App security is off, but custom authentication is enabled for this service". - Set it with `alter project security level prototype` (or `production`). - -If custom authentication is more than you need, `ALTER SETTINGS MODEL -BcryptCost = 8` shrinks the hash instead of removing it. Each step down halves -the cost; the default is 12. That is a judgement about the accounts in *that* -app — appropriate for machine accounts with generated passwords, not for a -database of human passwords. - ## Step-by-Step: Read-Write API with Microflow Handlers For write operations (insert, update, delete), the OData service delegates to microflows that map between the view entity and the underlying persistent entities. @@ -1150,180 +817,3 @@ grant ProductClient.User on ProductClient.ProductsEE ``` The consumer can now create, update, and delete products through the OData API, and the producer's microflows handle the mapping to persistent entities. - -## Advanced: Configuration Microflow for Custom Headers - -When the consumer needs to pass custom headers (e.g., for audit trails or user context), use a configuration microflow: - -```sql -/** - * Adds current user name as custom header for audit logging. - */ -create microflow ProductClient.SetClientHeaders ( - $httpResponse: System.HttpResponse -) -returns list of System.HttpHeader as $HttpHeaderList -begin - $HttpHeaderList = create list of System.HttpHeader; - $NewHttpHeader = create System.HttpHeader ( - key = 'X-Audit-User', - value = $currentUser/Name - ); - add $NewHttpHeader to $HttpHeaderList; - return $HttpHeaderList; -end; -``` - -Reference it in the client: - -```sql -create odata client ProductClient.ProductDataApiClient ( - ... - ConfigurationMicroflow: microflow ProductClient.SetClientHeaders -); -``` - -## API Versioning - -When your API contract changes, create a new version rather than breaking existing consumers: - -```sql --- v1: Original API (keep running for existing consumers) -create odata service ProductApi.ProductDataApi ( - path: 'odata/productdataapi/v1/', - version: '1.0.0', - ... -); - --- v2: New version with additional fields -create odata service ProductApi.ProductDataApi_v2 ( - path: 'odata/productdataapi/v2/', - version: '2.0.0', - ODataVersion: OData4, - ServiceName: 'ProductDataApi', - Summary: 'Product API v2 - includes weight and tags', - ... -) -authentication basic -{ - publish entity ProductApi.ProductWithPriceAndTagsVE as 'Product' ( - ReadMode: ReadFromDatabase, - InsertMode: microflow ProductApi.InsertProductV2, - UpdateMode: microflow ProductApi.UpdateProductV2, - DeleteMode: microflow ProductApi.DeleteProductV2 - ) - expose (...); -}; -``` - -## Folder Organization - -Use the `Folder` property to organize OData documents within modules. - -**MetadataUrl accepts three formats:** -1. **HTTP(S) URL** — fetches from remote service (production) -2. **file:///absolute/path** — reads from local absolute path -3. **./path or path/file.xml** — reads from local relative path (resolved against .mpr directory) - -```sql --- Format 1: HTTP(S) URL -create odata client ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'https://api.example.com/odata/v4/$metadata', - Folder: 'Integration/ProductAPI' -); - --- Format 2: Absolute file:// URI -create odata client ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml', - Folder: 'Integration/ProductAPI' -); - --- Format 3a: Relative path with ./ -create odata client ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: './metadata/productdataapi.xml', - Folder: 'Integration/ProductAPI' -); - --- Format 3b: Relative path without ./ -create odata client ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'metadata/productdataapi.xml', - Folder: 'Integration/ProductAPI' -); - -create odata service ProductApi.ProductDataApi ( - path: 'odata/productdataapi/v1/', - version: '1.0.0', - ODataVersion: OData4, - folder: 'Integration/APIs' -) -authentication basic -{ ... }; -``` - -Folders are created automatically if they don't exist. Use `/` for nested folders. - -## Module Organization Conventions - -Follow this naming convention for clean separation: - -| Module | Purpose | Contains | -|--------|---------|----------| -| `Shop` | Core domain | Persistent entities, business logic | -| `ShopApi` or `ShopViews` | API layer (producer) | View entities, OData service, CUD microflows | -| `ShopClient` or `ShopViewsClient` | API consumer | OData client, external entities, client constants | - -This keeps the API contract separate from the domain logic, and the consumer separate from the producer. - -## Checklist - -Before publishing: -- [ ] View entities expose only the fields consumers need (no internal IDs unless needed for writes) -- [ ] View entity has at least one `key` field for OData identity -- [ ] Module role created and granted on view entities (READ, optionally WRITE) -- [ ] OData service has AUTHENTICATION set (Basic, Session, or Microflow) -- [ ] GRANT ACCESS ON ODATA SERVICE to the API module role -- [ ] CUD microflows (if writable) accept `($ViewEntity, $HttpRequest)` parameters -- [ ] CUD microflows granted EXECUTE to the API module role - -Before consuming: -- [ ] Location constant created for environment-specific URLs -- [ ] OData client `MetadataUrl` points to either: - - HTTP(S) URL: `https://api.example.com/$metadata` - - Local file (absolute): `file:///path/to/metadata.xml` - - Local file (relative): `./metadata/service.xml` (resolved against `.mpr` directory) -- [ ] OData client uses `ServiceUrl: '@Module.Constant'` for runtime endpoint -- [ ] External entities match the published exposed names and types -- [ ] Module role created and granted on external entities (READ, optionally CREATE/WRITE/DELETE) - -## Exploration Commands - -Use these commands to inspect existing OData setup in a project: - -```sql --- List all published and consumed services -show odata services; -show odata clients; - --- Inspect a specific service -describe odata service ShopViews.ShopViewsApi; -describe odata client ShopViewsClient.ShopViewsApiClient; - --- See external entities and view entities -show entities in ShopViewsClient; -show external entities; -show external actions; - --- Browse available assets from cached $metadata contract -show contract entities from ShopViewsClient.ShopViewsApiClient; -show contract actions from ShopViewsClient.ShopViewsApiClient; -describe contract entity ShopViewsClient.ShopViewsApiClient.Product; -describe contract entity ShopViewsClient.ShopViewsApiClient.Product format mdl; - --- Check security setup -show access on odata service ShopViews.ShopViewsApi; -show module roles in ShopViews; -``` diff --git a/.claude/skills/mendix/organize-project.md b/.claude/skills/mendix/organize-project/SKILL.md similarity index 97% rename from .claude/skills/mendix/organize-project.md rename to .claude/skills/mendix/organize-project/SKILL.md index f1d989863..9e873c8b0 100644 --- a/.claude/skills/mendix/organize-project.md +++ b/.claude/skills/mendix/organize-project/SKILL.md @@ -1,3 +1,8 @@ +--- +name: organize-project +description: "Organise documents into folders and move them between folders and modules with MOVE. Use when a module has grown unstructured, when restructuring a project, or when a document is in the wrong place." +--- + # Project Organization: Folders and Moving Documents This skill covers organizing Mendix project documents (pages, microflows, snippets, nanoflows) into folders and moving them between folders and modules. diff --git a/.claude/skills/mendix/overview-pages.md b/.claude/skills/mendix/overview-pages/SKILL.md similarity index 96% rename from .claude/skills/mendix/overview-pages.md rename to .claude/skills/mendix/overview-pages/SKILL.md index f906412c4..dbfc4b484 100644 --- a/.claude/skills/mendix/overview-pages.md +++ b/.claude/skills/mendix/overview-pages/SKILL.md @@ -1,3 +1,8 @@ +--- +name: overview-pages +description: "The CRUD overview page pattern in MDL — a navigation snippet, a list page and a new/edit page wired together. Use when building the standard list-plus-edit screens for an entity." +--- + # Overview Pages - CRUD Page Pattern ## Overview @@ -613,14 +618,14 @@ create or modify snippet Module.NavigationMenu - **Do not use `create or replace snippet`** — that deletes the placeholder and creates a fresh UUID, silently breaking every page that references the old one - Page references in the final snippet resolve correctly because pages already exist -See [Resolve Forward References](./resolve-forward-references.md) for the full pattern including page→page and microflow→page cases, declaration ordering rules, and the choice between `CREATE OR MODIFY` and `ALTER SNIPPET`. +See [Resolve Forward References](../resolve-forward-references/SKILL.md) for the full pattern including page→page and microflow→page cases, declaration ordering rules, and the choice between `CREATE OR MODIFY` and `ALTER SNIPPET`. ## Related Skills -- [Create Page](./create-page.md) - Basic page creation syntax -- [ALTER PAGE/SNIPPET](./alter-page.md) - Modify existing pages/snippets in-place (SET, INSERT, DROP, REPLACE) -- [Master-Detail Pages](./master-detail-pages.md) - Selection binding pattern -- [Resolve Forward References](./resolve-forward-references.md) - Placeholder pattern, declaration ordering +- [Create Page](../create-page/SKILL.md) - Basic page creation syntax +- [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) - Modify existing pages/snippets in-place (SET, INSERT, DROP, REPLACE) +- [Master-Detail Pages](../master-detail-pages/SKILL.md) - Selection binding pattern +- [Resolve Forward References](../resolve-forward-references/SKILL.md) - Placeholder pattern, declaration ordering ## Snippet Commands Reference diff --git a/.claude/skills/mendix/patterns-crud.md b/.claude/skills/mendix/patterns-crud/SKILL.md similarity index 95% rename from .claude/skills/mendix/patterns-crud.md rename to .claude/skills/mendix/patterns-crud/SKILL.md index 72caf1b23..28d5e23b2 100644 --- a/.claude/skills/mendix/patterns-crud.md +++ b/.claude/skills/mendix/patterns-crud/SKILL.md @@ -1,3 +1,8 @@ +--- +name: patterns-crud +description: "Standard Create/Read/Update/Delete microflow patterns with the naming conventions that go with them. Use when writing the action microflows behind page buttons rather than looking up individual syntax." +--- + # CRUD Action Patterns Standard patterns for Create, Read, Update, Delete operations on entities. diff --git a/.claude/skills/mendix/patterns-data-processing.md b/.claude/skills/mendix/patterns-data-processing/SKILL.md similarity index 96% rename from .claude/skills/mendix/patterns-data-processing.md rename to .claude/skills/mendix/patterns-data-processing/SKILL.md index 88b015521..ac6fa5347 100644 --- a/.claude/skills/mendix/patterns-data-processing.md +++ b/.claude/skills/mendix/patterns-data-processing/SKILL.md @@ -1,3 +1,8 @@ +--- +name: patterns-data-processing +description: "Patterns for loops, aggregates, batch processing and list transformation — including which retrieve source is legal where, and why nested loops are the wrong tool. Use when a microflow processes a list, merges data, or does anything in bulk." +--- + # Data Processing Patterns Patterns for loops, aggregates, batch processing, and data transformation. diff --git a/.claude/skills/mendix/project-settings.md b/.claude/skills/mendix/project-settings/SKILL.md similarity index 96% rename from .claude/skills/mendix/project-settings.md rename to .claude/skills/mendix/project-settings/SKILL.md index 765836316..befd9c30f 100644 --- a/.claude/skills/mendix/project-settings.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -1,3 +1,8 @@ +--- +name: project-settings +description: "Read and change project settings in MDL — database configuration, constant overrides, after-startup and before-shutdown microflows, and the rest of Studio Pro's Settings dialog. Use when configuring a deployment or wiring a startup microflow." +--- + # Project Settings ## When to Use This Skill diff --git a/.claude/skills/mendix/regular-expressions.md b/.claude/skills/mendix/regular-expressions/SKILL.md similarity index 93% rename from .claude/skills/mendix/regular-expressions.md rename to .claude/skills/mendix/regular-expressions/SKILL.md index cdc4aae5e..160a88dcb 100644 --- a/.claude/skills/mendix/regular-expressions.md +++ b/.claude/skills/mendix/regular-expressions/SKILL.md @@ -1,3 +1,8 @@ +--- +name: regular-expressions +description: "Create and manage named regular-expression documents and bind them to attribute validation rules. Use when adding an email, phone or identifier pattern, changing a shared pattern, or working out why a regex cannot attach directly to an attribute." +--- + # Regular Expressions ## When to Use This Skill @@ -142,4 +147,4 @@ select QualifiedName, Expression from CATALOG.REGULAR_EXPRESSIONS; - `mxcli syntax regular-expression` — full syntax reference - `mxcli syntax validation-rule` — binding a pattern or a range to an attribute -- `mdl-entities.md` — attributes and validation +- `mdl-entities` — attributes and validation diff --git a/.claude/skills/mendix/resolve-forward-references.md b/.claude/skills/mendix/resolve-forward-references/SKILL.md similarity index 94% rename from .claude/skills/mendix/resolve-forward-references.md rename to .claude/skills/mendix/resolve-forward-references/SKILL.md index d5a3cc4d5..6fa7a6580 100644 --- a/.claude/skills/mendix/resolve-forward-references.md +++ b/.claude/skills/mendix/resolve-forward-references/SKILL.md @@ -1,3 +1,8 @@ +--- +name: resolve-forward-references +description: "Order MDL statements so that every reference resolves — execution is sequential and immediate, so a document must exist before anything points at it. Use when a script fails on a reference to something defined later in the same file." +--- + # Resolving Forward References in MDL Scripts ## Why Forward References Fail @@ -323,6 +328,6 @@ alter navigation Responsive ## Related Skills -- [Create Page](./create-page.md) — Full page syntax reference -- [Overview Pages](./overview-pages.md) — Overview + NewEdit page patterns -- [ALTER PAGE/SNIPPET](./alter-page.md) — In-place snippet modification +- [Create Page](../create-page/SKILL.md) — Full page syntax reference +- [Overview Pages](../overview-pages/SKILL.md) — Overview + NewEdit page patterns +- [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) — In-place snippet modification diff --git a/.claude/skills/mendix/rest-call-from-json.md b/.claude/skills/mendix/rest-call-from-json/SKILL.md similarity index 95% rename from .claude/skills/mendix/rest-call-from-json.md rename to .claude/skills/mendix/rest-call-from-json/SKILL.md index 029f6d95a..4b03aadb8 100644 --- a/.claude/skills/mendix/rest-call-from-json.md +++ b/.claude/skills/mendix/rest-call-from-json/SKILL.md @@ -1,3 +1,8 @@ +--- +name: rest-call-from-json +description: "Generate the whole integration stack from a JSON payload: JSON structure, non-persistent entities, import mapping, and the REST CALL microflow. Use when starting from an example response and needing everything between it and a working microflow." +--- + # REST Call from JSON Payload — End-to-End Skill Use this skill to generate the full stack of Mendix integration artifacts from a JSON payload: @@ -5,7 +10,7 @@ JSON Structure → Non-persistent entities → Import Mapping → microflow. > **Two approaches**: This skill uses the **inline REST CALL** approach (good for one-off calls > and quick prototyping). For structured APIs with reusable operations, use the **REST Client** -> approach instead — see [rest-client.md](rest-client.md) for `create rest client` + `send rest request` +> approach instead — see [rest-client](../rest-client/SKILL.md) for `create rest client` + `send rest request` > + optional `transform` with JSLT data transformers. ## Overview — Four Steps @@ -72,7 +77,7 @@ create association Module.MyRootObject_MyNestedObject ## Step 3 — Import Mapping -> **Full reference**: See [json-structures-and-mappings.md](json-structures-and-mappings.md) for complete import/export mapping syntax, domain model patterns, and common mistakes. +> **Full reference**: See [json-structures-and-mappings](../json-structures-and-mappings/SKILL.md) for complete import/export mapping syntax, domain model patterns, and common mistakes. ```sql create import mapping Module.IMM_MyMapping diff --git a/.claude/skills/mendix/rest-client.md b/.claude/skills/mendix/rest-client/SKILL.md similarity index 96% rename from .claude/skills/mendix/rest-client.md rename to .claude/skills/mendix/rest-client/SKILL.md index 81d3e5e40..933a404e3 100644 --- a/.claude/skills/mendix/rest-client.md +++ b/.claude/skills/mendix/rest-client/SKILL.md @@ -1,3 +1,8 @@ +--- +name: rest-client +description: "Call external REST APIs from Mendix — the three approaches (inline REST CALL, consumed REST client document, generated from OpenAPI) and how to choose. Use when integrating with any external REST API." +--- + # REST Integration Skill Use this skill when integrating with external REST APIs from Mendix. @@ -14,7 +19,7 @@ Mendix offers three ways to call REST APIs from microflows. Choose based on the Both REST Client approaches can be combined with **Data Transformers** (Mendix 11.9+) and **Import/Export Mappings** to map between JSON and entities. -No API to call against yet — or one you would rather not depend on while building? [mock-rest-apis.md](mock-rest-apis.md) covers standing up an endpoint you control and pointing the app at it. +No API to call against yet — or one you would rather not depend on while building? [mock-rest-apis](../mock-rest-apis/SKILL.md) covers standing up an endpoint you control and pointing the app at it. --- @@ -53,7 +58,7 @@ which fails at call time rather than at import time. Set `BaseUrl` explicitly in When provided, `BaseUrl` overrides the spec's value — useful when the spec points at production but you need to import against staging, a different version, or a local mock -(see [mock-rest-apis.md](mock-rest-apis.md)). +(see [mock-rest-apis](../mock-rest-apis/SKILL.md)). **Preview without writing:** ```sql @@ -299,7 +304,7 @@ drop data transformer Module.Name; ## JSON Structures & Mappings -See [json-structures-and-mappings.md](json-structures-and-mappings.md) for full reference. Quick summary: +See [json-structures-and-mappings](../json-structures-and-mappings/SKILL.md) for full reference. Quick summary: ```sql -- JSON structure from snippet diff --git a/.claude/skills/mendix/run-app.md b/.claude/skills/mendix/run-app/SKILL.md similarity index 95% rename from .claude/skills/mendix/run-app.md rename to .claude/skills/mendix/run-app/SKILL.md index df4fe6002..8009a9b25 100644 --- a/.claude/skills/mendix/run-app.md +++ b/.claude/skills/mendix/run-app/SKILL.md @@ -1,3 +1,8 @@ +--- +name: run-app +description: "Build and start the Mendix app in Docker, and restart it after changes. Use when asked to run, launch or restart the app in a container. For the fast inner loop prefer run-local." +--- + # Run App Skill This skill builds and starts the Mendix application in Docker. diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local/SKILL.md similarity index 98% rename from .claude/skills/mendix/run-local.md rename to .claude/skills/mendix/run-local/SKILL.md index 0194eaca1..f2b91b3e0 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -1,3 +1,8 @@ +--- +name: run-local +description: "The warm Docker-free dev loop, `mxcli run --local` — model changes live in about a second, with watch, screenshots, metrics, tracing and external preview. Use for the fastest edit-to-running-app cycle, especially when driving the model programmatically with MDL." +--- + # Warm Local Dev Loop — `mxcli run --local` ## Overview @@ -205,8 +210,8 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr | `--screenshot-path` / `--screenshot-url` | `.mxcli/run-local.png` / app root | Screenshot output / page (URL or `/path`) | | `--screenshot-user` / `--screenshot-password` | — | Log in once, reuse session (pages behind login) | | `--runtime-log` | `.mxcli/runtime.log` | Runtime log file: JVM stdout/stderr **and** the application log (microflow `LOG` output + server stack traces, via an attached file log subscriber). `-` disables. | -| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See `test-microflows.md`. | -| `--debug` | off | Enable the microflow debugger at boot + start a session, so `mxcli debug break/paused/…` works from another terminal (see `debug-microflows.md`). No breakpoints = no behaviour change; disabled on shutdown. | +| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See `test-microflows`. | +| `--debug` | off | Enable the microflow debugger at boot + start a session, so `mxcli debug break/paused/…` works from another terminal (see `debug-microflows`). No breakpoints = no behaviour change; disabled on shutdown. | | `--debug-pass` | `mxdebug` | Debugger password when `--debug` is set | | `--metrics` | off | Register a Prometheus meter registry at boot; the runtime serves metrics at `http://127.0.0.1:/prometheus` | | `--trace` | off | Enable OpenTelemetry tracing (bundled agent, console exporter → the runtime log) with default span filters | diff --git a/.claude/skills/mendix/runtime-admin-api.md b/.claude/skills/mendix/runtime-admin-api/SKILL.md similarity index 92% rename from .claude/skills/mendix/runtime-admin-api.md rename to .claude/skills/mendix/runtime-admin-api/SKILL.md index 383a874ed..c98be9078 100644 --- a/.claude/skills/mendix/runtime-admin-api.md +++ b/.claude/skills/mendix/runtime-admin-api/SKILL.md @@ -1,3 +1,8 @@ +--- +name: runtime-admin-api +description: "Call the Mendix M2EE admin API on port 8090 directly — OQL, runtime info, and the rest, with curl examples. Use when querying a running app from a script, or when debugging connectivity to the admin port." +--- + # Runtime Admin API Skill This skill documents the Mendix M2EE admin API exposed on port 8090, including how to call it directly with curl. @@ -198,7 +203,7 @@ The build patch `admin.addresses = ["*"]` is applied automatically by `mxcli doc ## Related Skills -- [/run-app](./run-app.md) — Start the Mendix app in Docker -- [/write-oql-queries](./write-oql-queries.md) — OQL syntax reference -- [/docker-workflow](./docker-workflow.md) — Full Docker build/run workflow -- [/database-connections](./database-connections.md) — Direct PostgreSQL access +- [/run-app](../run-app/SKILL.md) — Start the Mendix app in Docker +- [/write-oql-queries](../write-oql-queries/SKILL.md) — OQL syntax reference +- [/docker-workflow](../docker-workflow/SKILL.md) — Full Docker build/run workflow +- [/database-connections](../database-connections/SKILL.md) — Direct PostgreSQL access diff --git a/.claude/skills/mendix/scheduled-events-and-queues.md b/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md similarity index 95% rename from .claude/skills/mendix/scheduled-events-and-queues.md rename to .claude/skills/mendix/scheduled-events-and-queues/SKILL.md index 65f3deceb..03976820a 100644 --- a/.claude/skills/mendix/scheduled-events-and-queues.md +++ b/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md @@ -1,3 +1,8 @@ +--- +name: scheduled-events-and-queues +description: "Scheduled events (Mendix's cron) and task queues in MDL — the eight repeat variants and the fields each takes, and what a queue does and does not throttle. Use when running a microflow on a schedule, or bounding how many background tasks run at once." +--- + # Scheduled Events and Task Queues ## When to Use This Skill @@ -235,7 +240,7 @@ create queue Ops.Q_Imports folder 'Private/Queues' ( Parallelism: 3 ); ``` On `create or modify` the clause moves an existing document; omitting it leaves -placement alone. See `organize-project.md`. +placement alone. See `organize-project`. ## Validation Checklist @@ -277,5 +282,5 @@ Starlark lint rules can iterate both: `scheduled_events()` yields ## Related - `mxcli syntax scheduled-event`, `mxcli syntax queue` — full syntax reference -- `write-microflows.md` — writing the microflow the event calls -- `project-settings.md` — after-startup / before-shutdown microflows +- `write-microflows` — writing the microflow the event calls +- `project-settings` — after-startup / before-shutdown microflows diff --git a/.claude/skills/mendix/system-module.md b/.claude/skills/mendix/system-module/SKILL.md similarity index 53% rename from .claude/skills/mendix/system-module.md rename to .claude/skills/mendix/system-module/SKILL.md index 39919a45a..4237e9a8f 100644 --- a/.claude/skills/mendix/system-module.md +++ b/.claude/skills/mendix/system-module/SKILL.md @@ -1,7 +1,19 @@ +--- +name: system-module +description: "Reference for the built-in System module — User, FileDocument, Image, workflow and queue entities, and the associations you are allowed to make to them. Use when linking a record to System.User, handling file uploads, or working with workflow entities." +--- + # Mendix System Module Reference The `System` module is a built-in Mendix module present in every application. It provides core entities for user management, file handling, workflows, task queues, HTTP services, and more. These entities are not defined in the application's domain model but are available for use in microflows, pages, associations, and Java actions. +## Reference files + +- [`reference/workflow-and-queues.md`](reference/workflow-and-queues.md) — the + workflow engine's entities (`WorkflowInstance`, `UserTask`, the state + enumerations and how they relate), plus task queues and scheduled events. Open + it when querying workflow state or wiring anything to a queue. + ## When to Use This Skill Use this skill when: @@ -267,321 +279,6 @@ Tracks offline mobile synchronization failures. --- -## 5. Workflow Engine - -Mendix workflows use a rich set of System entities. These are managed by the runtime but can be queried and displayed in pages. - -### System.Workflow - -A running workflow instance. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Workflow instance name | -| Description | String | Workflow description | -| StartTime | DateTime | When the workflow started | -| EndTime | DateTime | When the workflow ended | -| DueDate | DateTime | Workflow deadline | -| CanBeRestarted | Boolean | Whether restart is allowed | -| CanBeContinued | Boolean | Whether continue is allowed | -| CanApplyJumpTo | Boolean | Whether jump-to is allowed | -| State | Enum (WorkflowState) | Current state | -| Reason | String | Reason for current state (e.g., abort reason) | - -| Association | Target | Type | Description | -|-------------|--------|------|-------------| -| Workflow_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | The workflow template | -| Workflow_ParentWorkflow | System.Workflow | Many-to-One | Parent (for sub-workflows) | - -### System.WorkflowDefinition - -A workflow template as defined in the model. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Definition name | -| Title | String | Display title | -| IsObsolete | Boolean | Whether superseded by newer version | -| IsLocked | Boolean | Whether locked for editing | - -### System.WorkflowUserTask - -An active user task waiting for completion. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Task name | -| Description | String | Task description | -| StartTime | DateTime | When the task became active | -| DueDate | DateTime | Task deadline | -| EndTime | DateTime | When the task was completed | -| Outcome | String | Selected outcome | -| State | Enum (WorkflowUserTaskState) | Task state | -| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus is determined | - -| Association | Target | Type | Description | -|-------------|--------|------|-------------| -| WorkflowUserTask_TargetUsers | System.User | Many-to-Many | Eligible users | -| WorkflowUserTask_Assignees | System.User | Many-to-Many | Actually assigned users | -| WorkflowUserTask_Workflow | System.Workflow | Many-to-One | Parent workflow | -| WorkflowUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | Task template | -| WorkflowUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | Eligible user groups | - -### System.WorkflowUserTaskDefinition - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Task definition name | -| IsObsolete | Boolean | Whether superseded | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowUserTaskDefinition_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | - -### System.WorkflowGroup - -A named group of users for task assignment. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Group name | -| Description | String | Group description | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowGroup_User | System.User | Many-to-Many | - -### System.WorkflowUserTaskOutcome - -Records who selected which outcome on an active user task. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Outcome | String | Selected outcome value | -| Time | DateTime | When the outcome was selected | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowUserTaskOutcome_WorkflowUserTask | System.WorkflowUserTask | Many-to-One | -| WorkflowUserTaskOutcome_User | System.User | Many-to-One | - -### System.WorkflowEvent - -Audit events during workflow execution. - -| Attribute | Type | Description | -|-----------|------|-------------| -| EventTime | DateTime | When the event occurred | -| EventType | Enum (WorkflowEventType) | Type of event | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowEvent_Initiator | System.User | Many-to-One | - -### System.WorkflowRecord - -Snapshot/audit record of a workflow instance. - -| Attribute | Type | Description | -|-----------|------|-------------| -| WorkflowKey | String | Workflow instance key | -| Name | String | Workflow name | -| Description | String | Workflow description | -| State | Enum (WorkflowState) | State at time of record | -| StartTime | DateTime | Workflow start time | -| DueDate | DateTime | Workflow due date | -| EndTime | DateTime | Workflow end time | -| Reason | String | State reason | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowRecord_Workflow | System.Workflow | Many-to-One | -| WorkflowRecord_Owner | System.User | Many-to-One | -| WorkflowRecord_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | - -### System.WorkflowActivityRecord - -Detailed audit of each workflow activity execution. - -| Attribute | Type | Description | -|-----------|------|-------------| -| ModelGUID | String | Activity GUID in model | -| ActivityKey | String | Unique activity key | -| PreviousActivityKey | String | Key of preceding activity | -| ActivityType | Enum (WorkflowActivityType) | Type of activity | -| Caption | String | Activity caption | -| State | Enum (WorkflowActivityExecutionState) | Execution state | -| StartTime | DateTime | When activity started | -| EndTime | DateTime | When activity ended | -| Outcome | String | Activity outcome | -| MicroflowName | String | Called microflow (if applicable) | -| TaskName | String | User task name (if applicable) | -| TaskDescription | String | User task description | -| TaskDueDate | DateTime | User task due date | -| TaskCompletionType | Enum (WorkflowUserTaskCompletionType) | How task consensus works | -| TaskRequiredUsers | Integer | Number of required users | -| TaskKey | String | User task key | -| Reason | String | State reason | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowActivityRecord_PreviousActivity | System.WorkflowActivityRecord | Many-to-One | -| WorkflowActivityRecord_Actor | System.User | Many-to-One | -| WorkflowActivityRecord_SubWorkflow | System.WorkflowRecord | Many-to-One | -| WorkflowActivityRecord_UserTask | System.WorkflowUserTask | Many-to-One | -| WorkflowActivityRecord_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | -| WorkflowActivityRecord_TaskTargetedUsers | System.User | Many-to-Many | -| WorkflowActivityRecord_TaskAssignedUsers | System.User | Many-to-Many | -| WorkflowActivityRecord_TaskTargetedGroups | System.WorkflowGroup | Many-to-Many | - -### System.WorkflowActivityDetails - -Metadata about a workflow activity (used for jump-to navigation). - -| Attribute | Type | Description | -|-----------|------|-------------| -| ActivityId | String | Activity identifier | -| ActivityCaption | String | Display caption | -| ActivityType | Enum (WorkflowActivityType) | Activity type | -| ExistsInCurrentVersion | Boolean | Whether activity exists in current model version | - -### System.WorkflowCurrentActivity - -Current activity state within a workflow (used for jump-to). - -| Attribute | Type | Description | -|-----------|------|-------------| -| Action | Enum (WorkflowCurrentActivityAction) | DoNothing or JumpTo | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowCurrentActivity_ActivityDetails | System.WorkflowActivityDetails | Many-to-One | -| WorkflowCurrentActivity_ApplicableTargets | System.WorkflowActivityDetails | Many-to-Many | -| WorkflowCurrentActivity_JumpToTarget | System.WorkflowActivityDetails | Many-to-One | - -### System.WorkflowJumpToDetails - -Details for jump-to operations. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Error | String | Error message if jump-to failed | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowJumpToDetails_Workflow | System.Workflow | Many-to-One | -| WorkflowJumpToDetails_CurrentActivities | System.WorkflowCurrentActivity | Many-to-Many | - -### System.WorkflowEndedUserTask - -Completed/archived user tasks. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Task name | -| Description | String | Task description | -| StartTime | DateTime | When task started | -| DueDate | DateTime | Task deadline | -| EndTime | DateTime | When task ended | -| Outcome | String | Final outcome | -| State | Enum (WorkflowUserTaskState) | Final state | -| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus was determined | -| UserTaskKey | String | Unique task key | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowEndedUserTask_Assignees | System.User | Many-to-Many | -| WorkflowEndedUserTask_TargetUsers | System.User | Many-to-Many | -| WorkflowEndedUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | -| WorkflowEndedUserTask_Workflow | System.Workflow | Many-to-One | -| WorkflowEndedUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | - -### System.WorkflowEndedUserTaskOutcome - -Individual outcome votes on ended user tasks. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Outcome | String | Selected outcome | -| Time | DateTime | When outcome was selected | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowEndedUserTaskOutcome_User | System.User | Many-to-One | -| WorkflowEndedUserTaskOutcome_WorkflowEndedUserTask | System.WorkflowEndedUserTask | Many-to-One | - ---- - -## 6. Task Queues & Scheduled Events - -### System.QueuedTask - -A task waiting to execute or currently running in a task queue. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Sequence | Long | Task sequence number | -| Status | Enum (QueueTaskStatus) | Current status | -| QueueId | String | Queue identifier | -| QueueName | String | Queue display name | -| ContextType | Enum (ContextType) | Execution context: System, User, Anonymous, ScheduledEvent | -| ContextData | String | Serialized context | -| MicroflowName | String | Microflow to execute | -| UserActionName | String | Java action to execute | -| Arguments | String | Serialized arguments | -| XASId | String | Cluster node identifier | -| ThreadId | Long | Execution thread ID | -| Created | DateTime | When task was queued | -| StartAt | DateTime | Scheduled start time | -| Started | DateTime | Actual start time | -| Retried | Long | Number of retry attempts | -| Retry | String | Retry configuration | -| ScheduledEventName | String | Associated scheduled event name | - -### System.ProcessedQueueTask - -Completed tasks (audit trail). Same attributes as `QueuedTask` plus: - -| Attribute | Type | Description | -|-----------|------|-------------| -| Finished | DateTime | When task finished | -| Duration | Long | Execution duration in milliseconds | -| ErrorMessage | String | Error message if task failed | - -### System.ScheduledEventInformation - -Runtime information about scheduled events. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Scheduled event name | -| Description | String | Event description | -| StartTime | DateTime | Last start time | -| EndTime | DateTime | Last end time | -| Status | Enum (EventStatus) | Running, Completed, Error, Stopped | - -| Association | Target | Type | -|-------------|--------|------| -| ScheduledEventInformation_XASInstance | System.XASInstance | Many-to-One | - -### System.XASInstance - -Cluster node information (for multi-instance deployments). - -| Attribute | Type | Description | -|-----------|------|-------------| -| XASId | String | Node identifier | -| LastUpdate | DateTime | Last heartbeat | -| AllowedNumberOfConcurrentUsers | Integer | License limit | -| PartnerName | String | Partner name (licensing) | -| CustomerName | String | Customer name (licensing) | - -### System.TaskQueueToken - -Token for task queue operations (internal use). - ---- - ## 7. Utility Entities ### System.Paging (non-persistent) diff --git a/.claude/skills/mendix/system-module/reference/workflow-and-queues.md b/.claude/skills/mendix/system-module/reference/workflow-and-queues.md new file mode 100644 index 000000000..f072e3f3b --- /dev/null +++ b/.claude/skills/mendix/system-module/reference/workflow-and-queues.md @@ -0,0 +1,317 @@ +# Workflow engine, task queues and scheduled events + +Supporting reference for [system-module](../SKILL.md). + +## 5. Workflow Engine + +Mendix workflows use a rich set of System entities. These are managed by the runtime but can be queried and displayed in pages. + +### System.Workflow + +A running workflow instance. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Workflow instance name | +| Description | String | Workflow description | +| StartTime | DateTime | When the workflow started | +| EndTime | DateTime | When the workflow ended | +| DueDate | DateTime | Workflow deadline | +| CanBeRestarted | Boolean | Whether restart is allowed | +| CanBeContinued | Boolean | Whether continue is allowed | +| CanApplyJumpTo | Boolean | Whether jump-to is allowed | +| State | Enum (WorkflowState) | Current state | +| Reason | String | Reason for current state (e.g., abort reason) | + +| Association | Target | Type | Description | +|-------------|--------|------|-------------| +| Workflow_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | The workflow template | +| Workflow_ParentWorkflow | System.Workflow | Many-to-One | Parent (for sub-workflows) | + +### System.WorkflowDefinition + +A workflow template as defined in the model. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Definition name | +| Title | String | Display title | +| IsObsolete | Boolean | Whether superseded by newer version | +| IsLocked | Boolean | Whether locked for editing | + +### System.WorkflowUserTask + +An active user task waiting for completion. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Task name | +| Description | String | Task description | +| StartTime | DateTime | When the task became active | +| DueDate | DateTime | Task deadline | +| EndTime | DateTime | When the task was completed | +| Outcome | String | Selected outcome | +| State | Enum (WorkflowUserTaskState) | Task state | +| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus is determined | + +| Association | Target | Type | Description | +|-------------|--------|------|-------------| +| WorkflowUserTask_TargetUsers | System.User | Many-to-Many | Eligible users | +| WorkflowUserTask_Assignees | System.User | Many-to-Many | Actually assigned users | +| WorkflowUserTask_Workflow | System.Workflow | Many-to-One | Parent workflow | +| WorkflowUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | Task template | +| WorkflowUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | Eligible user groups | + +### System.WorkflowUserTaskDefinition + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Task definition name | +| IsObsolete | Boolean | Whether superseded | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowUserTaskDefinition_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | + +### System.WorkflowGroup + +A named group of users for task assignment. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Group name | +| Description | String | Group description | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowGroup_User | System.User | Many-to-Many | + +### System.WorkflowUserTaskOutcome + +Records who selected which outcome on an active user task. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Outcome | String | Selected outcome value | +| Time | DateTime | When the outcome was selected | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowUserTaskOutcome_WorkflowUserTask | System.WorkflowUserTask | Many-to-One | +| WorkflowUserTaskOutcome_User | System.User | Many-to-One | + +### System.WorkflowEvent + +Audit events during workflow execution. + +| Attribute | Type | Description | +|-----------|------|-------------| +| EventTime | DateTime | When the event occurred | +| EventType | Enum (WorkflowEventType) | Type of event | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowEvent_Initiator | System.User | Many-to-One | + +### System.WorkflowRecord + +Snapshot/audit record of a workflow instance. + +| Attribute | Type | Description | +|-----------|------|-------------| +| WorkflowKey | String | Workflow instance key | +| Name | String | Workflow name | +| Description | String | Workflow description | +| State | Enum (WorkflowState) | State at time of record | +| StartTime | DateTime | Workflow start time | +| DueDate | DateTime | Workflow due date | +| EndTime | DateTime | Workflow end time | +| Reason | String | State reason | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowRecord_Workflow | System.Workflow | Many-to-One | +| WorkflowRecord_Owner | System.User | Many-to-One | +| WorkflowRecord_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | + +### System.WorkflowActivityRecord + +Detailed audit of each workflow activity execution. + +| Attribute | Type | Description | +|-----------|------|-------------| +| ModelGUID | String | Activity GUID in model | +| ActivityKey | String | Unique activity key | +| PreviousActivityKey | String | Key of preceding activity | +| ActivityType | Enum (WorkflowActivityType) | Type of activity | +| Caption | String | Activity caption | +| State | Enum (WorkflowActivityExecutionState) | Execution state | +| StartTime | DateTime | When activity started | +| EndTime | DateTime | When activity ended | +| Outcome | String | Activity outcome | +| MicroflowName | String | Called microflow (if applicable) | +| TaskName | String | User task name (if applicable) | +| TaskDescription | String | User task description | +| TaskDueDate | DateTime | User task due date | +| TaskCompletionType | Enum (WorkflowUserTaskCompletionType) | How task consensus works | +| TaskRequiredUsers | Integer | Number of required users | +| TaskKey | String | User task key | +| Reason | String | State reason | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowActivityRecord_PreviousActivity | System.WorkflowActivityRecord | Many-to-One | +| WorkflowActivityRecord_Actor | System.User | Many-to-One | +| WorkflowActivityRecord_SubWorkflow | System.WorkflowRecord | Many-to-One | +| WorkflowActivityRecord_UserTask | System.WorkflowUserTask | Many-to-One | +| WorkflowActivityRecord_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | +| WorkflowActivityRecord_TaskTargetedUsers | System.User | Many-to-Many | +| WorkflowActivityRecord_TaskAssignedUsers | System.User | Many-to-Many | +| WorkflowActivityRecord_TaskTargetedGroups | System.WorkflowGroup | Many-to-Many | + +### System.WorkflowActivityDetails + +Metadata about a workflow activity (used for jump-to navigation). + +| Attribute | Type | Description | +|-----------|------|-------------| +| ActivityId | String | Activity identifier | +| ActivityCaption | String | Display caption | +| ActivityType | Enum (WorkflowActivityType) | Activity type | +| ExistsInCurrentVersion | Boolean | Whether activity exists in current model version | + +### System.WorkflowCurrentActivity + +Current activity state within a workflow (used for jump-to). + +| Attribute | Type | Description | +|-----------|------|-------------| +| Action | Enum (WorkflowCurrentActivityAction) | DoNothing or JumpTo | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowCurrentActivity_ActivityDetails | System.WorkflowActivityDetails | Many-to-One | +| WorkflowCurrentActivity_ApplicableTargets | System.WorkflowActivityDetails | Many-to-Many | +| WorkflowCurrentActivity_JumpToTarget | System.WorkflowActivityDetails | Many-to-One | + +### System.WorkflowJumpToDetails + +Details for jump-to operations. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Error | String | Error message if jump-to failed | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowJumpToDetails_Workflow | System.Workflow | Many-to-One | +| WorkflowJumpToDetails_CurrentActivities | System.WorkflowCurrentActivity | Many-to-Many | + +### System.WorkflowEndedUserTask + +Completed/archived user tasks. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Task name | +| Description | String | Task description | +| StartTime | DateTime | When task started | +| DueDate | DateTime | Task deadline | +| EndTime | DateTime | When task ended | +| Outcome | String | Final outcome | +| State | Enum (WorkflowUserTaskState) | Final state | +| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus was determined | +| UserTaskKey | String | Unique task key | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowEndedUserTask_Assignees | System.User | Many-to-Many | +| WorkflowEndedUserTask_TargetUsers | System.User | Many-to-Many | +| WorkflowEndedUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | +| WorkflowEndedUserTask_Workflow | System.Workflow | Many-to-One | +| WorkflowEndedUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | + +### System.WorkflowEndedUserTaskOutcome + +Individual outcome votes on ended user tasks. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Outcome | String | Selected outcome | +| Time | DateTime | When outcome was selected | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowEndedUserTaskOutcome_User | System.User | Many-to-One | +| WorkflowEndedUserTaskOutcome_WorkflowEndedUserTask | System.WorkflowEndedUserTask | Many-to-One | + +--- +## 6. Task Queues & Scheduled Events + +### System.QueuedTask + +A task waiting to execute or currently running in a task queue. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Sequence | Long | Task sequence number | +| Status | Enum (QueueTaskStatus) | Current status | +| QueueId | String | Queue identifier | +| QueueName | String | Queue display name | +| ContextType | Enum (ContextType) | Execution context: System, User, Anonymous, ScheduledEvent | +| ContextData | String | Serialized context | +| MicroflowName | String | Microflow to execute | +| UserActionName | String | Java action to execute | +| Arguments | String | Serialized arguments | +| XASId | String | Cluster node identifier | +| ThreadId | Long | Execution thread ID | +| Created | DateTime | When task was queued | +| StartAt | DateTime | Scheduled start time | +| Started | DateTime | Actual start time | +| Retried | Long | Number of retry attempts | +| Retry | String | Retry configuration | +| ScheduledEventName | String | Associated scheduled event name | + +### System.ProcessedQueueTask + +Completed tasks (audit trail). Same attributes as `QueuedTask` plus: + +| Attribute | Type | Description | +|-----------|------|-------------| +| Finished | DateTime | When task finished | +| Duration | Long | Execution duration in milliseconds | +| ErrorMessage | String | Error message if task failed | + +### System.ScheduledEventInformation + +Runtime information about scheduled events. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Scheduled event name | +| Description | String | Event description | +| StartTime | DateTime | Last start time | +| EndTime | DateTime | Last end time | +| Status | Enum (EventStatus) | Running, Completed, Error, Stopped | + +| Association | Target | Type | +|-------------|--------|------| +| ScheduledEventInformation_XASInstance | System.XASInstance | Many-to-One | + +### System.XASInstance + +Cluster node information (for multi-instance deployments). + +| Attribute | Type | Description | +|-----------|------|-------------| +| XASId | String | Node identifier | +| LastUpdate | DateTime | Last heartbeat | +| AllowedNumberOfConcurrentUsers | Integer | License limit | +| PartnerName | String | Partner name (licensing) | +| CustomerName | String | Customer name (licensing) | + +### System.TaskQueueToken + +Token for task queue operations (internal use). + +--- diff --git a/.claude/skills/mendix/test-app.md b/.claude/skills/mendix/test-app/SKILL.md similarity index 95% rename from .claude/skills/mendix/test-app.md rename to .claude/skills/mendix/test-app/SKILL.md index 4e1ecb1c5..149c3662a 100644 --- a/.claude/skills/mendix/test-app.md +++ b/.claude/skills/mendix/test-app/SKILL.md @@ -1,3 +1,8 @@ +--- +name: test-app +description: "Verify a running Mendix app in a browser with Playwright, with OQL for data assertions. Use when asked to test or validate the app end to end, or to confirm that generated pages actually render." +--- + # Test App Skill This skill guides you through verifying a running Mendix application using playwright-cli for browser automation and mxcli oql for data assertions. @@ -21,7 +26,7 @@ The devcontainer created by `mxcli init` installs: - **Chromium (headless shell)** — installed via `@playwright/cli`'s **bundled** `playwright-core`, into a shared `PLAYWRIGHT_BROWSERS_PATH`, and exposed at the stable path `/usr/local/bin/mx-headless-shell`. The generated `.playwright/cli.config.json` pins `executablePath` to that symlink. - **Docker-in-Docker** — Mendix + PostgreSQL running via `mxcli docker run` -If the app calls an external REST API, that endpoint is a prerequisite too — a verification run that depends on a live third party is not repeatable. See [mock-rest-apis.md](mock-rest-apis.md). +If the app calls an external REST API, that endpoint is a prerequisite too — a verification run that depends on a live third party is not repeatable. See [mock-rest-apis](../mock-rest-apis/SKILL.md). The app must be running before verification: @@ -453,9 +458,9 @@ playwright-cli snapshot ## Related Skills -- [test-microflows.md](./test-microflows.md) - **MDL microflow tests** (business logic, no browser needed) -- [/run-app](./run-app.md) - Build and start the Mendix app in Docker -- [/docker-workflow](./docker-workflow.md) - Full Docker workflow reference -- [/demo-data](./demo-data.md) - Seed test data into PostgreSQL -- [/create-page](./create-page.md) - Page creation patterns (widget names for selectors) -- [/write-microflows](./write-microflows.md) - Microflow patterns (data persistence logic) +- [test-microflows](../test-microflows/SKILL.md) - **MDL microflow tests** (business logic, no browser needed) +- [/run-app](../run-app/SKILL.md) - Build and start the Mendix app in Docker +- [/docker-workflow](../docker-workflow/SKILL.md) - Full Docker workflow reference +- [/demo-data](../demo-data/SKILL.md) - Seed test data into PostgreSQL +- [/create-page](../create-page/SKILL.md) - Page creation patterns (widget names for selectors) +- [/write-microflows](../write-microflows/SKILL.md) - Microflow patterns (data persistence logic) diff --git a/.claude/skills/mendix/test-microflows.md b/.claude/skills/mendix/test-microflows/SKILL.md similarity index 57% rename from .claude/skills/mendix/test-microflows.md rename to .claude/skills/mendix/test-microflows/SKILL.md index 902984777..5ad788c53 100644 --- a/.claude/skills/mendix/test-microflows.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -1,7 +1,19 @@ +--- +name: test-microflows +description: "Write and run MDL-based microflow tests with `mxcli test` — annotations, file formats, and the warm local test loop. Use when testing microflow logic rather than the UI: return values, entity changes, control flow." +--- + # Test Microflows Skill This skill guides you through writing and running MDL-based microflow tests using `mxcli test`. +## Reference files + +- [`reference/annotations.md`](reference/annotations.md) — every test annotation: + `@test`, `@setup`, `@teardown`, `@cleanup`, `@skip`, parameters and expected + values, with what each one does and the exact spelling. **Check an annotation + here rather than guessing** — an unknown one is a parse error, not a warning. + ## When to Use This Skill Use this when: @@ -159,292 +171,6 @@ The markdown format turns your tests into living documentation. --- -## Annotations - -| Tag | Purpose | Example | -|-----|---------|---------| -| `@test` | Test name (required) | `@test string concatenation` | -| `@expect` | Assert a Mendix condition | `@expect $result = 'John Doe'` | -| `@expect` | Assert an entity attribute | `@expect $product/Name = 'TestProduct'` | -| `@expect` | Assert with a built-in | `@expect length($result) = 81` | -| `@verify` | OQL post-condition on the database | `@verify select count(*) as n from Mod.E = 1` | -| `@throws` | Expect error | `@throws 'validation failed'` | -| `@setup` | Microflow to run first | `@setup MyModule.ACT_SeedCustomers` | -| `@cleanup` | Rollback strategy | `@cleanup rollback` (default) or `@cleanup none` | - -A tag is read only when it **opens its line** (after the javadoc `*` and its -indentation). Quoting one inside a sentence — ``a test with `@expect $x = 1` -asserts …`` — is documentation, not an annotation, so a doc comment can explain -itself without giving the test assertions nobody wrote. - -### A test run leaves the project byte-identical - -`mxcli test` injects an `MxTest` module, builds, runs, and takes the injection -back out. When cleanup succeeds the project file is restored **byte-for-byte**, -so `git status` is clean afterwards and a CI step of the form "run the tests, -then assert the tree is clean" holds. - -This needs saying because restoring the *model* is not enough. Every unit write -stamps a fresh UUID into the `.mpr`'s `_Transaction` bookkeeping row, and the -inject/remove cycle relays SQLite's pages, so the file differs even once its -content matches again. Version control compares bytes, and a `.mpr` diff is -opaque — there is no cheap way to tell a bookkeeping GUID from a real model edit, -which is what made the spurious modification expensive rather than merely untidy. - -The restore is declined, deliberately, when **cleanup failed** or when the -`mprcontents/` tree changed during the run. In both cases the project is not in -the state the snapshot describes, and putting the old file back would turn a -visible, harmless discrepancy into an invisible, misleading one. - -### `@expect` — any Mendix condition, and nothing it cannot evaluate - -An `@expect` is **a Mendix expression that must evaluate to true**, not a fixed -`$var = value` shape. Anything the Mendix expression engine accepts works: - -```mdl -@expect $result = 'John Doe' -- equality -@expect $product/Name != 'Widget' -- inequality (<> also accepted) -@expect length($result) = 81 -- built-in functions -@expect find($result, '0') >= 0 -- any comparison operator -@expect substring($result, 0, 9) = substring($result, 9, 18) -@expect find($result, '0') >= 0 and $count > 3 -- and / or / not(...) -@expect $status = MyModule.Status.Open -- enumeration values -@expect count($Customers) = 5 -- how many rows a list holds -``` - -`<>` is accepted in the annotation and rewritten to `!=` on the way to the -model, because Mendix's expression engine rejects the `<>` spelling (CE0117). - -**An assertion the runner cannot compile is an ERROR, never a pass.** Unknown -functions, wrong arity, unbalanced parentheses and expressions that evaluate to -a value rather than a condition are all rejected by name: - -``` -ERROR a self-evident falsehood - @expect randomInt($result) = 1: randomInt() is not a Mendix expression - function at column 1 ("randomInt") -``` - -This is the one rule the annotation is built around. A test framework that -cannot evaluate an assertion has exactly one safe behaviour, and passing is not -it — an earlier version matched only `$var = ` and silently discarded -every other line, so `@expect 1 = 2` reported PASS. - -**A failure reports what came back**, not just what was wanted, whenever the -observed value's type is pinned down by the assertion itself: - -``` -FAIL the board is 81 squares - expected length($result) = 81, actual: 27 -``` - -The value is omitted rather than guessed when neither side of the comparison -establishes a type (`@expect $a = $b`), because Mendix's expression engine is -typed and a wrong guess would break the build instead of the test. - -#### `count($list)` — the one aggregate an assertion can make - -Counting a list is not a Mendix *expression* function; it is an Aggregate list -**activity**, so it cannot appear in the decision that evaluates an assertion. -`@expect count($Scans) = 2` is nevertheless accepted: the count is lifted into -the activity you would otherwise write by hand, ahead of the decision, and the -condition compares its result. - -```mdl -/** - * @test the seed microflow writes five brands - * @cleanup none - * @expect count($Brands) = 5 - */ -retrieve $Brands from eShop.CatalogBrand; -/ -``` - -The other four aggregates (`sum`, `average`, `minimum`, `maximum`) aggregate an -**attribute** over the list, which an assertion has no way to supply, so they are -refused with that explanation. Call a microflow that returns the figure and -assert on its result: - -```mdl -$Total = call microflow eShop.QRY_OrderTotal(); -``` - -The refusal matters more than the convenience: before it, a count assertion was -dropped during parsing, and a test with no assertions left passes as long as its -body does not throw — so `@expect count($Brands) = 999` reported PASS against an -empty table (#927). - -### `@setup` — the state a test needs before it runs - -`@setup` names a **microflow** to call before the test's own statements. A -fixture in a Mendix app is a microflow, so there is nothing to declare: - -```mdl -/** - * @test the seed microflow writes five brands - * @setup eShop.ACT_SeedCatalog - * @cleanup none - * @expect count($Brands) = 5 - */ -retrieve $Brands from eShop.CatalogBrand; -/ -``` - -Repeat it to compose fixtures; they run in the order written. Declare it **once -in the file's header comment** and every test in the file gets it, with the -file's fixtures running before a test's own: - -```mdl -/** - * Seeds every test below. - * @setup eShop.ACT_SeedCatalog - */ -``` - -The header is the file's first doc comment when it carries no `@test`. It may -only carry `@setup` — `@expect`, `@verify`, `@throws` and `@cleanup` describe one -test's execution, so a header carrying one is refused by name rather than -silently ignored. - -Two consequences worth knowing: - -- **The setup runs inside the test's transaction.** Under the `@cleanup - rollback` default it is undone with the test, so every test starts from the - same state — which is what makes a fixture worth having. Under `@cleanup none` - it persists like everything else that test writes. -- **A failing setup is an ERROR, not a FAIL**, naming the microflow. The test - never ran, so it neither passed nor failed, and a suite full of assertion - mismatches caused by one broken seed is exactly what this prevents. - -`@setup` calls a microflow with no arguments; a fixture that needs arguments -gets a wrapper microflow. There is no `@teardown` — `@cleanup rollback` is the -teardown. - -### `@verify` — asserting on what the microflow wrote - -`@expect` can only see what a microflow **returned**. Most Mendix microflows are -side effects, so `@verify` is how you assert on the rows one left behind: an OQL -query, a comparison, and the value it must satisfy. - -```mdl -/** - * @test dealing a board writes 81 cells - * @cleanup none - * @expect $result = 'ok' - * @verify select count(*) as n from Sudoku.Cell = 81 - * @verify select count(*) as n from Sudoku.Cell where Value = 0 > 0 - */ -$result = CALL MICROFLOW Sudoku.ACT_DealGame(); -/ -``` - -The query runs against the app **after** the microflow returns, over the same -admin API `mxcli oql` uses. Three rules follow from that, and each is enforced -rather than left to trip you up: - -- **`@cleanup none` is required.** `rollback` is the default, and it undoes the - test's writes before the query could see them — so a `@verify` on a rollback - test is **refused**, not run against the pre-test state. -- **The query must return exactly one row and one column.** Comparing a table to - a literal would mean guessing which cell was meant. Aggregate it - (`select count(*)`), or select one attribute of one row. -- **The expected value is a literal** — a number, a quoted string, `true`/`false` - or `empty`. It is split off at the **last** comparison operator outside quotes - and parentheses, so a `where Value = 5` in the query is left alone. -- **Every selected column needs a name.** Mendix's OQL rejects a bare - `select count(*)` with *"All OQL select columns must have a name"*, so write - `select count(*) as n`. That comes back as an ERROR, not a pass. - -Operators: `=`, `!=` (`<>` accepted), `<`, `<=`, `>`, `>=`. Numbers compare -numerically even though the runtime returns them as strings. - -**A `@verify` that cannot be evaluated is an ERROR, never a pass** — an unknown -entity, malformed OQL, a non-scalar result, or something that was never a query: - -``` -ERROR dealing a board writes 81 cells - @verify select count(*) as n from Sudoku.NoSuch = 1: OQL error: Unknown entity -``` - -and a false one fails with the value that came back: - -``` -FAIL dealing a board writes 81 cells - expected select count(*) as n from Sudoku.Cell = 81, actual: 27 -``` - -`@verify` needs the test endpoint, so it runs under `--local` (the default) and -`--attach`. The Docker / `--legacy-runner` path **refuses** a suite using it: -its tests execute during boot, so there is no point at which to query the app. - -### A test that asserts nothing says so - -Every result line carries what the test actually checked, and a run that -contains a vacuous test calls it out: - -``` - PASS the board is 81 squares (6ms, 2 assertions) - PASS asserts nothing at all (4ms, no assertions) ------------------------------------------------------------- -1 test(s) asserted nothing beyond "did not throw". Run with ---require-assertions to make that an error. -``` - -A test with no `@expect` and no `@throws` is a **smoke test** — it reports only -that the body did not throw. That is a legitimate thing to write, so it still -passes by default. What it may not do is look identical to a test with six -assertions: after `@expect` started failing closed, the cheapest way back to a -green suite is to delete the assertion, and that must not read as a repair. - -`--require-assertions` turns every vacuous test into an ERROR, for a project -that has decided each test must assert. The JUnit report carries the count as a -`` per case, and `classname` now identifies the -source file, so a failure in a multi-file run says where it lives. - -### `@cleanup` — what happens to a test's data - -**`rollback` is the default**, so by default a test's database writes do not -survive it. The endpoint opens a transaction around the call and rolls it back -afterwards, including when the test throws. - -```mdl -/** - * @test creating an order does not leak - * @expect $result = 'ok' - */ -$result = CALL MICROFLOW Sales.CreateOrder(Amount = 100); -/ - -/** - * @test seed data the next test needs - * @cleanup none - */ -$result = CALL MICROFLOW Sales.SeedCatalogue(); -/ -``` - -Use `@cleanup none` when the writes are the point — seeding a fixture, or -inspecting the result in the running app afterwards. - -Two things worth knowing: - -- **`--local` only.** Rollback needs the test endpoint, which owns the context - the test runs in. The Docker / `--legacy-runner` path executes tests inside - the after-startup action and has no such seam, so it always commits. -- **A rollback that fails is reported, loudly.** The run prints a `WARNING` per - affected test and a summary line, because the alternative — data left behind - while the suite still says PASS — is the failure mode this annotation exists - to prevent. `--verbose` tags every test with `[rolled back]`, `[committed]` or - `[ROLLBACK FAILED]`. - -A misspelled strategy (`@cleanup rollbak`) is a **parse error**, not a silent -fallback to committing. - -Rollback matters most under `--attach`, where the database is the one your dev -app is using. - ---- - ## Running Tests ```bash @@ -767,7 +493,7 @@ The JUnit XML works with GitHub Actions, Jenkins, Azure DevOps, GitLab CI, etc. ## Related Skills -- [test-app.md](test-app.md) — Playwright UI tests (pages, widgets, browser interactions) -- [write-microflows.md](write-microflows.md) — Microflow syntax reference -- [docker-workflow.md](docker-workflow.md) — Docker build and runtime workflow -- [verify-with-oql.md](verify-with-oql.md) — OQL queries for data verification +- [test-app](../test-app/SKILL.md) — Playwright UI tests (pages, widgets, browser interactions) +- [write-microflows](../write-microflows/SKILL.md) — Microflow syntax reference +- [docker-workflow](../docker-workflow/SKILL.md) — Docker build and runtime workflow +- [verify-with-oql](../verify-with-oql/SKILL.md) — OQL queries for data verification diff --git a/.claude/skills/mendix/test-microflows/reference/annotations.md b/.claude/skills/mendix/test-microflows/reference/annotations.md new file mode 100644 index 000000000..1aede67b7 --- /dev/null +++ b/.claude/skills/mendix/test-microflows/reference/annotations.md @@ -0,0 +1,289 @@ +# Test annotation reference + +Supporting reference for [test-microflows](../SKILL.md). + +## Annotations + +| Tag | Purpose | Example | +|-----|---------|---------| +| `@test` | Test name (required) | `@test string concatenation` | +| `@expect` | Assert a Mendix condition | `@expect $result = 'John Doe'` | +| `@expect` | Assert an entity attribute | `@expect $product/Name = 'TestProduct'` | +| `@expect` | Assert with a built-in | `@expect length($result) = 81` | +| `@verify` | OQL post-condition on the database | `@verify select count(*) as n from Mod.E = 1` | +| `@throws` | Expect error | `@throws 'validation failed'` | +| `@setup` | Microflow to run first | `@setup MyModule.ACT_SeedCustomers` | +| `@cleanup` | Rollback strategy | `@cleanup rollback` (default) or `@cleanup none` | + +A tag is read only when it **opens its line** (after the javadoc `*` and its +indentation). Quoting one inside a sentence — ``a test with `@expect $x = 1` +asserts …`` — is documentation, not an annotation, so a doc comment can explain +itself without giving the test assertions nobody wrote. + +### A test run leaves the project byte-identical + +`mxcli test` injects an `MxTest` module, builds, runs, and takes the injection +back out. When cleanup succeeds the project file is restored **byte-for-byte**, +so `git status` is clean afterwards and a CI step of the form "run the tests, +then assert the tree is clean" holds. + +This needs saying because restoring the *model* is not enough. Every unit write +stamps a fresh UUID into the `.mpr`'s `_Transaction` bookkeeping row, and the +inject/remove cycle relays SQLite's pages, so the file differs even once its +content matches again. Version control compares bytes, and a `.mpr` diff is +opaque — there is no cheap way to tell a bookkeeping GUID from a real model edit, +which is what made the spurious modification expensive rather than merely untidy. + +The restore is declined, deliberately, when **cleanup failed** or when the +`mprcontents/` tree changed during the run. In both cases the project is not in +the state the snapshot describes, and putting the old file back would turn a +visible, harmless discrepancy into an invisible, misleading one. + +### `@expect` — any Mendix condition, and nothing it cannot evaluate + +An `@expect` is **a Mendix expression that must evaluate to true**, not a fixed +`$var = value` shape. Anything the Mendix expression engine accepts works: + +```mdl +@expect $result = 'John Doe' -- equality +@expect $product/Name != 'Widget' -- inequality (<> also accepted) +@expect length($result) = 81 -- built-in functions +@expect find($result, '0') >= 0 -- any comparison operator +@expect substring($result, 0, 9) = substring($result, 9, 18) +@expect find($result, '0') >= 0 and $count > 3 -- and / or / not(...) +@expect $status = MyModule.Status.Open -- enumeration values +@expect count($Customers) = 5 -- how many rows a list holds +``` + +`<>` is accepted in the annotation and rewritten to `!=` on the way to the +model, because Mendix's expression engine rejects the `<>` spelling (CE0117). + +**An assertion the runner cannot compile is an ERROR, never a pass.** Unknown +functions, wrong arity, unbalanced parentheses and expressions that evaluate to +a value rather than a condition are all rejected by name: + +``` +ERROR a self-evident falsehood + @expect randomInt($result) = 1: randomInt() is not a Mendix expression + function at column 1 ("randomInt") +``` + +This is the one rule the annotation is built around. A test framework that +cannot evaluate an assertion has exactly one safe behaviour, and passing is not +it — an earlier version matched only `$var = ` and silently discarded +every other line, so `@expect 1 = 2` reported PASS. + +**A failure reports what came back**, not just what was wanted, whenever the +observed value's type is pinned down by the assertion itself: + +``` +FAIL the board is 81 squares + expected length($result) = 81, actual: 27 +``` + +The value is omitted rather than guessed when neither side of the comparison +establishes a type (`@expect $a = $b`), because Mendix's expression engine is +typed and a wrong guess would break the build instead of the test. + +#### `count($list)` — the one aggregate an assertion can make + +Counting a list is not a Mendix *expression* function; it is an Aggregate list +**activity**, so it cannot appear in the decision that evaluates an assertion. +`@expect count($Scans) = 2` is nevertheless accepted: the count is lifted into +the activity you would otherwise write by hand, ahead of the decision, and the +condition compares its result. + +```mdl +/** + * @test the seed microflow writes five brands + * @cleanup none + * @expect count($Brands) = 5 + */ +retrieve $Brands from eShop.CatalogBrand; +/ +``` + +The other four aggregates (`sum`, `average`, `minimum`, `maximum`) aggregate an +**attribute** over the list, which an assertion has no way to supply, so they are +refused with that explanation. Call a microflow that returns the figure and +assert on its result: + +```mdl +$Total = call microflow eShop.QRY_OrderTotal(); +``` + +The refusal matters more than the convenience: before it, a count assertion was +dropped during parsing, and a test with no assertions left passes as long as its +body does not throw — so `@expect count($Brands) = 999` reported PASS against an +empty table (#927). + +### `@setup` — the state a test needs before it runs + +`@setup` names a **microflow** to call before the test's own statements. A +fixture in a Mendix app is a microflow, so there is nothing to declare: + +```mdl +/** + * @test the seed microflow writes five brands + * @setup eShop.ACT_SeedCatalog + * @cleanup none + * @expect count($Brands) = 5 + */ +retrieve $Brands from eShop.CatalogBrand; +/ +``` + +Repeat it to compose fixtures; they run in the order written. Declare it **once +in the file's header comment** and every test in the file gets it, with the +file's fixtures running before a test's own: + +```mdl +/** + * Seeds every test below. + * @setup eShop.ACT_SeedCatalog + */ +``` + +The header is the file's first doc comment when it carries no `@test`. It may +only carry `@setup` — `@expect`, `@verify`, `@throws` and `@cleanup` describe one +test's execution, so a header carrying one is refused by name rather than +silently ignored. + +Two consequences worth knowing: + +- **The setup runs inside the test's transaction.** Under the `@cleanup + rollback` default it is undone with the test, so every test starts from the + same state — which is what makes a fixture worth having. Under `@cleanup none` + it persists like everything else that test writes. +- **A failing setup is an ERROR, not a FAIL**, naming the microflow. The test + never ran, so it neither passed nor failed, and a suite full of assertion + mismatches caused by one broken seed is exactly what this prevents. + +`@setup` calls a microflow with no arguments; a fixture that needs arguments +gets a wrapper microflow. There is no `@teardown` — `@cleanup rollback` is the +teardown. + +### `@verify` — asserting on what the microflow wrote + +`@expect` can only see what a microflow **returned**. Most Mendix microflows are +side effects, so `@verify` is how you assert on the rows one left behind: an OQL +query, a comparison, and the value it must satisfy. + +```mdl +/** + * @test dealing a board writes 81 cells + * @cleanup none + * @expect $result = 'ok' + * @verify select count(*) as n from Sudoku.Cell = 81 + * @verify select count(*) as n from Sudoku.Cell where Value = 0 > 0 + */ +$result = CALL MICROFLOW Sudoku.ACT_DealGame(); +/ +``` + +The query runs against the app **after** the microflow returns, over the same +admin API `mxcli oql` uses. Three rules follow from that, and each is enforced +rather than left to trip you up: + +- **`@cleanup none` is required.** `rollback` is the default, and it undoes the + test's writes before the query could see them — so a `@verify` on a rollback + test is **refused**, not run against the pre-test state. +- **The query must return exactly one row and one column.** Comparing a table to + a literal would mean guessing which cell was meant. Aggregate it + (`select count(*)`), or select one attribute of one row. +- **The expected value is a literal** — a number, a quoted string, `true`/`false` + or `empty`. It is split off at the **last** comparison operator outside quotes + and parentheses, so a `where Value = 5` in the query is left alone. +- **Every selected column needs a name.** Mendix's OQL rejects a bare + `select count(*)` with *"All OQL select columns must have a name"*, so write + `select count(*) as n`. That comes back as an ERROR, not a pass. + +Operators: `=`, `!=` (`<>` accepted), `<`, `<=`, `>`, `>=`. Numbers compare +numerically even though the runtime returns them as strings. + +**A `@verify` that cannot be evaluated is an ERROR, never a pass** — an unknown +entity, malformed OQL, a non-scalar result, or something that was never a query: + +``` +ERROR dealing a board writes 81 cells + @verify select count(*) as n from Sudoku.NoSuch = 1: OQL error: Unknown entity +``` + +and a false one fails with the value that came back: + +``` +FAIL dealing a board writes 81 cells + expected select count(*) as n from Sudoku.Cell = 81, actual: 27 +``` + +`@verify` needs the test endpoint, so it runs under `--local` (the default) and +`--attach`. The Docker / `--legacy-runner` path **refuses** a suite using it: +its tests execute during boot, so there is no point at which to query the app. + +### A test that asserts nothing says so + +Every result line carries what the test actually checked, and a run that +contains a vacuous test calls it out: + +``` + PASS the board is 81 squares (6ms, 2 assertions) + PASS asserts nothing at all (4ms, no assertions) +------------------------------------------------------------ +1 test(s) asserted nothing beyond "did not throw". Run with +--require-assertions to make that an error. +``` + +A test with no `@expect` and no `@throws` is a **smoke test** — it reports only +that the body did not throw. That is a legitimate thing to write, so it still +passes by default. What it may not do is look identical to a test with six +assertions: after `@expect` started failing closed, the cheapest way back to a +green suite is to delete the assertion, and that must not read as a repair. + +`--require-assertions` turns every vacuous test into an ERROR, for a project +that has decided each test must assert. The JUnit report carries the count as a +`` per case, and `classname` now identifies the +source file, so a failure in a multi-file run says where it lives. + +### `@cleanup` — what happens to a test's data + +**`rollback` is the default**, so by default a test's database writes do not +survive it. The endpoint opens a transaction around the call and rolls it back +afterwards, including when the test throws. + +```mdl +/** + * @test creating an order does not leak + * @expect $result = 'ok' + */ +$result = CALL MICROFLOW Sales.CreateOrder(Amount = 100); +/ + +/** + * @test seed data the next test needs + * @cleanup none + */ +$result = CALL MICROFLOW Sales.SeedCatalogue(); +/ +``` + +Use `@cleanup none` when the writes are the point — seeding a fixture, or +inspecting the result in the running app afterwards. + +Two things worth knowing: + +- **`--local` only.** Rollback needs the test endpoint, which owns the context + the test runs in. The Docker / `--legacy-runner` path executes tests inside + the after-startup action and has no such seam, so it always commits. +- **A rollback that fails is reported, loudly.** The run prints a `WARNING` per + affected test and a summary line, because the alternative — data left behind + while the suite still says PASS — is the failure mode this annotation exists + to prevent. `--verbose` tags every test with `[rolled back]`, `[committed]` or + `[ROLLBACK FAILED]`. + +A misspelled strategy (`@cleanup rollbak`) is a **parse error**, not a silent +fallback to committing. + +Rollback matters most under `--attach`, where the database is the one your dev +app is using. + +--- diff --git a/.claude/skills/mendix/theme-styling.md b/.claude/skills/mendix/theme-styling/SKILL.md similarity index 98% rename from .claude/skills/mendix/theme-styling.md rename to .claude/skills/mendix/theme-styling/SKILL.md index 51c778282..f1092144d 100644 --- a/.claude/skills/mendix/theme-styling.md +++ b/.claude/skills/mendix/theme-styling/SKILL.md @@ -1,3 +1,8 @@ +--- +name: theme-styling +description: "The SCSS workflow and its traps — where styling actually compiles, custom-variables.scss, themesource directories, hot reload, and design-property errors. Use when writing or debugging SCSS, or when styling silently fails to appear." +--- + # Theme & Styling — SCSS Workflow and Caveats ## When to Use This Skill diff --git a/.claude/skills/mendix/validation-microflows.md b/.claude/skills/mendix/validation-microflows/SKILL.md similarity index 97% rename from .claude/skills/mendix/validation-microflows.md rename to .claude/skills/mendix/validation-microflows/SKILL.md index 617b43804..5c6e7eab9 100644 --- a/.claude/skills/mendix/validation-microflows.md +++ b/.claude/skills/mendix/validation-microflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: validation-microflows +description: "Validation microflows for new/edit pages — attribute checks, feedback messages, and conditional validation chains. Use when a form needs input validation with messages shown against the fields." +--- + # Validation Microflows Skill This skill provides guidance for creating validation microflows in MDL that validate user input on NewEdit pages and provide feedback to users. diff --git a/.claude/skills/mendix/verify-with-oql.md b/.claude/skills/mendix/verify-with-oql/SKILL.md similarity index 89% rename from .claude/skills/mendix/verify-with-oql.md rename to .claude/skills/mendix/verify-with-oql/SKILL.md index b4aa9aa95..97d9c8ae0 100644 --- a/.claude/skills/mendix/verify-with-oql.md +++ b/.claude/skills/mendix/verify-with-oql/SKILL.md @@ -1,3 +1,8 @@ +--- +name: verify-with-oql +description: "Verify microflow side effects and data changes with OQL against a running app. Use after executing changes to confirm data was created, updated or deleted as expected, or to back a browser test with a data assertion." +--- + # Verify with OQL Skill This skill documents how to verify microflow side effects and data changes using OQL queries against a running Mendix app. @@ -170,7 +175,7 @@ This loop avoids container restarts and database resets, making each iteration t - **OQL is read-only** — `mxcli oql` uses the `preview_execute_oql` action which cannot modify data - **OQL won't see rows you wrote directly into Postgres until the runtime reloads.** `mxcli oql` executes through the *running app's* query layer (not a separate DB), so rows seeded with direct - SQL `INSERT`s (see [demo-data.md](./demo-data.md)) are invisible until the runtime re-reads them. + SQL `INSERT`s (see [demo-data](../demo-data/SKILL.md)) are invisible until the runtime re-reads them. After seeding, run `mxcli docker reload` (or restart the app) before trusting an OQL `count(*)` of `0`. To confirm the rows landed *before* a reload, query Postgres directly (`mxcli sql …`). - **Check before and after** — query the state before triggering an action to establish a baseline @@ -183,7 +188,7 @@ This loop avoids container restarts and database resets, making each iteration t ## Related Skills -- [/test-app](./test-app.md) — Full Playwright test patterns and setup -- [/runtime-admin-api](./runtime-admin-api.md) — Admin API details and curl examples -- [/docker-workflow](./docker-workflow.md) — Build, run, and hot reload workflow -- [/write-oql-queries](./write-oql-queries.md) — OQL syntax reference +- [/test-app](../test-app/SKILL.md) — Full Playwright test patterns and setup +- [/runtime-admin-api](../runtime-admin-api/SKILL.md) — Admin API details and curl examples +- [/docker-workflow](../docker-workflow/SKILL.md) — Build, run, and hot reload workflow +- [/write-oql-queries](../write-oql-queries/SKILL.md) — OQL syntax reference diff --git a/.claude/skills/mendix/write-lint-rules.md b/.claude/skills/mendix/write-lint-rules/SKILL.md similarity index 99% rename from .claude/skills/mendix/write-lint-rules.md rename to .claude/skills/mendix/write-lint-rules/SKILL.md index 4692bfbee..99eccf45b 100644 --- a/.claude/skills/mendix/write-lint-rules.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-lint-rules +description: "Write custom Starlark lint rules in .claude/lint-rules/ that run beside the built-ins under `mxcli lint`. Use when a project convention should be enforced automatically." +--- + # Writing Custom Starlark Lint Rules Custom lint rules are written in Starlark (a Python-like language) and placed in `.claude/lint-rules/` as `.star` files. They run alongside the built-in rules when `mxcli lint -p app.mpr` is executed. diff --git a/.claude/skills/mendix/write-microflows.md b/.claude/skills/mendix/write-microflows.md deleted file mode 100644 index 2224799cd..000000000 --- a/.claude/skills/mendix/write-microflows.md +++ /dev/null @@ -1,1900 +0,0 @@ -# Mendix Microflow Skill - -This skill provides comprehensive guidance for writing Mendix microflows in MDL (Mendix Definition Language) syntax. - -## When to Use This Skill - -Use this skill when: -- Writing CREATE MICROFLOW statements -- Debugging microflow syntax errors -- Converting Studio Pro microflows to MDL -- Understanding microflow control flow and structure - -If you're not sure whether the logic belongs in a microflow or a nanoflow, read the next section first. The mirror lives in [write-nanoflows.md](./write-nanoflows.md) — keep both copies in sync. - -## When to Use a Microflow vs a Nanoflow - -| Scenario | Use | -|----------|-----| -| Querying the database | Microflow | -| Calling REST services or external actions | Microflow | -| Running Java actions | Microflow | -| File generation or download | Microflow | -| Transactional commits (rollback on error) | Microflow | -| Background scheduled logic | Microflow | -| Client-side form validation before save | Nanoflow | -| UI navigation and page routing | Nanoflow | -| Calling device features (GPS, phone, camera) | Nanoflow | -| Offline data access and local storage | Nanoflow | -| Calling JavaScript actions (NanoflowCommons) | Nanoflow | -| Showing progress indicators / confirmation dialogs | Nanoflow | - -**Rule of thumb:** A nanoflow runs before the server call. A microflow IS the server call. - -## Key Differences from Nanoflows - -| Aspect | Microflow | Nanoflow | -|--------|-----------|----------| -| **Execution** | Server-side | Client-side (browser/mobile) | -| **Database access** | Full | No direct access | -| **Transactions** | Supported | Not supported | -| **Java actions** | Supported | Not supported | -| **JavaScript actions** | Not supported | Supported | -| **SYNCHRONIZE** | Not available | Available (offline sync) | -| **File downloads** | Supported | Not supported | -| **Error handling** | Full `ON ERROR` blocks + `RAISE ERROR` | Per-action `ON ERROR` supported; `RAISE ERROR` / `ErrorEvent` forbidden | -| **Offline** | Not available | Available | -| **Binary return type** | Supported | Not supported | - -For nanoflow-specific authoring guidance, see [write-nanoflows.md](./write-nanoflows.md). - -## Microflow Structure - -**CRITICAL: All microflows MUST have JavaDoc-style documentation** - -```mdl -/** - * Microflow description explaining what it does - * - * Detailed explanation of the business logic, use cases, - * and any important implementation notes. - * - * @param $Parameter1 Description of first parameter - * @param $Parameter2 Description of second parameter - * @returns Description of return value - * @since 1.0.0 - * @author Team Name - */ -create microflow Module.MicroflowName ( - $Parameter1: type, - $Parameter2: type -) -returns ReturnType as $ReturnVariable -[folder 'FolderPath'] -begin - -- Microflow logic here - return $ReturnVariable; -end; -``` - -### `@excluded` — documents excluded from the project - -`@excluded` before a `create microflow` marks the document **"Exclude from project"** -(the same checkbox Studio Pro offers). The document stays in the `.mpr`, does not -build, and `show microflows` reports it in the `Excluded` column. - -```mdl -@excluded -create microflow MyModule.LegacyCalc () -returns Integer -begin - return 7; -end; -``` - -Two rules follow, and both are enforced rather than documented-and-hoped: - -- **An absent `@excluded` never un-excludes.** It means "the script does not say", - not "make this active" — so re-running a `create or modify` you wrote before the - document was excluded leaves the exclusion alone. Un-exclude in Studio Pro. - (Before #914 the rewrite cleared it, which is how a valid project ended up - failing **CE0122** — see the next rule.) -- **A name is not unique when a twin is excluded.** Mendix allows two documents of - the same name in one module as long as at most one is active — verified on - 11.13.0: the excluded pair builds at 0 errors, the same pair both active is - `[error] CE0122 "Duplicate document name"`. `create or modify`, `describe` and - the other by-name lookups therefore target the **live** document; the excluded - twin is neither rewritten nor deleted. - -The same applies to every document type that carries the flag — nanoflows, pages, -snippets, enumerations, queues, workflows, Java/JavaScript actions, mappings, JSON -structures, REST/OData services, image collections and the agent documents. - -### FOLDER Option - -Place microflows in folders for organization: - -```mdl -create microflow MyModule.ACT_ProcessOrder ($Order: MyModule.Order) -returns boolean as $success -folder 'Orders/Processing' -begin - -- logic - return true; -end; -``` - -**Key Rules:** -- Parameters start with `$` prefix -- Return variable must be declared or used -- Every microflow must end with `return` statement -- Every body statement ends with a semicolon `;` — **required**, not optional. This - includes block terminators: `end if;`, `end loop;`, `end while;`, `end case;`. - A missing one is a parse error (`missing ';' at 'return'`), not a warning. -- Microflow ends with `/` separator - -### Parameter Types - -```mdl --- Primitive types -$Name: string -$count: integer -$Amount: decimal -$IsActive: boolean -$date: datetime - --- Entity types -$Customer: Module.Entity - --- List types -$ProductList: list of Module.Product - --- Enumeration types -$status: enum Module.OrderStatus -``` - -## Variable Declarations - -### ✅ CORRECT Syntax - -```mdl --- Primitive types with initialization -declare $Counter integer = 0; -declare $message string = 'Hello'; -declare $IsValid boolean = true; -declare $Today datetime = [%CurrentDateTime%]; -declare $status Enumeration(Module.OrderStatus) = Module.OrderStatus.Open; -``` - -> **You cannot `declare` an object (entity) variable.** `declare` becomes a -> *Create Variable* activity, which Mendix only allows to hold **primitive** types -> (String, Integer/Long, Decimal, Boolean, DateTime, Enumeration). An object type -> is rejected by Studio Pro/mxbuild with CE0053 ("Selected type is not allowed"), -> plus CE0038 ("Value required") and CE7247 on any following `set` — whether or -> not you give it an initializer. `mxcli check` now flags it as **MDL043**. There -> is **no** "empty object variable" activity. Get objects from one of these: -> - a microflow **parameter**: `create microflow M.Save ($Product: Test.Product) ...` -> - a **retrieve**: `retrieve $Product from Test.Product where Code = $c limit 1;` -> - a **create object**: `$Product = create Test.Product (Name = $n);` -> - a **loop iterator**: `loop $Product in $Products ...` - -> **You cannot `declare` a list either.** Same *Create Variable* restriction — -> Studio Pro rejects a list with CE0053/CE0038, and `mxcli check` flags it as -> **MDL040**. Get lists from: -> - a microflow **parameter**: `create microflow M.Process ($Items: list of Test.Product) ...` -> - a **retrieve**: `retrieve $Products from Test.Product where IsActive = true;` -> - a **create list**: `$Products = create list of Test.Product;` - -> **Decimal values into an `integer`/`long` fail CE0117.** Integer division -> (`$a div $b`) is always a Decimal even for two integers, and so are `random()` -> and the duration `*Between` functions (`secondsBetween`, `minutesBetween`, -> `hoursBetween`, `daysBetween`, `weeksBetween`). Assigning any of them straight -> to an `integer`/`long` variable fails `mx check` with **CE0117** (`mxcli check` -> now flags it as **MDL041**). Either declare the target `decimal`, or round it: -> ```mdl -> declare $Avg decimal = $Total div $Count; -- ✅ Decimal target -> declare $Whole integer = round($Total div $Count); -- ✅ rounded to Integer -> declare $Secs integer = round(secondsBetween($a,$b)); -- ✅ rounded to Integer -> declare $Bad integer = $Total div $Count; -- ❌ CE0117 / MDL041 -> ``` -> The `calendar*Between` functions (`calendarMonthsBetween`, `calendarYearsBetween`) -> return whole units (Integer) and are fine to assign directly. - -### ❌ INCORRECT Syntax - -```mdl --- WRONG: Declaring an object/entity variable (CE0053/CE0038, MDL043) -declare $Product Test.Product; -- bare object declare is invalid -declare $Product Test.Product = $someObj; -- initialized object declare is also invalid - --- WRONG: Declaring a list variable (CE0053/CE0038, MDL040) -declare $ProductList list of Test.Product = empty; -- use a parameter, retrieve, or create list - --- WRONG: Using AS keyword (not supported in mxcli) -declare $Product as Test.Product; -- ERROR: parse error - --- WRONG: No value (CE0038, MDL061) -declare $X string; -- a Create Variable activity requires a value - --- WRONG: Missing type -declare $Counter = 0; -- Type inference not always supported - --- WRONG: Using 'OF' instead of 'of' -declare $list list of Test.Product; -- Case sensitive -``` - -## Common Pitfalls - -### 1. Object (Entity) Variables Cannot Be Declared - -**Error**: CE0053 - "Selected type is not allowed" (+ CE0038, CE7247) — passes -`mxcli check` only until MDL043 was added; previously surfaced at mxbuild. - -❌ **INCORRECT:** -```mdl -declare $Product Test.Product; -- object Create Variable — rejected -declare $Product Test.Product = $In; -- aliasing a parameter — rejected -declare $Product as Test.Product; -- AS keyword also not supported -``` - -✅ **CORRECT** — get the object from a source that produces one: -```mdl --- as a microflow parameter -create microflow Test.Save ($Product: Test.Product) returns boolean as $ok ... - --- from a retrieve (single object) -retrieve $Product from Test.Product where Code = $Code limit 1; - --- from a create object -$Product = create Test.Product (Name = $Name); - --- from a loop iterator -loop $Product in $Products -begin - change $Product (Processed = true); -end -``` - -**Explanation**: `declare` becomes a *Create Variable* activity, which in Mendix -only holds primitive types. Objects (and lists) have no Create Variable form — use -the parameter/retrieve/create/loop sources above. To "keep a reference" to an -existing object, just use the variable you already have (`$In`, the loop variable, -the retrieve/create output); Mendix has no aliasing activity. - -### 2. XPath Association Navigation - -**Error**: CE0117 - "Error in expression" - -❌ **INCORRECT:** -```mdl --- Using simple association name -declare $CustomerName string = $Order/Customer/Name; -set $Name = $Product/Category/Name; -``` - -✅ **CORRECT:** -```mdl --- Use fully qualified association name: Module.AssociationName -declare $CustomerName string = $Order/Shop.Order_Customer/Name; -set $Name = $Product/Shop.Product_Category/Name; -``` - -**Explanation**: XPath navigation requires the full qualified association name in the format `Module.AssociationName`. - -### 3. Missing Attributes - -**Error**: Attribute references must exist in entity definition - -❌ **INCORRECT:** -```mdl --- Referencing Status when it doesn't exist in Order entity -change $Order ( - status = 'PROCESSING', - ProcessedDate = [%CurrentDateTime%]); -``` - -✅ **CORRECT:** -```mdl --- First, ensure entity has the attributes -create persistent entity Shop.Order ( - OrderNumber: string(50), - status: string(50), -- ← Must be defined - ProcessedDate: datetime -- ← Must be defined -); - --- Then reference them -change $Order ( - status = 'PROCESSING', - ProcessedDate = [%CurrentDateTime%]); -``` - -### 4. Flow Must End with RETURN - -**Error**: CE0105 - "Activity cannot be the last object of a flow" - -❌ **INCORRECT:** -```mdl -begin - declare $success boolean = true; - log info 'Done'; - -- Missing RETURN! -end; -``` - -✅ **CORRECT:** -```mdl -begin - declare $success boolean = true; - log info 'Done'; - return $success; -- ← Always required -end; -``` - -### 5. Unreachable Code After RETURN - -**Error**: CE0104 - "Action activity is unreachable" - -❌ **INCORRECT:** -```mdl -if $value < 0 then - return false; - log info 'This will never execute'; -- ← Unreachable! -end if; -``` - -✅ **CORRECT:** -```mdl -if $value < 0 then - log info 'Value is negative'; - return false; -end if; -``` - -### 6. Unused Variables - -**Warning**: CW0094 - "Variable 'X' is never used" - -```mdl --- Studio Pro will warn if parameters/variables are declared but never used -create microflow Test.Example ( - $ProductCode: string -- ← Warning if never referenced -) -returns boolean as $success -begin - set $success = true; -- ProductCode never used - return $success; -end; -``` - -### 7. Using SET on Undeclared Variables - -**Error**: MDL executor validates that all variables used with `set` are declared first. - -❌ **INCORRECT:** -```mdl -begin - if $value > 10 then - set $message = 'High'; -- ERROR: $Message not declared! - end if; - return true; -end; -``` - -✅ **CORRECT:** -```mdl -begin - declare $message string = ''; -- Declare first - if $value > 10 then - set $message = 'High'; -- Now SET works - end if; - return true; -end; -``` - -**Note**: Parameters are automatically declared by the parameter list. The `returns type as $Var` syntax names the return variable but does NOT declare it - you must still use `declare $Var type = value;` if you want to use SET on it. - -### 8. RETURN Inside a Loop - -**Error**: CE0068 - "End events cannot be placed inside a loop." (MDL062) - -A `return` builds an End event, and Mendix does not allow one inside a loop — -whether the return sits in the loop body directly or inside a branch within it. - -❌ **INCORRECT:** -```mdl -loop $Part in $PartList -begin - if $Part/IsMatch then - return true; -- End event inside the loop - end if; -end loop; -``` - -✅ **CORRECT** — leave the loop with `break`, and return once after it: -```mdl -declare $Found boolean = false; -loop $Part in $PartList -begin - if $Part/IsMatch then - set $Found = true; - break; - end if; -end loop; -return $Found; -``` - -### 9. Two Activities Creating the Same Variable - -**Error**: CE0111 - "Duplicate variable name 'X'." (MDL063) - -A microflow's variable names are unique **flow-wide**. Branches and loop bodies -do not open a scope, and parameters and loop iterators share the same namespace. -The trap is that every activity with an output **creates** its variable — there -is no form in which a call, a retrieve, an aggregate or an import mapping writes -into one that already exists. - -❌ **INCORRECT:** -```mdl -declare $Session string = ''; -$Session = call microflow Mod.Login(); -- the call creates $Session too -``` - -✅ **CORRECT** — let the activity create it: -```mdl -$Session = call microflow Mod.Login(); -``` - -Assigning to an existing variable is fine, because `set` is a *Change Variable* -activity and creates nothing: - -```mdl -declare $Session string = ''; -set $Session = 'anonymous'; -- valid, any number of times -``` - -### 10. Calling a Rule or Microflow Inside an Expression - -**Error**: CE0117 - "Error(s) in expression." (MDL066) - -A Mendix **expression** has no user-callable functions. Its library is built-in -and unqualified (`length`, `toString`, `contains`, ...); microflows, rules and -Java actions are called by **activities**. So a qualified call in a value -position is not an expression at all — mxbuild rejects it whichever document it -names. - -❌ **INCORRECT:** -```mdl -declare $Active Boolean = Sample.Rule_IsActive(IsActive = $IsActive); -declare $Next Integer = Sample.MF_Increment(N = $N); -- a microflow is no better -``` - -✅ **CORRECT** — a microflow or Java action is an activity: -```mdl -$Next = call microflow Sample.MF_Increment(N = $N); -``` - -A **rule** has no call activity at all: Mendix can only evaluate one as a -decision's condition, and that is the single position where a bare qualified -call is valid MDL. - -```mdl -if Sample.Rule_IsActive(IsActive = $IsActive) then - ... -end if; -``` - -The name in that position must resolve to a real **rule** — a microflow there is -the same CE0117, and mxcli refuses the statement rather than writing it. - -## Control Flow - -### IF Statements - -```mdl --- Simple IF -if $value > 10 then - set $message = 'Greater than 10'; -end if; - --- IF/ELSE -if $value > 100 then - set $Category = 'High'; -else - set $Category = 'Low'; -end if; - --- Nested IF -if $Score >= 90 then - set $Grade = 'A'; -else - if $Score >= 80 then - set $Grade = 'B'; - else - set $Grade = 'C'; - end if; -end if; -``` - -**Important**: Always close with `end if` (not just `end`). - -### Enumeration Comparisons - -**CRITICAL**: When comparing enumeration values, use the fully qualified enumeration value, NOT a string literal. - -```mdl --- CORRECT: Use fully qualified enumeration value -if $task/status = Module.TaskStatus.Completed then - set $IsComplete = true; -end if; - -if $Order/OrderStatus != Module.OrderStatus.Cancelled then - -- Process the order -end if; - --- WRONG: Do NOT use string literals --- IF $Task/Status = 'Completed' THEN -- INCORRECT! -``` - -Putting an enumeration **into** a string is the same mistake — concatenating it -directly is rejected, so render it first with `getCaption()` (the caption) or -`toString()` (the value name): - -```mdl --- CORRECT -log warning 'Unexpected status: ' + getCaption($Order/Status); - --- WRONG -log warning 'Unexpected status: ' + $Order/Status; -``` - -**Where the string form is and is not accepted** (verified against mxbuild 11.13.0 — -one microflow per row, `mx check` read per construct): - -| Context | `'Draft'` | Note | -|---------|-----------|------| -| Comparison in a decision — `if $O/Status = 'Draft'` | ❌ **CE0117** | The one that bites | -| Concatenation — `'x' + $O/Status` | ❌ **CE0117** | Use `getCaption()` / `toString()` | -| `change $O (Status = 'Draft')` | ✅ accepted | Slot is already enum-typed | -| `create M.E (Status = 'Draft')` | ✅ accepted | Same | -| Attribute `DEFAULT 'Draft'` | ✅ accepted | Documented as the legacy form | -| XPath constraint `[Status = 'Draft']` | ✅ accepted | Enums are strings at DB level | - -`mxcli check` does **not** flag the two failing rows (it does not type expressions — -see `docs/11-proposals/PROPOSAL_expression_type_checking.md`), so a script can pass -`check` and fail the build. The qualified form is valid in every row above: use it -everywhere and the distinction never has to be remembered. - -**Checking for empty enumeration:** -```mdl -if $entity/status = empty then - -- Enumeration is not set -end if; -``` - -### CASE Statements (Enum Split) - -Use `case` when a microflow branches on an enumeration value. - -```mdl -case $Status - when Open, Pending then - return true; - when Closed then - return false; - when (empty) then - return false; -end case; -``` - -`(empty)` represents an unset enumeration value. Multiple values can share one `when` branch by separating them with commas. Case values are bare identifiers — do **not** quote them. - -> **Every value needs a branch, including `(empty)` — and there is no `else`.** -> A Mendix enum split is an exclusive split with one outgoing flow per condition -> value, so an uncovered value fails the build with **CE0079** *"The 'X' condition -> value should be configured in properties for an outgoing flow."* `mxcli check` -> reports a missing `(empty)` branch as **MDL056**, and an `else` branch as -> **MDL008** (an `else` does not stand in for the missing flows: mxbuild reports -> CE0079 for each uncovered value *and* CE0773 on the else flow itself). -> -> The `(empty)` branch is required **even when the attribute is `not null`** — -> verified on Mendix 11.6.6. If several values share a path, put them in one -> branch (`when Open, Pending then`) rather than reaching for `else`. - -### Type Split And Cast Statements - -Use `split type` when a microflow branches on an object's runtime specialization. -Use `cast` inside a type branch to create the specialized variable used by the branch body. - -Branches are `when then`, the same as an enumeration split — one -statement, two subjects. - -```mdl -declare $IsSpecialized boolean = false; -split type $Input - when Sample.SpecializedInput then - cast $SpecificInput; - set $IsSpecialized = true; - when Sample.BaseInput then - when (empty) then -end split; -return $IsSpecialized; -``` - -Branch values are qualified entity names. - -> **`when (empty) then` is the null-object branch, not a default.** It is -> Mendix's `(empty)` outgoing flow, taken when the split variable is empty. It -> does **not** cover types you did not name — see the CE0090 note below. -> -> **The older spelling still works.** `case Sample.SpecializedInput` (no `then`) -> and `else` for the empty branch parse and build the identical flow, but warn -> **MDL065**. `case` introduced a *branch* here while introducing the *subject* -> in `case $x when V then` and in expressions, so the word meant two things -> (mxcli #913); `else` read as a default and never was one. - -> **Every type needs a branch — including the base entity.** An object-type -> decision gets one outgoing flow per listed type, and a type with no flow fails -> the build with **CE0090** *"The 'X' value should be configured for an outgoing -> flow."* The base entity (the split variable's own type) counts: `case -> Sample.BaseInput` above is what covers "it is not any of the specializations". -> -> **The `(empty)` branch does not stand in for the base-type case.** It is -> accepted — it serializes as `Microflows$NoCase` — but it does not satisfy -> coverage, so one named type plus `when (empty) then` still fails CE0090. -> Measured on 11.13.0: `when Zoo.Dog then` + an empty branch gives CE0090 for -> `Zoo.Cat` **and** `Zoo.Animal`; branch on every type, keep the empty branch, -> and it is 0 errors. Verified on Mendix 11.6.6 and 11.13.0. -> -> You cannot drop the empty branch either — that is **CE0089**. mxcli emits the -> flow unconditionally for that reason. -> -> **The split needs somewhere to go afterwards.** Branch bodies converge on a -> merge that continues to the microflow's end event, so a non-void microflow -> needs a `return` after `end split;` — otherwise `mxcli check` reports MDL003 -> and the build fails **CE0067** *"The 'Return value' property is required."* -> Doing the per-branch work into a variable and returning it once (above) is the -> clearest shape; returning inside every branch also works, but still needs the -> trailing `return`. - -**`cast` only stores the output variable.** Studio Pro persists Microflows$CastAction with a single `VariableName` field — the source variable is implicit (the type-split's input). Use `cast $SpecificName;` to give the specialized variable its name. The two-variable form `$Output = cast $Source;` parses but `$Source` is dropped on roundtrip; prefer the single-variable form. - -### LOOP Statements - -```mdl --- Basic loop -loop $Product in $ProductList -begin - set $count = $count + 1; -end loop; - --- Loop with object modification -loop $Product in $ProductList -begin - change $Product (IsActive = true); - commit $Product; -end loop; - --- Loop with conditional logic -loop $Product in $ProductList -begin - if $Product/IsActive then - set $ActiveCount = $ActiveCount + 1; - end if; -end loop; - --- Label a loop with a note (loops have no caption in Mendix) -@annotation 'Process each product' -loop $Product in $ProductList -begin - set $count = $count + 1; -end loop; -``` - -> **`@caption` does nothing on a loop.** Mendix for-loops have no caption -> property, so `@caption` on a `loop` is silently dropped (`mxcli check` flags -> it as **MDL042**). To label a loop, use `@annotation 'text'` — it attaches a -> note, exactly like drawing one onto the loop in Studio Pro. - -**Note**: -- Loop variable (`$Product`) is scoped to the loop body -- The loop variable type is **automatically derived** from the list type (e.g., `list of Test.Product` → `Test.Product`) -- CHANGE statements inside loops use the derived type to resolve attribute names - -> **Nothing a loop defines survives past `end loop;`.** The iterator *and* -> anything the body creates (a `retrieve`, a `$X = create …`, a call output) are -> visible only inside the body; using one afterwards is -> `CE0108 "Variable 'X' is defined but not in scope at this location."` -> (`mxcli check` flags it as **MDL053**). -> -> ```mdl -> -- WRONG: $Last is created inside the loop, read outside it -> loop $Item in $Items -> begin -> $Last = create Test.Product (Name = $Item/Name); -> end loop; -> commit $Last; -- MDL053 / CE0108 -> -> -- RIGHT: declare before the loop, assign inside, read after -> declare $LastName string = ''; -> loop $Item in $Items -> begin -> set $LastName = $Item/Name; -> end loop; -> log info node 'Test' $LastName; -> ``` -> -> Visibility and *naming* are separate rules: names must also be unique across -> the **whole** microflow, so two loops cannot share an iterator name either -> (`CE0111`, flagged as **MDL052**). - -### Performance: Batch Commit After Loop - -**CRITICAL**: Do NOT commit inside a loop. Each `commit` inside a loop issues a separate database transaction, which causes N round-trips for N records and degrades performance significantly. - -❌ **INCORRECT — commit inside loop (N transactions):** -```mdl -loop $Binding in $BindingsList -begin - $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); - commit $NewBatch with events; -- ❌ one DB transaction per record -end loop; -``` - -✅ **CORRECT — create list before loop, commit once after:** -```mdl -$BatchList = create list of BatteryOntology.MaterialBatch; -loop $Binding in $BindingsList -begin - $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); - add $NewBatch to $BatchList; -- accumulate in memory -end loop; -commit $BatchList with events on error rollback; -- ✅ single transaction -``` - -**Pattern:** -1. Before the loop: `$XxxList = create list of Module.Entity;` -2. Inside the loop: `add $NewXxx to $XxxList;` (replaces `commit`) -3. After the loop: `commit $XxxList with events on error rollback;` - -This applies whenever the loop **creates** new objects. For loops that only **change** existing objects, the same pattern applies — accumulate changed objects in a list, commit the list once outside the loop. - - -## Object Operations - -### CREATE Object - -```mdl -$NewProduct = create Test.Product ( - Name = $Name, - Code = $Code, - IsActive = true, - CreateDate = [%CurrentDateTime%]); -``` - -**Syntax Rules:** -- Variable assignment on left side (`$NewProduct =`) -- Entity type is fully qualified -- Attributes in parentheses, comma separated -- Closing `)` followed by semicolon -- Syntax aligned with CALL MICROFLOW/CALL JAVA ACTION - -**The Commit flag** (Studio Pro's "Commit" dropdown) is the optional `commit` -modifier after the member list: - -```mdl -$Order = create Sales.Order (Number = $Nr); -- Commit: No (default) -$Order = create Sales.Order (Number = $Nr) commit; -- Commit: Yes -$Order = create Sales.Order (Number = $Nr) commit without events;-- Commit: YesWithoutEvents -``` - -Omit it for the default. This is a **modifier on the create**, not the separate -`commit $Var;` activity — see COMMIT Object below for that one. - -### CHANGE Object - -```mdl -change $Product ( - Name = $NewName, - ModifiedDate = [%CurrentDateTime%]); - --- Commit the changed object as part of the change activity -change $Product (Name = $NewName) commit; -change $Product (Name = $NewName) commit without events; - --- Refresh the changed object in the client -change $Product (Name = $NewName) refresh; - --- Both: commit comes first -change $Product (Name = $NewName) commit refresh; -``` - -**Note**: Only specify attributes you want to change. Syntax aligned with CREATE. - -### COMMIT Object - -```mdl --- Commit without events -commit $Product; - --- Commit with events (triggers event handlers) -commit $Product with events; - --- Commit with refresh in client (updates UI after commit) -commit $Product refresh; - --- Commit with events and refresh -commit $Product with events refresh; -``` - -**Best Practice**: Use `with events` when you want before/after commit event handlers to execute. Use `refresh` when the committed object is displayed in the client and you want the UI to update immediately. - -> **Re-sorting a database-datasource grid needs `refresh`.** A plain `commit $Obj;` updates the committed attribute *values* in the grid, but a grid backed by a **database** datasource does **not** re-run its sort — so after changing a sort key (e.g. a reorder that rewrites a `SequenceNumber`), the row stays in its old position until you `commit $Obj refresh;`. The `refresh` re-queries the datasource, which re-applies the sort. (Ledger #57.) - -> **Binding a microflow to an entity event is MDL — you do NOT need to map it manually in Studio Pro.** After writing a handler microflow (e.g. a `BeforeCommit` validation), wire it directly: -> ```mdl -> alter entity Sales.Order -> add event handler on before commit call Sales.ACT_ValidateOrder($currentObject) raise error; -> ``` -> Works for `before`/`after` × `create`/`commit`/`delete`/`rollback`, and inside `create entity` too. See [generate-domain-model.md](generate-domain-model.md) for the full syntax. (There is no need for an `EVT_*` naming convention plus a manual Studio Pro mapping step.) - -## List Operations - -```mdl --- Existing variable form -add $Item to $Items; - --- Expression-valued add, useful when round-tripping Studio Pro list-add values -add head($SourceItems) to $Items; -``` - -Use expression-valued `add` only when the expression returns an object compatible with the target list element type. - -### `contains` is overloaded — string vs list - -`contains(a, b)` is both a **string** function (`contains(haystack, needle)` → substring test) and a **list** operation (`contains(list, object)` → membership test). mxcli picks the right serialization automatically: - -```mdl --- STRING contains — assign to a PRE-DECLARED Boolean (a Change Variable action) -declare $HasAt Boolean = false; -set $HasAt = contains($Email, '@'); - --- LIST contains — do NOT pre-declare the output (the list op creates it) -set $Found = contains($Items, $Item); -``` - -The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`. - -## Database Operations - -### RETRIEVE Statement - -```mdl --- Retrieve all -retrieve $ProductList from Test.Product; - --- Retrieve with WHERE -retrieve $ProductList from Test.Product - where Code = $SearchCode; - --- Retrieve with multiple conditions -retrieve $ProductList from Test.Product - where IsActive = true - and Price > 100; - --- Retrieve single object -retrieve $Product from Test.Product - where Code = $ProductCode; -``` - -**Important**: -- Use `from Module.Entity` (fully qualified) -- RETRIEVE with `limit 1` returns a **single entity** -- RETRIEVE without `limit 1` returns a **list** (`list of Module.Entity`) -- Use `limit 1` when you expect exactly one result (e.g., lookup by unique key) - -**Sorting and paging** — use `sort by`, **not** `order by`: - -```mdl -retrieve $Recent from Sales.Order - where Status = Sales.OrderStatus.Open - sort by Sales.Order.OrderDate desc, Sales.Order.OrderNumber asc - limit $PageSize - offset $Offset; -``` - -- The keyword is **`sort by`** (one or more `Module.Entity.Attr asc|desc`, comma-separated). `order by` is **not** valid on a microflow `retrieve` — it's reserved for `select ... from CATALOG.*` queries and will cause a parse error here. -- `limit` and `offset` accept **a variable or expression**, not only a literal — `limit $PageSize`, `offset $Offset`, even `limit $Base + 5` all work. A bare literal (`limit 20`) is just the simplest case. - -### Retrieve by Association (in-memory, over an association path) - -To get the object(s) related to one you already have, retrieve **over an -association** — `retrieve $out from $source/Module.Association;`. This is an -*association* (in-memory) retrieve, not a database query, so it has no `where` / -`sort by` / `limit`. **The result type depends on the direction you navigate:** - -```mdl --- FORWARD (from the reference-owner / "one" side) → a SINGLE object. --- An Expense has one Employee (Expense_Employee: from Expense to Employee): -retrieve $Employee from $Expense/MyFirstModule.Expense_Employee; -change $Employee (Name = 'Updated'); -- change it directly — do NOT loop - --- REVERSE (from the "many" side) → a LIST of the related objects. --- One Employee has many Expenses (same association, navigated the other way): -retrieve $Expenses from $Employee/MyFirstModule.Expense_Employee; -loop $Expense in $Expenses -begin - change $Expense (Amount = 0); -end loop; -``` - -- **Forward Reference traversal returns a single object** — do **not** `loop` over - it. Looping a single object passes `mxcli check` but produces an invalid project - (mxbuild `StorageLoadException` — the loop's `change` writes an unqualified - attribute). Loop only over the list-valued (reverse / ReferenceSet) form. -- The association is always fully qualified (`Module.Association`), and the start - variable is an object you already have (a parameter, a prior retrieve/create, or - a loop iterator). Both forms above are mxbuild-verified (0 errors). - -**Enumeration attributes in WHERE**: XPath is a database query, so enum values are stored as plain strings. Both forms are valid — mxcli converts the qualified name to a string literal in BSON: - -```mdl --- Preferred: qualified name (mxcli converts to 'Open' in BSON) -retrieve $Open from Module.Order - where [Status = Module.OrderStatus.Open]; - --- Also accepted: string literal (the value key, case-sensitive) -retrieve $Open from Module.Order - where [Status = 'Open']; - --- Multiple enum values with OR -retrieve $InProgress from Module.Order - where [Status = Module.OrderStatus.Open or Status = Module.OrderStatus.Processing]; -``` - -This is different from IF/SET expressions — see "Enumeration Comparisons" section above. - -## XPath Navigation - -### Attribute Access - -```mdl --- Read attribute -declare $ProductName string = $Product/Name; -declare $Price decimal = $Product/Price; - --- Write attribute (alternative to CHANGE) -set $Product/Price = $NewPrice; -set $Product/ModifiedDate = [%CurrentDateTime%]; -``` - -### Association Navigation - -```mdl --- Navigate to related object -declare $CustomerName string = $Order/Shop.Order_Customer/Name; -declare $CategoryName string = $Product/Shop.Product_Category/Name; - --- Set association -set $Order/Shop.Order_Customer = $Customer; -set $Order/Shop.Order_Product = $Product; -``` - -**Critical**: Always use fully qualified association names (`Module.AssociationName`). - -### XPath in Expressions - -```mdl --- Use in calculations -declare $MonthlyTotal decimal = $Product/MonthlyTotal; -declare $DailyAverage decimal = $MonthlyTotal div 30; - --- Use in conditions -if $Product/IsActive then - set $count = $count + 1; -end if; - --- Combine with operators -set $TotalPrice = $Product/Price * $Quantity; -``` - -## Operators - -### Arithmetic - -```mdl -$Result = $A + $B; -- Addition -$Result = $A - $B; -- Subtraction -$Result = $A * $B; -- Multiplication -$Result = $A div $B; -- Division (use 'div', not '/') -``` - -**Important**: Use `div` for division, NOT `/`. In a Mendix expression `/` is the -member/association separator (`$obj/Attr`), so `$A / $B` is not division — -`mxcli check` rejects it as **MDL045** (it would fail the build with CE0117). -Integer/decimal division always yields a Decimal; wrap it in `round()`/`trunc()` -for an Integer result (else **MDL041**). - -### Comparison - -```mdl -$A = $B -- Equals -$A != $B -- Not equals -$A > $B -- Greater than -$A >= $B -- Greater than or equal -$A < $B -- Less than -$A <= $B -- Less than or equal -$A = empty -- Check if empty/null -$A != empty -- Check if not empty -``` - -### Boolean Logic - -```mdl -$Result = $A and $B; -- Logical AND -$Result = $A or $B; -- Logical OR -$Result = not $A; -- Logical NOT - --- Complex expressions -if $IsActive and $IsValid and $HasStock then - set $CanProcess = true; -end if; -``` - -### Date construction - -`dateTime(...)` / `dateTimeUTC(...)` build a date from **literal numeric -constants only** — a variable or computed argument fails the build with CE0117 -(`mxcli check` flags it as **MDL046**). To build a date from variables, step off -a literal anchor with `addDays()` / `addMonths()` (which *do* take variables): - -```mdl --- WRONG: variable args to dateTime() (CE0117 / MDL046) -set $D = dateTime(2026, $Month, $Day); - --- RIGHT: anchor on a literal, then step with addMonths/addDays -set $D = addDays(addMonths(dateTime(2026, 1, 1), $Month - 1), $Day - 1); -``` - -## Logging - -```mdl --- Log levels -log info 'Information message'; -log warning 'Warning message'; -log error 'Error message'; - --- With node name -log info node 'OrderService' 'Processing order'; -log warning node 'ValidationService' 'Invalid data detected'; - --- With variables (use concatenation) -log info node 'OrderService' 'Order processed: ' + $OrderNumber; -log error node 'Service' 'Error: ' + $ErrorMessage; -``` - -## Activity Annotations - -Annotations use `@` prefix syntax placed before the activity they apply to: - -```mdl --- Canvas position (always shown in DESCRIBE output) -@position(200, 200) -commit $Order with events; - --- Custom caption (overrides auto-generated caption) -@caption 'Save the order' -commit $Order with events; - --- Background color (Blue, Green, Red, Yellow, Purple, Gray) -@color Green -log info node 'App' 'Success'; - --- Visual note attached to the next activity (creates AnnotationFlow) -@annotation 'Validate the order before processing' -commit $Order with events; - --- Multiple annotations stacked on a single activity -@position(400, 200) -@caption 'Persist product' -@color Blue -@annotation 'Step 2: Save to database' -commit $Product; -``` - -**Rules:** -- `@annotation` before an activity attaches the note to that activity -- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity -- `@annotation` at the end (no following activity) creates a free-floating note -- Escape single quotes by doubling: `@annotation 'Don''t forget'` -- `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default -- DESCRIBE MICROFLOW shows `@` annotations before their activities - -## Special Values - -```mdl -empty -- Null/empty value -[%CurrentDateTime%] -- Current date/time -[%CurrentUser%] -- Current user object -toString($value) -- Convert to string -``` - -> **No `randomInt`.** Mendix has no `randomInt` function. Use `random()` (returns a -> Decimal in [0,1)) and round to an integer range — e.g. a value in 0..8 is -> `round(random() * 8)`. Because `random()` is a Decimal, assigning it (or `div`, -> `secondsBetween()`, and the other duration `*Between` functions) directly to an -> `integer` variable fails the build with CE0117 — wrap it in `round()`/`floor()`/`ceil()`. -> `mxcli check` now flags an unknown expression function like `randomInt` as -> **MDL044** (with a "did you mean random()?" hint), and a Decimal assigned to an -> integer target as **MDL041** — before the build does. -> -> **MDL044 also blocks `mxcli exec`**, not just `check`: a call to a name Mendix -> has no built-in for is CE0117 at build time, so exec refuses to write the -> microflow rather than leaving you to find out from mxbuild. Two names that -> look plausible and are not real: `currentDeviceType()` and `trunc()` (use -> `round`/`floor`/`ceil`). If exec rejects a function you believe IS a Mendix -> built-in, build it once and — if mxbuild accepts it — add it to `funcTable` in -> `mdl/exprcheck/func_checker.go`; that table is the rule's only allow-list. - -## Complete Example - -```mdl -create microflow Shop.ProcessOrder ( - $OrderNumber: string -) -returns boolean as $success -comment 'Process order with validation and status update' -begin - declare $success boolean = false; - - -- Find the order (the retrieve establishes $Order — objects are never declared) - retrieve $Order from Shop.Order - where OrderNumber = $OrderNumber; - - -- Validate order exists - if $Order = empty then - log warning node 'OrderService' 'Order not found: ' + $OrderNumber; - return false; - end if; - - -- Validate customer association - if $Order/Shop.Order_Customer = empty then - log error node 'OrderService' 'Order has no customer'; - return false; - end if; - - -- Update order status - change $Order ( - status = 'PROCESSING', - ProcessedDate = [%CurrentDateTime%]); - - commit $Order with events; - - -- Log success - log info node 'OrderService' 'Order processed: ' + $OrderNumber; - set $success = true; - return $success; -end; -/ -``` - -## Calling Microflows - -### ✅ CORRECT Syntax - -```mdl --- Call with result assignment (no SET keyword) -$Result = call microflow Module.ProcessOrder(Order = $Order); - --- Call without result (void microflow) -call microflow Module.SendNotification(message = $message); - --- Call with error handling -$Result = call microflow Module.ExternalService(data = $data) on error continue; - --- Run the call on a task queue (background execution). The clause goes after --- the arguments and before any ON ERROR, and works on CALL JAVA ACTION too. -call microflow Module.ACT_Refresh() in queue Module.RefreshQueue; -call java action Module.RefreshData(Url = $Url) in queue Module.RefreshQueue; -``` - -**Queued calls** — the queue must already exist (`create queue Module.RefreshQueue -(Parallelism: 2)`), and a queued **Java action must `returns void`** or the build -fails with CE7038. Rewriting a microflow that has a queued call must restate the -`in queue` clause; a rewrite that omits it is refused rather than silently -dropping the binding. See `.claude/skills/mendix/scheduled-events-and-queues.md`. - -### ❌ INCORRECT Syntax - -```mdl --- WRONG: Do NOT use SET with CALL MICROFLOW -set $Result = call microflow Module.ProcessOrder(Order = $Order); -- ERROR! - --- CORRECT: Direct variable assignment -$Result = call microflow Module.ProcessOrder(Order = $Order); -``` - -**Important**: The `set` keyword is for changing existing variable values, NOT for capturing microflow return values. Use direct assignment (`$var = call microflow ...`). - -### Parameter Name Matching - -**CRITICAL**: Parameter names in `call microflow` must **exactly match** the parameter names declared in the target microflow's signature (without the `$` prefix). A mismatch causes a build error (MxBuild) but may fail silently at MDL execution time. - -```mdl --- Target microflow declaration: -create microflow Module.SendEmail ($Recipient: string, $Subject: string) -begin ... end; - --- CORRECT: parameter names match the declaration -call microflow Module.SendEmail(Recipient = $Email, Subject = $title); - --- WRONG: parameter name does not match (EmailAddress vs Recipient) -call microflow Module.SendEmail(EmailAddress = $Email, Subject = $title); -- BUILD ERROR! -``` - -When calling microflows, always check the target's parameter list. Use `describe microflow Module.Name` to see the exact parameter names. - -## Page Navigation - -### SHOW PAGE - -```mdl --- Open page with parameter (canonical syntax) -show page Module.EditPage($Product = $Product); - --- Widget-style syntax also accepted in microflows -show page Module.EditPage(Product: $Product); -``` - -Both `($Param = $value)` and `(Param: $value)` syntaxes are accepted in microflow SHOW PAGE statements. Similarly, widget Action: properties accept both `show_page Module.Page(Param: $value)` and `show_page Module.Page($Param = $value)`. - -### CLOSE PAGE - -```mdl -close page; -``` - -### SHOW HOME PAGE - -```mdl -show home page; -``` - -## Implicit Variable Creation (CE0111 Duplicate Variable) - -These statements **implicitly create a new variable** with the name on the left side: - -- `$Var = call microflow ...` -- `$Var = call java action ...` -- `$Var = call nanoflow ...` -- `$Var = create Module.Entity (...)` -- `retrieve $Var from Module.Entity ...` - -**Do NOT use `declare` before these** — it creates a duplicate variable (CE0111): - -```mdl --- WRONG: Duplicate variable — DECLARE + CALL both create $Result -declare $Result boolean = false; -$Result = call java action Module.DoSomething(); -- CE0111! - --- CORRECT: Let CALL create the variable, use a different name if you need a default -declare $success boolean = false; -$CallResult = call java action Module.DoSomething(); -set $success = $CallResult; - --- CORRECT: Simple pass-through (no default needed) -$Result = call java action Module.DoSomething(); -return $Result; -``` - -The same applies to RETRIEVE: - -```mdl --- WRONG: declaring a list at all (CE0053/CE0038, MDL040) — and duplicate variable -declare $Items list of Module.Entity = empty; -retrieve $Items from Module.Entity where Active = true; -- CE0111! - --- CORRECT: Let RETRIEVE create the variable -retrieve $Items from Module.Entity where Active = true; -``` - -**Note**: `returns type as $Var` in the microflow signature does NOT create an activity variable — it only names the return value. So `$Var = call java action ...` after `returns as $Var` is fine (one creation). - -**Variables are scoped to the branch that creates them.** A variable first created -inside an `if`/`else` arm (including by `$Var = call ...`) is not visible outside -that arm. To use one value after a conditional, `declare` it *before* the -conditional and assign in every branch: - -```mdl --- WRONG: $GTotalText is created only in the `then` arm → not declared in `else` -if $HasVariance then - $GTotalText = call microflow Module.FMT_Variance($v); -- created here only -else - set $GTotalText = 'n/a'; -- error: not declared -end if; - --- CORRECT: declare before, then set in each branch (call into a temp, then set) -declare $GTotalText string = ''; -if $HasVariance then - $Tmp = call microflow Module.FMT_Variance($v); - set $GTotalText = $Tmp; -else - set $GTotalText = 'n/a'; -end if; -``` - -**Fallback chains reuse the same name = CE0111.** Because each `$Var = call microflow …` (or retrieve/create) is a *fresh* variable creation, the natural "try A, else try B" shape is invalid — even when the retry is inside an `if`: - -```mdl --- WRONG: the second call re-creates $Summary — CE0111 -$Summary = call microflow M.Inner(Tag = 'description'); -if trim($Summary) = '' then - $Summary = call microflow M.Inner(Tag = 'summary'); -- CE0111! -end if; - --- CORRECT: one variable per call, then a plain `set` picks the winner -$Summary = call microflow M.Inner(Tag = 'description'); -if trim($Summary) = '' then - $SummaryFallback = call microflow M.Inner(Tag = 'summary'); - set $Summary = $SummaryFallback; -end if; -``` - -`mxcli check --references` catches this (CE0111) before the build. - -## Legacy SOAP Web Service Calls - -`call web service` preserves legacy Mendix SOAP activities. Prefer REST clients -for new integrations; this syntax exists mainly so existing projects can -round-trip without dropping SOAP actions. - -```mdl --- Structured form. Resolved SOAP references use normal qualified names. -$Root = call web service SampleSOAP.OrderService -operation FetchSampleItems -send mapping SampleSOAP.OrderRequest -receive mapping SampleSOAP.OrderResponse -timeout 30 -on error rollback; - --- Quoted raw IDs are accepted when old project references are dangling or unavailable. -$Root = call web service 'sample-service-id' -operation FetchSampleItems -send mapping 'sample-send-mapping-id' -receive mapping 'sample-receive-mapping-id'; - --- Raw escape hatch emitted for unsupported SOAP fields. -$Root = call web service raw 'AQID'; -``` - -**Design note:** the raw payload is base64-encoded BSON for the complete action -and is authoritative on re-exec. Treat this as round-trip support, not a -recommended authoring format for new integrations. - -## REST Service Calls - -MDL supports two patterns for calling REST APIs from microflows: - -### SEND REST REQUEST — Consumed REST Service Operations - -Calls an operation defined in a consumed REST service (created via `create rest client`). The URL, headers, authentication, and response mapping are configured in the REST client document — the microflow only references the operation. - -```mdl --- Fire and forget (RESPONSE NONE operation) -send rest request Module.ServiceName.OperationName; - --- With output variable (RESPONSE JSON operation — maps to entity) -$Result = send rest request Module.ServiceName.OperationName; - --- With request body (POST/PUT operations) -$Result = send rest request Module.ServiceName.CreateItem - body $NewItem; -``` - -**CRITICAL: `$latestHttpResponse` system variable** - -After every `send rest request`, Mendix automatically populates `$latestHttpResponse` (type `System.HttpResponse`). Use this to check call success — do **NOT** check the output variable directly: - -```mdl --- ✅ CORRECT: check $latestHttpResponse -$RootResult = send rest request Module.Service.GetData; -if $latestHttpResponse/Content != empty then - -- Process $RootResult (the mapped entity) -end if; - --- ❌ WRONG: checking the output variable directly causes CE0117 -if $RootResult != empty then -- ERROR! -``` - -**Key attributes on `$latestHttpResponse`:** -- `Content` (String) — response body as string. **Capital `C`** — it is inherited from the parent entity `System.HttpMessage`, so `DESCRIBE ENTITY System.HttpResponse` does not list it. Lowercase `$latestHttpResponse/content` fails CE0117. -- `StatusCode` (Integer) — HTTP status code (200, 404, etc.) - -**Restrictions:** -- `send rest request` does **NOT** support custom error handling (`on error continue/rollback` causes CE6035). Errors are always handled by aborting. -- The operation must be defined via `create rest client` with a three-part qualified name: `Module.ServiceDocument.OperationName`. - -### REST CALL — Inline HTTP Calls - -Direct HTTP call with URL, headers, auth, body, and response handling specified inline. Useful for one-off calls or when no REST client document exists. - -```mdl --- Simple GET returning string -$response = rest call get 'https://api.example.com/data' - header Accept = 'application/json' - timeout 30 - returns string; - --- POST with JSON body -$response = rest call post 'https://api.example.com/items' - header 'Content-Type' = 'application/json' - header Accept = 'application/json' - body '{{"name": "{1}", "value": {2}}' with ( - {1} = $ItemName, - {2} = toString($ItemValue) - ) - timeout 30 - returns string - on error continue; - --- POST a BINARY body (upload a file document's contents) --- The expression is the FileDocument's Contents MEMBER, not the document --- itself, and the content type goes on a header. A consumed REST CLIENT --- document has no binary body — `Body: file from $Doc` there is refused as --- MDL-REST02 — so binary uploads belong here. -$response = rest call post 'https://api.example.com/upload' - header 'ContentType' = 'application/pdf' - body binary $Doc/Contents - timeout 300 - returns response; - --- GET with URL template parameters -$response = rest call get 'https://api.example.com/users/{1}' with ( - {1} = toString($UserId) -) - header Accept = 'application/json' - returns string; - --- With basic authentication -$response = rest call get 'https://api.example.com/secure' - header Accept = 'application/json' - auth basic $username password $password - timeout 30 - returns string; - --- DELETE (no response) -rest call delete 'https://api.example.com/items/{1}' with ( - {1} = $ItemId -) - returns nothing - on error continue; -``` - -**REST CALL response types:** -- `returns string` — response body as string variable -- `returns nothing` / `returns none` — ignore response -- `returns response` — returns `System.HttpResponse` object -- `returns mapping Module.ImportMapping as Module.Entity` — single object result -- `returns mapping Module.ImportMapping as list of Module.Entity` — list result -- `returns Module.MyFile` — store the body in a **file document** - -**The file document form takes a specialization, never `System.FileDocument` -itself.** Mendix rejects the base type as a return type with `CE0362`, and -MDL064 reports it before the write. Create one first: - -```mdl -create persistent entity MyModule.MyFile extends System.FileDocument (); - -create microflow MyModule.ACT_Download ($Location: String) -begin - $file = rest call get '{1}' with ({1} = $Location) - header 'Accept' = 'application/octet-stream' - timeout 300 - returns MyModule.MyFile; -end; -``` - -There is **no** equivalent for an HttpResponse specialization: Mendix allows only -`User`, `FileDocument`, `Image` and `Paging` to be specialized (`CE1540`), so -`returns response` already names the only type that result can have. - -**Pick `as` vs `as list of` based on the call site, not the mapping shape.** The same import mapping can yield either a single object or a list — Studio Pro stores the cardinality on the microflow's `ImportMappingCall` (`Range.SingleObject` + `ForceSingleOccurrence`). Use `as Module.Entity` when the response is a single object (the mapping may still be list-typed; Studio Pro binds the first item). Use `as list of Module.Entity` when the response should bind a list. Mismatching the cardinality with the surrounding code produces `mx check` `CE0117` at the End event or `CE0013` / `CE0100` on downstream loop / aggregate / list-operation activities. - -**REST CALL supports full error handling** (`on error continue`, `on error rollback`, custom error handlers). - -## File Downloads - -Use `download file` to stream a `System.FileDocument` from a microflow. Add -`show in browser` when the action should open the file inline instead of forcing -a download. - -```mdl -download file $GeneratedReport show in browser; -download file $GeneratedExport; -``` - -## Empty Java-Action Argument (`empty`) - -When `describe` round-trips a Java-action call that has an unbound parameter -in Studio Pro, it emits `empty` as the argument value. In this Java-action -argument context, `empty` preserves the -underlying empty `BasicCodeActionParameterValue.Argument` so that the next -`describe → exec → describe` cycle stays symmetric. - -```mdl -$Total = call java action SampleModule.Recalculate( - CompanyId = empty, - RecalculateAll = true, - ItemList = empty -); -``` - -New scripts should bind every parameter to a real expression. Use `empty` -for a Java-action argument only when regenerating MDL from an existing project -that already had an unbound parameter. - -## Microflow-Typed Java-Action Parameters - -Some Java actions take a **microflow** — a callback the action invokes later. -`MCPServer.AddTool` (`ExecutingMicroflow`) and `MCPServer.CreateMCPServer` -(`AuthenticationMicroflow`) are the ones you meet first. Pass the microflow's -qualified name as a quoted string; mxcli resolves the parameter's declared type -from the Java action and stores a microflow reference, not a string literal. - -```mdl -$Tool = call java action MCPServer.AddTool( - McpServer = $Server, - Name = 'memory_add', - Description = 'Stores a memory', - ExecutingMicroflow = 'MyModule.MF_MemoryAdd', - Schema = '' -); -``` - -`DESCRIBE JAVA ACTION` prints such a parameter's type as the bare word -`Microflow` (`Nanoflow` for JavaScript actions), and that spelling is what -`CREATE JAVA ACTION` accepts, so the round-trip is stable. - -## Error Handling - -MDL supports error handling for activities that may fail (microflow calls, commits, external service calls, etc.). - -### Error Handling Types - -```mdl --- ON ERROR CONTINUE: Ignore error and continue execution -call microflow Module.RiskyOperation() on error continue; - --- ON ERROR ROLLBACK: Rollback transaction and propagate error -commit $Order with events on error rollback; - --- ON ERROR { ... }: Custom error handler with rollback -$Result = call microflow Module.ExternalService(data = $data) on error { - log error node 'ServiceError' 'External service failed'; - return $DefaultResult; -}; - --- ON ERROR WITHOUT ROLLBACK { ... }: Custom handler, keep changes -commit $Order on error without rollback { - log warning node 'CommitError' 'Commit failed, using fallback'; - change $Order (status = 'PENDING'); -}; -``` - -### Error Handling Semantics - -| Syntax | Behavior | -|--------|----------| -| `on error continue` | Catch error silently, continue normal flow | -| `on error rollback` | Rollback database changes, propagate error | -| `on error { ... }` | Execute handler block, then continue (with rollback) | -| `on error without rollback { ... }` | Execute handler block, keep database changes | - -### When to Use Each Type - -- **CONTINUE**: Non-critical operations where failure is acceptable -- **ROLLBACK**: Critical operations where data integrity must be preserved -- **Custom handlers**: When you need to log errors, set fallback values, or notify users - -### Example: Robust External Call - -```mdl -/** - * Calls external service with error handling - */ -create microflow Module.SafeExternalCall ( - $RequestData: string -) -returns Module.Response as $response -begin - -- The call output establishes $response — objects are never declared - $response = call microflow Module.CallExternalAPI(data = $RequestData) - on error without rollback { - log error node 'ExternalAPI' 'API call failed for: ' + $RequestData; - -- Create error response - $response = create Module.Response ( - success = false, - message = 'External service unavailable'); - }; - - return $response; -end; -/ -``` - -## UNSUPPORTED Syntax (Will Cause Parse Errors) - -**CRITICAL**: The following syntax is NOT implemented and will cause parse errors. Do NOT use these patterns: - -### ROLLBACK Statement (Supported!) - -```mdl --- CORRECT: ROLLBACK is now supported -rollback $Order; - --- With REFRESH to update client UI -rollback $Order refresh; -``` - -**Use Case**: Revert uncommitted changes to an object. Useful when validation fails and you want to restore the object to its database state. - -### RETRIEVE with LIMIT (Supported!) - -```mdl --- CORRECT: LIMIT is supported -retrieve $Product from Module.Product where IsActive = true limit 1; - --- LIMIT 1 returns a single entity (not a list) --- Without LIMIT, returns a list -retrieve $ProductList from Module.Product where IsActive = true; -``` - -### WHILE Loop - -```mdl --- WHILE loops iterate while a condition is true -while $Counter < 10 -begin - set $Counter = $Counter + 1; -end while; - --- FOR EACH loops iterate over a list -loop $item in $ItemList -begin - -- Process each item -end loop; -``` - -### CASE with string values, `else`, or an alias - -`case … end case` **is supported** — see [CASE Statements (Enum Split)](#case-statements-enum-split) -above for the correct form. What is not supported is the SQL-flavoured spelling of -it: quoted values, an `else` fallback, and an `AS` alias all fail. - -```mdl --- WRONG: case values are not string literals (parse error) -case $Order/Status - when 'Active' then set $Result = 1; -end case; - --- WRONG: case values are not qualified (parse error) -case $Order/Status - when MyModule.Status.Active then set $Result = 1; -end case; - --- WRONG: no AS alias (parse error: mismatched input 'as' expecting WHEN) -case $Order/Status as s - when Active then set $Result = 1; -end case; - --- WRONG: no else branch (MDL008 → mxbuild CE0079 + CE0773) -case $Order/Status - when Active then set $Result = 1; - else set $Result = 0; -end case; - --- CORRECT: bare enum values, one branch per value, including (empty) -case $Order/Status - when Active then set $Result = 1; - when Inactive then set $Result = 2; - when (empty) then set $Result = 0; -end case; -``` - -An enum split is the *only* thing `case` does — it branches on an enumeration, not -on arbitrary expressions. For anything else (a string comparison, a numeric range), -use nested `if … else … end if`. - -### TRY/CATCH Block - -```mdl --- WRONG: TRY/CATCH not supported -TRY - commit $Order; -CATCH - log error 'Commit failed'; -end TRY; - --- CORRECT: Use ON ERROR on specific activities -commit $Order on error { - log error 'Commit failed'; -}; -``` - -### BREAK/CONTINUE in Loops - -```mdl --- WRONG: BREAK/CONTINUE not supported -loop $item in $ItemList -begin - if $item/Skip = true then - continue; -- NOT SUPPORTED - end if; - if $item/Stop = true then - break; -- NOT SUPPORTED - end if; -end loop; - --- CORRECT: Use conditional logic -loop $item in $ItemList -begin - if $item/Skip = false and $item/Stop = false then - -- Process item - end if; -end loop; -``` - -### Reserved Words as Identifiers - -**Best practice: Always quote all identifiers** (attribute names, parameter names, entity names) with double quotes. This eliminates all reserved keyword conflicts and is always safe — quotes are stripped automatically by the parser. - -> **Exception — never quote `$`-prefixed variable/parameter references.** The quote -> rule is for *bare* names (entities, attributes, associations, declared parameter -> names). Variable/parameter **references** in expressions stay **unquoted**: -> `$Customer/Name`, `$currentObject`, `retrieve … from $List`. Quoting the `$` token -> (`"$Customer"`) breaks resolution. - -```mdl -create persistent entity Module."item" ( - "check": boolean default false, - "text": string(500), - "format": string(50), - "value": decimal, - "create": datetime, - "delete": datetime -); -``` - -Quoted identifiers also work for microflow parameter names: -```mdl -create microflow Module."Process" ("select": string, "type": integer) -begin - log info 'Processing'; - return; -end; -``` - -## Validation Checklist - -Before executing a microflow script, verify: - -- [ ] **No object (entity) or list is `declare`d** — objects come from a parameter, retrieve, create object, or loop iterator (MDL043); lists from a parameter, retrieve, or create list (MDL040) -- [ ] **All primitive variables are declared before SET** (`declare $var type = value;`) -- [ ] XPath association navigation uses qualified names (`Module.AssociationName`) -- [ ] All referenced attributes exist in entity definitions -- [ ] Every flow path ends with `return` -- [ ] No code appears after `return` statements -- [ ] Division uses `div` operator (not `/`) -- [ ] All entity/association names are fully qualified -- [ ] **CALL MICROFLOW parameter names exactly match target signature** (use `describe microflow` to verify) -- [ ] Microflow ends with `/` separator -- [ ] Parameters start with `$` prefix -- [ ] Proper closing for control structures (`end if`, `end loop`) - -## Common Studio Pro Errors - -| Error Code | Message | Fix | -|------------|---------|-----| -| CE0053 | Selected type is not allowed | Don't `declare` an object/list — get it from a parameter, retrieve, create, or loop (MDL043/MDL040) | -| CE0117 | Error in expression | Use qualified association names; use `not(expr)` not bare `not expr` | -| CE0104 | Action activity is unreachable | Remove code after RETURN | -| CE0105 | Must end with end event | Add RETURN statement | -| CE0008 | No action defined | Define action for activity | -| CW0094 | Variable never used | Remove unused variables or use them | -| MDL | Variable not declared | Use `declare $var type = value;` before SET | - -## Tips for Success - -1. **Always use fully qualified names**: `Module.Entity`, `Module.Association` -2. **Test incrementally**: Create simple microflows first, then add complexity -3. **Check entity definitions**: Ensure all attributes exist before referencing -4. **Use meaningful variable names**: `$Customer` not `$c`, `$ProductList` not `$list` -5. **Comment complex logic**: Use `--` for inline comments -6. **Log important events**: Help with debugging and auditing -7. **Handle empty cases**: Check for `= empty` before using objects -8. **Use WITH EVENTS appropriately**: Only when you need event handlers -9. **Validate before executing**: Use `mxcli check script.mdl -p app.mpr --references` to catch errors - -## Related Documentation - -- [MDL Syntax Guide](../../docs/02-features/mdl-syntax.md) -- [OQL Syntax Guide](../../docs/syntax-proposals/OQL_SYNTAX_GUIDE.md) -- [Microflow Examples](../../examples/doctype-tests/microflow-examples.mdl) -- [Mendix Microflow Documentation](https://docs.mendix.com/refguide/microflows/) - -## Quick Reference - -### Variable Declaration Pattern -```mdl -declare $primitive type = value; -- Primitives (String/Integer/Decimal/Boolean/DateTime) -declare $status Enumeration(Module.Enum) = …; -- Enumerations are primitives too --- Objects: never declare. Use a parameter, retrieve (limit 1), `$obj = create Module.Entity(...)`, or a loop iterator. --- Lists: never declare. Use a parameter, retrieve, or `$list = create list of Module.Entity;` -``` - -### Object Operation Pattern -```mdl -$var = create Module.Entity (attr = value); -change $var (attr = value); -commit $var [with events] [refresh]; -``` - -### Flow Control Pattern -```mdl -if condition then ... [else ...] end if; -loop $var in $list begin ... end loop; -return $value; -``` - -### XPath Pattern -```mdl -$var/attributename -- Attribute -$var/Module.AssociationName -- Association -$var/Module.AssociationName/attribute -- Chained -``` - -### Annotation Pattern -```mdl -@position(200, 200) -@caption 'Persist order' -@color Green -@annotation 'Note about the next activity' -commit $Order; -- Annotations apply here -``` - -### Execute Database Query Pattern -```mdl --- Static query (3-part name: Module.Connection.Query) -$Results = execute database query Module.Conn.QueryName; - --- Dynamic SQL override -$Results = execute database query Module.Conn.QueryName - dynamic 'SELECT * FROM table LIMIT 10'; - --- Parameterized query (names must match query PARAMETER definitions) -$Results = execute database query Module.Conn.QueryName - (paramName = $Variable); - --- Runtime connection override -$Results = execute database query Module.Conn.QueryName - connection (DBSource = $url, DBUsername = $user, DBPassword = $Pass); - --- Fire-and-forget (no output variable) -execute database query Module.Conn.QueryName; -``` -**Note:** Only `on error rollback` is supported (the default). `on error continue` is not available for this action. - -### Page Navigation Pattern -```mdl -show page Module.Page($Param = $value); -- Canonical -show page Module.Page(Param: $value); -- Widget-style (also valid) -close page; -show home page; -``` - -### Error Handling Pattern -```mdl -call microflow ... on error continue; -- Ignore error -call microflow ... on error rollback; -- Rollback on error -call microflow ... on error { log ...; return ...; }; -- Custom handler -call microflow ... on error without rollback { ... }; -- No rollback -``` diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md new file mode 100644 index 000000000..b3349e93a --- /dev/null +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -0,0 +1,587 @@ +--- +name: write-microflows +description: "Microflow syntax reference in MDL — every activity type, control flow, expressions, and the mistakes that fail `mxcli check`. Use before writing any CREATE MICROFLOW, and when debugging a microflow syntax error." +--- + +# Mendix Microflow Skill + +This skill provides comprehensive guidance for writing Mendix microflows in MDL (Mendix Definition Language) syntax. + +## Reference files + +`SKILL.md` covers the shape of a microflow and the decisions. The detail lives +beside it, and is worth opening when you hit one of these: + +- [`reference/control-flow.md`](reference/control-flow.md) — every form of `if`, + `case`, `split type`, `loop` and `while`, plus error handling (`try`/custom + handlers) and the `@position` / `@merge` / `@anchor` activity annotations. +- [`reference/data-operations.md`](reference/data-operations.md) — create, change, + commit, delete; list operations and aggregates; `retrieve` from database, + association and list; XPath navigation. +- [`reference/integration.md`](reference/integration.md) — `rest call` and + `send rest request`, legacy SOAP, calling Java actions (including the `empty` + argument and microflow-typed parameters), and file downloads. +- [`reference/pitfalls.md`](reference/pitfalls.md) — **read this when + `mxcli check` rejects something you believe is correct.** The anti-patterns, the + CE0111 duplicate-variable trap, the syntax that looks plausible and does not + parse, and the Studio Pro errors each one produces. + +## When to Use This Skill + +Use this skill when: +- Writing CREATE MICROFLOW statements +- Debugging microflow syntax errors +- Converting Studio Pro microflows to MDL +- Understanding microflow control flow and structure + +If you're not sure whether the logic belongs in a microflow or a nanoflow, read the next section first. The mirror lives in [write-nanoflows](../write-nanoflows/SKILL.md) — keep both copies in sync. + +## When to Use a Microflow vs a Nanoflow + +| Scenario | Use | +|----------|-----| +| Querying the database | Microflow | +| Calling REST services or external actions | Microflow | +| Running Java actions | Microflow | +| File generation or download | Microflow | +| Transactional commits (rollback on error) | Microflow | +| Background scheduled logic | Microflow | +| Client-side form validation before save | Nanoflow | +| UI navigation and page routing | Nanoflow | +| Calling device features (GPS, phone, camera) | Nanoflow | +| Offline data access and local storage | Nanoflow | +| Calling JavaScript actions (NanoflowCommons) | Nanoflow | +| Showing progress indicators / confirmation dialogs | Nanoflow | + +**Rule of thumb:** A nanoflow runs before the server call. A microflow IS the server call. + +## Key Differences from Nanoflows + +| Aspect | Microflow | Nanoflow | +|--------|-----------|----------| +| **Execution** | Server-side | Client-side (browser/mobile) | +| **Database access** | Full | No direct access | +| **Transactions** | Supported | Not supported | +| **Java actions** | Supported | Not supported | +| **JavaScript actions** | Not supported | Supported | +| **SYNCHRONIZE** | Not available | Available (offline sync) | +| **File downloads** | Supported | Not supported | +| **Error handling** | Full `ON ERROR` blocks + `RAISE ERROR` | Per-action `ON ERROR` supported; `RAISE ERROR` / `ErrorEvent` forbidden | +| **Offline** | Not available | Available | +| **Binary return type** | Supported | Not supported | + +For nanoflow-specific authoring guidance, see [write-nanoflows](../write-nanoflows/SKILL.md). + +## Microflow Structure + +**CRITICAL: All microflows MUST have JavaDoc-style documentation** + +```mdl +/** + * Microflow description explaining what it does + * + * Detailed explanation of the business logic, use cases, + * and any important implementation notes. + * + * @param $Parameter1 Description of first parameter + * @param $Parameter2 Description of second parameter + * @returns Description of return value + * @since 1.0.0 + * @author Team Name + */ +create microflow Module.MicroflowName ( + $Parameter1: type, + $Parameter2: type +) +returns ReturnType as $ReturnVariable +[folder 'FolderPath'] +begin + -- Microflow logic here + return $ReturnVariable; +end; +``` + +### `@excluded` — documents excluded from the project + +`@excluded` before a `create microflow` marks the document **"Exclude from project"** +(the same checkbox Studio Pro offers). The document stays in the `.mpr`, does not +build, and `show microflows` reports it in the `Excluded` column. + +```mdl +@excluded +create microflow MyModule.LegacyCalc () +returns Integer +begin + return 7; +end; +``` + +Two rules follow, and both are enforced rather than documented-and-hoped: + +- **An absent `@excluded` never un-excludes.** It means "the script does not say", + not "make this active" — so re-running a `create or modify` you wrote before the + document was excluded leaves the exclusion alone. Un-exclude in Studio Pro. + (Before #914 the rewrite cleared it, which is how a valid project ended up + failing **CE0122** — see the next rule.) +- **A name is not unique when a twin is excluded.** Mendix allows two documents of + the same name in one module as long as at most one is active — verified on + 11.13.0: the excluded pair builds at 0 errors, the same pair both active is + `[error] CE0122 "Duplicate document name"`. `create or modify`, `describe` and + the other by-name lookups therefore target the **live** document; the excluded + twin is neither rewritten nor deleted. + +The same applies to every document type that carries the flag — nanoflows, pages, +snippets, enumerations, queues, workflows, Java/JavaScript actions, mappings, JSON +structures, REST/OData services, image collections and the agent documents. + +### FOLDER Option + +Place microflows in folders for organization: + +```mdl +create microflow MyModule.ACT_ProcessOrder ($Order: MyModule.Order) +returns boolean as $success +folder 'Orders/Processing' +begin + -- logic + return true; +end; +``` + +**Key Rules:** +- Parameters start with `$` prefix +- Return variable must be declared or used +- Every microflow must end with `return` statement +- Every body statement ends with a semicolon `;` — **required**, not optional. This + includes block terminators: `end if;`, `end loop;`, `end while;`, `end case;`. + A missing one is a parse error (`missing ';' at 'return'`), not a warning. +- Microflow ends with `/` separator + +### Parameter Types + +```mdl +-- Primitive types +$Name: string +$count: integer +$Amount: decimal +$IsActive: boolean +$date: datetime + +-- Entity types +$Customer: Module.Entity + +-- List types +$ProductList: list of Module.Product + +-- Enumeration types +$status: enum Module.OrderStatus +``` + +## Variable Declarations + +### ✅ CORRECT Syntax + +```mdl +-- Primitive types with initialization +declare $Counter integer = 0; +declare $message string = 'Hello'; +declare $IsValid boolean = true; +declare $Today datetime = [%CurrentDateTime%]; +declare $status Enumeration(Module.OrderStatus) = Module.OrderStatus.Open; +``` + +> **You cannot `declare` an object (entity) variable.** `declare` becomes a +> *Create Variable* activity, which Mendix only allows to hold **primitive** types +> (String, Integer/Long, Decimal, Boolean, DateTime, Enumeration). An object type +> is rejected by Studio Pro/mxbuild with CE0053 ("Selected type is not allowed"), +> plus CE0038 ("Value required") and CE7247 on any following `set` — whether or +> not you give it an initializer. `mxcli check` now flags it as **MDL043**. There +> is **no** "empty object variable" activity. Get objects from one of these: +> - a microflow **parameter**: `create microflow M.Save ($Product: Test.Product) ...` +> - a **retrieve**: `retrieve $Product from Test.Product where Code = $c limit 1;` +> - a **create object**: `$Product = create Test.Product (Name = $n);` +> - a **loop iterator**: `loop $Product in $Products ...` + +> **You cannot `declare` a list either.** Same *Create Variable* restriction — +> Studio Pro rejects a list with CE0053/CE0038, and `mxcli check` flags it as +> **MDL040**. Get lists from: +> - a microflow **parameter**: `create microflow M.Process ($Items: list of Test.Product) ...` +> - a **retrieve**: `retrieve $Products from Test.Product where IsActive = true;` +> - a **create list**: `$Products = create list of Test.Product;` + +> **Decimal values into an `integer`/`long` fail CE0117.** Integer division +> (`$a div $b`) is always a Decimal even for two integers, and so are `random()` +> and the duration `*Between` functions (`secondsBetween`, `minutesBetween`, +> `hoursBetween`, `daysBetween`, `weeksBetween`). Assigning any of them straight +> to an `integer`/`long` variable fails `mx check` with **CE0117** (`mxcli check` +> now flags it as **MDL041**). Either declare the target `decimal`, or round it: +> ```mdl +> declare $Avg decimal = $Total div $Count; -- ✅ Decimal target +> declare $Whole integer = round($Total div $Count); -- ✅ rounded to Integer +> declare $Secs integer = round(secondsBetween($a,$b)); -- ✅ rounded to Integer +> declare $Bad integer = $Total div $Count; -- ❌ CE0117 / MDL041 +> ``` +> The `calendar*Between` functions (`calendarMonthsBetween`, `calendarYearsBetween`) +> return whole units (Integer) and are fine to assign directly. + +### ❌ INCORRECT Syntax + +```mdl +-- WRONG: Declaring an object/entity variable (CE0053/CE0038, MDL043) +declare $Product Test.Product; -- bare object declare is invalid +declare $Product Test.Product = $someObj; -- initialized object declare is also invalid + +-- WRONG: Declaring a list variable (CE0053/CE0038, MDL040) +declare $ProductList list of Test.Product = empty; -- use a parameter, retrieve, or create list + +-- WRONG: Using AS keyword (not supported in mxcli) +declare $Product as Test.Product; -- ERROR: parse error + +-- WRONG: No value (CE0038, MDL061) +declare $X string; -- a Create Variable activity requires a value + +-- WRONG: Missing type +declare $Counter = 0; -- Type inference not always supported + +-- WRONG: Using 'OF' instead of 'of' +declare $list list of Test.Product; -- Case sensitive +``` + +## Operators + +### Arithmetic + +```mdl +$Result = $A + $B; -- Addition +$Result = $A - $B; -- Subtraction +$Result = $A * $B; -- Multiplication +$Result = $A div $B; -- Division (use 'div', not '/') +``` + +**Important**: Use `div` for division, NOT `/`. In a Mendix expression `/` is the +member/association separator (`$obj/Attr`), so `$A / $B` is not division — +`mxcli check` rejects it as **MDL045** (it would fail the build with CE0117). +Integer/decimal division always yields a Decimal; wrap it in `round()`/`trunc()` +for an Integer result (else **MDL041**). + +### Comparison + +```mdl +$A = $B -- Equals +$A != $B -- Not equals +$A > $B -- Greater than +$A >= $B -- Greater than or equal +$A < $B -- Less than +$A <= $B -- Less than or equal +$A = empty -- Check if empty/null +$A != empty -- Check if not empty +``` + +### Boolean Logic + +```mdl +$Result = $A and $B; -- Logical AND +$Result = $A or $B; -- Logical OR +$Result = not $A; -- Logical NOT + +-- Complex expressions +if $IsActive and $IsValid and $HasStock then + set $CanProcess = true; +end if; +``` + +### Date construction + +`dateTime(...)` / `dateTimeUTC(...)` build a date from **literal numeric +constants only** — a variable or computed argument fails the build with CE0117 +(`mxcli check` flags it as **MDL046**). To build a date from variables, step off +a literal anchor with `addDays()` / `addMonths()` (which *do* take variables): + +```mdl +-- WRONG: variable args to dateTime() (CE0117 / MDL046) +set $D = dateTime(2026, $Month, $Day); + +-- RIGHT: anchor on a literal, then step with addMonths/addDays +set $D = addDays(addMonths(dateTime(2026, 1, 1), $Month - 1), $Day - 1); +``` + +## Logging + +```mdl +-- Log levels +log info 'Information message'; +log warning 'Warning message'; +log error 'Error message'; + +-- With node name +log info node 'OrderService' 'Processing order'; +log warning node 'ValidationService' 'Invalid data detected'; + +-- With variables (use concatenation) +log info node 'OrderService' 'Order processed: ' + $OrderNumber; +log error node 'Service' 'Error: ' + $ErrorMessage; +``` + +## Special Values + +```mdl +empty -- Null/empty value +[%CurrentDateTime%] -- Current date/time +[%CurrentUser%] -- Current user object +toString($value) -- Convert to string +``` + +> **No `randomInt`.** Mendix has no `randomInt` function. Use `random()` (returns a +> Decimal in [0,1)) and round to an integer range — e.g. a value in 0..8 is +> `round(random() * 8)`. Because `random()` is a Decimal, assigning it (or `div`, +> `secondsBetween()`, and the other duration `*Between` functions) directly to an +> `integer` variable fails the build with CE0117 — wrap it in `round()`/`floor()`/`ceil()`. +> `mxcli check` now flags an unknown expression function like `randomInt` as +> **MDL044** (with a "did you mean random()?" hint), and a Decimal assigned to an +> integer target as **MDL041** — before the build does. +> +> **MDL044 also blocks `mxcli exec`**, not just `check`: a call to a name Mendix +> has no built-in for is CE0117 at build time, so exec refuses to write the +> microflow rather than leaving you to find out from mxbuild. Two names that +> look plausible and are not real: `currentDeviceType()` and `trunc()` (use +> `round`/`floor`/`ceil`). If exec rejects a function you believe IS a Mendix +> built-in, build it once and — if mxbuild accepts it — add it to `funcTable` in +> `mdl/exprcheck/func_checker.go`; that table is the rule's only allow-list. + +## Complete Example + +```mdl +create microflow Shop.ProcessOrder ( + $OrderNumber: string +) +returns boolean as $success +comment 'Process order with validation and status update' +begin + declare $success boolean = false; + + -- Find the order (the retrieve establishes $Order — objects are never declared) + retrieve $Order from Shop.Order + where OrderNumber = $OrderNumber; + + -- Validate order exists + if $Order = empty then + log warning node 'OrderService' 'Order not found: ' + $OrderNumber; + return false; + end if; + + -- Validate customer association + if $Order/Shop.Order_Customer = empty then + log error node 'OrderService' 'Order has no customer'; + return false; + end if; + + -- Update order status + change $Order ( + status = 'PROCESSING', + ProcessedDate = [%CurrentDateTime%]); + + commit $Order with events; + + -- Log success + log info node 'OrderService' 'Order processed: ' + $OrderNumber; + set $success = true; + return $success; +end; +/ +``` + +## Calling Microflows + +### ✅ CORRECT Syntax + +```mdl +-- Call with result assignment (no SET keyword) +$Result = call microflow Module.ProcessOrder(Order = $Order); + +-- Call without result (void microflow) +call microflow Module.SendNotification(message = $message); + +-- Call with error handling +$Result = call microflow Module.ExternalService(data = $data) on error continue; + +-- Run the call on a task queue (background execution). The clause goes after +-- the arguments and before any ON ERROR, and works on CALL JAVA ACTION too. +call microflow Module.ACT_Refresh() in queue Module.RefreshQueue; +call java action Module.RefreshData(Url = $Url) in queue Module.RefreshQueue; +``` + +**Queued calls** — the queue must already exist (`create queue Module.RefreshQueue +(Parallelism: 2)`), and a queued **Java action must `returns void`** or the build +fails with CE7038. Rewriting a microflow that has a queued call must restate the +`in queue` clause; a rewrite that omits it is refused rather than silently +dropping the binding. See `.claude/skills/mendix/scheduled-events-and-queues`. + +### ❌ INCORRECT Syntax + +```mdl +-- WRONG: Do NOT use SET with CALL MICROFLOW +set $Result = call microflow Module.ProcessOrder(Order = $Order); -- ERROR! + +-- CORRECT: Direct variable assignment +$Result = call microflow Module.ProcessOrder(Order = $Order); +``` + +**Important**: The `set` keyword is for changing existing variable values, NOT for capturing microflow return values. Use direct assignment (`$var = call microflow ...`). + +### Parameter Name Matching + +**CRITICAL**: Parameter names in `call microflow` must **exactly match** the parameter names declared in the target microflow's signature (without the `$` prefix). A mismatch causes a build error (MxBuild) but may fail silently at MDL execution time. + +```mdl +-- Target microflow declaration: +create microflow Module.SendEmail ($Recipient: string, $Subject: string) +begin ... end; + +-- CORRECT: parameter names match the declaration +call microflow Module.SendEmail(Recipient = $Email, Subject = $title); + +-- WRONG: parameter name does not match (EmailAddress vs Recipient) +call microflow Module.SendEmail(EmailAddress = $Email, Subject = $title); -- BUILD ERROR! +``` + +When calling microflows, always check the target's parameter list. Use `describe microflow Module.Name` to see the exact parameter names. + +## Page Navigation + +### SHOW PAGE + +```mdl +-- Open page with parameter (canonical syntax) +show page Module.EditPage($Product = $Product); + +-- Widget-style syntax also accepted in microflows +show page Module.EditPage(Product: $Product); +``` + +Both `($Param = $value)` and `(Param: $value)` syntaxes are accepted in microflow SHOW PAGE statements. Similarly, widget Action: properties accept both `show_page Module.Page(Param: $value)` and `show_page Module.Page($Param = $value)`. + +### CLOSE PAGE + +```mdl +close page; +``` + +### SHOW HOME PAGE + +```mdl +show home page; +``` + +## Validation Checklist + +Before executing a microflow script, verify: + +- [ ] **No object (entity) or list is `declare`d** — objects come from a parameter, retrieve, create object, or loop iterator (MDL043); lists from a parameter, retrieve, or create list (MDL040) +- [ ] **All primitive variables are declared before SET** (`declare $var type = value;`) +- [ ] XPath association navigation uses qualified names (`Module.AssociationName`) +- [ ] All referenced attributes exist in entity definitions +- [ ] Every flow path ends with `return` +- [ ] No code appears after `return` statements +- [ ] Division uses `div` operator (not `/`) +- [ ] All entity/association names are fully qualified +- [ ] **CALL MICROFLOW parameter names exactly match target signature** (use `describe microflow` to verify) +- [ ] Microflow ends with `/` separator +- [ ] Parameters start with `$` prefix +- [ ] Proper closing for control structures (`end if`, `end loop`) + +## Tips for Success + +1. **Always use fully qualified names**: `Module.Entity`, `Module.Association` +2. **Test incrementally**: Create simple microflows first, then add complexity +3. **Check entity definitions**: Ensure all attributes exist before referencing +4. **Use meaningful variable names**: `$Customer` not `$c`, `$ProductList` not `$list` +5. **Comment complex logic**: Use `--` for inline comments +6. **Log important events**: Help with debugging and auditing +7. **Handle empty cases**: Check for `= empty` before using objects +8. **Use WITH EVENTS appropriately**: Only when you need event handlers +9. **Validate before executing**: Use `mxcli check script.mdl -p app.mpr --references` to catch errors + +## Related Documentation + +- [MDL Syntax Guide](../../docs/02-features/mdl-syntax.md) +- [OQL Syntax Guide](../../docs/syntax-proposals/OQL_SYNTAX_GUIDE.md) +- [Microflow Examples](../../examples/doctype-tests/microflow-examples.mdl) +- [Mendix Microflow Documentation](https://docs.mendix.com/refguide/microflows/) + +## Quick Reference + +### Variable Declaration Pattern +```mdl +declare $primitive type = value; -- Primitives (String/Integer/Decimal/Boolean/DateTime) +declare $status Enumeration(Module.Enum) = …; -- Enumerations are primitives too +-- Objects: never declare. Use a parameter, retrieve (limit 1), `$obj = create Module.Entity(...)`, or a loop iterator. +-- Lists: never declare. Use a parameter, retrieve, or `$list = create list of Module.Entity;` +``` + +### Object Operation Pattern +```mdl +$var = create Module.Entity (attr = value); +change $var (attr = value); +commit $var [with events] [refresh]; +``` + +### Flow Control Pattern +```mdl +if condition then ... [else ...] end if; +loop $var in $list begin ... end loop; +return $value; +``` + +### XPath Pattern +```mdl +$var/attributename -- Attribute +$var/Module.AssociationName -- Association +$var/Module.AssociationName/attribute -- Chained +``` + +### Annotation Pattern +```mdl +@position(200, 200) +@caption 'Persist order' +@color Green +@annotation 'Note about the next activity' +commit $Order; -- Annotations apply here +``` + +### Execute Database Query Pattern +```mdl +-- Static query (3-part name: Module.Connection.Query) +$Results = execute database query Module.Conn.QueryName; + +-- Dynamic SQL override +$Results = execute database query Module.Conn.QueryName + dynamic 'SELECT * FROM table LIMIT 10'; + +-- Parameterized query (names must match query PARAMETER definitions) +$Results = execute database query Module.Conn.QueryName + (paramName = $Variable); + +-- Runtime connection override +$Results = execute database query Module.Conn.QueryName + connection (DBSource = $url, DBUsername = $user, DBPassword = $Pass); + +-- Fire-and-forget (no output variable) +execute database query Module.Conn.QueryName; +``` +**Note:** Only `on error rollback` is supported (the default). `on error continue` is not available for this action. + +### Page Navigation Pattern +```mdl +show page Module.Page($Param = $value); -- Canonical +show page Module.Page(Param: $value); -- Widget-style (also valid) +close page; +show home page; +``` + +### Error Handling Pattern +```mdl +call microflow ... on error continue; -- Ignore error +call microflow ... on error rollback; -- Rollback on error +call microflow ... on error { log ...; return ...; }; -- Custom handler +call microflow ... on error without rollback { ... }; -- No rollback +``` diff --git a/.claude/skills/mendix/write-microflows/reference/control-flow.md b/.claude/skills/mendix/write-microflows/reference/control-flow.md new file mode 100644 index 000000000..663413e0f --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/control-flow.md @@ -0,0 +1,378 @@ +# Control flow, error handling and annotations + +Supporting reference for [write-microflows](../SKILL.md). + +## Control Flow + +### IF Statements + +```mdl +-- Simple IF +if $value > 10 then + set $message = 'Greater than 10'; +end if; + +-- IF/ELSE +if $value > 100 then + set $Category = 'High'; +else + set $Category = 'Low'; +end if; + +-- Nested IF +if $Score >= 90 then + set $Grade = 'A'; +else + if $Score >= 80 then + set $Grade = 'B'; + else + set $Grade = 'C'; + end if; +end if; +``` + +**Important**: Always close with `end if` (not just `end`). + +### Enumeration Comparisons + +**CRITICAL**: When comparing enumeration values, use the fully qualified enumeration value, NOT a string literal. + +```mdl +-- CORRECT: Use fully qualified enumeration value +if $task/status = Module.TaskStatus.Completed then + set $IsComplete = true; +end if; + +if $Order/OrderStatus != Module.OrderStatus.Cancelled then + -- Process the order +end if; + +-- WRONG: Do NOT use string literals +-- IF $Task/Status = 'Completed' THEN -- INCORRECT! +``` + +Putting an enumeration **into** a string is the same mistake — concatenating it +directly is rejected, so render it first with `getCaption()` (the caption) or +`toString()` (the value name): + +```mdl +-- CORRECT +log warning 'Unexpected status: ' + getCaption($Order/Status); + +-- WRONG +log warning 'Unexpected status: ' + $Order/Status; +``` + +**Where the string form is and is not accepted** (verified against mxbuild 11.13.0 — +one microflow per row, `mx check` read per construct): + +| Context | `'Draft'` | Note | +|---------|-----------|------| +| Comparison in a decision — `if $O/Status = 'Draft'` | ❌ **CE0117** | The one that bites | +| Concatenation — `'x' + $O/Status` | ❌ **CE0117** | Use `getCaption()` / `toString()` | +| `change $O (Status = 'Draft')` | ✅ accepted | Slot is already enum-typed | +| `create M.E (Status = 'Draft')` | ✅ accepted | Same | +| Attribute `DEFAULT 'Draft'` | ✅ accepted | Documented as the legacy form | +| XPath constraint `[Status = 'Draft']` | ✅ accepted | Enums are strings at DB level | + +`mxcli check` does **not** flag the two failing rows (it does not type expressions — +see `docs/11-proposals/PROPOSAL_expression_type_checking.md`), so a script can pass +`check` and fail the build. The qualified form is valid in every row above: use it +everywhere and the distinction never has to be remembered. + +**Checking for empty enumeration:** +```mdl +if $entity/status = empty then + -- Enumeration is not set +end if; +``` + +### CASE Statements (Enum Split) + +Use `case` when a microflow branches on an enumeration value. + +```mdl +case $Status + when Open, Pending then + return true; + when Closed then + return false; + when (empty) then + return false; +end case; +``` + +`(empty)` represents an unset enumeration value. Multiple values can share one `when` branch by separating them with commas. Case values are bare identifiers — do **not** quote them. + +> **Every value needs a branch, including `(empty)` — and there is no `else`.** +> A Mendix enum split is an exclusive split with one outgoing flow per condition +> value, so an uncovered value fails the build with **CE0079** *"The 'X' condition +> value should be configured in properties for an outgoing flow."* `mxcli check` +> reports a missing `(empty)` branch as **MDL056**, and an `else` branch as +> **MDL008** (an `else` does not stand in for the missing flows: mxbuild reports +> CE0079 for each uncovered value *and* CE0773 on the else flow itself). +> +> The `(empty)` branch is required **even when the attribute is `not null`** — +> verified on Mendix 11.6.6. If several values share a path, put them in one +> branch (`when Open, Pending then`) rather than reaching for `else`. + +### Type Split And Cast Statements + +Use `split type` when a microflow branches on an object's runtime specialization. +Use `cast` inside a type branch to create the specialized variable used by the branch body. + +Branches are `when then`, the same as an enumeration split — one +statement, two subjects. + +```mdl +declare $IsSpecialized boolean = false; +split type $Input + when Sample.SpecializedInput then + cast $SpecificInput; + set $IsSpecialized = true; + when Sample.BaseInput then + when (empty) then +end split; +return $IsSpecialized; +``` + +Branch values are qualified entity names. + +> **`when (empty) then` is the null-object branch, not a default.** It is +> Mendix's `(empty)` outgoing flow, taken when the split variable is empty. It +> does **not** cover types you did not name — see the CE0090 note below. +> +> **The older spelling still works.** `case Sample.SpecializedInput` (no `then`) +> and `else` for the empty branch parse and build the identical flow, but warn +> **MDL065**. `case` introduced a *branch* here while introducing the *subject* +> in `case $x when V then` and in expressions, so the word meant two things +> (mxcli #913); `else` read as a default and never was one. + +> **Every type needs a branch — including the base entity.** An object-type +> decision gets one outgoing flow per listed type, and a type with no flow fails +> the build with **CE0090** *"The 'X' value should be configured for an outgoing +> flow."* The base entity (the split variable's own type) counts: `case +> Sample.BaseInput` above is what covers "it is not any of the specializations". +> +> **The `(empty)` branch does not stand in for the base-type case.** It is +> accepted — it serializes as `Microflows$NoCase` — but it does not satisfy +> coverage, so one named type plus `when (empty) then` still fails CE0090. +> Measured on 11.13.0: `when Zoo.Dog then` + an empty branch gives CE0090 for +> `Zoo.Cat` **and** `Zoo.Animal`; branch on every type, keep the empty branch, +> and it is 0 errors. Verified on Mendix 11.6.6 and 11.13.0. +> +> You cannot drop the empty branch either — that is **CE0089**. mxcli emits the +> flow unconditionally for that reason. +> +> **The split needs somewhere to go afterwards.** Branch bodies converge on a +> merge that continues to the microflow's end event, so a non-void microflow +> needs a `return` after `end split;` — otherwise `mxcli check` reports MDL003 +> and the build fails **CE0067** *"The 'Return value' property is required."* +> Doing the per-branch work into a variable and returning it once (above) is the +> clearest shape; returning inside every branch also works, but still needs the +> trailing `return`. + +**`cast` only stores the output variable.** Studio Pro persists Microflows$CastAction with a single `VariableName` field — the source variable is implicit (the type-split's input). Use `cast $SpecificName;` to give the specialized variable its name. The two-variable form `$Output = cast $Source;` parses but `$Source` is dropped on roundtrip; prefer the single-variable form. + +### LOOP Statements + +```mdl +-- Basic loop +loop $Product in $ProductList +begin + set $count = $count + 1; +end loop; + +-- Loop with object modification +loop $Product in $ProductList +begin + change $Product (IsActive = true); + commit $Product; +end loop; + +-- Loop with conditional logic +loop $Product in $ProductList +begin + if $Product/IsActive then + set $ActiveCount = $ActiveCount + 1; + end if; +end loop; + +-- Label a loop with a note (loops have no caption in Mendix) +@annotation 'Process each product' +loop $Product in $ProductList +begin + set $count = $count + 1; +end loop; +``` + +> **`@caption` does nothing on a loop.** Mendix for-loops have no caption +> property, so `@caption` on a `loop` is silently dropped (`mxcli check` flags +> it as **MDL042**). To label a loop, use `@annotation 'text'` — it attaches a +> note, exactly like drawing one onto the loop in Studio Pro. + +**Note**: +- Loop variable (`$Product`) is scoped to the loop body +- The loop variable type is **automatically derived** from the list type (e.g., `list of Test.Product` → `Test.Product`) +- CHANGE statements inside loops use the derived type to resolve attribute names + +> **Nothing a loop defines survives past `end loop;`.** The iterator *and* +> anything the body creates (a `retrieve`, a `$X = create …`, a call output) are +> visible only inside the body; using one afterwards is +> `CE0108 "Variable 'X' is defined but not in scope at this location."` +> (`mxcli check` flags it as **MDL053**). +> +> ```mdl +> -- WRONG: $Last is created inside the loop, read outside it +> loop $Item in $Items +> begin +> $Last = create Test.Product (Name = $Item/Name); +> end loop; +> commit $Last; -- MDL053 / CE0108 +> +> -- RIGHT: declare before the loop, assign inside, read after +> declare $LastName string = ''; +> loop $Item in $Items +> begin +> set $LastName = $Item/Name; +> end loop; +> log info node 'Test' $LastName; +> ``` +> +> Visibility and *naming* are separate rules: names must also be unique across +> the **whole** microflow, so two loops cannot share an iterator name either +> (`CE0111`, flagged as **MDL052**). + +### Performance: Batch Commit After Loop + +**CRITICAL**: Do NOT commit inside a loop. Each `commit` inside a loop issues a separate database transaction, which causes N round-trips for N records and degrades performance significantly. + +❌ **INCORRECT — commit inside loop (N transactions):** +```mdl +loop $Binding in $BindingsList +begin + $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); + commit $NewBatch with events; -- ❌ one DB transaction per record +end loop; +``` + +✅ **CORRECT — create list before loop, commit once after:** +```mdl +$BatchList = create list of BatteryOntology.MaterialBatch; +loop $Binding in $BindingsList +begin + $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); + add $NewBatch to $BatchList; -- accumulate in memory +end loop; +commit $BatchList with events on error rollback; -- ✅ single transaction +``` + +**Pattern:** +1. Before the loop: `$XxxList = create list of Module.Entity;` +2. Inside the loop: `add $NewXxx to $XxxList;` (replaces `commit`) +3. After the loop: `commit $XxxList with events on error rollback;` + +This applies whenever the loop **creates** new objects. For loops that only **change** existing objects, the same pattern applies — accumulate changed objects in a list, commit the list once outside the loop. +## Activity Annotations + +Annotations use `@` prefix syntax placed before the activity they apply to: + +```mdl +-- Canvas position (always shown in DESCRIBE output) +@position(200, 200) +commit $Order with events; + +-- Custom caption (overrides auto-generated caption) +@caption 'Save the order' +commit $Order with events; + +-- Background color (Blue, Green, Red, Yellow, Purple, Gray) +@color Green +log info node 'App' 'Success'; + +-- Visual note attached to the next activity (creates AnnotationFlow) +@annotation 'Validate the order before processing' +commit $Order with events; + +-- Multiple annotations stacked on a single activity +@position(400, 200) +@caption 'Persist product' +@color Blue +@annotation 'Step 2: Save to database' +commit $Product; +``` + +**Rules:** +- `@annotation` before an activity attaches the note to that activity +- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity +- `@annotation` at the end (no following activity) creates a free-floating note +- Escape single quotes by doubling: `@annotation 'Don''t forget'` +- `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default +- DESCRIBE MICROFLOW shows `@` annotations before their activities +- `@start(x, y)` positions the **start event** and goes on the first statement, because the start has no statement of its own. Omit it and the start is derived — one spacing unit (160) left of the first activity, on its centre line — and a rewrite re-derives it so the start follows the activities when they move. A start that is not at the derived spot was placed by hand (in Studio Pro or with `@start`): it survives a rewrite that does not mention it, and DESCRIBE emits `@start` for it. An explicit `@start` overrides both (#951) +## Error Handling + +MDL supports error handling for activities that may fail (microflow calls, commits, external service calls, etc.). + +### Error Handling Types + +```mdl +-- ON ERROR CONTINUE: Ignore error and continue execution +call microflow Module.RiskyOperation() on error continue; + +-- ON ERROR ROLLBACK: Rollback transaction and propagate error +commit $Order with events on error rollback; + +-- ON ERROR { ... }: Custom error handler with rollback +$Result = call microflow Module.ExternalService(data = $data) on error { + log error node 'ServiceError' 'External service failed'; + return $DefaultResult; +}; + +-- ON ERROR WITHOUT ROLLBACK { ... }: Custom handler, keep changes +commit $Order on error without rollback { + log warning node 'CommitError' 'Commit failed, using fallback'; + change $Order (status = 'PENDING'); +}; +``` + +### Error Handling Semantics + +| Syntax | Behavior | +|--------|----------| +| `on error continue` | Catch error silently, continue normal flow | +| `on error rollback` | Rollback database changes, propagate error | +| `on error { ... }` | Execute handler block, then continue (with rollback) | +| `on error without rollback { ... }` | Execute handler block, keep database changes | + +### When to Use Each Type + +- **CONTINUE**: Non-critical operations where failure is acceptable +- **ROLLBACK**: Critical operations where data integrity must be preserved +- **Custom handlers**: When you need to log errors, set fallback values, or notify users + +### Example: Robust External Call + +```mdl +/** + * Calls external service with error handling + */ +create microflow Module.SafeExternalCall ( + $RequestData: string +) +returns Module.Response as $response +begin + -- The call output establishes $response — objects are never declared + $response = call microflow Module.CallExternalAPI(data = $RequestData) + on error without rollback { + log error node 'ExternalAPI' 'API call failed for: ' + $RequestData; + -- Create error response + $response = create Module.Response ( + success = false, + message = 'External service unavailable'); + }; + + return $response; +end; +/ +``` diff --git a/.claude/skills/mendix/write-microflows/reference/data-operations.md b/.claude/skills/mendix/write-microflows/reference/data-operations.md new file mode 100644 index 000000000..bb3e1a313 --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/data-operations.md @@ -0,0 +1,238 @@ +# Objects, lists, database and XPath + +Supporting reference for [write-microflows](../SKILL.md). + +## Object Operations + +### CREATE Object + +```mdl +$NewProduct = create Test.Product ( + Name = $Name, + Code = $Code, + IsActive = true, + CreateDate = [%CurrentDateTime%]); +``` + +**Syntax Rules:** +- Variable assignment on left side (`$NewProduct =`) +- Entity type is fully qualified +- Attributes in parentheses, comma separated +- Closing `)` followed by semicolon +- Syntax aligned with CALL MICROFLOW/CALL JAVA ACTION + +**The Commit flag** (Studio Pro's "Commit" dropdown) is the optional `commit` +modifier after the member list: + +```mdl +$Order = create Sales.Order (Number = $Nr); -- Commit: No (default) +$Order = create Sales.Order (Number = $Nr) commit; -- Commit: Yes +$Order = create Sales.Order (Number = $Nr) commit without events;-- Commit: YesWithoutEvents +``` + +Omit it for the default. This is a **modifier on the create**, not the separate +`commit $Var;` activity — see COMMIT Object below for that one. + +### CHANGE Object + +```mdl +change $Product ( + Name = $NewName, + ModifiedDate = [%CurrentDateTime%]); + +-- Commit the changed object as part of the change activity +change $Product (Name = $NewName) commit; +change $Product (Name = $NewName) commit without events; + +-- Refresh the changed object in the client +change $Product (Name = $NewName) refresh; + +-- Both: commit comes first +change $Product (Name = $NewName) commit refresh; +``` + +**Note**: Only specify attributes you want to change. Syntax aligned with CREATE. + +### COMMIT Object + +```mdl +-- Commit without events +commit $Product; + +-- Commit with events (triggers event handlers) +commit $Product with events; + +-- Commit with refresh in client (updates UI after commit) +commit $Product refresh; + +-- Commit with events and refresh +commit $Product with events refresh; +``` + +**Best Practice**: Use `with events` when you want before/after commit event handlers to execute. Use `refresh` when the committed object is displayed in the client and you want the UI to update immediately. + +> **Re-sorting a database-datasource grid needs `refresh`.** A plain `commit $Obj;` updates the committed attribute *values* in the grid, but a grid backed by a **database** datasource does **not** re-run its sort — so after changing a sort key (e.g. a reorder that rewrites a `SequenceNumber`), the row stays in its old position until you `commit $Obj refresh;`. The `refresh` re-queries the datasource, which re-applies the sort. (Ledger #57.) + +> **Binding a microflow to an entity event is MDL — you do NOT need to map it manually in Studio Pro.** After writing a handler microflow (e.g. a `BeforeCommit` validation), wire it directly: +> ```mdl +> alter entity Sales.Order +> add event handler on before commit call Sales.ACT_ValidateOrder($currentObject) raise error; +> ``` +> Works for `before`/`after` × `create`/`commit`/`delete`/`rollback`, and inside `create entity` too. See [generate-domain-model](../../generate-domain-model/SKILL.md) for the full syntax. (There is no need for an `EVT_*` naming convention plus a manual Studio Pro mapping step.) +## List Operations + +```mdl +-- Existing variable form +add $Item to $Items; + +-- Expression-valued add, useful when round-tripping Studio Pro list-add values +add head($SourceItems) to $Items; +``` + +Use expression-valued `add` only when the expression returns an object compatible with the target list element type. + +### `contains` is overloaded — string vs list + +`contains(a, b)` is both a **string** function (`contains(haystack, needle)` → substring test) and a **list** operation (`contains(list, object)` → membership test). mxcli picks the right serialization automatically: + +```mdl +-- STRING contains — assign to a PRE-DECLARED Boolean (a Change Variable action) +declare $HasAt Boolean = false; +set $HasAt = contains($Email, '@'); + +-- LIST contains — do NOT pre-declare the output (the list op creates it) +set $Found = contains($Items, $Item); +``` + +The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`. +## Database Operations + +### RETRIEVE Statement + +```mdl +-- Retrieve all +retrieve $ProductList from Test.Product; + +-- Retrieve with WHERE +retrieve $ProductList from Test.Product + where Code = $SearchCode; + +-- Retrieve with multiple conditions +retrieve $ProductList from Test.Product + where IsActive = true + and Price > 100; + +-- Retrieve single object +retrieve $Product from Test.Product + where Code = $ProductCode; +``` + +**Important**: +- Use `from Module.Entity` (fully qualified) +- RETRIEVE with `limit 1` returns a **single entity** +- RETRIEVE without `limit 1` returns a **list** (`list of Module.Entity`) +- Use `limit 1` when you expect exactly one result (e.g., lookup by unique key) + +**Sorting and paging** — use `sort by`, **not** `order by`: + +```mdl +retrieve $Recent from Sales.Order + where Status = Sales.OrderStatus.Open + sort by Sales.Order.OrderDate desc, Sales.Order.OrderNumber asc + limit $PageSize + offset $Offset; +``` + +- The keyword is **`sort by`** (one or more `Module.Entity.Attr asc|desc`, comma-separated). `order by` is **not** valid on a microflow `retrieve` — it's reserved for `select ... from CATALOG.*` queries and will cause a parse error here. +- `limit` and `offset` accept **a variable or expression**, not only a literal — `limit $PageSize`, `offset $Offset`, even `limit $Base + 5` all work. A bare literal (`limit 20`) is just the simplest case. + +### Retrieve by Association (in-memory, over an association path) + +To get the object(s) related to one you already have, retrieve **over an +association** — `retrieve $out from $source/Module.Association;`. This is an +*association* (in-memory) retrieve, not a database query, so it has no `where` / +`sort by` / `limit`. **The result type depends on the direction you navigate:** + +```mdl +-- FORWARD (from the reference-owner / "one" side) → a SINGLE object. +-- An Expense has one Employee (Expense_Employee: from Expense to Employee): +retrieve $Employee from $Expense/MyFirstModule.Expense_Employee; +change $Employee (Name = 'Updated'); -- change it directly — do NOT loop + +-- REVERSE (from the "many" side) → a LIST of the related objects. +-- One Employee has many Expenses (same association, navigated the other way): +retrieve $Expenses from $Employee/MyFirstModule.Expense_Employee; +loop $Expense in $Expenses +begin + change $Expense (Amount = 0); +end loop; +``` + +- **Forward Reference traversal returns a single object** — do **not** `loop` over + it. Looping a single object passes `mxcli check` but produces an invalid project + (mxbuild `StorageLoadException` — the loop's `change` writes an unqualified + attribute). Loop only over the list-valued (reverse / ReferenceSet) form. +- The association is always fully qualified (`Module.Association`), and the start + variable is an object you already have (a parameter, a prior retrieve/create, or + a loop iterator). Both forms above are mxbuild-verified (0 errors). + +**Enumeration attributes in WHERE**: XPath is a database query, so enum values are stored as plain strings. Both forms are valid — mxcli converts the qualified name to a string literal in BSON: + +```mdl +-- Preferred: qualified name (mxcli converts to 'Open' in BSON) +retrieve $Open from Module.Order + where [Status = Module.OrderStatus.Open]; + +-- Also accepted: string literal (the value key, case-sensitive) +retrieve $Open from Module.Order + where [Status = 'Open']; + +-- Multiple enum values with OR +retrieve $InProgress from Module.Order + where [Status = Module.OrderStatus.Open or Status = Module.OrderStatus.Processing]; +``` + +This is different from IF/SET expressions — see "Enumeration Comparisons" section above. +## XPath Navigation + +### Attribute Access + +```mdl +-- Read attribute +declare $ProductName string = $Product/Name; +declare $Price decimal = $Product/Price; + +-- Write attribute (alternative to CHANGE) +set $Product/Price = $NewPrice; +set $Product/ModifiedDate = [%CurrentDateTime%]; +``` + +### Association Navigation + +```mdl +-- Navigate to related object +declare $CustomerName string = $Order/Shop.Order_Customer/Name; +declare $CategoryName string = $Product/Shop.Product_Category/Name; + +-- Set association +set $Order/Shop.Order_Customer = $Customer; +set $Order/Shop.Order_Product = $Product; +``` + +**Critical**: Always use fully qualified association names (`Module.AssociationName`). + +### XPath in Expressions + +```mdl +-- Use in calculations +declare $MonthlyTotal decimal = $Product/MonthlyTotal; +declare $DailyAverage decimal = $MonthlyTotal div 30; + +-- Use in conditions +if $Product/IsActive then + set $count = $count + 1; +end if; + +-- Combine with operators +set $TotalPrice = $Product/Price * $Quantity; +``` diff --git a/.claude/skills/mendix/write-microflows/reference/integration.md b/.claude/skills/mendix/write-microflows/reference/integration.md new file mode 100644 index 000000000..de1c73da7 --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/integration.md @@ -0,0 +1,212 @@ +# REST, SOAP, Java actions and files + +Supporting reference for [write-microflows](../SKILL.md). + +## Legacy SOAP Web Service Calls + +`call web service` preserves legacy Mendix SOAP activities. Prefer REST clients +for new integrations; this syntax exists mainly so existing projects can +round-trip without dropping SOAP actions. + +```mdl +-- Structured form. Resolved SOAP references use normal qualified names. +$Root = call web service SampleSOAP.OrderService +operation FetchSampleItems +send mapping SampleSOAP.OrderRequest +receive mapping SampleSOAP.OrderResponse +timeout 30 +on error rollback; + +-- Quoted raw IDs are accepted when old project references are dangling or unavailable. +$Root = call web service 'sample-service-id' +operation FetchSampleItems +send mapping 'sample-send-mapping-id' +receive mapping 'sample-receive-mapping-id'; + +-- Raw escape hatch emitted for unsupported SOAP fields. +$Root = call web service raw 'AQID'; +``` + +**Design note:** the raw payload is base64-encoded BSON for the complete action +and is authoritative on re-exec. Treat this as round-trip support, not a +recommended authoring format for new integrations. +## REST Service Calls + +MDL supports two patterns for calling REST APIs from microflows: + +### SEND REST REQUEST — Consumed REST Service Operations + +Calls an operation defined in a consumed REST service (created via `create rest client`). The URL, headers, authentication, and response mapping are configured in the REST client document — the microflow only references the operation. + +```mdl +-- Fire and forget (RESPONSE NONE operation) +send rest request Module.ServiceName.OperationName; + +-- With output variable (RESPONSE JSON operation — maps to entity) +$Result = send rest request Module.ServiceName.OperationName; + +-- With request body (POST/PUT operations) +$Result = send rest request Module.ServiceName.CreateItem + body $NewItem; +``` + +**CRITICAL: `$latestHttpResponse` system variable** + +After every `send rest request`, Mendix automatically populates `$latestHttpResponse` (type `System.HttpResponse`). Use this to check call success — do **NOT** check the output variable directly: + +```mdl +-- ✅ CORRECT: check $latestHttpResponse +$RootResult = send rest request Module.Service.GetData; +if $latestHttpResponse/Content != empty then + -- Process $RootResult (the mapped entity) +end if; + +-- ❌ WRONG: checking the output variable directly causes CE0117 +if $RootResult != empty then -- ERROR! +``` + +**Key attributes on `$latestHttpResponse`:** +- `Content` (String) — response body as string. **Capital `C`** — it is inherited from the parent entity `System.HttpMessage`, so `DESCRIBE ENTITY System.HttpResponse` does not list it. Lowercase `$latestHttpResponse/content` fails CE0117. +- `StatusCode` (Integer) — HTTP status code (200, 404, etc.) + +**Restrictions:** +- `send rest request` does **NOT** support custom error handling (`on error continue/rollback` causes CE6035). Errors are always handled by aborting. +- The operation must be defined via `create rest client` with a three-part qualified name: `Module.ServiceDocument.OperationName`. + +### REST CALL — Inline HTTP Calls + +Direct HTTP call with URL, headers, auth, body, and response handling specified inline. Useful for one-off calls or when no REST client document exists. + +```mdl +-- Simple GET returning string +$response = rest call get 'https://api.example.com/data' + header Accept = 'application/json' + timeout 30 + returns string; + +-- POST with JSON body +$response = rest call post 'https://api.example.com/items' + header 'Content-Type' = 'application/json' + header Accept = 'application/json' + body '{{"name": "{1}", "value": {2}}' with ( + {1} = $ItemName, + {2} = toString($ItemValue) + ) + timeout 30 + returns string + on error continue; + +-- POST a BINARY body (upload a file document's contents) +-- The expression is the FileDocument's Contents MEMBER, not the document +-- itself, and the content type goes on a header. A consumed REST CLIENT +-- document has no binary body — `Body: file from $Doc` there is refused as +-- MDL-REST02 — so binary uploads belong here. +$response = rest call post 'https://api.example.com/upload' + header 'ContentType' = 'application/pdf' + body binary $Doc/Contents + timeout 300 + returns response; + +-- GET with URL template parameters +$response = rest call get 'https://api.example.com/users/{1}' with ( + {1} = toString($UserId) +) + header Accept = 'application/json' + returns string; + +-- With basic authentication +$response = rest call get 'https://api.example.com/secure' + header Accept = 'application/json' + auth basic $username password $password + timeout 30 + returns string; + +-- DELETE (no response) +rest call delete 'https://api.example.com/items/{1}' with ( + {1} = $ItemId +) + returns nothing + on error continue; +``` + +**REST CALL response types:** +- `returns string` — response body as string variable +- `returns nothing` / `returns none` — ignore response +- `returns response` — returns `System.HttpResponse` object +- `returns mapping Module.ImportMapping as Module.Entity` — single object result +- `returns mapping Module.ImportMapping as list of Module.Entity` — list result +- `returns Module.MyFile` — store the body in a **file document** + +**The file document form takes a specialization, never `System.FileDocument` +itself.** Mendix rejects the base type as a return type with `CE0362`, and +MDL064 reports it before the write. Create one first: + +```mdl +create persistent entity MyModule.MyFile extends System.FileDocument (); + +create microflow MyModule.ACT_Download ($Location: String) +begin + $file = rest call get '{1}' with ({1} = $Location) + header 'Accept' = 'application/octet-stream' + timeout 300 + returns MyModule.MyFile; +end; +``` + +There is **no** equivalent for an HttpResponse specialization: Mendix allows only +`User`, `FileDocument`, `Image` and `Paging` to be specialized (`CE1540`), so +`returns response` already names the only type that result can have. + +**Pick `as` vs `as list of` based on the call site, not the mapping shape.** The same import mapping can yield either a single object or a list — Studio Pro stores the cardinality on the microflow's `ImportMappingCall` (`Range.SingleObject` + `ForceSingleOccurrence`). Use `as Module.Entity` when the response is a single object (the mapping may still be list-typed; Studio Pro binds the first item). Use `as list of Module.Entity` when the response should bind a list. Mismatching the cardinality with the surrounding code produces `mx check` `CE0117` at the End event or `CE0013` / `CE0100` on downstream loop / aggregate / list-operation activities. + +**REST CALL supports full error handling** (`on error continue`, `on error rollback`, custom error handlers). +## File Downloads + +Use `download file` to stream a `System.FileDocument` from a microflow. Add +`show in browser` when the action should open the file inline instead of forcing +a download. + +```mdl +download file $GeneratedReport show in browser; +download file $GeneratedExport; +``` +## Empty Java-Action Argument (`empty`) + +When `describe` round-trips a Java-action call that has an unbound parameter +in Studio Pro, it emits `empty` as the argument value. In this Java-action +argument context, `empty` preserves the +underlying empty `BasicCodeActionParameterValue.Argument` so that the next +`describe → exec → describe` cycle stays symmetric. + +```mdl +$Total = call java action SampleModule.Recalculate( + CompanyId = empty, + RecalculateAll = true, + ItemList = empty +); +``` + +New scripts should bind every parameter to a real expression. Use `empty` +for a Java-action argument only when regenerating MDL from an existing project +that already had an unbound parameter. +## Microflow-Typed Java-Action Parameters + +Some Java actions take a **microflow** — a callback the action invokes later. +`MCPServer.AddTool` (`ExecutingMicroflow`) and `MCPServer.CreateMCPServer` +(`AuthenticationMicroflow`) are the ones you meet first. Pass the microflow's +qualified name as a quoted string; mxcli resolves the parameter's declared type +from the Java action and stores a microflow reference, not a string literal. + +```mdl +$Tool = call java action MCPServer.AddTool( + McpServer = $Server, + Name = 'memory_add', + Description = 'Stores a memory', + ExecutingMicroflow = 'MyModule.MF_MemoryAdd', + Schema = '' +); +``` + +`DESCRIBE JAVA ACTION` prints such a parameter's type as the bare word +`Microflow` (`Nanoflow` for JavaScript actions), and that spelling is what +`CREATE JAVA ACTION` accepts, so the round-trip is stable. diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md new file mode 100644 index 000000000..e7027e7e2 --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -0,0 +1,509 @@ +# Pitfalls, unsupported syntax and build errors + +Supporting reference for [write-microflows](../SKILL.md). + +## Common Pitfalls + +### 1. Object (Entity) Variables Cannot Be Declared + +**Error**: CE0053 - "Selected type is not allowed" (+ CE0038, CE7247) — passes +`mxcli check` only until MDL043 was added; previously surfaced at mxbuild. + +❌ **INCORRECT:** +```mdl +declare $Product Test.Product; -- object Create Variable — rejected +declare $Product Test.Product = $In; -- aliasing a parameter — rejected +declare $Product as Test.Product; -- AS keyword also not supported +``` + +✅ **CORRECT** — get the object from a source that produces one: +```mdl +-- as a microflow parameter +create microflow Test.Save ($Product: Test.Product) returns boolean as $ok ... + +-- from a retrieve (single object) +retrieve $Product from Test.Product where Code = $Code limit 1; + +-- from a create object +$Product = create Test.Product (Name = $Name); + +-- from a loop iterator +loop $Product in $Products +begin + change $Product (Processed = true); +end +``` + +**Explanation**: `declare` becomes a *Create Variable* activity, which in Mendix +only holds primitive types. Objects (and lists) have no Create Variable form — use +the parameter/retrieve/create/loop sources above. To "keep a reference" to an +existing object, just use the variable you already have (`$In`, the loop variable, +the retrieve/create output); Mendix has no aliasing activity. + +### 2. XPath Association Navigation + +**Error**: CE0117 - "Error in expression" + +❌ **INCORRECT:** +```mdl +-- Using simple association name +declare $CustomerName string = $Order/Customer/Name; +set $Name = $Product/Category/Name; +``` + +✅ **CORRECT:** +```mdl +-- Use fully qualified association name: Module.AssociationName +declare $CustomerName string = $Order/Shop.Order_Customer/Name; +set $Name = $Product/Shop.Product_Category/Name; +``` + +**Explanation**: XPath navigation requires the full qualified association name in the format `Module.AssociationName`. + +### 3. Missing Attributes + +**Error**: Attribute references must exist in entity definition + +❌ **INCORRECT:** +```mdl +-- Referencing Status when it doesn't exist in Order entity +change $Order ( + status = 'PROCESSING', + ProcessedDate = [%CurrentDateTime%]); +``` + +✅ **CORRECT:** +```mdl +-- First, ensure entity has the attributes +create persistent entity Shop.Order ( + OrderNumber: string(50), + status: string(50), -- ← Must be defined + ProcessedDate: datetime -- ← Must be defined +); + +-- Then reference them +change $Order ( + status = 'PROCESSING', + ProcessedDate = [%CurrentDateTime%]); +``` + +### 4. Flow Must End with RETURN + +**Error**: CE0105 - "Activity cannot be the last object of a flow" + +❌ **INCORRECT:** +```mdl +begin + declare $success boolean = true; + log info 'Done'; + -- Missing RETURN! +end; +``` + +✅ **CORRECT:** +```mdl +begin + declare $success boolean = true; + log info 'Done'; + return $success; -- ← Always required +end; +``` + +### 5. Unreachable Code After RETURN + +**Error**: CE0104 - "Action activity is unreachable" + +❌ **INCORRECT:** +```mdl +if $value < 0 then + return false; + log info 'This will never execute'; -- ← Unreachable! +end if; +``` + +✅ **CORRECT:** +```mdl +if $value < 0 then + log info 'Value is negative'; + return false; +end if; +``` + +### 6. Unused Variables + +**Warning**: CW0094 - "Variable 'X' is never used" + +```mdl +-- Studio Pro will warn if parameters/variables are declared but never used +create microflow Test.Example ( + $ProductCode: string -- ← Warning if never referenced +) +returns boolean as $success +begin + set $success = true; -- ProductCode never used + return $success; +end; +``` + +### 7. Using SET on Undeclared Variables + +**Error**: MDL executor validates that all variables used with `set` are declared first. + +❌ **INCORRECT:** +```mdl +begin + if $value > 10 then + set $message = 'High'; -- ERROR: $Message not declared! + end if; + return true; +end; +``` + +✅ **CORRECT:** +```mdl +begin + declare $message string = ''; -- Declare first + if $value > 10 then + set $message = 'High'; -- Now SET works + end if; + return true; +end; +``` + +**Note**: Parameters are automatically declared by the parameter list. The `returns type as $Var` syntax names the return variable but does NOT declare it - you must still use `declare $Var type = value;` if you want to use SET on it. + +### 8. RETURN Inside a Loop + +**Error**: CE0068 - "End events cannot be placed inside a loop." (MDL062) + +A `return` builds an End event, and Mendix does not allow one inside a loop — +whether the return sits in the loop body directly or inside a branch within it. + +❌ **INCORRECT:** +```mdl +loop $Part in $PartList +begin + if $Part/IsMatch then + return true; -- End event inside the loop + end if; +end loop; +``` + +✅ **CORRECT** — leave the loop with `break`, and return once after it: +```mdl +declare $Found boolean = false; +loop $Part in $PartList +begin + if $Part/IsMatch then + set $Found = true; + break; + end if; +end loop; +return $Found; +``` + +### 9. Two Activities Creating the Same Variable + +**Error**: CE0111 - "Duplicate variable name 'X'." (MDL063) + +A microflow's variable names are unique **flow-wide**. Branches and loop bodies +do not open a scope, and parameters and loop iterators share the same namespace. +The trap is that every activity with an output **creates** its variable — there +is no form in which a call, a retrieve, an aggregate or an import mapping writes +into one that already exists. + +❌ **INCORRECT:** +```mdl +declare $Session string = ''; +$Session = call microflow Mod.Login(); -- the call creates $Session too +``` + +✅ **CORRECT** — let the activity create it: +```mdl +$Session = call microflow Mod.Login(); +``` + +Assigning to an existing variable is fine, because `set` is a *Change Variable* +activity and creates nothing: + +```mdl +declare $Session string = ''; +set $Session = 'anonymous'; -- valid, any number of times +``` + +### 10. Calling a Rule or Microflow Inside an Expression + +**Error**: CE0117 - "Error(s) in expression." (MDL066) + +A Mendix **expression** has no user-callable functions. Its library is built-in +and unqualified (`length`, `toString`, `contains`, ...); microflows, rules and +Java actions are called by **activities**. So a qualified call in a value +position is not an expression at all — mxbuild rejects it whichever document it +names. + +❌ **INCORRECT:** +```mdl +declare $Active Boolean = Sample.Rule_IsActive(IsActive = $IsActive); +declare $Next Integer = Sample.MF_Increment(N = $N); -- a microflow is no better +``` + +✅ **CORRECT** — a microflow or Java action is an activity: +```mdl +$Next = call microflow Sample.MF_Increment(N = $N); +``` + +A **rule** has no call activity at all: Mendix can only evaluate one as a +decision's condition, and that is the single position where a bare qualified +call is valid MDL. + +```mdl +if Sample.Rule_IsActive(IsActive = $IsActive) then + ... +end if; +``` + +The name in that position must resolve to a real **rule** — a microflow there is +the same CE0117, and mxcli refuses the statement rather than writing it. +## Implicit Variable Creation (CE0111 Duplicate Variable) + +These statements **implicitly create a new variable** with the name on the left side: + +- `$Var = call microflow ...` +- `$Var = call java action ...` +- `$Var = call nanoflow ...` +- `$Var = create Module.Entity (...)` +- `retrieve $Var from Module.Entity ...` + +**Do NOT use `declare` before these** — it creates a duplicate variable (CE0111): + +```mdl +-- WRONG: Duplicate variable — DECLARE + CALL both create $Result +declare $Result boolean = false; +$Result = call java action Module.DoSomething(); -- CE0111! + +-- CORRECT: Let CALL create the variable, use a different name if you need a default +declare $success boolean = false; +$CallResult = call java action Module.DoSomething(); +set $success = $CallResult; + +-- CORRECT: Simple pass-through (no default needed) +$Result = call java action Module.DoSomething(); +return $Result; +``` + +The same applies to RETRIEVE: + +```mdl +-- WRONG: declaring a list at all (CE0053/CE0038, MDL040) — and duplicate variable +declare $Items list of Module.Entity = empty; +retrieve $Items from Module.Entity where Active = true; -- CE0111! + +-- CORRECT: Let RETRIEVE create the variable +retrieve $Items from Module.Entity where Active = true; +``` + +**Note**: `returns type as $Var` in the microflow signature does NOT create an activity variable — it only names the return value. So `$Var = call java action ...` after `returns as $Var` is fine (one creation). + +**Variables are scoped to the branch that creates them.** A variable first created +inside an `if`/`else` arm (including by `$Var = call ...`) is not visible outside +that arm. To use one value after a conditional, `declare` it *before* the +conditional and assign in every branch: + +```mdl +-- WRONG: $GTotalText is created only in the `then` arm → not declared in `else` +if $HasVariance then + $GTotalText = call microflow Module.FMT_Variance($v); -- created here only +else + set $GTotalText = 'n/a'; -- error: not declared +end if; + +-- CORRECT: declare before, then set in each branch (call into a temp, then set) +declare $GTotalText string = ''; +if $HasVariance then + $Tmp = call microflow Module.FMT_Variance($v); + set $GTotalText = $Tmp; +else + set $GTotalText = 'n/a'; +end if; +``` + +**Fallback chains reuse the same name = CE0111.** Because each `$Var = call microflow …` (or retrieve/create) is a *fresh* variable creation, the natural "try A, else try B" shape is invalid — even when the retry is inside an `if`: + +```mdl +-- WRONG: the second call re-creates $Summary — CE0111 +$Summary = call microflow M.Inner(Tag = 'description'); +if trim($Summary) = '' then + $Summary = call microflow M.Inner(Tag = 'summary'); -- CE0111! +end if; + +-- CORRECT: one variable per call, then a plain `set` picks the winner +$Summary = call microflow M.Inner(Tag = 'description'); +if trim($Summary) = '' then + $SummaryFallback = call microflow M.Inner(Tag = 'summary'); + set $Summary = $SummaryFallback; +end if; +``` + +`mxcli check --references` catches this (CE0111) before the build. +## UNSUPPORTED Syntax (Will Cause Parse Errors) + +**CRITICAL**: The following syntax is NOT implemented and will cause parse errors. Do NOT use these patterns: + +### ROLLBACK Statement (Supported!) + +```mdl +-- CORRECT: ROLLBACK is now supported +rollback $Order; + +-- With REFRESH to update client UI +rollback $Order refresh; +``` + +**Use Case**: Revert uncommitted changes to an object. Useful when validation fails and you want to restore the object to its database state. + +### RETRIEVE with LIMIT (Supported!) + +```mdl +-- CORRECT: LIMIT is supported +retrieve $Product from Module.Product where IsActive = true limit 1; + +-- LIMIT 1 returns a single entity (not a list) +-- Without LIMIT, returns a list +retrieve $ProductList from Module.Product where IsActive = true; +``` + +### WHILE Loop + +```mdl +-- WHILE loops iterate while a condition is true +while $Counter < 10 +begin + set $Counter = $Counter + 1; +end while; + +-- FOR EACH loops iterate over a list +loop $item in $ItemList +begin + -- Process each item +end loop; +``` + +### CASE with string values, `else`, or an alias + +`case … end case` **is supported** — see [CASE Statements (Enum Split)](#case-statements-enum-split) +above for the correct form. What is not supported is the SQL-flavoured spelling of +it: quoted values, an `else` fallback, and an `AS` alias all fail. + +```mdl +-- WRONG: case values are not string literals (parse error) +case $Order/Status + when 'Active' then set $Result = 1; +end case; + +-- WRONG: case values are not qualified (parse error) +case $Order/Status + when MyModule.Status.Active then set $Result = 1; +end case; + +-- WRONG: no AS alias (parse error: mismatched input 'as' expecting WHEN) +case $Order/Status as s + when Active then set $Result = 1; +end case; + +-- WRONG: no else branch (MDL008 → mxbuild CE0079 + CE0773) +case $Order/Status + when Active then set $Result = 1; + else set $Result = 0; +end case; + +-- CORRECT: bare enum values, one branch per value, including (empty) +case $Order/Status + when Active then set $Result = 1; + when Inactive then set $Result = 2; + when (empty) then set $Result = 0; +end case; +``` + +An enum split is the *only* thing `case` does — it branches on an enumeration, not +on arbitrary expressions. For anything else (a string comparison, a numeric range), +use nested `if … else … end if`. + +### TRY/CATCH Block + +```mdl +-- WRONG: TRY/CATCH not supported +TRY + commit $Order; +CATCH + log error 'Commit failed'; +end TRY; + +-- CORRECT: Use ON ERROR on specific activities +commit $Order on error { + log error 'Commit failed'; +}; +``` + +### BREAK/CONTINUE in Loops + +```mdl +-- WRONG: BREAK/CONTINUE not supported +loop $item in $ItemList +begin + if $item/Skip = true then + continue; -- NOT SUPPORTED + end if; + if $item/Stop = true then + break; -- NOT SUPPORTED + end if; +end loop; + +-- CORRECT: Use conditional logic +loop $item in $ItemList +begin + if $item/Skip = false and $item/Stop = false then + -- Process item + end if; +end loop; +``` + +### Reserved Words as Identifiers + +**Best practice: Always quote all identifiers** (attribute names, parameter names, entity names) with double quotes. This eliminates all reserved keyword conflicts and is always safe — quotes are stripped automatically by the parser. + +> **Exception — never quote `$`-prefixed variable/parameter references.** The quote +> rule is for *bare* names (entities, attributes, associations, declared parameter +> names). Variable/parameter **references** in expressions stay **unquoted**: +> `$Customer/Name`, `$currentObject`, `retrieve … from $List`. Quoting the `$` token +> (`"$Customer"`) breaks resolution. + +```mdl +create persistent entity Module."item" ( + "check": boolean default false, + "text": string(500), + "format": string(50), + "value": decimal, + "create": datetime, + "delete": datetime +); +``` + +Quoted identifiers also work for microflow parameter names: +```mdl +create microflow Module."Process" ("select": string, "type": integer) +begin + log info 'Processing'; + return; +end; +``` +## Common Studio Pro Errors + +| Error Code | Message | Fix | +|------------|---------|-----| +| CE0053 | Selected type is not allowed | Don't `declare` an object/list — get it from a parameter, retrieve, create, or loop (MDL043/MDL040) | +| CE0117 | Error in expression | Use qualified association names; use `not(expr)` not bare `not expr` | +| CE0104 | Action activity is unreachable | Remove code after RETURN | +| CE0105 | Must end with end event | Add RETURN statement | +| CE0008 | No action defined | Define action for activity | +| CW0094 | Variable never used | Remove unused variables or use them | +| MDL | Variable not declared | Use `declare $var type = value;` before SET | diff --git a/.claude/skills/mendix/write-nanoflows.md b/.claude/skills/mendix/write-nanoflows/SKILL.md similarity index 98% rename from .claude/skills/mendix/write-nanoflows.md rename to .claude/skills/mendix/write-nanoflows/SKILL.md index 3ed54310f..88bb2af38 100644 --- a/.claude/skills/mendix/write-nanoflows.md +++ b/.claude/skills/mendix/write-nanoflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-nanoflows +description: "Nanoflow syntax in MDL — shared with microflows, but client-side and restricted. Use before writing any CREATE NANOFLOW, when a nanoflow validation error needs explaining, or when deciding between a nanoflow and a microflow." +--- + # Mendix Nanoflow Skill This skill provides guidance for writing Mendix nanoflows in MDL syntax. Nanoflows share syntax with microflows but execute client-side with restricted capabilities. @@ -10,7 +15,7 @@ Use this skill when: - Understanding nanoflow restrictions vs microflows - Building mobile or offline-capable features -If you're not sure whether the logic belongs in a nanoflow or a microflow, read the next section first. The mirror lives in [write-microflows.md](./write-microflows.md) — keep both copies in sync. +If you're not sure whether the logic belongs in a nanoflow or a microflow, read the next section first. The mirror lives in [write-microflows](../write-microflows/SKILL.md) — keep both copies in sync. ## When to Use a Nanoflow vs a Microflow diff --git a/.claude/skills/mendix/write-oql-queries.md b/.claude/skills/mendix/write-oql-queries/SKILL.md similarity index 73% rename from .claude/skills/mendix/write-oql-queries.md rename to .claude/skills/mendix/write-oql-queries/SKILL.md index 30da3732b..074d0dd3e 100644 --- a/.claude/skills/mendix/write-oql-queries.md +++ b/.claude/skills/mendix/write-oql-queries/SKILL.md @@ -1,5 +1,16 @@ +--- +name: write-oql-queries +description: "Write OQL for Mendix VIEW entities — joins, aggregates, calculated fields, and the syntax the runtime actually accepts. Use when creating a VIEW entity or building a report or analytics query." +--- + # Skill: Write OQL Queries for Mendix VIEW Entities +## Reference files + +- [`reference/patterns.md`](reference/patterns.md) — the recurring OQL shapes + (aggregation, joins across associations, date bucketing, ranking, filtered + counts) and a full worked query, to adapt rather than derive. + ## Purpose Generate correct OQL (Object Query Language) queries for Mendix VIEW entities. This skill helps you create VIEW entities with proper OQL syntax that will execute successfully in Mendix runtime. @@ -316,204 +327,6 @@ left outer join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode - **Association traversal** (`alias/Module.Association/entity`): When joining on a Mendix-defined association - **JOIN ON** (`join entity on condition`): When joining on arbitrary conditions or non-association fields -## Common OQL Patterns - -### Pattern 1: Date-based Aggregation -```sql -create view entity Finance.MonthlySummary ( - Year: integer, - Month: integer, - TotalAmount: decimal, - TransactionCount: integer -) as ( - select - datepart(YEAR, t.Date) as Year, - datepart(MONTH, t.Date) as Month, - sum(t.Amount) as TotalAmount, - count(t.ID) as TransactionCount - from Finance.Transaction as t - where t.Status != 'VOIDED' - GROUP by datepart(YEAR, t.Date), datepart(MONTH, t.Date) -); -``` - -### Pattern 2: Conditional Aggregation -```sql -create view entity Finance.CategorySummary ( - Category: string(200), - Income: decimal, - Expense: decimal, - Net: decimal -) as ( - select - c.Name as Category, - sum(case when t.Type = 'INCOME' then t.Amount else 0 end) as Income, - sum(case when t.Type = 'EXPENSE' then t.Amount else 0 end) as Expense, - sum(case when t.Type = 'INCOME' then t.Amount - when t.Type = 'EXPENSE' then -t.Amount else 0 end) as Net - from Finance.Transaction as t - inner join Finance.Transaction_Category/Finance.Category as c - GROUP by c.Name -); -``` - -### Pattern 3: Association Navigation -```sql -create view entity Shop.OrderDetails ( - OrderId: long, - CustomerName: string(400), - TotalItems: integer, - TotalPrice: decimal -) as ( - select - o.OrderId as OrderId, - c.FirstName + ' ' + c.LastName as CustomerName, - count(ol.OrderLineId) as TotalItems, - o.TotalPrice as TotalPrice - from Shop.CustomerOrder as o - inner join Shop.Order_Customer/Shop.Customer as c - left join Shop.OrderLine_Order/Shop.OrderLine as ol - GROUP by o.OrderId, o.TotalPrice, c.FirstName, c.LastName -); -``` - -### Pattern 4: Calculations with Division -```sql -create view entity Finance.BudgetVariance ( - Category: string(200), - Budget: decimal, - Actual: decimal, - Variance: decimal, - VariancePercent: decimal -) as ( - select - c.Name as Category, - bl.PlannedAmount as Budget, - bl.ActualAmount as Actual, - bl.ActualAmount - bl.PlannedAmount as Variance, - (bl.ActualAmount - bl.PlannedAmount) * 100.0 : bl.PlannedAmount as VariancePercent - from Finance.BudgetLine as bl - inner join Finance.BudgetLine_Category/Finance.Category as c - where bl.PlannedAmount > 0 -); -``` - -### Pattern 5: IN Expression with Value List -```sql -create view entity Shop.HighPriorityTasks ( - TaskId: integer, - TaskTitle: string(200), - Priority: string(50) -) as ( - select - t.TaskId as TaskId, - t.TaskTitle as TaskTitle, - t.TaskPriority as Priority - from Shop.Task as t - where t.TaskPriority in ('HIGH', 'CRITICAL') -); -``` - -### Pattern 6: IN Expression with Subquery -```sql -create view entity Shop.CustomersWithOrders ( - CustomerId: integer, - CustomerName: string(200) -) as ( - select - c.CustomerId as CustomerId, - c.Name as CustomerName - from Shop.Customer as c - where c.CustomerId in ( - select distinct o.CustomerId - from Shop.Order as o - where o.Status = 'COMPLETED' - ) -); -``` - -### Pattern 7: Scalar Subquery in SELECT -```sql -create view entity Shop.ProductsAboveAverage ( - ProductId: integer, - Name: string(200), - Price: decimal, - PriceDifferenceFromAvg: decimal -) as ( - select - p.ProductId as ProductId, - p.Name as Name, - p.Price as Price, - p.Price - (select avg(p2.Price) from Shop.Product as p2) as PriceDifferenceFromAvg - from Shop.Product as p - where p.Price > (select avg(p3.Price) from Shop.Product as p3) -); -``` - -### Pattern 8: Correlated Subquery -```sql -create view entity Shop.OrdersWithCustomerStats ( - OrderId: integer, - OrderNumber: string(50), - CustomerTotalOrders: integer, - CustomerTotalSpend: decimal -) as ( - select - o.OrderId as OrderId, - o.OrderNumber as OrderNumber, - (select count(o2.OrderId) from Shop.Order as o2 where o2.CustomerId = o.CustomerId) as CustomerTotalOrders, - (select sum(o3.TotalAmount) from Shop.Order as o3 where o3.CustomerId = o.CustomerId) as CustomerTotalSpend - from Shop.Order as o -); -``` - -### Pattern 9: Correlated Subquery via Association -```sql --- Get the latest price for each product using association traversal -create view entity Shop.ProductCurrentPrice ( - ProductId: string(50), - Name: string(200), - PriceInEuro: decimal, - IsActive: boolean -) as ( - select - p.ProductId as ProductId, - p.Name as Name, - (select pr.PriceInEuro - from Shop.Price as pr - where pr.StartDate <= '[%BeginOfTomorrow%]' - and pr/Shop.Price_Product = p.ID - ORDER by pr.StartDate desc - limit 1) as PriceInEuro, - p.IsActive as IsActive - from Shop.Product as p - where p.IsActive -); -``` - -**Key points:** -- Use `pr/Shop.Price_Product = p.ID` (association path with `.ID`) -- Never use bare alias: `pr/Shop.Price_Product = p` will fail -- ORDER BY and LIMIT are valid inside correlated subqueries; at the view level, ORDER BY is allowed only when paired with LIMIT (MDL030) - -### Pattern 10: JOIN with ON Clause (Non-Association) -```sql --- When joining on arbitrary conditions (not Mendix associations) -create view entity Shop.ProductComparison ( - ProductId: integer, - ProductName: string(200), - CompetitorPrice: decimal -) as ( - select - p.ProductId as ProductId, - p.Name as ProductName, - cp.Price as CompetitorPrice - from Shop.Product as p - left join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode - where cp.CompetitorName = 'ACME' -); -``` - ## Step-by-Step Process ### Step 1: Define VIEW Entity Schema @@ -668,49 +481,6 @@ create view entity Finance.TopItems (...) as ( ); ``` -## Complete Example - -### User Request -"Create a VIEW entity showing monthly revenue with order statistics" - -### Response -```sql -/** - * Monthly revenue summary with order statistics - * - * Time-series view of revenue and order metrics - * aggregated by month and year. - * - * @since 1.0.0 - * @see Shop.CustomerOrder - */ -@position(1400, 450) -create view entity Shop.MonthlyRevenue ( - Year: integer, - Month: integer, - TotalOrders: integer, - TotalRevenue: decimal, - AverageOrderValue: decimal -) as ( - select - datepart(YEAR, o.OrderDate) as Year, - datepart(MONTH, o.OrderDate) as Month, - count(o.OrderId) as TotalOrders, - sum(o.TotalPrice) as TotalRevenue, - avg(o.TotalPrice) as AverageOrderValue - from Shop.CustomerOrder as o - GROUP by datepart(YEAR, o.OrderDate), datepart(MONTH, o.OrderDate) -); -``` - -### Why This Works -1. ✅ All columns have explicit AS aliases -2. ✅ Lowercase aggregates: `sum()`, `avg()`, `count()` -3. ✅ Proper COUNT: `count(o.OrderId)` not `count(*)` -4. ✅ Comma syntax for DATEPART: `datepart(YEAR, o.OrderDate)` -5. ✅ GROUP BY matches SELECT non-aggregated expressions -6. ✅ ORDER BY omitted (UI sorts) — or, for a top-N view, paired with a LIMIT (MDL030) - ## Testing OQL Queries Use `mxcli oql` to test queries against a running Mendix runtime (read-only preview mode): diff --git a/.claude/skills/mendix/write-oql-queries/reference/patterns.md b/.claude/skills/mendix/write-oql-queries/reference/patterns.md new file mode 100644 index 000000000..612a22846 --- /dev/null +++ b/.claude/skills/mendix/write-oql-queries/reference/patterns.md @@ -0,0 +1,243 @@ +# OQL patterns and a worked example + +Supporting reference for [write-oql-queries](../SKILL.md). + +## Common OQL Patterns + +### Pattern 1: Date-based Aggregation +```sql +create view entity Finance.MonthlySummary ( + Year: integer, + Month: integer, + TotalAmount: decimal, + TransactionCount: integer +) as ( + select + datepart(YEAR, t.Date) as Year, + datepart(MONTH, t.Date) as Month, + sum(t.Amount) as TotalAmount, + count(t.ID) as TransactionCount + from Finance.Transaction as t + where t.Status != 'VOIDED' + GROUP by datepart(YEAR, t.Date), datepart(MONTH, t.Date) +); +``` + +### Pattern 2: Conditional Aggregation +```sql +create view entity Finance.CategorySummary ( + Category: string(200), + Income: decimal, + Expense: decimal, + Net: decimal +) as ( + select + c.Name as Category, + sum(case when t.Type = 'INCOME' then t.Amount else 0 end) as Income, + sum(case when t.Type = 'EXPENSE' then t.Amount else 0 end) as Expense, + sum(case when t.Type = 'INCOME' then t.Amount + when t.Type = 'EXPENSE' then -t.Amount else 0 end) as Net + from Finance.Transaction as t + inner join Finance.Transaction_Category/Finance.Category as c + GROUP by c.Name +); +``` + +### Pattern 3: Association Navigation +```sql +create view entity Shop.OrderDetails ( + OrderId: long, + CustomerName: string(400), + TotalItems: integer, + TotalPrice: decimal +) as ( + select + o.OrderId as OrderId, + c.FirstName + ' ' + c.LastName as CustomerName, + count(ol.OrderLineId) as TotalItems, + o.TotalPrice as TotalPrice + from Shop.CustomerOrder as o + inner join Shop.Order_Customer/Shop.Customer as c + left join Shop.OrderLine_Order/Shop.OrderLine as ol + GROUP by o.OrderId, o.TotalPrice, c.FirstName, c.LastName +); +``` + +### Pattern 4: Calculations with Division +```sql +create view entity Finance.BudgetVariance ( + Category: string(200), + Budget: decimal, + Actual: decimal, + Variance: decimal, + VariancePercent: decimal +) as ( + select + c.Name as Category, + bl.PlannedAmount as Budget, + bl.ActualAmount as Actual, + bl.ActualAmount - bl.PlannedAmount as Variance, + (bl.ActualAmount - bl.PlannedAmount) * 100.0 : bl.PlannedAmount as VariancePercent + from Finance.BudgetLine as bl + inner join Finance.BudgetLine_Category/Finance.Category as c + where bl.PlannedAmount > 0 +); +``` + +### Pattern 5: IN Expression with Value List +```sql +create view entity Shop.HighPriorityTasks ( + TaskId: integer, + TaskTitle: string(200), + Priority: string(50) +) as ( + select + t.TaskId as TaskId, + t.TaskTitle as TaskTitle, + t.TaskPriority as Priority + from Shop.Task as t + where t.TaskPriority in ('HIGH', 'CRITICAL') +); +``` + +### Pattern 6: IN Expression with Subquery +```sql +create view entity Shop.CustomersWithOrders ( + CustomerId: integer, + CustomerName: string(200) +) as ( + select + c.CustomerId as CustomerId, + c.Name as CustomerName + from Shop.Customer as c + where c.CustomerId in ( + select distinct o.CustomerId + from Shop.Order as o + where o.Status = 'COMPLETED' + ) +); +``` + +### Pattern 7: Scalar Subquery in SELECT +```sql +create view entity Shop.ProductsAboveAverage ( + ProductId: integer, + Name: string(200), + Price: decimal, + PriceDifferenceFromAvg: decimal +) as ( + select + p.ProductId as ProductId, + p.Name as Name, + p.Price as Price, + p.Price - (select avg(p2.Price) from Shop.Product as p2) as PriceDifferenceFromAvg + from Shop.Product as p + where p.Price > (select avg(p3.Price) from Shop.Product as p3) +); +``` + +### Pattern 8: Correlated Subquery +```sql +create view entity Shop.OrdersWithCustomerStats ( + OrderId: integer, + OrderNumber: string(50), + CustomerTotalOrders: integer, + CustomerTotalSpend: decimal +) as ( + select + o.OrderId as OrderId, + o.OrderNumber as OrderNumber, + (select count(o2.OrderId) from Shop.Order as o2 where o2.CustomerId = o.CustomerId) as CustomerTotalOrders, + (select sum(o3.TotalAmount) from Shop.Order as o3 where o3.CustomerId = o.CustomerId) as CustomerTotalSpend + from Shop.Order as o +); +``` + +### Pattern 9: Correlated Subquery via Association +```sql +-- Get the latest price for each product using association traversal +create view entity Shop.ProductCurrentPrice ( + ProductId: string(50), + Name: string(200), + PriceInEuro: decimal, + IsActive: boolean +) as ( + select + p.ProductId as ProductId, + p.Name as Name, + (select pr.PriceInEuro + from Shop.Price as pr + where pr.StartDate <= '[%BeginOfTomorrow%]' + and pr/Shop.Price_Product = p.ID + ORDER by pr.StartDate desc + limit 1) as PriceInEuro, + p.IsActive as IsActive + from Shop.Product as p + where p.IsActive +); +``` + +**Key points:** +- Use `pr/Shop.Price_Product = p.ID` (association path with `.ID`) +- Never use bare alias: `pr/Shop.Price_Product = p` will fail +- ORDER BY and LIMIT are valid inside correlated subqueries; at the view level, ORDER BY is allowed only when paired with LIMIT (MDL030) + +### Pattern 10: JOIN with ON Clause (Non-Association) +```sql +-- When joining on arbitrary conditions (not Mendix associations) +create view entity Shop.ProductComparison ( + ProductId: integer, + ProductName: string(200), + CompetitorPrice: decimal +) as ( + select + p.ProductId as ProductId, + p.Name as ProductName, + cp.Price as CompetitorPrice + from Shop.Product as p + left join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode + where cp.CompetitorName = 'ACME' +); +``` +## Complete Example + +### User Request +"Create a VIEW entity showing monthly revenue with order statistics" + +### Response +```sql +/** + * Monthly revenue summary with order statistics + * + * Time-series view of revenue and order metrics + * aggregated by month and year. + * + * @since 1.0.0 + * @see Shop.CustomerOrder + */ +@position(1400, 450) +create view entity Shop.MonthlyRevenue ( + Year: integer, + Month: integer, + TotalOrders: integer, + TotalRevenue: decimal, + AverageOrderValue: decimal +) as ( + select + datepart(YEAR, o.OrderDate) as Year, + datepart(MONTH, o.OrderDate) as Month, + count(o.OrderId) as TotalOrders, + sum(o.TotalPrice) as TotalRevenue, + avg(o.TotalPrice) as AverageOrderValue + from Shop.CustomerOrder as o + GROUP by datepart(YEAR, o.OrderDate), datepart(MONTH, o.OrderDate) +); +``` + +### Why This Works +1. ✅ All columns have explicit AS aliases +2. ✅ Lowercase aggregates: `sum()`, `avg()`, `count()` +3. ✅ Proper COUNT: `count(o.OrderId)` not `count(*)` +4. ✅ Comma syntax for DATEPART: `datepart(YEAR, o.OrderDate)` +5. ✅ GROUP BY matches SELECT non-aggregated expressions +6. ✅ ORDER BY omitted (UI sorts) — or, for a top-N view, paired with a LIMIT (MDL030) diff --git a/.claude/skills/mendix/write-rules.md b/.claude/skills/mendix/write-rules/SKILL.md similarity index 91% rename from .claude/skills/mendix/write-rules.md rename to .claude/skills/mendix/write-rules/SKILL.md index d8ab4b0c1..0a5566949 100644 --- a/.claude/skills/mendix/write-rules.md +++ b/.claude/skills/mendix/write-rules/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-rules +description: "Write Mendix rules — a special kind of microflow returning a Boolean or enumeration, callable only from a decision. Use when writing CREATE RULE, or deciding whether logic belongs in a rule or a microflow." +--- + # Mendix Rule Skill Guidance for writing Mendix **rules** in MDL. Mendix's own reference calls a rule @@ -10,8 +15,8 @@ returns a Boolean or an enumeration, and it can only be used from a decision. - Deciding whether logic belongs in a rule or a microflow - Understanding what `mxcli check` refuses in a rule body and why -The mirrors are [write-microflows.md](./write-microflows.md) and -[write-nanoflows.md](./write-nanoflows.md) — a rule is the third flavour and +The mirrors are [write-microflows](../write-microflows/SKILL.md) and +[write-nanoflows](../write-nanoflows/SKILL.md) — a rule is the third flavour and shares their body syntax exactly. ## When to Use a Rule vs a Microflow diff --git a/.claude/skills/mendix/write-workflows.md b/.claude/skills/mendix/write-workflows/SKILL.md similarity index 93% rename from .claude/skills/mendix/write-workflows.md rename to .claude/skills/mendix/write-workflows/SKILL.md index 66d8e8b66..71c41ddaa 100644 --- a/.claude/skills/mendix/write-workflows.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-workflows +description: "Author Mendix workflows in MDL — user tasks, decisions, parallel splits, jumps, waits and boundary events, with CREATE, ALTER and DROP. Use when building a business process with human steps, timers or parallel branches." +--- + # Mendix Workflows Skill Guidance for **authoring** workflows in Mendix projects with MDL — not just @@ -210,7 +215,7 @@ bodies empty. return nothing — the System module's **enumerations** are not in the project file, so mxcli cannot resolve them. Constrain on an attribute instead (`[EndTime = empty]` selects open tasks) rather than naming a System enum value. System **entities** are -documented in `system-module.md`. +documented in `system-module`. ## Platform rules @@ -236,11 +241,11 @@ hand-rolling admin-API calls: | To see | Read | |---|---| -| Live instances and open tasks (OQL against the running app) | [`verify-with-oql.md`](verify-with-oql.md), [`write-oql-queries.md`](write-oql-queries.md) | -| The exception that stopped an instance | [`analyze-runtime.md`](analyze-runtime.md) — `run --local` tees the runtime log to `/.mxcli/runtime.log` | -| `System.Workflow` / `System.WorkflowUserTask` / `System.WorkflowDefinition` shapes | [`system-module.md`](system-module.md) | -| Driving a task end to end and asserting the result | [`test-app.md`](test-app.md), [`run-local.md`](run-local.md) | -| Raw admin API, incl. `POST /dev/preview_execute_oql` | [`runtime-admin-api.md`](runtime-admin-api.md) | +| Live instances and open tasks (OQL against the running app) | [`verify-with-oql`](../verify-with-oql/SKILL.md), [`write-oql-queries`](../write-oql-queries/SKILL.md) | +| The exception that stopped an instance | [`analyze-runtime`](../analyze-runtime/SKILL.md) — `run --local` tees the runtime log to `/.mxcli/runtime.log` | +| `System.Workflow` / `System.WorkflowUserTask` / `System.WorkflowDefinition` shapes | [`system-module`](../system-module/SKILL.md) | +| Driving a task end to end and asserting the result | [`test-app`](../test-app/SKILL.md), [`run-local`](../run-local/SKILL.md) | +| Raw admin API, incl. `POST /dev/preview_execute_oql` | [`runtime-admin-api`](../runtime-admin-api/SKILL.md) | Two traps worth knowing before you start: diff --git a/.claude/skills/mendix/xpath-constraints.md b/.claude/skills/mendix/xpath-constraints/SKILL.md similarity index 97% rename from .claude/skills/mendix/xpath-constraints.md rename to .claude/skills/mendix/xpath-constraints/SKILL.md index f7274c29a..c15a48f89 100644 --- a/.claude/skills/mendix/xpath-constraints.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -1,3 +1,8 @@ +--- +name: xpath-constraints +description: "XPath constraint syntax for MDL — retrieve WHERE clauses, page data sources, and row-level entity access, including association paths and functions. Use when writing or debugging any XPath in a project." +--- + # XPath Constraints in MDL This skill provides reference for writing XPath constraint expressions in MDL RETRIEVE statements, page data sources, and security rules. @@ -253,7 +258,7 @@ Note the double single-quotes for escaping inside the string literal. Both forms are accepted by mxcli in the write direction. `DESCRIBE MICROFLOW` always shows the qualified name form for readability, even though BSON stores `'Open'`. -**Do NOT use qualified names in expression context (IF, SET, DECLARE) for comparisons** — those contexts use a different form. See `write-microflows.md` "Enumeration Comparisons" section. +**Do NOT use qualified names in expression context (IF, SET, DECLARE) for comparisons** — those contexts use a different form. See `write-microflows` "Enumeration Comparisons" section. ```mdl -- Preferred (mxcli converts to 'Open' in BSON): diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a8b049a..fc5f35428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +- **`@start(x, y)` positions a microflow's start event** (#951) — written on the first statement, the one the start flows into, because the start has no statement of its own; the same placement `@merge` uses for the other implicit node. It is optional: omitted, the start is derived one spacing unit left of the first activity on that activity's centre line, as before. `DESCRIBE` emits it only for a start that is *not* at that derived spot, so an ordinary description does not grow a line restating its own arithmetic while one carrying a hand-placed start still round-trips exactly. + +- **A rewritten microflow no longer strands its start event** (#951) — `CREATE OR MODIFY MICROFLOW` moved every activity to where the script asked and left the start where the *previous* layout had put it, joined to its own first activity by a long, mostly-empty diagonal across the canvas. Measured on a real project: activities at `360;340`, start at `40;200`. + + The cause was the fix for the opposite report. #884 was a describe→exec round-trip *moving* a hand-placed start (`145;200` came back as `100;200`), fixed by carrying the stored position over on every rewrite — which then pinned the start of every rewritten flow. Both reports are real, and neither is answerable without asking where the stored value came from. A start sitting at the derived spot is mxcli's own arithmetic handed back, carries no intent, and is now re-derived so it follows the activities; a start anywhere else was placed by a person and still survives. `@start(x, y)` states the position outright and beats both, which is the other half of what #951 reported — before it there was no way to move a start once one had been preserved. + + Neither `mx check` nor a successful build detects this, in either direction: the Mendix model carries no geometry rules, so a stranded start is a valid document that builds and runs and is merely drawn wrong — 0 errors on mxbuild 11.13.0 before and after. + ## [0.19.0] - 2026-08-21 Headline: **A statement mxcli accepts is now a statement mxcli honours.** This release closes a long list of clauses, annotations and document properties that parsed cleanly, reported success and were then dropped on the floor: a test's `@setup` and `@verify`, `DELETE_BEHAVIOR PREVENT`, a List View's specialization templates, a REST call's file-document response, a widget's `contentparams`, a workflow's boundary events, a decision's rule call. Alongside that, **rules** become the last microflow-family document type mxcli can both read and write, `mxcli check --references` **type-checks expressions** against the catalog, and skills can now ship **assets** — Java actions, Vega specs, MDL — rather than prose alone. diff --git a/CLAUDE.md b/CLAUDE.md index e731d1971..b9cc0ec0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -714,7 +714,7 @@ Both namespaces are discoverable by typing `/mxcli` in Claude Code. Add new cont ### mxcli init `mxcli init` creates a `.claude/` folder with skills, commands, CLAUDE.md, and VS Code MDL extension in a target Mendix project. Source of truth for synced assets: -- Skills: `.claude/skills/mendix/` — `make sync-skills` copies these to the `cmd/mxcli/skills/` embed dir (`//go:embed skills/*.md`), which `mxcli init` writes into the project. **Edit the `mendix/` source, not the embed dir** (it is regenerated). The top-level `.claude/skills/*.md` are contributor/dev skills and are **not** synced. +- Skills: `.claude/skills/mendix//SKILL.md` — directory-shaped, per the [Agent Skills](https://agentskills.io) standard, with `name` and `description` frontmatter. `make sync-skills` mirrors the tree into the `cmd/mxcli/skills/` embed dir (`//go:embed all:skills`), and `mxcli init` writes it into the project **twice**: `.ai-context/skills/` for every tool, and `.claude/skills/` — the only path Claude Code scans — when the project is set up for Claude. **Edit the `mendix/` source, not the embed dir** (it is regenerated, and the sync is `rsync --delete`). The `description` is the routing mechanism; the table in the generated CLAUDE.md is a shortcut, not the index. Upgrading a project retires the flat `.md` files older mxcli versions wrote, but never a skill the user added. The top-level `.claude/skills/*.md` are contributor/dev skills and are **not** synced. - Commands: `.claude/commands/mendix/` (the `mxcli-dev/` folder is **not** synced) - VS Code extension: `vscode-mdl/vscode-mdl-*.vsix` diff --git a/Makefile b/Makefile index 0a52fae45..04843449d 100644 --- a/Makefile +++ b/Makefile @@ -44,15 +44,13 @@ define copy-if-changed endef # Sync skills from .claude/skills/mendix to cmd/mxcli/skills for embedding +# Skills are directory-shaped (/SKILL.md, Agent Skills standard), so this +# mirrors a tree rather than copying a flat list. --delete matters: a renamed or +# removed skill must not linger in the embed dir, or the binary keeps shipping it. sync-skills: @mkdir -p cmd/mxcli/skills - @changed=0; for f in .claude/skills/mendix/*.md; do \ - dst="cmd/mxcli/skills/$$(basename $$f)"; \ - if [ ! -f "$$dst" ] || ! cmp -s "$$f" "$$dst"; then \ - cp "$$f" "$$dst"; changed=$$((changed + 1)); \ - fi; \ - done; \ - if [ $$changed -gt 0 ]; then echo "Synced $$changed skill file(s)"; fi + @rsync -a --delete --exclude='.DS_Store' .claude/skills/mendix/ cmd/mxcli/skills/ 2>/dev/null \ + || { rm -rf cmd/mxcli/skills && mkdir -p cmd/mxcli/skills && cp -R .claude/skills/mendix/. cmd/mxcli/skills/; } # Sync skill packs from .claude/skills/packs to cmd/mxcli/skillpacks for embedding. # Recursive, unlike sync-skills: a pack is a directory tree and flattening it @@ -160,7 +158,13 @@ release: clean grammar vscode-ext sync-all @ls -lh $(BUILD_DIR)/ # Run tests -test: grammar +# `sync-all` is not optional here. The embed dirs (cmd/mxcli/skills, skillpacks, +# commands, lint-rules) are GENERATED from .claude/, and several tests read them +# back. `make build` has always synced first; `make test` did not, so a checkout +# where the skills layout had changed under a bare `go build` failed six tests in +# cmd/mxcli with an error that pointed at the go:embed directive rather than at +# the missing build step (mxcli-formula1 finding 68). +test: grammar sync-all CGO_ENABLED=0 go test ./... # Dual-engine read-parity harness: run read queries through the legacy (sdk/mpr) diff --git a/README.md b/README.md index c3943d2f6..20bb39203 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,7 @@ mxcli add-tool cursor **Universal (all tools):** - `AGENTS.md` - Comprehensive guide for AI assistants -- `.ai-context/skills/` - MDL pattern guides (write-microflows.md, create-page.md, etc.) +- `.ai-context/skills/` - MDL pattern guides, one `/SKILL.md` each (write-microflows, create-page, ...) - `.ai-context/examples/` - Example MDL scripts **Tool-Specific:** diff --git a/cmd/mxcli/cmd_widget.go b/cmd/mxcli/cmd_widget.go index 0cd9c8601..57639a1ee 100644 --- a/cmd/mxcli/cmd_widget.go +++ b/cmd/mxcli/cmd_widget.go @@ -49,7 +49,7 @@ var widgetInitCmd = &cobra.Command{ Use: "init", Short: "Extract definitions for all project widgets", Long: `Scan the project's widgets/ directory, extract .def.json for each .mpk, -and generate skill documentation in .claude/skills/widgets/. +and generate a widget skill in .ai-context/skills/widgets/ and .claude/skills/widgets/. This enables CREATE PAGE to use any project widget via the pluggable engine. @@ -68,8 +68,12 @@ Requires --project (-p) to locate the project's widgets/ directory.`, var widgetDocsCmd = &cobra.Command{ Use: "docs", Short: "Generate widget skill documentation", - Long: `Generate per-widget markdown documentation in .claude/skills/widgets/ from .mpk definitions.`, - RunE: runWidgetDocs, + Long: "Generate a widget skill from the project's .mpk definitions.\n\n" + + "Writes SKILL.md — the index, with Agent Skills frontmatter naming the widgets\n" + + "found — plus one file per widget carrying its full property table, enumeration\n" + + "values, nested object properties, child slots and object lists. Written to\n" + + ".ai-context/skills/widgets/ and .claude/skills/widgets/, whichever exist.", + RunE: runWidgetDocs, } func init() { @@ -213,12 +217,9 @@ func generateWidgetDocs(projectPath string) error { return err } if generated > 0 { - projectDir := filepath.Dir(projectPath) - docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets") - if _, statErr := os.Stat(filepath.Join(projectDir, ".ai-context")); statErr == nil { - docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets") + for _, dir := range executor.WidgetDocsDirs(filepath.Dir(projectPath)) { + fmt.Printf("Generated %d widget docs in %s\n", generated, dir) } - fmt.Printf("Generated %d widget docs in %s\n", generated, docsDir) } return nil } diff --git a/cmd/mxcli/init.go b/cmd/mxcli/init.go index 9afcdbbce..77d4ff117 100644 --- a/cmd/mxcli/init.go +++ b/cmd/mxcli/init.go @@ -262,33 +262,21 @@ Container Runtime: fmt.Printf(" Created %s (System module excluded from lint)\n", filepath.Base(path)) } - // Write universal skills to .ai-context/skills/ - skillCount := 0 - err = fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - // Read from embedded FS - content, err := skillsFS.ReadFile(path) - if err != nil { - return err - } - // Write to target directory - targetPath := filepath.Join(skillsDir, d.Name()) - if err := os.WriteFile(targetPath, content, 0644); err != nil { - return err - } - skillCount++ - return nil - }) + // Write the skills. One code path with the SessionStart refresh, so a + // fresh init and an upgraded project end up with byte-identical trees — + // including the `.claude/skills/` copy Claude Code actually scans. + skillRes, err := syncAIContextSkills(absDir) if err != nil { fmt.Fprintf(os.Stderr, "Error writing skills: %v\n", err) os.Exit(1) } - fmt.Printf(" Created %d skill files in .ai-context/skills/\n", skillCount) + for _, dest := range skillDests(absDir) { + rel, relErr := filepath.Rel(absDir, dest) + if relErr != nil { + rel = dest + } + fmt.Printf(" Created %d skills in %s/\n", skillRes.Total, filepath.ToSlash(rel)) + } // Write tool-specific configurations for _, toolName := range tools { diff --git a/cmd/mxcli/init_claudemd.go b/cmd/mxcli/init_claudemd.go index e29ce53d8..256e72b45 100644 --- a/cmd/mxcli/init_claudemd.go +++ b/cmd/mxcli/init_claudemd.go @@ -112,20 +112,24 @@ func generateClaudeMD(projectName, mprFile string) string { // ── IMPORTANT: Before Writing MDL ─────────────────────────────── w("## IMPORTANT: Before Writing MDL Scripts or Working with Data\n\n") w("**Read the relevant skill files FIRST before writing any MDL, seeding data, or doing database/import work:**\n\n") + w("Every skill is a " + bt + "/SKILL.md" + bt + " directory with a " + bt + "description" + bt + " in its frontmatter (the\n") + w("[Agent Skills](https://agentskills.io) standard), so a tool that reads them announces the whole set on\n") + w("its own. The table below is a shortcut to the ones worth reading before you start, **not** the index —\n") + w("list " + bt + ".ai-context/skills/" + bt + " for everything available.\n\n") w("| Skill File | When to Read |\n") w("|------------|-------------|\n") - w("| " + bt + ".ai-context/skills/write-microflows.md" + bt + " | **Before writing any microflow** - syntax, common mistakes, validation checklist |\n") - w("| " + bt + ".ai-context/skills/create-page.md" + bt + " | **Before creating any page** - widget syntax reference |\n") - w("| " + bt + ".ai-context/skills/alter-page.md" + bt + " | **Before modifying pages** - ALTER PAGE/SNIPPET SET, INSERT, DROP, REPLACE |\n") - w("| " + bt + ".ai-context/skills/overview-pages.md" + bt + " | CRUD page patterns (overview + edit) |\n") - w("| " + bt + ".ai-context/skills/master-detail-pages.md" + bt + " | Master-detail page patterns |\n") - w("| " + bt + ".ai-context/skills/generate-domain-model.md" + bt + " | Entity, association, enumeration syntax |\n") - w("| " + bt + ".ai-context/skills/organize-project.md" + bt + " | Folders, MOVE command, project structure |\n") - w("| " + bt + ".ai-context/skills/manage-security.md" + bt + " | Security roles, GRANT/REVOKE, access control |\n") - w("| " + bt + ".ai-context/skills/manage-navigation.md" + bt + " | Navigation profiles, menus, home/login pages |\n") - w("| " + bt + ".ai-context/skills/check-syntax.md" + bt + " | **Pre-flight** validation checklist |\n") - w("| " + bt + ".ai-context/skills/demo-data.md" + bt + " | **READ for any database/import work** - Mendix ID system, demo data |\n") - w("| " + bt + ".ai-context/skills/test-microflows.md" + bt + " | **READ for testing** - test annotations, file formats, Docker setup |\n") + w("| " + bt + ".ai-context/skills/write-microflows/SKILL.md" + bt + " | **Before writing any microflow** - syntax, common mistakes, validation checklist |\n") + w("| " + bt + ".ai-context/skills/create-page/SKILL.md" + bt + " | **Before creating any page** - widget syntax reference |\n") + w("| " + bt + ".ai-context/skills/alter-page/SKILL.md" + bt + " | **Before modifying pages** - ALTER PAGE/SNIPPET SET, INSERT, DROP, REPLACE |\n") + w("| " + bt + ".ai-context/skills/overview-pages/SKILL.md" + bt + " | CRUD page patterns (overview + edit) |\n") + w("| " + bt + ".ai-context/skills/master-detail-pages/SKILL.md" + bt + " | Master-detail page patterns |\n") + w("| " + bt + ".ai-context/skills/generate-domain-model/SKILL.md" + bt + " | Entity, association, enumeration syntax |\n") + w("| " + bt + ".ai-context/skills/organize-project/SKILL.md" + bt + " | Folders, MOVE command, project structure |\n") + w("| " + bt + ".ai-context/skills/manage-security/SKILL.md" + bt + " | Security roles, GRANT/REVOKE, access control |\n") + w("| " + bt + ".ai-context/skills/manage-navigation/SKILL.md" + bt + " | Navigation profiles, menus, home/login pages |\n") + w("| " + bt + ".ai-context/skills/check-syntax/SKILL.md" + bt + " | **Pre-flight** validation checklist |\n") + w("| " + bt + ".ai-context/skills/demo-data/SKILL.md" + bt + " | **READ for any database/import work** - Mendix ID system, demo data |\n") + w("| " + bt + ".ai-context/skills/test-microflows/SKILL.md" + bt + " | **READ for testing** - test annotations, file formats, Docker setup |\n") w("\n") w("**Always validate before presenting to user:**\n\n") w(bt3 + "bash\n") @@ -410,7 +414,9 @@ func generateClaudeMD(projectName, mprFile string) string { // ── Skills Reference ──────────────────────────────────────────── w("## Skills Reference\n\n") - w("Skills are in " + bt + ".ai-context/skills/" + bt + ". Read the relevant skill before starting work.\n\n") + w("Skills are in " + bt + ".ai-context/skills//SKILL.md" + bt + " (and " + bt + ".claude/skills/" + bt + " for Claude\n") + w("Code, which discovers them from there). Each one's frontmatter says what it covers and when to reach\n") + w("for it. Read the relevant skill before starting work.\n\n") w("### Quick Reference\n\n") w("| Skill | Purpose |\n") @@ -633,7 +639,7 @@ func generateClaudeMD(projectName, mprFile string) string { w(bt3 + "\n\n") w("## MDL Reference\n\n") - w("For detailed MDL syntax, see the skill files in " + bt + ".ai-context/skills/" + bt + ".\n") + w("For detailed MDL syntax, see the skill files in " + bt + ".ai-context/skills//SKILL.md" + bt + ".\n") return sb.String() } diff --git a/cmd/mxcli/init_skills_references_test.go b/cmd/mxcli/init_skills_references_test.go new file mode 100644 index 000000000..412f516e6 --- /dev/null +++ b/cmd/mxcli/init_skills_references_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io/fs" + "path" + "strings" + "testing" +) + +// A supporting file is inert unless SKILL.md links it: Claude loads the body, and +// only follows a link it can see. The Agent Skills docs are explicit — "Reference +// supporting files from SKILL.md so Claude knows what each file contains and when +// to load it" — so an unlinked reference file is dead weight that ships with every +// project and is never read. +// +// This is the one property the split of the six largest skills depends on. It +// cannot be checked by reading a single file, which is why it gets a test rather +// than a convention. +func TestEverySupportingFileIsLinkedFromItsSkill(t *testing.T) { + names := embeddedSkillNames(t) + + for _, skill := range names { + body, err := skillsFS.ReadFile("skills/" + skill + "/SKILL.md") + if err != nil { + t.Errorf("%s: %v", skill, err) + continue + } + text := string(body) + + var supporting []string + err = fs.WalkDir(skillsFS, "skills/"+skill, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || path.Base(p) == "SKILL.md" { + return err + } + if strings.HasSuffix(p, ".md") { + rel := strings.TrimPrefix(p, "skills/"+skill+"/") + supporting = append(supporting, rel) + } + return nil + }) + if err != nil { + t.Errorf("%s: walking: %v", skill, err) + continue + } + + for _, rel := range supporting { + if !strings.Contains(text, "("+rel+")") { + t.Errorf("%s/SKILL.md does not link %s — nothing will ever open it", skill, rel) + } + } + } +} + +// Splitting is only worth doing if the body actually got smaller. These six were +// 811–1906 lines, well past the documented "keep SKILL.md under 500 lines", and +// they are the ones agents load most often. The bound is deliberately loose: a +// body that is all essential beats one that hit a number by dropping something, +// and the heading-preservation check lives in the split itself. +func TestLargeSkillsWereSplit(t *testing.T) { + const bound = 700 + + for _, skill := range embeddedSkillNames(t) { + body, err := skillsFS.ReadFile("skills/" + skill + "/SKILL.md") + if err != nil { + t.Errorf("%s: %v", skill, err) + continue + } + if n := strings.Count(string(body), "\n"); n > bound { + t.Errorf("%s/SKILL.md is %d lines (> %d) and has no supporting files to move detail into; "+ + "a body this long is loaded whole every time the skill is used", skill, n, bound) + } + } +} diff --git a/cmd/mxcli/init_skills_standard_test.go b/cmd/mxcli/init_skills_standard_test.go new file mode 100644 index 000000000..cdd62665e --- /dev/null +++ b/cmd/mxcli/init_skills_standard_test.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Issue #906: the bundled skills were flat `.md` files with no frontmatter, +// routed by a hand-maintained table in the generated CLAUDE.md that had drifted +// to 12 of 68. Nothing discovered them — `mxcli init` did not even create +// `.claude/skills/`, the one directory Claude Code scans. These tests pin the +// three properties that fix depends on. + +var frontmatter = regexp.MustCompile(`(?s)\A---\n(.*?)\n---\n`) + +// Every shipped skill is a `/SKILL.md` carrying `name` and `description`. +// A skill without a description is no more discoverable than the flat file it +// replaced: the description IS the index now. +func TestEmbeddedSkillsCarryAgentSkillsFrontmatter(t *testing.T) { + names := embeddedSkillNames(t) + if len(names) < 50 { + t.Fatalf("only %d skills embedded; the sync or the embed directive is wrong", len(names)) + } + + for _, n := range names { + body, err := skillsFS.ReadFile("skills/" + n + "/SKILL.md") + if err != nil { + t.Errorf("%s: %v", n, err) + continue + } + m := frontmatter.FindSubmatch(body) + if m == nil { + t.Errorf("%s/SKILL.md has no YAML frontmatter", n) + continue + } + fm := string(m[1]) + + // A stray SECOND frontmatter block is invisible to every check below: + // FindSubmatch matches the first one, and that one is correct. It is + // exactly what a rename-and-prepend leaves behind — the #906 migration + // prepended a block to `custom-widgets`, which already had one, so the + // skill shipped opening with a duplicate `name` and a `---` fence + // rendered as body. Nothing in the suite could see it + // (mxcli-formula1 finding 68). + if body := string(body[len(m[0]):]); strings.HasPrefix(strings.TrimLeft(body, "\n"), "---\n") { + t.Errorf("%s/SKILL.md has a second frontmatter block; everything after the first `---` "+ + "is body, so it renders as a stray fence and a duplicate name", n) + } + + var gotName, gotDesc string + for _, line := range strings.Split(fm, "\n") { + if v, ok := strings.CutPrefix(line, "name: "); ok { + gotName = strings.TrimSpace(v) + } + if v, ok := strings.CutPrefix(line, "description: "); ok { + gotDesc = strings.Trim(strings.TrimSpace(v), `"`) + } + } + if gotName != n { + t.Errorf("%s: frontmatter name is %q; it must match the directory or the skill is addressed by two names", n, gotName) + } + switch { + case gotDesc == "": + t.Errorf("%s: no description — nothing can decide when to invoke it", n) + case len(gotDesc) < 40: + t.Errorf("%s: description is %d chars; too short to say when to use it: %q", n, len(gotDesc), gotDesc) + case len(gotDesc) > 600: + t.Errorf("%s: description is %d chars; skill listings are truncated well before that", n, len(gotDesc)) + } + } +} + +// The point of the migration: a Claude project gets `.claude/skills//SKILL.md`, +// which is the only path Claude Code scans. Before this, `mxcli init` created +// `.claude/commands/`, `.claude/lint-rules/` and no skills directory at all. +func TestSyncWritesTheDirectoryClaudeCodeScans(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := syncAIContextSkills(dir); err != nil { + t.Fatal(err) + } + + names := embeddedSkillNames(t) + for _, base := range []string{".ai-context", ".claude"} { + p := filepath.Join(dir, base, "skills", names[0], "SKILL.md") + if _, err := os.Stat(p); err != nil { + t.Errorf("%s not written: %v", p, err) + } + } + + // Control: without `.claude/`, the Claude copy is not created — the project + // was not set up for it, and mxcli should not invent the directory. + bare := t.TempDir() + if _, err := syncAIContextSkills(bare); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(bare, ".claude", "skills")); err == nil { + t.Error(".claude/skills/ was created in a project that has no .claude/") + } +} + +// Upgrading a project written by an older mxcli must retire the flat files it +// used to own — otherwise 67 orphans sit beside the new tree, and an agent +// reading the directory cannot tell which copy is current. A file mxcli never +// wrote is left alone: the docs invite users to add their own skills here. +func TestSyncRetiresLegacyFlatSkillsButKeepsUserFiles(t *testing.T) { + dir := t.TempDir() + skillsDir := filepath.Join(dir, ".ai-context", "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatal(err) + } + + names := embeddedSkillNames(t) + legacy := filepath.Join(skillsDir, names[0]+".md") + if err := os.WriteFile(legacy, []byte("# shipped by an older mxcli\n"), 0o644); err != nil { + t.Fatal(err) + } + mine := filepath.Join(skillsDir, "our-house-conventions.md") + if err := os.WriteFile(mine, []byte("# ours\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := syncAIContextSkills(dir) + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("%s survived the upgrade; it now contradicts %s/SKILL.md", filepath.Base(legacy), names[0]) + } + if !containsString(res.Removed, names[0]+".md") { + t.Errorf("the retirement was not reported: %v", res.Removed) + } + if _, err := os.Stat(mine); err != nil { + t.Error("a user's own skill file was deleted; only names mxcli ships may be retired") + } +} + +func containsString(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +} diff --git a/cmd/mxcli/init_skills_sync.go b/cmd/mxcli/init_skills_sync.go index bdf188396..45d7b145d 100644 --- a/cmd/mxcli/init_skills_sync.go +++ b/cmd/mxcli/init_skills_sync.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strings" ) // init_skills_sync.go keeps a project's .ai-context/skills/ in step with the @@ -31,50 +32,150 @@ import ( type skillSyncResult struct { Total int // skills the binary carries Changed []string // names whose on-disk content differed (added or updated) + Removed []string // legacy flat files retired by the directory layout } // Stale reports whether anything on disk disagreed with the binary. -func (r skillSyncResult) Stale() bool { return len(r.Changed) > 0 } +func (r skillSyncResult) Stale() bool { return len(r.Changed) > 0 || len(r.Removed) > 0 } -// syncAIContextSkills rewrites /.ai-context/skills/ from the binary's -// embedded copies, reporting which files differed. Writing only the files that -// changed keeps mtimes meaningful, so "when did this guidance last move" stays +// skillDests returns every directory a project's skills are mirrored into. +// +// `.ai-context/skills/` is the vendor-neutral home and is always written. +// `.claude/skills/` is written too when the project is set up for Claude Code, +// because that is the only path it scans for skills — one level deep, `/ +// SKILL.md`. Before this existed a project carried 68 skills that Claude Code +// could not see at all, and routing depended entirely on a hand-maintained +// table in CLAUDE.md (issue #906). +// +// Presence of `.claude/` is the signal rather than a flag, so the SessionStart +// refresh — which has no memory of which tools were chosen at init — keeps both +// copies current without being told. +func skillDests(projectDir string) []string { + dests := []string{filepath.Join(projectDir, ".ai-context", "skills")} + if st, err := os.Stat(filepath.Join(projectDir, ".claude")); err == nil && st.IsDir() { + dests = append(dests, filepath.Join(projectDir, ".claude", "skills")) + } + return dests +} + +// syncAIContextSkills rewrites a project's skill directories from the binary's +// embedded copies, reporting what differed. Writing only the files that changed +// keeps mtimes meaningful, so "when did this guidance last move" stays // answerable from the filesystem. func syncAIContextSkills(projectDir string) (skillSyncResult, error) { var res skillSyncResult - skillsDir := filepath.Join(projectDir, ".ai-context", "skills") - - entries, err := fs.ReadDir(skillsFS, "skills") - if err != nil { - return res, fmt.Errorf("reading embedded skills: %w", err) + seen := map[string]bool{} + for i, dest := range skillDests(projectDir) { + one, err := syncSkillTree(dest) + if err != nil { + return res, err + } + if i == 0 { + res.Total = one.Total + } + for _, n := range one.Changed { + if !seen["c:"+n] { + seen["c:"+n] = true + res.Changed = append(res.Changed, n) + } + } + for _, n := range one.Removed { + if !seen["r:"+n] { + seen["r:"+n] = true + res.Removed = append(res.Removed, n) + } + } } + sort.Strings(res.Changed) + sort.Strings(res.Removed) + return res, nil +} + +// syncSkillTree mirrors the embedded skills into one destination directory. +func syncSkillTree(skillsDir string) (skillSyncResult, error) { + var res skillSyncResult + if err := os.MkdirAll(skillsDir, 0o755); err != nil { return res, fmt.Errorf("creating %s: %w", skillsDir, err) } - for _, e := range entries { - if e.IsDir() { - continue + live := map[string]bool{} + err := fs.WalkDir(skillsFS, "skills", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel("skills", filepath.FromSlash(p)) + if relErr != nil || rel == "." { + return relErr + } + target := filepath.Join(skillsDir, rel) + if d.IsDir() { + live[rel] = true + return os.MkdirAll(target, 0o755) } - want, err := skillsFS.ReadFile("skills/" + e.Name()) + want, err := skillsFS.ReadFile(p) if err != nil { - return res, fmt.Errorf("reading embedded skill %s: %w", e.Name(), err) + return fmt.Errorf("reading embedded skill %s: %w", p, err) + } + live[rel] = true + if filepath.Base(rel) == "SKILL.md" { + res.Total++ } - res.Total++ - - target := filepath.Join(skillsDir, e.Name()) if have, readErr := os.ReadFile(target); readErr == nil && bytes.Equal(have, want) { - continue + return nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err } if err := os.WriteFile(target, want, 0o644); err != nil { - return res, fmt.Errorf("writing %s: %w", target, err) + return fmt.Errorf("writing %s: %w", target, err) } - res.Changed = append(res.Changed, e.Name()) + res.Changed = append(res.Changed, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return res, err } + + res.Removed = retireFlatSkills(skillsDir, live) sort.Strings(res.Changed) + sort.Strings(res.Removed) return res, nil } +// retireFlatSkills deletes the pre-#906 flat `.md` files this binary used +// to write, once `/SKILL.md` has replaced them. +// +// The sync has never deleted anything, which was right while the layout was +// stable and is exactly wrong across a layout change: without this, every +// project upgraded from an older mxcli keeps 67 orphaned files that no longer +// match the shipped guidance, sitting beside the ones that do. An agent reading +// the directory cannot tell which is current. +// +// Only names the binary itself used to own are removed, and only when the +// replacement directory is present, so a user's own skill file in the same +// directory — which the docs explicitly invite — is never touched. +func retireFlatSkills(skillsDir string, live map[string]bool) []string { + entries, err := os.ReadDir(skillsDir) + if err != nil { + return nil + } + var removed []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + name := strings.TrimSuffix(e.Name(), ".md") + if !live[filepath.Join(name, "SKILL.md")] { + continue // not a skill this binary ships — leave it alone + } + if err := os.Remove(filepath.Join(skillsDir, e.Name())); err == nil { + removed = append(removed, e.Name()) + } + } + return removed +} + // reportSkillSync prints a one-line summary, and nothing at all when the project // was already current — this runs on every session start, so silence is the // common case and the only acceptable one. @@ -82,6 +183,24 @@ func reportSkillSync(w io.Writer, res skillSyncResult) { if !res.Stale() { return } - fmt.Fprintf(w, "Refreshed %d of %d skill file(s) in .ai-context/skills/ to match this mxcli: %v\n", - len(res.Changed), res.Total, res.Changed) + if len(res.Changed) > 0 { + fmt.Fprintf(w, "Refreshed %d file(s) across %d skills to match this mxcli: %s\n", + len(res.Changed), res.Total, abridge(res.Changed)) + } + if len(res.Removed) > 0 { + fmt.Fprintf(w, "Retired %d file(s) superseded by the /SKILL.md layout: %s\n", + len(res.Removed), abridge(res.Removed)) + } +} + +// abridge renders a file list without turning a first-run or post-upgrade +// refresh into a wall of 68 names. The count above it is the number that +// matters; the names are there to answer "which one moved" on the ordinary +// one-or-two-file refresh. +func abridge(names []string) string { + const max = 6 + if len(names) <= max { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s and %d more", strings.Join(names[:max], ", "), len(names)-max) } diff --git a/cmd/mxcli/init_skills_sync_test.go b/cmd/mxcli/init_skills_sync_test.go index d53ffb1f9..d47fddb7b 100644 --- a/cmd/mxcli/init_skills_sync_test.go +++ b/cmd/mxcli/init_skills_sync_test.go @@ -11,7 +11,8 @@ import ( "testing" ) -// embeddedSkillNames returns the skills this binary carries. +// embeddedSkillNames returns the skills this binary carries. Skills are +// directory-shaped (`/SKILL.md`), so a name is a directory, not a file. func embeddedSkillNames(t *testing.T) []string { t.Helper() entries, err := fs.ReadDir(skillsFS, "skills") @@ -21,15 +22,29 @@ func embeddedSkillNames(t *testing.T) []string { var names []string for _, e := range entries { if !e.IsDir() { - names = append(names, e.Name()) + continue } + if _, err := skillsFS.ReadFile("skills/" + e.Name() + "/SKILL.md"); err != nil { + t.Errorf("%s has no SKILL.md; it will not be discovered", e.Name()) + continue + } + names = append(names, e.Name()) } if len(names) == 0 { - t.Fatal("no embedded skills; the embed directive is broken") + // Naming the go:embed line here sent a reviewer hunting a directive that + // was fine: `cmd/mxcli/skills` is GENERATED, and a bare `go build` after + // the layout changed leaves it stale or empty. Name the build step + // instead (mxcli-formula1 finding 68). + t.Fatal("no embedded skills: cmd/mxcli/skills is empty or stale. " + + "It is generated from .claude/skills/mendix/ — run `make sync-skills` " + + "(or `make build`, which does it) before `go test`.") } return names } +// skillPath is a skill's file, relative to a skills directory. +func skillPath(name string) string { return filepath.Join(name, "SKILL.md") } + // mxcli-formula1 §16: a project initialised on Monday still served Monday's // skills from Tuesday's binary — the files are written once by `mxcli init` and // nothing re-wrote them on upgrade. Stale guidance, no warning. @@ -43,7 +58,10 @@ func TestSyncAIContextSkills_RefreshesStaleGuidance(t *testing.T) { if err := os.MkdirAll(skillsDir, 0o755); err != nil { t.Fatal(err) } - stale := filepath.Join(skillsDir, names[0]) + if err := os.MkdirAll(filepath.Join(skillsDir, names[0]), 0o755); err != nil { + t.Fatal(err) + } + stale := filepath.Join(skillsDir, skillPath(names[0])) if err := os.WriteFile(stale, []byte("# guidance from an older mxcli\n"), 0o644); err != nil { t.Fatal(err) } @@ -61,11 +79,11 @@ func TestSyncAIContextSkills_RefreshesStaleGuidance(t *testing.T) { // Every embedded skill now matches the binary, not just the one that existed. for _, n := range names { - want, err := skillsFS.ReadFile("skills/" + n) + want, err := skillsFS.ReadFile("skills/" + n + "/SKILL.md") if err != nil { t.Fatal(err) } - got, err := os.ReadFile(filepath.Join(skillsDir, n)) + got, err := os.ReadFile(filepath.Join(skillsDir, skillPath(n))) if err != nil { t.Fatalf("%s missing after sync: %v", n, err) } @@ -76,8 +94,8 @@ func TestSyncAIContextSkills_RefreshesStaleGuidance(t *testing.T) { var out bytes.Buffer reportSkillSync(&out, res) - if !strings.Contains(out.String(), names[0]) { - t.Errorf("the refresh should name what it changed:\n%s", out.String()) + if !strings.Contains(out.String(), "Refreshed") { + t.Errorf("the refresh should report what it changed:\n%s", out.String()) } } @@ -91,12 +109,12 @@ func TestSyncAIContextSkills_CurrentProjectIsSilentAndUntouched(t *testing.T) { if err != nil { t.Fatalf("first sync failed: %v", err) } - if len(first.Changed) != first.Total { - t.Fatalf("a fresh project should write every skill: %d of %d", len(first.Changed), first.Total) + if len(first.Changed) < first.Total { + t.Fatalf("a fresh project should write every skill: %d files for %d skills", len(first.Changed), first.Total) } skillsDir := filepath.Join(dir, ".ai-context", "skills") - probe := filepath.Join(skillsDir, first.Changed[0]) + probe := filepath.Join(skillsDir, filepath.FromSlash(first.Changed[0])) before, err := os.Stat(probe) if err != nil { t.Fatal(err) diff --git a/cmd/mxcli/skills_content.go b/cmd/mxcli/skills_content.go index 7751e51dd..f1ac90347 100644 --- a/cmd/mxcli/skills_content.go +++ b/cmd/mxcli/skills_content.go @@ -16,9 +16,14 @@ import ( "embed" ) -// Embed all skill files from the synced directory +// Embed all skill files from the synced directory. // -//go:embed skills/*.md +// Skills are directory-shaped — `/SKILL.md` per the Agent Skills standard +// — so this embeds a tree, and `all:` is required for the same reason it is on +// skillpacks below: a plain go:embed of a directory skips `_`- and `.`-prefixed +// files, and a skill may carry references or assets beside its SKILL.md. +// +//go:embed all:skills var skillsFS embed.FS // Embed skill packs from the synced directory — skills that carry assets, not diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index e8360a840..6d7908750 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -239,12 +239,13 @@ func init() { Register(SyntaxFeature{ Path: "microflow.layout", - Summary: "Canvas layout annotations — @position, @anchor, @curve, @caption, @color", + Summary: "Canvas layout annotations — @position, @start, @anchor, @curve, @caption, @color", Keywords: []string{ - "position", "anchor", "curve", "layout", "canvas", + "position", "start", "anchor", "curve", "layout", "canvas", "annotation", "caption", "color", "excluded", "bezier", }, Syntax: "@position(x, y) -- the activity's centre point\n" + + "@start(x, y) -- the start event, on the FIRST statement\n" + "@anchor(from: right, to: left) -- which SIDE each end of the outgoing flow attaches to\n" + "@curve(from: (40, -90), to: (-40, 90)) -- the flow's bezier control vectors\n" + "@merge(x, y) -- the implicit merge that closes a split\n" + @@ -254,9 +255,15 @@ func init() { "Mendix stores no waypoints — a flow's shape is two control vectors, each a\n" + "pixel offset from its end of the line. (0, 0) at both ends is straight.\n" + "@position on a split belongs to the SPLIT, so its end-if join has its own\n" + - "annotation. Container Size is still computed, not authorable.", + "annotation. Container Size is still computed, not authorable.\n\n" + + "@start and @merge position the two nodes that have no statement of their\n" + + "own, so each is written on the statement it belongs to. Omit @start and the\n" + + "start is placed one spacing unit left of the first activity, on its centre\n" + + "line — and a rewrite MOVES it to follow the activities. A start that is not\n" + + "at that derived spot was put there by hand: it survives a rewrite, and\n" + + "DESCRIBE emits @start for it so the description round-trips exactly.", Example: "create microflow MyModule.ACT_Flow ($In: String)\nreturns String as $Out\nbegin\n" + - " @position(200, 100)\n @anchor(from: bottom, to: top)\n" + + " @start(145, 100)\n @position(200, 100)\n @anchor(from: bottom, to: top)\n" + " @curve(from: (40, -90), to: (-40, 90))\n declare $Tmp String = $In;\n" + " @position(200, 300)\n declare $Out String = $Tmp;\n return $Out;\nend;", SeeAlso: []string{"microflow", "microflow.create"}, diff --git a/cmd/mxcli/tool_templates.go b/cmd/mxcli/tool_templates.go index 871335075..71f2ef4ce 100644 --- a/cmd/mxcli/tool_templates.go +++ b/cmd/mxcli/tool_templates.go @@ -186,7 +186,7 @@ See AGENTS.md for complete documentation and .ai-context/skills/ for patterns. ## Before Writing MDL -1. Read relevant skill file: .ai-context/skills/write-microflows.md or create-page.md +1. Read relevant skill file: .ai-context/skills/write-microflows/SKILL.md or create-page/SKILL.md 2. Validate: ./mxcli check script.mdl -p %s --references 3. Execute: ./mxcli exec script.mdl -p %s `, projectName, mprFile, mprFile, mprFile, mprFile, mprFile, mprFile, mprFile, mprFile) @@ -237,12 +237,12 @@ func generateContinueConfig(projectName, mprPath string) string { { "name": "mdl-syntax", "description": "Show MDL syntax reference", - "prompt": "Read and summarize: .ai-context/skills/write-microflows.md" + "prompt": "Read and summarize: .ai-context/skills/write-microflows/SKILL.md" }, { "name": "page-syntax", "description": "Show page creation syntax", - "prompt": "Read and summarize: .ai-context/skills/create-page.md" + "prompt": "Read and summarize: .ai-context/skills/create-page/SKILL.md" } ] } @@ -257,7 +257,7 @@ func generateAiderConfig(projectName, mprPath string) string { # Files to read for context read-files: - AGENTS.md - - .ai-context/skills/*.md + - .ai-context/skills/*/SKILL.md # Project description description: | @@ -442,7 +442,7 @@ system_prompt_id = "mendix-mdl" # Skills from .vibe/skills/ are auto-discovered # Additional context files -# skill_paths = [".ai-context/skills"] +# skill_paths = [".ai-context/skills"] # /SKILL.md per skill # Tool permissions for MDL workflow [tools.bash] @@ -564,7 +564,7 @@ func generateOpenCodeConfig(projectName, mprPath string) string { "instructions": [ "AGENTS.md", ".opencode/skills/**/SKILL.md", - ".ai-context/skills/*.md" + ".ai-context/skills/*/SKILL.md" ] } ` diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index a080c5712..0002239e1 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -161,6 +161,7 @@ AUTHENTICATION Basic, Session | Validation | `VALIDATION FEEDBACK $Entity/Attribute MESSAGE 'message';` | Requires attribute path + MESSAGE | | Log | `LOG INFO\|WARNING\|ERROR [NODE 'name'] 'message';` | | | Position | `@position(x, y)` | Canvas position (before activity) | +| Start event | `@start(x, y)` | Canvas position of the start, on the **first** statement. Omit it and the start is placed one spacing unit left of the first activity and MOVES with it on a rewrite; a start that is not at that derived spot is treated as hand-placed, survives a rewrite, and is emitted by DESCRIBE (#951) | | Caption | `@caption 'text'` | Custom caption (before activity) | | Color | `@color Green` | Background color (before activity) | | Annotation | `@annotation 'text'` | Visual note attached to next activity | diff --git a/docs-site/src/language/microflow-structure.md b/docs-site/src/language/microflow-structure.md index 56709aacc..722ba35fa 100644 --- a/docs-site/src/language/microflow-structure.md +++ b/docs-site/src/language/microflow-structure.md @@ -138,6 +138,24 @@ Set the canvas position of the next activity: $Order = CREATE Sales.Order (Status = 'New'); ``` +### Start event + +The start event has no statement of its own, so `@start` goes on the **first** +statement — the one the start flows into: + +```sql +@start(145, 200) +@position(260, 200) +$Order = CREATE Sales.Order (Status = 'New'); +``` + +It is optional. Omitted, the start is placed one spacing unit left of the first +activity on that activity's centre line, and a rewrite re-derives it so the start +follows the activities when they move. A start that is *not* at that derived spot +was placed on purpose — in Studio Pro or with `@start` — so it survives a rewrite +that does not mention it, and `DESCRIBE` emits an `@start` line for it. An +explicit `@start` overrides both. + ### Caption Set a custom caption displayed on the activity in the canvas: diff --git a/docs-site/src/reference/microflow/create-microflow.md b/docs-site/src/reference/microflow/create-microflow.md index a7b64d3d8..19b7bef4d 100644 --- a/docs-site/src/reference/microflow/create-microflow.md +++ b/docs-site/src/reference/microflow/create-microflow.md @@ -148,11 +148,23 @@ Annotations are placed before an activity to control visual appearance in the mi ```sql @position(x, y) -- Canvas position +@start(x, y) -- Canvas position of the start event (first statement only) @caption 'text' -- Custom caption @color Green -- Background color @annotation 'text' -- Visual note attached to next activity ``` +`@start` positions the start event, which has no statement of its own, so it is +written on the first statement — the one the start flows into. It is optional: +omit it and the start is placed one spacing unit left of the first activity, on +that activity's centre line, and a later rewrite re-derives it so it follows the +activities when they move. + +A start that is *not* at that derived spot — one dragged somewhere in Studio Pro, +or written with `@start` — is treated as placed on purpose. It survives a rewrite +that does not mention it, and `DESCRIBE` emits an `@start` line for it so the +description reproduces the flow exactly. An explicit `@start` overrides both. + ## Parameters `module.Name` diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index a7bdc48e0..2d6a85736 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -37,7 +37,7 @@ This is an empty repo. Provision it as a Mendix app developed with mxcli. ./mxcli init --sync-skills ``` -3. Read `.ai-context/skills/bootstrap-app.md` and follow it end to end. It begins by +3. Read `.ai-context/skills/bootstrap-app/SKILL.md` and follow it end to end. It begins by interviewing me about the app, so ask me those questions and wait for my answers before running anything else. diff --git a/docs-site/src/tutorial/claude-code.md b/docs-site/src/tutorial/claude-code.md index 7d8f9cf2f..be9e26491 100644 --- a/docs-site/src/tutorial/claude-code.md +++ b/docs-site/src/tutorial/claude-code.md @@ -44,7 +44,7 @@ The `CLAUDE.md` file gives Claude Code project-level context. It describes what ### Skills -The `.claude/skills/` directory (and `.ai-context/skills/` for the universal copy) contains markdown files that teach Claude specific MDL patterns. For example, `write-microflows.md` explains microflow syntax, common mistakes, and a validation checklist. Claude reads the relevant skill before generating MDL, which dramatically improves output quality. +The `.claude/skills/` directory (and `.ai-context/skills/` for the universal copy) holds one directory per skill, each with a `SKILL.md`. For example, `write-microflows/SKILL.md` explains microflow syntax, common mistakes, and a validation checklist. Claude Code discovers them from `.claude/skills/` automatically and reads the relevant one before generating MDL, which dramatically improves output quality. ### Commands diff --git a/docs-site/src/tutorial/opencode.md b/docs-site/src/tutorial/opencode.md index 20d941179..2f15c2fda 100644 --- a/docs-site/src/tutorial/opencode.md +++ b/docs-site/src/tutorial/opencode.md @@ -54,7 +54,7 @@ The `opencode.json` file is OpenCode's primary configuration. It points to `AGEN "instructions": [ "AGENTS.md", ".opencode/skills/**/SKILL.md", - ".ai-context/skills/*.md" + ".ai-context/skills/*/SKILL.md" ] } ``` diff --git a/docs-site/src/tutorial/skills.md b/docs-site/src/tutorial/skills.md index bc67db5d0..379c79f59 100644 --- a/docs-site/src/tutorial/skills.md +++ b/docs-site/src/tutorial/skills.md @@ -1,17 +1,53 @@ # Skills and CLAUDE.md -Skills are markdown files that teach AI assistants how to write correct MDL. Each skill covers a specific topic -- creating pages, writing microflows, managing security -- and contains syntax references, examples, and validation checklists. When an AI assistant needs to generate MDL, it reads the relevant skill first, which dramatically improves output quality. +Skills teach AI assistants how to write correct MDL. Each skill covers a specific topic -- creating pages, writing microflows, managing security -- and contains syntax references, examples, and validation checklists. When an AI assistant needs to generate MDL, it reads the relevant skill first, which dramatically improves output quality. + +## The format + +Skills follow the [Agent Skills](https://agentskills.io) standard: each one is a +directory holding a `SKILL.md`, whose YAML frontmatter says what it covers and +when to use it. + +``` +.ai-context/skills/ +└── write-microflows/ + └── SKILL.md +``` + +```markdown +--- +name: write-microflows +description: Microflow syntax reference in MDL -- every activity type, control flow, + expressions, and the mistakes that fail `mxcli check`. Use before writing any + CREATE MICROFLOW, and when debugging a microflow syntax error. +--- + +# Mendix Microflow Skill +... +``` + +The `description` is what makes a skill findable. A tool that reads skills loads +every description up front -- a line or two each -- and pulls in the full body +only when one is actually needed. So the assistant knows the whole set exists +without any of them costing context until used, and without a hand-maintained +index that can fall out of step. ## Where skills live -Skills are installed in two locations: +`mxcli init` installs the same set in two places: | Location | Used by | |----------|---------| -| `.claude/skills/` | Claude Code (tool-specific copy) | -| `.ai-context/skills/` | All tools (universal copy) | +| `.ai-context/skills//SKILL.md` | All tools -- the vendor-neutral copy, referenced by the generated OpenCode, Cursor, Continue, Windsurf and Aider configs | +| `.claude/skills//SKILL.md` | Claude Code, which discovers skills from this path automatically | -Both directories contain the same files. The `.claude/skills/` copy exists because Claude Code has a built-in mechanism for reading files from its `.claude/` directory. The `.ai-context/skills/` copy is the universal location that any AI tool can access. +The `.claude/skills/` copy exists because that is the only directory Claude Code +scans, one level deep. Both copies are refreshed from the mxcli binary on every +session by `mxcli init --sync-skills`, so they cannot drift apart. + +If you are upgrading a project created by an older mxcli, the flat `.md` +files it used to write are retired automatically on the next refresh. Skill files +you added yourself are left alone. ## Available skills @@ -19,21 +55,21 @@ Both directories contain the same files. The `.claude/skills/` copy exists becau | Skill File | Topic | |------------|-------| -| `generate-domain-model.md` | Entity, attribute, and association syntax | -| `write-microflows.md` | Microflow syntax, activities, common mistakes | -| `create-page.md` | Page and widget syntax reference | -| `alter-page.md` | ALTER PAGE/SNIPPET for modifying existing pages | -| `overview-pages.md` | CRUD page patterns (overview + edit) | -| `master-detail-pages.md` | Master-detail page patterns | -| `manage-security.md` | Module roles, user roles, access control, GRANT/REVOKE | -| `manage-navigation.md` | Navigation profiles, home pages, menus | -| `demo-data.md` | Mendix ID system, association storage, demo data insertion | -| `xpath-constraints.md` | XPath syntax in WHERE clauses, nested predicates | -| `database-connections.md` | External database connections from microflows | -| `check-syntax.md` | Pre-flight validation checklist | -| `organize-project.md` | Folders, MOVE command, project structure conventions | -| `test-microflows.md` | Test annotations, file formats, Docker setup | -| `patterns-data-processing.md` | Delta merge, batch processing, list operations | +| `generate-domain-model/SKILL.md` | Entity, attribute, and association syntax | +| `write-microflows/SKILL.md` | Microflow syntax, activities, common mistakes | +| `create-page/SKILL.md` | Page and widget syntax reference | +| `alter-page/SKILL.md` | ALTER PAGE/SNIPPET for modifying existing pages | +| `overview-pages/SKILL.md` | CRUD page patterns (overview + edit) | +| `master-detail-pages/SKILL.md` | Master-detail page patterns | +| `manage-security/SKILL.md` | Module roles, user roles, access control, GRANT/REVOKE | +| `manage-navigation/SKILL.md` | Navigation profiles, home pages, menus | +| `demo-data/SKILL.md` | Mendix ID system, association storage, demo data insertion | +| `xpath-constraints/SKILL.md` | XPath syntax in WHERE clauses, nested predicates | +| `database-connections/SKILL.md` | External database connections from microflows | +| `check-syntax/SKILL.md` | Pre-flight validation checklist | +| `organize-project/SKILL.md` | Folders, MOVE command, project structure conventions | +| `test-microflows/SKILL.md` | Test annotations, file formats, Docker setup | +| `patterns-data-processing/SKILL.md` | Delta merge, batch processing, list operations | ## What a skill file contains @@ -106,20 +142,33 @@ Think of `CLAUDE.md` as the "system prompt" for Claude Code in the context of yo ## Adding custom skills -You can create your own skill files to teach the AI about your project's patterns and conventions. Add markdown files to `.ai-context/skills/` (or `.claude/skills/` for Claude Code): +You can create your own skills to teach the AI about your project's patterns and conventions. Add a directory with a `SKILL.md` to `.ai-context/skills/` (and `.claude/skills/` if you use Claude Code): ``` .ai-context/skills/ -├── write-microflows.md # Built-in (installed by mxcli init) -├── create-page.md # Built-in -├── our-naming-conventions.md # Custom: your team's naming rules -├── order-processing-pattern.md # Custom: how orders work in your app -└── api-integration-guide.md # Custom: how to call external APIs +├── write-microflows/SKILL.md # Built-in (installed by mxcli init) +├── create-page/SKILL.md # Built-in +├── our-naming-conventions/SKILL.md # Custom: your team's naming rules +├── order-processing-pattern/SKILL.md # Custom: how orders work in your app +└── api-integration-guide/SKILL.md # Custom: how to call external APIs ``` -A custom skill file is just a markdown document. Write it the same way you would explain something to a new team member: +`mxcli init --sync-skills` only rewrites the skills mxcli itself ships, so your +own directories survive every upgrade. + +A custom skill is a markdown document with two lines of frontmatter. Write the +body the way you would explain something to a new team member, and write the +`description` so an assistant can tell from it alone whether this is the skill +for the job: ```markdown +--- +name: order-processing-pattern +description: How orders are processed in this application -- validation rules, + status enumeration, logging node and confirmation email. Use when writing or + changing any microflow that touches an Order. +--- + # Order Processing Pattern When creating microflows that process orders in our application, follow these rules: diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index d50c675be..9e4788a25 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -484,6 +484,7 @@ it is for pages. | Validation | `validation feedback $entity/attribute message 'message';` | Requires attribute path + MESSAGE | | Log | `log info\|warning\|error [node 'name'] 'message';` | | | Position | `@position(x, y)` | Canvas position (before activity) | +| Start event | `@start(x, y)` | Canvas position of the start, on the **first** statement. Omit it and the start is placed one spacing unit left of the first activity and MOVES with it on a rewrite; a start that is not at that derived spot is treated as hand-placed, survives a rewrite, and is emitted by DESCRIBE (#951) | | Caption | `@caption 'text'` | Custom caption (before activity) | | Color | `@color Green` | Background color (before activity) | | Annotation | `@annotation 'text'` | Visual note attached to next activity | diff --git a/mdl-examples/bug-tests/951-microflow-start-event-position.mdl b/mdl-examples/bug-tests/951-microflow-start-event-position.mdl new file mode 100644 index 000000000..eb2060ee9 --- /dev/null +++ b/mdl-examples/bug-tests/951-microflow-start-event-position.mdl @@ -0,0 +1,96 @@ +-- Repro: a full rewrite via CREATE OR MODIFY MICROFLOW stranded the StartEvent. +-- Every activity moved to where the script asked; the start stayed where the +-- PREVIOUS layout had put it, joined to its own first activity by a long +-- diagonal line across an otherwise empty canvas. Upstream #951. +-- +-- create ... activities at 200;200, 360;200, 520;200 -> start at 40;200 +-- rewrite ... activities at 360;340, 520;340, 680;340 -> start at 40;200 +-- ^ stranded +-- +-- The cause was the fix for the OPPOSITE report. #884: a Studio Pro flow whose +-- start sat at 145;200 came back at 100;200 on a describe -> exec round-trip, +-- because the start has no statement to annotate and the builder always derived +-- its position — (first activity X) minus one spacing unit. That was fixed by +-- carrying the stored position over on every rewrite, which then pinned the +-- start of every rewritten flow. Both reports are real and they pull opposite +-- ways, so neither is answerable without asking where the stored value CAME +-- from: +-- +-- * a start at the derived spot is mxcli's own arithmetic handed back. It +-- carries no intent, so it is re-derived and follows the activities. +-- * a start anywhere else was put there by a person. It survives the rewrite, +-- and DESCRIBE emits @start(x, y) for it so the description round-trips. +-- * @start(x, y) on the first statement states the position outright and beats +-- both — without it there was no way to move a start once one was preserved, +-- which is the other half of what #951 reported. +-- +-- `mx check` is NOT the oracle here, and neither is a successful build. The +-- Mendix model carries no geometry rules: a flow whose start sits 300px from +-- its first activity is a perfectly valid document that builds and runs, it is +-- just drawn wrong when opened. Measured on mxbuild 11.13.0 — 0 errors before +-- the fix and 0 errors after. Verify by reading the coordinates back. + +create module M951; + +-- 1. Fresh CREATE: the start is derived, one spacing unit (160) left of the +-- first activity, on its centre line. Expect StartEvent at 40;200. +create or modify microflow M951.Layout () +begin + @position(200, 200) + log 'one'; + @position(360, 200) + log 'two'; + @position(520, 200) + log 'three'; +end; + +-- 2. REWRITE moving every activity right and down. The start was derived, so it +-- follows. Expect StartEvent at 200;340 — NOT the 40;200 of #951. +create or modify microflow M951.Layout () +begin + @position(360, 340) + log 'one'; + @position(520, 340) + log 'two'; + @position(680, 340) + log 'three'; +end; + +-- 3. A hand-placed start: 145 is not derivable from 260 (260 - 160 = 100). +-- Expect StartEvent at 145;200. +create or modify microflow M951.Hand () +begin + @start(145, 200) + @position(260, 200) + log 'one'; + @position(420, 200) + log 'two'; +end; + +-- 4. DESCRIBE must emit `@start(145, 200)` for it — without that line the +-- description cannot reproduce the flow, which is #884. +describe microflow M951.Hand; + +-- 5. A rewrite that does NOT mention the start keeps it: 145;200 survives even +-- though the flow gained an activity. Expect StartEvent still at 145;200. +create or modify microflow M951.Hand () +begin + @position(260, 200) + log 'one'; + @position(420, 200) + log 'two'; + @position(580, 200) + log 'three'; +end; + +-- 6. @start moves a start that was previously preserved. Expect 100;200, which +-- is also the derived spot for a first activity at 260 — so from here on the +-- start is derived again and will follow the activities. +create or modify microflow M951.Hand () +begin + @start(100, 200) + @position(260, 200) + log 'one'; +end; + +describe microflow M951.Hand; diff --git a/mdl-examples/bug-tests/953-split-type-merge-overlap.mdl b/mdl-examples/bug-tests/953-split-type-merge-overlap.mdl new file mode 100644 index 000000000..b72e2f3f2 --- /dev/null +++ b/mdl-examples/bug-tests/953-split-type-merge-overlap.mdl @@ -0,0 +1,114 @@ +-- ============================================================================ +-- SPLIT TYPE stacked the element after END SPLIT on top of its own merge. +-- +-- Reported against v0.19.0: "the end event renders on top of the merge in +-- Studio Pro, connected by a zero-length sequence flow", and separately "the +-- merge x looks over-advanced relative to its branches (branch activities at +-- x=720, merge at x=1480)". Two defects, both in addStructuredInheritanceSplit: +-- +-- 1. It ended with `fb.posX = mergeX` — the merge's own centre — so whatever +-- followed END SPLIT was drawn at exactly that point. addEnumSplit and +-- addIfStatement both step past the merge first. +-- +-- 2. Branch width came from every branch body concatenated into ONE list and +-- measured as a single left-to-right run. Branches are stacked vertically, +-- so only the widest one matters; summing them slid the merge right by an +-- activity-plus-spacing per extra branch. +-- +-- MEASURED on mxbuild 11.13.0 with the three microflows below (describe prints +-- the stored coordinates, so no Studio Pro is needed to see it): +-- +-- merge element after branches +-- ThreeBranches before 1480 1480 (stacked) 720 +-- after 920 1040 720 +-- FourBranches before 1760 1760 (stacked) 720 +-- after 920 1040 720 <- no drift +-- UnevenBranches before 1760 1760 (stacked) 720, 880 +-- after 1200 1320 720, 880 +-- +-- The CONTROL is EnumSplitComparison: identical graph shape as a CASE, and its +-- geometry is byte-identical before and after the fix (merge 890, end 970). If +-- that moved, the fix reached further than it should have. +-- +-- Neither defect is visible below Studio Pro: the model is valid either way, so +-- `mx check` reports 0 errors on the stacked version. Verified. +-- +-- Regression coverage: mdl/executor/cmd_microflows_builder_split_geometry_test.go +-- ============================================================================ + +CREATE OR MODIFY MODULE Bug953; + +CREATE OR MODIFY PERSISTENT ENTITY Bug953.Animal ( "Name": String(50) ); +CREATE OR MODIFY PERSISTENT ENTITY Bug953.Dog GENERALIZATION Bug953.Animal (); +CREATE OR MODIFY PERSISTENT ENTITY Bug953.Cat GENERALIZATION Bug953.Animal (); +CREATE OR MODIFY ENUMERATION Bug953.Kind ( Dog, Other ); + +-- Three branches, one activity each. The `return` after END SPLIT is the +-- element that used to land on the merge. +CREATE OR MODIFY MICROFLOW Bug953.ThreeBranches ($Animal: Bug953.Animal) +RETURNS String +BEGIN + declare $result string = 'none'; + split type $Animal + when Bug953.Dog then + $result = 'dog'; + when Bug953.Animal then + $result = 'animal'; + when (empty) then + $result = 'empty'; + end split; + return $result; +END; + +-- One more branch, same width. The merge must not move. +CREATE OR MODIFY MICROFLOW Bug953.FourBranches ($Animal: Bug953.Animal) +RETURNS String +BEGIN + declare $result string = 'none'; + split type $Animal + when Bug953.Dog then + $result = 'dog'; + when Bug953.Cat then + $result = 'cat'; + when Bug953.Animal then + $result = 'animal'; + when (empty) then + $result = 'empty'; + end split; + return $result; +END; + +-- One branch twice as long. The merge must follow the WIDEST branch — this is +-- the control for "does not drift with branch count", which a builder ignoring +-- the branches entirely would also satisfy. +CREATE OR MODIFY MICROFLOW Bug953.UnevenBranches ($Animal: Bug953.Animal) +RETURNS String +BEGIN + declare $result string = 'none'; + split type $Animal + when Bug953.Dog then + $result = 'dog'; + $result = 'dog again'; + when Bug953.Animal then + $result = 'animal'; + when (empty) then + $result = 'empty'; + end split; + return $result; +END; + +-- The control: the same shape as a CASE. Untouched by the fix. +CREATE OR MODIFY MICROFLOW Bug953.EnumSplitComparison ($Kind: Bug953.Kind) +RETURNS String +BEGIN + declare $result string = 'none'; + case $Kind + when Dog then + $result = 'dog'; + when Other then + $result = 'other'; + when (empty) then + $result = 'none'; + end case; + return $result; +END; diff --git a/mdl-examples/bug-tests/contracts/stale-contract-v1.xml b/mdl-examples/bug-tests/contracts/stale-contract-v1.xml new file mode 100644 index 000000000..b94e518a3 --- /dev/null +++ b/mdl-examples/bug-tests/contracts/stale-contract-v1.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdl-examples/bug-tests/contracts/stale-contract-v2.xml b/mdl-examples/bug-tests/contracts/stale-contract-v2.xml new file mode 100644 index 000000000..307654556 --- /dev/null +++ b/mdl-examples/bug-tests/contracts/stale-contract-v2.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl b/mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl new file mode 100644 index 000000000..943938f5b --- /dev/null +++ b/mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl @@ -0,0 +1,59 @@ +-- ============================================================================ +-- CREATE OR MODIFY ODATA CLIENT left the cached $metadata at the snapshot +-- taken when the client was created. +-- +-- Reported against mxcli-formula1: the backend's F1LiveNowApi gained two entity +-- sets (Trace, Stints) and four attributes on Order. The contract file was +-- refreshed from the running backend and contained all five sets, but: +-- +-- * re-running the CREATE OR MODIFY below reported "Unchanged OData client", +-- * SHOW CONTRACT ENTITIES kept listing three entity types, +-- * the CREATE OR MODIFY EXTERNAL ENTITIES that followed imported the old +-- shape and silently missed the new sets. +-- +-- The only way out was DROP ODATA CLIENT + recreate, which is destructive: it +-- invalidates the client ID the existing external entities point at. +-- +-- The modify branch updated every property except the one that changes most +-- often. It now normalizes MetadataUrl and re-fetches the contract, the same +-- way the create path does. +-- +-- Manual repro (needs a project; MetadataUrl resolves against the .mpr): +-- +-- mkdir -p /contracts +-- cp mdl-examples/bug-tests/contracts/stale-contract-v1.xml \ +-- /contracts/live-now-metadata.xml +-- mxcli exec mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl \ +-- -p /app.mpr # -> Cached $metadata: 3 entity types +-- +-- cp mdl-examples/bug-tests/contracts/stale-contract-v2.xml \ +-- /contracts/live-now-metadata.xml +-- mxcli exec mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl \ +-- -p /app.mpr # -> Refreshed $metadata: 5 entity types +-- +-- mxcli -p /app.mpr -c "show contract entities from F1Now.NowApi" +-- # -> lists Trace and Stints +-- +-- Before the fix the second run printed "Unchanged OData client" and the SHOW +-- still listed three. Running the script twice with the file left alone is the +-- control: it must stay "Unchanged", or every re-run churns the document and +-- breaks the idempotence ADR-0008 guarantees. +-- +-- Regression coverage: mdl/executor/cmd_odata_metadata_refresh_test.go +-- ============================================================================ + +create or modify module F1Now; + +create or modify constant F1Now.ApiLocation + type String + default 'http://localhost:8080/odata/livenow/'; + +-- Live timing feed. Re-run this statement after refreshing +-- contracts/live-now-metadata.xml and the cached contract follows it. +create or modify odata client F1Now.NowApi ( + Version: '1.0.0', + ODataVersion: OData4, + MetadataUrl: './contracts/live-now-metadata.xml', + ServiceUrl: '@F1Now.ApiLocation', + Timeout: '300' +); diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index bd5eccf49..f780d63a0 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -269,6 +269,21 @@ type ActivityAnnotations struct { // unaddressable, and it routinely landed on top of a neighbouring activity. Merge *Position + // Start positions the StartEvent — the implicit node every flow begins at: + // @start(x, y), written on the FIRST statement, the one the start flows into. + // + // Same shape and same reason as Merge: the node has no statement of its own, + // so it is annotated on the statement it belongs to. Without it the start's + // placement was inferred rather than stated, and the two things an inference + // has to serve pull apart — a start a person dragged somewhere must survive a + // rebuild (#884), while one mxcli derived must follow the activities when + // they move (#951). An explicit position settles both by not guessing. + // + // DESCRIBE emits it only for a start that is not where the layout would have + // put it, so a described flow round-trips exactly without every description + // growing a line that just restates the arithmetic. (upstream #951) + Start *Position + // InvalidCurves holds the raw text of any @curve parameter whose coordinates // were not a whole-number (x, y) pair, so validation can refuse it rather // than silently straightening the edge. diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 8e8c3362d..e486ef4e4 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -509,7 +509,20 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.pendingAnnotations = nil } - branchWidth := fb.measurer.measureStatements(appendInheritanceBodies(s)).Width + // The branches are laid out one BELOW another, all starting at branchStartX, + // so the room they need is the WIDEST branch — not the total. This used to + // measure every body concatenated into one list, which made the merge slide + // right by a whole activity-plus-spacing per extra branch and left it far + // past the branches it joins (mendixlabs/mxcli#953: three one-activity + // branches starting at x=720 put the merge at x=1480 instead of x=920). + // `layout.go`'s measureInheritanceSplitStatement always took the max, so the + // builder disagreed with its own measurer. + branchWidth := 0 + for _, body := range inheritanceBranchBodies(s) { + if w := fb.measurer.measureStatements(body).Width; w > branchWidth { + branchWidth = w + } + } if branchWidth == 0 { branchWidth = HorizontalSpacing / 2 } @@ -618,7 +631,19 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt // roundtrip does not accumulate `else` blocks; exec re-creates it here. addBranch("", s.ElseBody) - fb.posX = mergeX + // Where the next element goes. This was `mergeX`, i.e. the merge's own centre, + // so whatever followed `end split` was drawn ON TOP of the merge, joined by a + // zero-length sequence flow (#953). The model is valid either way, so nothing + // below this line — not `mx check`, not the build — can notice. + // + // The spacing constants are centre-to-centre and tuned for a 40px edge gap, so + // clearing a 40-wide merge before a 120-wide activity needs MergeSize + half a + // pitch. That is what addIfStatement uses. addEnumSplit uses only + // HorizontalSpacing/2, which leaves an activity's left edge exactly touching + // the merge (measured: merge 890, activity 970, both edges at 910) — legible, + // but not the house gap, and not worth re-laying out every existing enum split + // to change. + fb.posX = mergeX + MergeSize + HorizontalSpacing/2 fb.posY = centerY fb.endsWithReturn = savedEndsWithReturn if allBranchesReturn { @@ -755,13 +780,18 @@ func appendEnumBodies(s *ast.EnumSplitStmt) []ast.MicroflowStatement { return stmts } -func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStatement { - var stmts []ast.MicroflowStatement +// inheritanceBranchBodies returns each branch of a type split as its own body, +// including the `(empty)` branch. +// +// It deliberately does NOT flatten them into one list. The predecessor did, and +// every caller then measured the branches as if they ran end to end when they +// are in fact stacked vertically (#953). +func inheritanceBranchBodies(s *ast.InheritanceSplitStmt) [][]ast.MicroflowStatement { + bodies := make([][]ast.MicroflowStatement, 0, len(s.Cases)+1) for _, c := range s.Cases { - stmts = append(stmts, c.Body...) + bodies = append(bodies, c.Body) } - stmts = append(stmts, s.ElseBody...) - return stmts + return append(bodies, s.ElseBody) } type inheritanceSplitCaseOrderAnchor struct { diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index b36b378eb..86dab9217 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -49,13 +49,23 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a } // Create StartEvent - Position is the CENTER point (RelativeMiddlePoint in Mendix) - // A position carried over from the microflow being replaced wins: the start - // has no statement to annotate, so a rebuild would otherwise move a - // hand-laid-out one to the derived spot. + // + // Three sources, weakest first. The derived spot above is the fallback; a + // hand-placed position carried over from the flow being replaced beats it + // (#884); an explicit @start(x, y) beats both, because it is the one source + // that states the position rather than inferring it (#951). startX, startY := fb.posX, fb.posY if fb.startPosition != nil { startX, startY = fb.startPosition.X, fb.startPosition.Y } + for _, stmt := range stmts { + ann := getStatementAnnotations(stmt) + if ann == nil || ann.Start == nil { + continue + } + startX, startY = ann.Start.X, ann.Start.Y + break + } startEvent := µflows.StartEvent{ BaseMicroflowObject: microflows.BaseMicroflowObject{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, diff --git a/mdl/executor/cmd_microflows_builder_split_geometry_test.go b/mdl/executor/cmd_microflows_builder_split_geometry_test.go new file mode 100644 index 000000000..19dfcfeb6 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_split_geometry_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Geometry tests for the split builders (mendixlabs/mxcli#953). +// +// The split builders had no positional coverage at all before this file — +// grep for `Position` in the enum-split and inheritance-split tests returned +// nothing — which is why a type split shipped drawing the element after +// `END SPLIT` directly on top of its own merge. Nothing downstream can catch +// that: the model is valid, so `mx check` and the build both pass, and the +// overlap is only visible by opening the microflow in Studio Pro. + +// logStmt is a one-activity branch body. +func geoLog(msg string) ast.MicroflowStatement { + return &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: msg}} +} + +func newGeometryBuilder() *flowBuilder { + return &flowBuilder{spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} +} + +func soleMerge(t *testing.T, fb *flowBuilder) *microflows.ExclusiveMerge { + t.Helper() + var found *microflows.ExclusiveMerge + for _, obj := range fb.objects { + if m, ok := obj.(*microflows.ExclusiveMerge); ok { + if found != nil { + t.Fatal("more than one merge; the fixture is not the shape this test assumes") + } + found = m + } + } + if found == nil { + t.Fatal("no merge was created") + } + return found +} + +// typeSplit builds a type split whose branches each hold one activity. +func typeSplit(branchBodies ...[]ast.MicroflowStatement) *ast.InheritanceSplitStmt { + s := &ast.InheritanceSplitStmt{Variable: "obj"} + for i, body := range branchBodies { + if i == len(branchBodies)-1 { + s.ElseBody = body + break + } + s.Cases = append(s.Cases, ast.InheritanceSplitCase{ + Entity: ast.QualifiedName{Module: "M", Name: "Sub"}, + Body: body, + }) + } + return s +} + +// The element after END SPLIT must not land on the merge. +// +// `addStructuredInheritanceSplit` ended with `fb.posX = mergeX` — the merge's +// own centre — so the end event was drawn on top of it, joined by a zero-length +// sequence flow. Reported from the field as "the end event renders on top of +// the merge in Studio Pro". +func TestTypeSplitLeavesRoomAfterTheMerge(t *testing.T) { + fb := newGeometryBuilder() + fb.addStructuredInheritanceSplit(typeSplit( + []ast.MicroflowStatement{geoLog("a")}, + []ast.MicroflowStatement{geoLog("b")}, + )) + + merge := soleMerge(t, fb) + if fb.posX == merge.Position.X { + t.Fatalf("the next element would be placed at x=%d, exactly on the merge", fb.posX) + } + + // The spacing constants are centre-to-centre and tuned for a 40px edge gap, + // so clearing a MergeSize-wide merge before a full-width activity needs + // MergeSize + half a pitch. Same rule addIfStatement uses. + if want := merge.Position.X + MergeSize + HorizontalSpacing/2; fb.posX != want { + t.Errorf("next x = %d, want %d", fb.posX, want) + } + + // The gap actually rendered, stated in edge terms so the intent survives a + // change to the constants. + gap := (fb.posX - ActivityWidth/2) - (merge.Position.X + MergeSize/2) + if gap != HorizontalSpacing-ActivityWidth { + t.Errorf("edge-to-edge gap after the merge = %d, want %d", gap, HorizontalSpacing-ActivityWidth) + } +} + +// The merge is placed from the WIDEST branch, not from all of them end to end. +// +// The width came from every branch body concatenated into one list and measured +// as a single run, so the merge slid right by an activity-plus-spacing for each +// extra branch — reported as "the merge x looks over-advanced relative to its +// branches". Branches are stacked vertically; only the widest one matters. +func TestTypeSplitMergeDoesNotDriftWithBranchCount(t *testing.T) { + build := func(n int) *microflows.ExclusiveMerge { + bodies := make([][]ast.MicroflowStatement, n) + for i := range bodies { + bodies[i] = []ast.MicroflowStatement{geoLog("x")} + } + fb := newGeometryBuilder() + fb.addStructuredInheritanceSplit(typeSplit(bodies...)) + return soleMerge(t, fb) + } + + two, five := build(2), build(5) + if two.Position.X != five.Position.X { + t.Errorf("merge moved from x=%d to x=%d when three more one-activity branches were added; "+ + "branch width is being summed across branches instead of maxed", + two.Position.X, five.Position.X) + } + + // And it sits where one branch's worth of room puts it, not somewhere merely + // stable. splitX defaults to 0; the split box is ActivityWidth wide. + branchStartX := ActivityWidth + HorizontalSpacing/2 + if want := branchStartX + ActivityWidth + HorizontalSpacing/2; two.Position.X != want { + t.Errorf("merge x = %d, want %d (branch start + one activity + half a pitch)", two.Position.X, want) + } +} + +// Control for the test above: it must be the branch WIDTH that moves the merge, +// or "does not drift with branch count" would also pass against a builder that +// ignored the branches entirely. +func TestTypeSplitMergeDoesFollowBranchWidth(t *testing.T) { + fb := newGeometryBuilder() + fb.addStructuredInheritanceSplit(typeSplit( + []ast.MicroflowStatement{geoLog("a"), geoLog("b")}, // the wide one + []ast.MicroflowStatement{geoLog("c")}, + )) + wide := soleMerge(t, fb) + + fb2 := newGeometryBuilder() + fb2.addStructuredInheritanceSplit(typeSplit( + []ast.MicroflowStatement{geoLog("a")}, + []ast.MicroflowStatement{geoLog("c")}, + )) + narrow := soleMerge(t, fb2) + + if wide.Position.X <= narrow.Position.X { + t.Errorf("merge at x=%d for a two-activity branch vs x=%d for a one-activity branch; "+ + "the branches are not being measured at all", wide.Position.X, narrow.Position.X) + } +} + +// The enum split is the construct the field report compared against, and it was +// already correct. Pinned here so a later attempt to unify the two cannot move +// it silently: every existing enum split in every project would be re-laid out. +func TestEnumSplitGeometryIsUnchanged(t *testing.T) { + build := func(n int) (*microflows.ExclusiveMerge, int) { + s := &ast.EnumSplitStmt{Variable: "kind"} + for i := 0; i < n; i++ { + s.Cases = append(s.Cases, ast.EnumSplitCase{ + Values: []string{"V"}, + Body: []ast.MicroflowStatement{geoLog("x")}, + }) + } + s.ElseBody = []ast.MicroflowStatement{geoLog("e")} + fb := newGeometryBuilder() + fb.addEnumSplit(s) + return soleMerge(t, fb), fb.posX + } + + two, nextTwo := build(2) + five, _ := build(5) + if two.Position.X != five.Position.X { + t.Errorf("enum split merge moved with branch count: x=%d then x=%d", two.Position.X, five.Position.X) + } + if want := two.Position.X + HorizontalSpacing/2; nextTwo != want { + t.Errorf("enum split next x = %d, want %d — this test pins existing behaviour; "+ + "changing it re-lays-out every enum split ever written", nextTwo, want) + } +} diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index c389f0ad2..1dc0d0864 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -273,12 +273,14 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { restServices, _ := loadRestServices(ctx) builder := &flowBuilder{ - // Carry over the StartEvent position of the microflow being replaced. - // The start has no MDL statement to annotate and DESCRIBE cannot emit - // it, so a rebuild would otherwise move a hand-laid-out one to the - // derived spot — a Studio Pro flow's 145;200 became 100;200 on a - // describe→exec round-trip, the only coordinate in it that did not - // survive. Preserved the way the folder and allowed roles already are. + // Carry over a HAND-PLACED StartEvent position from the microflow being + // replaced, the way the folder and allowed roles already are: a Studio + // Pro flow's 145;200 became 100;200 on a describe→exec round-trip, the + // only coordinate in it that did not survive (#884). A start sitting + // where mxcli's own layout would have put it is not carried over — that + // pinned the start of every rewritten flow, stranding it across the + // canvas from activities the same script had just moved (#951). An + // explicit @start(x, y) on the first statement overrides both. startPosition: storedStartPosition(ctx, existingID), posX: 200, posY: 200, @@ -331,22 +333,21 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { } // storedStartPosition reads the StartEvent position off the microflow being -// replaced, or nil for a fresh CREATE (where the position is derived from the -// first annotated activity). Best-effort: a backend that cannot read the flow -// yields the derived placement rather than failing the statement. +// replaced, when a person put it there rather than mxcli's own layout — see +// authoredStartPosition for how the two are told apart, and why carrying the +// position over unconditionally pinned the start of every rewritten flow (#951). +// +// Nil for a fresh CREATE, and nil for a start sitting where the layout would +// have put it anyway; both derive from the new first activity. Best-effort: a +// backend that cannot read the flow yields the derived placement rather than +// failing the statement. func storedStartPosition(ctx *ExecContext, existingID model.ID) *model.Point { if existingID == "" || ctx.Backend == nil { return nil } mf, err := ctx.Backend.GetMicroflow(existingID) - if err != nil || mf == nil || mf.ObjectCollection == nil { + if err != nil || mf == nil { return nil } - for _, o := range mf.ObjectCollection.Objects { - if se, ok := o.(*microflows.StartEvent); ok { - p := se.GetPosition() - return &p - } - } - return nil + return authoredStartPosition(mf.ObjectCollection) } diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index ec1dad140..a6a8ce282 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -783,6 +783,8 @@ func formatMicroflowActivities( // Build annotation map for @annotation emission annotationsByTarget := buildAnnotationsByTarget(mf.ObjectCollection) + lines = append(lines, startAnnotationLines(mf.ObjectCollection)...) + // flowsByOrigin / flowsByDest are threaded into traverseFlow so @anchor // emission is per-call — no package-level globals, safe under concurrent // describe (e.g. captureDescribeParallel). @@ -1014,6 +1016,8 @@ func formatMicroflowActivitiesWithSourceMap( // Build annotation map for @annotation emission annotationsByTarget := buildAnnotationsByTarget(mf.ObjectCollection) + lines = append(lines, startAnnotationLines(mf.ObjectCollection)...) + traverseFlow(ctx, startID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, sourceMap, headerLineCount, annotationsByTarget) return lines diff --git a/mdl/executor/cmd_microflows_start_position.go b/mdl/executor/cmd_microflows_start_position.go new file mode 100644 index 000000000..3127fece1 --- /dev/null +++ b/mdl/executor/cmd_microflows_start_position.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package executor - StartEvent placement across a microflow rewrite. +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// derivedStartPosition returns where mxcli's own layout puts the StartEvent of a +// stored flow: one spacing unit left of the object the start flows into, on that +// object's centre line. This is buildFlowGraph's rule read backwards, and the two +// are pinned together by TestDerivedStartPositionMatchesTheBuilder. +// +// Reports false when the flow has no start, or the start leads nowhere — with no +// first object there is nothing to derive from, and nothing can be concluded +// about where the stored coordinates came from. +func derivedStartPosition(oc *microflows.MicroflowObjectCollection) (model.Point, bool) { + if oc == nil { + return model.Point{}, false + } + var startID model.ID + byID := make(map[model.ID]microflows.MicroflowObject, len(oc.Objects)) + for _, o := range oc.Objects { + byID[o.GetID()] = o + if _, ok := o.(*microflows.StartEvent); ok { + startID = o.GetID() + } + } + if startID == "" { + return model.Point{}, false + } + for _, f := range oc.Flows { + if f.OriginID != startID { + continue + } + first, ok := byID[f.DestinationID] + if !ok { + continue + } + p := first.GetPosition() + return model.Point{X: p.X - HorizontalSpacing, Y: p.Y}, true + } + return model.Point{}, false +} + +// authoredStartPosition returns the StartEvent position of a stored flow when +// those coordinates say something mxcli's own layout would not have produced — +// that is, when a person put the start there. +// +// The two reports this arbitrates are the same bug seen from opposite sides, and +// neither is answerable without asking where the stored value came from: +// +// - #884: a Studio Pro flow whose start sat at 145;200 came back at 100;200 +// on a describe→exec round-trip. 145 is not derivable from anything in the +// description, so a rebuild has to be told; carrying the stored value over +// is how it is told. +// - #951: carrying it over UNCONDITIONALLY then pinned the start of every +// rewritten flow. Move the activities and the start stays behind — measured +// at 40;200 while its own first activity had moved to 360;340, joined by a +// long diagonal line across an otherwise empty canvas. +// +// A start at the derived spot is mxcli's own arithmetic handed back, so it +// carries no intent and is re-derived. A start anywhere else was placed by +// someone and is kept. A person who placed one exactly where the layout would +// have is indistinguishable from the layout — and re-deriving gives the same +// point for an unchanged flow, so the ambiguity costs nothing. +// +// Returns nil when there is no start, when nothing can be derived, or when the +// stored start is at the derived spot. +func authoredStartPosition(oc *microflows.MicroflowObjectCollection) *model.Point { + derived, ok := derivedStartPosition(oc) + if !ok { + return nil + } + for _, o := range oc.Objects { + se, ok := o.(*microflows.StartEvent) + if !ok { + continue + } + p := se.GetPosition() + if p == derived { + return nil + } + return &p + } + return nil +} + +// startAnnotationLines returns the `@start(x, y)` line a description needs, or +// nothing at all. +// +// Emitted only for a start that is not where the layout would have put it, so an +// ordinary description does not grow a line restating its own arithmetic — and, +// more than cosmetics, so a described flow does not come back with its start +// pinned to a spot the next rewrite has to strand it at (#951). A start the +// arithmetic cannot reconstruct is emitted, which is what makes a described flow +// round-trip exactly (#884). +// +// The line goes ahead of the leading statement's own annotations, because the +// statement the start flows into is the one it belongs to — the same placement +// @merge uses for the other node with no statement of its own. +// +// Shared by both describers: formatMicroflowActivities and +// formatMicroflowActivitiesWithSourceMap are near-duplicates, and a describer +// that emits it while its twin does not makes the round-trip depend on which +// command the author happened to run. +func startAnnotationLines(oc *microflows.MicroflowObjectCollection) []string { + p := authoredStartPosition(oc) + if p == nil { + return nil + } + return []string{fmt.Sprintf("@start(%d, %d)", p.X, p.Y)} +} diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index 3022d4fd2..2b0adf07a 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -152,6 +152,7 @@ Microflows: @caption 'text' -- Custom caption for activity @color Green -- Background color for activity @position(100, 200) -- Canvas position for activity + @start(60, 200) -- Canvas position for the start event return $ReturnVar; end; / diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 1a31d0eb2..653442f75 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -983,7 +983,11 @@ func createODataClient(ctx *ExecContext, stmt *ast.CreateODataClientStmt) error svc.ODataVersion = stmt.ODataVersion } if stmt.MetadataUrl != "" { - svc.MetadataUrl = stmt.MetadataUrl + normalized, nerr := normalizeMetadataURL(ctx, stmt.MetadataUrl) + if nerr != nil { + return fmt.Errorf("failed to normalize MetadataUrl: %w", nerr) + } + svc.MetadataUrl = normalized } if stmt.TimeoutExpression != "" { svc.TimeoutExpression = stmt.TimeoutExpression @@ -1048,6 +1052,17 @@ func createODataClient(ctx *ExecContext, stmt *ast.CreateODataClientStmt) error } } } + // Re-fetch $metadata. The cached contract is a snapshot taken + // when the client was created; leaving it alone here made + // CREATE OR MODIFY converge every property except the one that + // changes most often. A service that gains entity sets then + // reports "Unchanged OData client", and the CREATE EXTERNAL + // ENTITIES that follows imports the old shape silently + // (mxcli-formula1: F1LiveNowApi gained Trace and Stints, and + // the only way out was DROP + recreate, which invalidates the + // client ID the external entities point at). + refreshed := refreshCachedMetadata(ctx, svc, stmt) + if err := ctx.Backend.UpdateConsumedODataService(svc); err != nil { return mdlerrors.NewBackend("update OData client", err) } @@ -1060,6 +1075,11 @@ func createODataClient(ctx *ExecContext, stmt *ast.CreateODataClientStmt) error } invalidateHierarchy(ctx) ctx.ReportMutation("Modified", "OData client: %s.%s", modName, svc.Name) + if refreshed { + if sum := contractSummary(svc.Metadata); sum != "" { + fmt.Fprintf(ctx.Output, " Refreshed $metadata: %s\n", sum) + } + } return nil } return mdlerrors.NewAlreadyExistsMsg("OData client", modName+"."+svc.Name, fmt.Sprintf("OData client already exists: %s.%s (use create or modify to update)", modName, svc.Name)) @@ -1137,13 +1157,8 @@ Got: %s`, stmt.ServiceUrl) // Fetch and cache $metadata from the service URL // Normalize local file paths to absolute file:// URLs for Studio Pro compatibility if newSvc.MetadataUrl != "" { - mprDir := "" - if ctx.MprPath != "" { - mprDir = filepath.Dir(ctx.MprPath) - } - // Normalize MetadataUrl: convert relative paths to absolute file:// URLs - normalizedUrl, err := pathutil.NormalizeURL(newSvc.MetadataUrl, mprDir) + normalizedUrl, err := normalizeMetadataURL(ctx, newSvc.MetadataUrl) if err != nil { return fmt.Errorf("failed to normalize MetadataUrl: %w", err) } @@ -1170,21 +1185,74 @@ Got: %s`, stmt.ServiceUrl) } invalidateHierarchy(ctx) fmt.Fprintf(ctx.Output, "Created OData client: %s.%s\n", stmt.Name.Module, stmt.Name.Name) - if newSvc.Metadata != "" { - // Parse to show summary - if doc, err := types.ParseEdmx(newSvc.Metadata); err == nil { - entityCount := 0 - actionCount := 0 - for _, s := range doc.Schemas { - entityCount += len(s.EntityTypes) - } - actionCount = len(doc.Actions) - fmt.Fprintf(ctx.Output, " Cached $metadata: %d entity types, %d actions\n", entityCount, actionCount) - } + if sum := contractSummary(newSvc.Metadata); sum != "" { + fmt.Fprintf(ctx.Output, " Cached $metadata: %s\n", sum) } return nil } +// normalizeMetadataURL resolves a MetadataUrl against the project directory, +// turning a relative path into the absolute file:// URL that Studio Pro stores +// and that fetchODataMetadata expects. An empty URL stays empty. +func normalizeMetadataURL(ctx *ExecContext, rawURL string) (string, error) { + if rawURL == "" { + return "", nil + } + mprDir := "" + if ctx != nil && ctx.MprPath != "" { + mprDir = filepath.Dir(ctx.MprPath) + } + return pathutil.NormalizeURL(rawURL, mprDir) +} + +// contractSummary renders a cached $metadata document as the counts worth +// printing, or "" when there is nothing to parse. +func contractSummary(metadata string) string { + if metadata == "" { + return "" + } + doc, err := types.ParseEdmx(metadata) + if err != nil { + return "" + } + entityCount := 0 + for _, s := range doc.Schemas { + entityCount += len(s.EntityTypes) + } + return fmt.Sprintf("%d entity types, %d actions", entityCount, len(doc.Actions)) +} + +// refreshCachedMetadata re-reads the contract at svc.MetadataUrl into +// svc.Metadata / svc.MetadataHash, reporting whether it actually changed. +// +// A fetch that fails is a warning, not an error, and leaves the cached contract +// in place: losing a contract that was merely unreachable is worse than serving +// a stale one, and the caller has nothing else to fall back on. An unchanged +// contract leaves svc untouched, so the write is elided and the statement +// reports "Unchanged" rather than churning the document. +func refreshCachedMetadata(ctx *ExecContext, svc *model.ConsumedODataService, stmt *ast.CreateODataClientStmt) bool { + if svc == nil || svc.MetadataUrl == "" { + return false + } + auth := metadataAuthFromStmt(ctx, stmt) + metadata, hash, err := fetchODataMetadata(svc.MetadataUrl, auth) + if err != nil { + fmt.Fprintf(ctx.Output, "Warning: could not refresh $metadata: %v\n", err) + for _, hint := range auth.hints() { + fmt.Fprintf(ctx.Output, " %s\n", hint) + } + fmt.Fprintf(ctx.Output, " The client keeps the contract cached earlier, which may be out of date.\n") + return false + } + if metadata == "" || hash == svc.MetadataHash { + return false + } + svc.Metadata = metadata + svc.MetadataHash = hash + svc.Validated = true + return true +} + // alterODataClient handles ALTER ODATA CLIENT command. func alterODataClient(ctx *ExecContext, stmt *ast.AlterODataClientStmt) error { diff --git a/mdl/executor/cmd_odata_metadata_refresh_test.go b/mdl/executor/cmd_odata_metadata_refresh_test.go new file mode 100644 index 000000000..d753358fe --- /dev/null +++ b/mdl/executor/cmd_odata_metadata_refresh_test.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// edmxWith renders a minimal EDMX document carrying the named entity types, so +// a test can change the contract the way a backend release does — by adding an +// entity set — rather than by editing an opaque blob. +func edmxWith(names ...string) string { + var b strings.Builder + b.WriteString(``) + for _, n := range names { + b.WriteString(``) + } + b.WriteString(``) + return b.String() +} + +// odataModifyFixture wires a mock project holding one OData client whose cached +// contract is whatever the file at contractPath held when the fixture was built +// — i.e. the state CREATE ODATA CLIENT leaves behind. The returned pointer is +// the service the executor mutates; updated records what reached the backend. +type odataModifyFixture struct { + ctx *ExecContext + out *strings.Builder + svc *model.ConsumedODataService + seedHash string + seedCached string + updated **model.ConsumedODataService +} + +// newODataModifyFixture takes the project directory separately from the contract +// path: a relative MetadataUrl resolves against the .mpr, not against wherever +// the contract happens to sit. +func newODataModifyFixture(t *testing.T, projectDir, contractPath string) *odataModifyFixture { + t.Helper() + + cached, hash, err := fetchODataMetadata("file://"+contractPath, nil) + if err != nil { + t.Fatalf("seeding the cached contract failed: %v", err) + } + + mod := mkModule("MyModule") + svc := &model.ConsumedODataService{ + BaseElement: model.BaseElement{ID: nextID("cos")}, + ContainerID: mod.ID, + Name: "NowApi", + ODataVersion: "4.0", + MetadataUrl: "file://" + contractPath, + Metadata: cached, + MetadataHash: hash, + Validated: true, + } + h := mkHierarchy(mod) + withContainer(h, svc.ContainerID, mod.ID) + + var updated *model.ConsumedODataService + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{mod}, nil + }, + ListConsumedODataServicesFunc: func() ([]*model.ConsumedODataService, error) { + return []*model.ConsumedODataService{svc}, nil + }, + UpdateConsumedODataServiceFunc: func(s *model.ConsumedODataService) error { + updated = s + return nil + }, + } + + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h), + withMprPath(filepath.Join(projectDir, "app.mpr"))) + + var sb strings.Builder + ctx.Output = &sb + + return &odataModifyFixture{ + ctx: ctx, out: &sb, svc: svc, + seedHash: hash, seedCached: cached, updated: &updated, + } +} + +func (f *odataModifyFixture) modify(t *testing.T, metadataUrl string) { + t.Helper() + stmt := &ast.CreateODataClientStmt{ + Name: ast.QualifiedName{Module: "MyModule", Name: "NowApi"}, + CreateOrModify: true, + MetadataUrl: metadataUrl, + } + if err := createODataClient(f.ctx, stmt); err != nil { + t.Fatalf("create or modify odata client: %v", err) + } + if *f.updated == nil { + t.Fatal("the modify branch never reached the backend") + } +} + +// mxcli-formula1: CREATE OR REPLACE / CREATE OR MODIFY updated every property of +// an OData client except the cached contract, which stayed at the snapshot taken +// when the client was created. A consumed service that gained entity sets was +// therefore invisible: the refreshed contract file on disk had five, SHOW +// CONTRACT ENTITIES kept showing three, the statement reported "Unchanged", and +// the CREATE EXTERNAL ENTITIES that followed imported the old shape without +// saying so. The only way out was DROP + recreate, which invalidates the client +// ID the existing external entities point at. +func TestCreateOrModifyODataClient_RefreshesCachedContract(t *testing.T) { + dir := t.TempDir() + contract := filepath.Join(dir, "f1-now-metadata.xml") + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Driver", "Order")), 0o600); err != nil { + t.Fatal(err) + } + + f := newODataModifyFixture(t, dir, contract) + + // The backend gains two entity sets and the contract file is refreshed. + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Driver", "Order", "Trace", "Stints")), 0o600); err != nil { + t.Fatal(err) + } + + f.modify(t, contract) + + got := *f.updated + for _, want := range []string{"Trace", "Stints"} { + if !strings.Contains(got.Metadata, `Name="`+want+`"`) { + t.Errorf("cached contract is missing entity type %q — it is still the snapshot from creation", want) + } + } + if got.MetadataHash == f.seedHash { + t.Error("MetadataHash still matches the contract cached at creation") + } + if !got.Validated { + t.Error("Validated should stay true after a successful refresh") + } + if out := f.out.String(); !strings.Contains(out, "Refreshed $metadata: 5 entity types") { + t.Errorf("no refresh reported to the user; output was:\n%s", out) + } +} + +// The control: an unchanged contract must not be rewritten, or every re-run of a +// script would churn the document and the statement would stop reporting +// "Unchanged". Same code path, only the file is left alone. +func TestCreateOrModifyODataClient_UnchangedContractIsNotRewritten(t *testing.T) { + dir := t.TempDir() + contract := filepath.Join(dir, "f1-now-metadata.xml") + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Driver", "Order")), 0o600); err != nil { + t.Fatal(err) + } + + f := newODataModifyFixture(t, dir, contract) + + f.modify(t, contract) + + got := *f.updated + if got.Metadata != f.seedCached || got.MetadataHash != f.seedHash { + t.Error("an unchanged contract was rewritten, so the write can no longer be elided") + } + if out := f.out.String(); strings.Contains(out, "Refreshed $metadata") { + t.Errorf("a refresh was reported for an unchanged contract:\n%s", out) + } +} + +// A relative MetadataUrl has to be resolved against the project directory on the +// modify path too. It was stored raw, so a client written with './contracts/…' +// ended up with a URL Studio Pro cannot open and that fetchODataMetadata — which +// documents its input as already normalized — could not read back. +func TestCreateOrModifyODataClient_NormalizesRelativeMetadataUrl(t *testing.T) { + dir := t.TempDir() + contracts := filepath.Join(dir, "contracts") + if err := os.MkdirAll(contracts, 0o755); err != nil { + t.Fatal(err) + } + contract := filepath.Join(contracts, "f1-now-metadata.xml") + if err := os.WriteFile(contract, []byte(edmxWith("Session")), 0o600); err != nil { + t.Fatal(err) + } + + f := newODataModifyFixture(t, dir, contract) + + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Trace")), 0o600); err != nil { + t.Fatal(err) + } + + // The MprPath the fixture set is dir/app.mpr, so this is the spelling a + // script in the project actually uses. + f.modify(t, "./contracts/f1-now-metadata.xml") + + got := *f.updated + if !strings.HasPrefix(got.MetadataUrl, "file://") { + t.Errorf("MetadataUrl = %q, want an absolute file:// URL", got.MetadataUrl) + } + if !strings.Contains(got.Metadata, `Name="Trace"`) { + t.Error("the relative URL did not resolve to the contract on disk, so nothing was refreshed") + } +} diff --git a/mdl/executor/start_event_position_test.go b/mdl/executor/start_event_position_test.go index bfa7b95f6..cf481a19a 100644 --- a/mdl/executor/start_event_position_test.go +++ b/mdl/executor/start_event_position_test.go @@ -1,18 +1,30 @@ // SPDX-License-Identifier: Apache-2.0 -// A microflow's StartEvent position is not expressible in MDL and is not -// captured by DESCRIBE, so a describe→exec round-trip used to move it: a -// Studio-Pro-authored flow whose start sat at 145;200 came back at 100;200, -// because the builder derives the start as (first annotated activity X − -// spacing) and 145 is not derivable from 260. +// Where a microflow's StartEvent lands across a rewrite, and why it takes three +// sources to get right. // -// Every other coordinate in that flow round-trips exactly, so this was the one -// piece of a hand-laid-out microflow mxcli could not preserve. It is preserved -// the way the folder and the allowed module roles already are on CREATE OR -// MODIFY: read off the stored document and carried over. +// The start has no statement of its own, so the builder derives its position: +// one spacing unit left of the first activity, on that activity's centre line. +// Two reports pull that derivation in opposite directions. +// +// - #884: a Studio Pro flow whose start sat at 145;200 came back at 100;200 on +// a describe→exec round-trip — 145 is not derivable from 260, and every +// other coordinate in the flow survived. Carrying the stored value over +// fixed it. +// - #951: carrying it over UNCONDITIONALLY pinned the start of every rewritten +// flow. Measured on a real project: activities moved to 360;340 by the very +// script doing the rewrite, start left behind at 40;200. +// +// Neither is answerable without asking where the stored value came from, which +// authoredStartPosition does — a start at the derived spot is mxcli's own +// arithmetic handed back and carries no intent; a start anywhere else was placed +// by a person. On top of that, @start(x, y) states the position outright, which +// is what DESCRIBE emits for a start that arithmetic cannot reconstruct. package executor import ( + "context" + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" @@ -29,7 +41,10 @@ func startEventOf(objects []microflows.MicroflowObject) *microflows.StartEvent { return nil } -func buildFlowWithStart(t *testing.T, start *model.Point) *microflows.StartEvent { +// buildFlowWithStart builds a one-activity flow whose activity is annotated at +// 260;200, so the derived start is 100;200. start is the carried-over position +// (nil for a fresh CREATE); explicitStart is an @start(x, y) on the statement. +func buildFlowWithStart(t *testing.T, start *model.Point, explicitStart *ast.Position) *microflows.StartEvent { t.Helper() fb := &flowBuilder{ posX: 100, posY: 200, baseY: 200, spacing: HorizontalSpacing, @@ -39,8 +54,11 @@ func buildFlowWithStart(t *testing.T, start *model.Point) *microflows.StartEvent startPosition: start, } stmt := &ast.LogStmt{ - Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, - Annotations: &ast.ActivityAnnotations{Position: &ast.Position{X: 260, Y: 200}}, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, + Annotations: &ast.ActivityAnnotations{ + Position: &ast.Position{X: 260, Y: 200}, + Start: explicitStart, + }, } fb.buildFlowGraph([]ast.MicroflowStatement{stmt}, nil) se := startEventOf(fb.objects) @@ -52,7 +70,7 @@ func buildFlowWithStart(t *testing.T, start *model.Point) *microflows.StartEvent // The stored position wins over the derived one. func TestStartEvent_PreservesStoredPosition(t *testing.T) { - se := buildFlowWithStart(t, &model.Point{X: 145, Y: 200}) + se := buildFlowWithStart(t, &model.Point{X: 145, Y: 200}, nil) if se.Position.X != 145 || se.Position.Y != 200 { t.Errorf("StartEvent at %d;%d, want 145;200 — a hand-laid-out start must survive a rebuild", se.Position.X, se.Position.Y) @@ -62,8 +80,182 @@ func TestStartEvent_PreservesStoredPosition(t *testing.T) { // With nothing stored (a fresh CREATE) the derived placement is unchanged: // one spacing unit left of the first annotated activity. func TestStartEvent_DerivesWhenNothingStored(t *testing.T) { - se := buildFlowWithStart(t, nil) + se := buildFlowWithStart(t, nil, nil) if want := 260 - HorizontalSpacing; se.Position.X != want { t.Errorf("StartEvent at %d, want %d (derived)", se.Position.X, want) } } + +// @start(x, y) beats a carried-over position: it states where the start goes, +// where the carry-over only infers it. Without this there is no way to move a +// start once one has been preserved. (#951) +func TestStartEvent_ExplicitAnnotationBeatsTheCarriedOverPosition(t *testing.T) { + se := buildFlowWithStart(t, &model.Point{X: 145, Y: 200}, &ast.Position{X: 500, Y: 620}) + if se.Position.X != 500 || se.Position.Y != 620 { + t.Errorf("StartEvent at %d;%d, want 500;620 — @start must win over the carry-over", + se.Position.X, se.Position.Y) + } +} + +// --- Which stored positions are carried over at all (#951) --- + +// oneActivityFlow is a stored flow: start → activity → end, with the start and +// the activity where the caller puts them. +func oneActivityFlow(start, activity model.Point) *microflows.MicroflowObjectCollection { + se := µflows.StartEvent{BaseMicroflowObject: mkObj("start")} + se.Position = start + act := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("act")}, + } + act.Position = activity + end := µflows.EndEvent{BaseMicroflowObject: mkObj("end")} + return µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{se, act, end}, + Flows: []*microflows.SequenceFlow{mkFlow("start", "act"), mkFlow("act", "end")}, + } +} + +func TestAuthoredStartPosition(t *testing.T) { + cases := []struct { + name string + start model.Point + activity model.Point + want *model.Point + }{{ + // #884: Studio Pro put the start at 145 where the layout would put 100. + name: "hand-placed start is carried over", + start: model.Point{X: 145, Y: 200}, + activity: model.Point{X: 260, Y: 200}, + want: &model.Point{X: 145, Y: 200}, + }, { + // #951: mxcli's own arithmetic, handed back. Carrying it over is what + // stranded the start when the next rewrite moved the activities. + name: "start at the derived spot is not carried over", + start: model.Point{X: 40, Y: 200}, + activity: model.Point{X: 200, Y: 200}, + want: nil, + }, { + // Same X, different centre line — a start left on the old baseline + // while the flow moved down is exactly the diagonal from #951. + name: "start off the activity centre line is carried over", + start: model.Point{X: 200, Y: 200}, + activity: model.Point{X: 360, Y: 340}, + want: &model.Point{X: 200, Y: 200}, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := authoredStartPosition(oneActivityFlow(tc.start, tc.activity)) + switch { + case tc.want == nil && got != nil: + t.Fatalf("carried over %d;%d, want nothing carried over — the rebuild must "+ + "re-derive it from wherever the activities now are", got.X, got.Y) + case tc.want != nil && got == nil: + t.Fatalf("carried over nothing, want %d;%d — a position nobody can "+ + "reconstruct by arithmetic must survive the rebuild", tc.want.X, tc.want.Y) + case tc.want != nil && *got != *tc.want: + t.Fatalf("carried over %d;%d, want %d;%d", got.X, got.Y, tc.want.X, tc.want.Y) + } + }) + } +} + +// A flow the derivation cannot be run against yields no carry-over rather than a +// wrong one: with no first object there is nothing to compare the start to, so +// its provenance is unknown and inventing an answer either pins a derived start +// or discards a hand-placed one. +func TestAuthoredStartPosition_UnderivableFlowsCarryNothing(t *testing.T) { + se := µflows.StartEvent{BaseMicroflowObject: mkObj("start")} + se.Position = model.Point{X: 145, Y: 200} + + for _, tc := range []struct { + name string + oc *microflows.MicroflowObjectCollection + }{ + {"nil collection", nil}, + {"no start event", µflows.MicroflowObjectCollection{}}, + {"start flows nowhere", µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{se}, + }}, + {"start flows to an object that is not in the collection", µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{se}, + Flows: []*microflows.SequenceFlow{mkFlow("start", "missing")}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := authoredStartPosition(tc.oc); got != nil { + t.Errorf("carried over %d;%d, want nothing", got.X, got.Y) + } + }) + } +} + +// derivedStartPosition is buildFlowGraph's placement rule read backwards, so the +// two have to agree — a builder that changed its spacing or its centre line +// while the reader did not would classify every generated start as hand-placed +// and pin it, which is #951 again with a longer fuse. +// +// Asserted by running the builder and the reader over the same flow rather than +// by restating the arithmetic, which would pass against either one being wrong. +func TestDerivedStartPositionMatchesTheBuilder(t *testing.T) { + built := buildFlowWithStart(t, nil, nil) + + act := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("act")}, + } + act.Position = model.Point{X: 260, Y: 200} + derived, ok := derivedStartPosition(oneActivityFlow(built.Position, act.Position)) + if !ok { + t.Fatal("derivedStartPosition found nothing to derive from") + } + if derived != built.Position { + t.Errorf("the reader derives %d;%d where the builder places %d;%d — "+ + "the two placements have drifted apart", + derived.X, derived.Y, built.Position.X, built.Position.Y) + } +} + +// --- What DESCRIBE emits (#951) --- + +// Both describers, because they are near-duplicates: DESCRIBE MICROFLOW goes +// through formatMicroflowActivities and the ELK/source-map path through +// formatMicroflowActivitiesWithSourceMap. Testing one leaves the round-trip +// depending on which command the author happened to run — the first cut of this +// fix emitted from the source-map one only, and DESCRIBE dropped the line. +func TestDescribeEmitsStartAnnotationOnlyWhenItCarriesInformation(t *testing.T) { + ctx := newTestExecutor().newExecContext(context.Background()) + + describers := map[string]func(*microflows.Microflow) []string{ + "formatMicroflowActivities": func(mf *microflows.Microflow) []string { + return formatMicroflowActivities(ctx, mf, nil, nil) + }, + "formatMicroflowActivitiesWithSourceMap": func(mf *microflows.Microflow) []string { + return formatMicroflowActivitiesWithSourceMap(ctx, mf, nil, nil, nil, 0) + }, + } + + for name, describe := range describers { + t.Run(name+"/hand-placed start is emitted", func(t *testing.T) { + mf := µflows.Microflow{ + ObjectCollection: oneActivityFlow(model.Point{X: 145, Y: 200}, model.Point{X: 260, Y: 200}), + } + out := strings.Join(describe(mf), "\n") + if !strings.Contains(out, "@start(145, 200)") { + t.Errorf("no @start line — a start at 145 cannot be reconstructed from a "+ + "description that does not mention it:\n%s", out) + } + }) + + t.Run(name+"/derived start is not emitted", func(t *testing.T) { + mf := µflows.Microflow{ + ObjectCollection: oneActivityFlow(model.Point{X: 100, Y: 200}, model.Point{X: 260, Y: 200}), + } + out := strings.Join(describe(mf), "\n") + if strings.Contains(out, "@start(") { + t.Errorf("@start emitted for a start the layout would place there anyway — "+ + "every description would grow a line restating its own arithmetic, and "+ + "pin the start of every flow it round-trips:\n%s", out) + } + }) + } +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 8f5c3a2d2..a4f65ecb6 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -1390,6 +1390,7 @@ var knownActivityAnnotations = map[string]bool{ "anchor": true, "curve": true, "merge": true, + "start": true, } // checkUnknownAnnotations rejects an @annotation name the visitor does not @@ -1419,8 +1420,8 @@ func (v *microflowValidator) checkUnknownAnnotations(s ast.MicroflowStatement) { v.addViolation("MDL059", linter.SeverityError, fmt.Sprintf("unknown annotation `@%s` — it parses but does nothing, so whatever it was "+ "meant to express is silently lost", name), - fmt.Sprintf("mxcli implements @position(x, y), @caption, @color, @annotation, @excluded and "+ - "@anchor on a microflow statement. If `@%s` is a typo of one of those, correct it; "+ - "container size and edge geometry are not authorable (upstream #884).", name)) + fmt.Sprintf("mxcli implements @position(x, y), @start(x, y), @caption, @color, @annotation, "+ + "@excluded, @anchor, @curve and @merge on a microflow statement. If `@%s` is a typo of "+ + "one of those, correct it; container size is not authorable (upstream #884).", name)) } } diff --git a/mdl/executor/widget_defs.go b/mdl/executor/widget_defs.go index 709476e91..1e8a23467 100644 --- a/mdl/executor/widget_defs.go +++ b/mdl/executor/widget_defs.go @@ -652,10 +652,6 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { projectDir := filepath.Dir(projectPath) widgetsDir := filepath.Join(projectDir, "widgets") defsDir := filepath.Join(projectDir, ".mxcli", "widgets") - docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets") - if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil { - docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets") - } matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk")) if err != nil { @@ -665,12 +661,42 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { return 0, nil } - if err := os.MkdirAll(docsDir, 0755); err != nil { - return 0, fmt.Errorf("failed to create docs directory: %w", err) + // Make sure the .def.json files exist BEFORE rendering. They carry the MDL + // keyword routing — child slots and object lists — and without them the + // generated example collapses to a bare one-liner with no `{ … }` block at + // all. `mxcli widget docs` on a fresh project used to emit exactly that, for + // every widget, with no indication anything was missing: a data grid + // documented as `PLUGGABLEWIDGET '…' widget1` and nothing about columns. + // Only `refresh catalog` happened to generate defs first, so the output + // depended on which command the user had run last. + // + // Best-effort: a project whose defs cannot be extracted still gets docs, just + // the thinner ones — that is strictly better than no docs, and the caller is + // told how many widgets ended up without routing. + if _, defErr := RefreshWidgetDefinitions(projectPath, false, nil); defErr != nil { + log.Printf("warning: widget definitions could not be refreshed, MDL examples will omit child slots: %v", defErr) + } + + docsDirs := WidgetDocsDirs(projectDir) + for _, d := range docsDirs { + if err := os.MkdirAll(d, 0755); err != nil { + return 0, fmt.Errorf("failed to create docs directory: %w", err) + } + } + + // The embedded definitions cover the built-in widgets that are never + // extracted to .def.json. Best-effort: a registry that cannot be built just + // means those widgets fall back to the thinner rendering. + registry, regErr := NewWidgetRegistry() + if regErr != nil { + log.Printf("warning: widget registry unavailable, built-in widgets will omit child slots: %v", regErr) + registry = nil } var generated int var indexEntries []string + var widgetNames []string + var withoutRouting []string for _, mpkPath := range matches { // A bundled .mpk (e.g. Charts.mpk) contains many widgetFiles; ParseMPK @@ -684,11 +710,16 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { for _, mpkDef := range mpkDefs { mdlName := DeriveMDLName(mpkDef.ID) filename := strings.ToLower(mdlName) + ".md" - outPath := filepath.Join(docsDir, filename) - // Load the matching .def.json (may not exist for built-in widgets like - // COMBOBOX / GALLERY — those have hand-crafted definitions in - // sdk/widgets/definitions/ that we don't extract per-project). + // Load the matching .def.json, falling back to the embedded definition. + // + // Built-in widgets like COMBOBOX and GALLERY have hand-crafted + // definitions in sdk/widgets/definitions/ and are deliberately not + // extracted per-project, so their .def.json never exists. Reading only + // from disk therefore documented nine widgets — combobox, gallery, the + // four data-grid filters, dropdownsort, image, barcodescanner — with an + // MDL example missing every child block, as if extraction had failed. + // The registry has had their routing all along. var def *WidgetDefinition defPath := filepath.Join(defsDir, strings.ToLower(mdlName)+".def.json") if data, err := os.ReadFile(defPath); err == nil { @@ -697,41 +728,41 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { def = nil } } + if def == nil && registry != nil { + if embedded, ok := registry.Get(mdlName); ok { + def = embedded + } + } doc := widgetDocMarkdown(mpkDef, def, mdlName) - if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil { - log.Printf("warning: failed to write %s: %v", filename, err) + if writeErr := writeToAll(docsDirs, filename, doc); writeErr != nil { + log.Printf("warning: failed to write %s: %v", filename, writeErr) continue } + if def == nil { + withoutRouting = append(withoutRouting, mdlName) + } kind := "CUSTOMWIDGET" if mpkDef.IsPluggable { kind = "PLUGGABLEWIDGET" } - indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |", - kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties))) + indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | [%s](%s) | `%s` | %s | %d |", + kind, mdlName, filename, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties))) + widgetNames = append(widgetNames, mpkDef.Name) generated++ } } - var indexBuf strings.Builder - indexBuf.WriteString("# Available Widgets\n\n") - indexBuf.WriteString("Auto-generated. See individual files for property details, child slots, and object lists.\n\n") - indexBuf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n") - indexBuf.WriteString("|--------|------|-----------|--------------|-------|\n") - for _, entry := range indexEntries { - indexBuf.WriteString(entry) - indexBuf.WriteString("\n") + skill := widgetSkillMarkdown(indexEntries, widgetNames, withoutRouting) + if err := writeToAll(docsDirs, "SKILL.md", skill); err != nil { + return generated, fmt.Errorf("failed to write skill: %w", err) } - indexBuf.WriteString("\n**Usage in MDL:**\n```sql\n") - indexBuf.WriteString("-- React pluggable widgets\n") - indexBuf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n") - indexBuf.WriteString("-- Legacy custom widgets\n") - indexBuf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n") - indexBuf.WriteString("```\n") - - if err := os.WriteFile(filepath.Join(docsDir, "_index.md"), []byte(indexBuf.String()), 0644); err != nil { - return generated, fmt.Errorf("failed to write index: %w", err) + // `_index.md` was the pre-#906 name, and it is not a skill: a leading + // underscore hides it from a plain glob, and nothing discovered it. Retire it + // so an upgraded project does not keep a second, staler index beside SKILL.md. + for _, d := range docsDirs { + _ = os.Remove(filepath.Join(d, "_index.md")) } return generated, nil @@ -773,22 +804,13 @@ func widgetDocMarkdown(mpkDef *mpk.WidgetDefinition, def *WidgetDefinition, mdlN if len(mpkDef.Properties) > 0 { buf.WriteString("## Properties\n\n") - buf.WriteString("| Property | Type | Required | Default | Description |\n") - buf.WriteString("|----------|------|----------|---------|-------------|\n") + buf.WriteString("| Property | Type | Required | Default | Values / notes | Group | Description |\n") + buf.WriteString("|----------|------|----------|---------|----------------|-------|-------------|\n") for _, prop := range mpkDef.Properties { if prop.IsSystem { continue } - req := "" - if prop.Required { - req = "Yes" - } - desc := prop.Description - if len(desc) > 80 { - desc = desc[:77] + "..." - } - buf.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n", - prop.Key, prop.Type, req, prop.DefaultValue, desc)) + writePropertyRow(&buf, prop, 0) } buf.WriteString("\n") } @@ -825,5 +847,195 @@ func widgetDocMarkdown(mpkDef *mpk.WidgetDefinition, def *WidgetDefinition, mdlN } } + buf.WriteString(fmt.Sprintf("---\n\nRegenerated by `mxcli widget docs` and by `refresh catalog`. "+ + "For the same data live from the `.mpk` — including anything added by a widget upgrade since this "+ + "file was written — run `mxcli widget describe %s -p `.\n", strings.ToLower(mdlName))) + + return buf.String() +} + +// writePropertyRow renders one property, then its children indented beneath it. +// +// The three columns beyond name/type exist because their absence was the whole +// problem: an `enumeration` row showed its default but never its alternatives, +// and an `object` row showed nothing at all about the properties that go inside +// it — so `columns` in a data grid documented itself as "object, required" and +// left the reader unable to write a single column. Measured across a 42-widget +// project: 134 enumeration rows named 0 permitted values, 23 object rows exposed +// 0 children. `mxcli widget describe` had all of it; only this renderer dropped it. +func writePropertyRow(buf *strings.Builder, prop mpk.PropertyDef, depth int) { + req := "" + if prop.Required { + req = "Yes" + } + + // Enumerations name their options, and an object property announces the + // children rendered beneath it. Their absence was the whole problem: an + // `enumeration` row showed its default but never its alternatives, and an + // `object` row said nothing about what goes inside — so `columns` in a data + // grid documented itself as "object, required" and left the reader unable to + // write a single column. Measured across a 42-widget project: 134 enumeration + // rows named 0 permitted values, 23 object rows exposed 0 children. + // `mxcli widget describe` reads the SAME PropertyDef and printed all of it; + // only this renderer dropped it. + var notes []string + if len(prop.EnumValues) > 0 { + keys := make([]string, 0, len(prop.EnumValues)) + for _, v := range prop.EnumValues { + keys = append(keys, "`"+v+"`") + } + notes = append(notes, strings.Join(keys, " \\| ")) + } + if prop.IsList { + notes = append(notes, "list") + } + if prop.OnChange != "" { + notes = append(notes, "on change → `"+prop.OnChange+"`") + } + if len(prop.Children) > 0 { + notes = append(notes, fmt.Sprintf("%d sub-properties below", len(prop.Children))) + } + + name := "`" + prop.Key + "`" + if depth > 0 { + name = strings.Repeat(" ", depth*4) + "↳ `" + prop.Key + "`" + } + + desc := prop.Description + if desc == "" { + desc = prop.Caption + } + + buf.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s | %s |\n", + name, prop.Type, req, cellText(prop.DefaultValue), + strings.Join(notes, "; "), cellText(prop.Category), cellText(desc))) + + for _, child := range prop.Children { + if child.IsSystem { + continue + } + writePropertyRow(buf, child, depth+1) + } +} + +// cellText makes a value safe for a markdown table cell. +// +// Descriptions used to be cut at 77 characters plus an ellipsis, which reliably +// removed the operative half of the sentence ("Must include '%d' to denote number +// posit…"). They are kept whole now; newlines are folded because a table cell +// cannot hold one, and pipes are escaped because an unescaped one silently splits +// the row into extra columns. +func cellText(s string) string { + s = strings.Join(strings.Fields(s), " ") + return strings.ReplaceAll(s, "|", "\\|") +} + +// WidgetDocsDirs returns every skills directory the widget docs belong in. +// +// It used to be either/or — `.ai-context/skills/widgets/` when `.ai-context` +// existed, `.claude/skills/widgets/` otherwise — which meant a Claude project +// with `.ai-context/` present got its bundled skills in `.claude/skills/` and its +// widget docs only in the other tree. `mxcli init` writes skills to both, so +// these follow the same rule. +func WidgetDocsDirs(projectDir string) []string { + var dirs []string + if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil { + dirs = append(dirs, filepath.Join(projectDir, ".ai-context", "skills", "widgets")) + } + if _, err := os.Stat(filepath.Join(projectDir, ".claude")); err == nil { + dirs = append(dirs, filepath.Join(projectDir, ".claude", "skills", "widgets")) + } + if len(dirs) == 0 { + // Neither tree exists yet: keep the historical default so a project that + // has never seen `mxcli init` still gets something. + dirs = append(dirs, filepath.Join(projectDir, ".claude", "skills", "widgets")) + } + return dirs +} + +// writeToAll writes one generated file into every destination directory. +func writeToAll(dirs []string, name, content string) error { + for _, d := range dirs { + if err := os.WriteFile(filepath.Join(d, name), []byte(content), 0644); err != nil { + return err + } + } + return nil +} + +// widgetSkillMarkdown renders the SKILL.md that fronts the per-widget files. +// +// This is the navigation half of the Agent Skills progressive-disclosure shape: +// the description is always loaded, this body loads when the skill is invoked, +// and the per-widget files load only when the body sends a reader to one. The +// description names the project's actual widgets, which a hand-written skill +// cannot do — so "does this project have a chart widget?" is answerable from the +// skill listing alone, at no context cost. +func widgetSkillMarkdown(indexEntries, widgetNames, withoutRouting []string) string { + var buf strings.Builder + + buf.WriteString("---\n") + buf.WriteString("name: widgets\n") + buf.WriteString("description: " + widgetSkillDescription(widgetNames) + "\n") + buf.WriteString("---\n\n") + + buf.WriteString("# Widgets in this project\n\n") + buf.WriteString("Generated from the `.mpk` files in `widgets/` by `mxcli widget docs` and by\n") + buf.WriteString("`refresh catalog`. One file per widget holds its full property table, child\n") + buf.WriteString("slots and object lists — **read the file for the widget you are placing**, not\n") + buf.WriteString("this page.\n\n") + + buf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n") + buf.WriteString("|--------|------|-----------|--------------|-------|\n") + for _, entry := range indexEntries { + buf.WriteString(entry) + buf.WriteString("\n") + } + + buf.WriteString("\n## Usage in MDL\n\n```sql\n") + buf.WriteString("-- React pluggable widgets\n") + buf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n") + buf.WriteString("-- Legacy custom widgets\n") + buf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n") + buf.WriteString("```\n\n") + + buf.WriteString("## When these files are not enough\n\n") + buf.WriteString("They are a snapshot: a widget upgraded since the last `refresh catalog` is\n") + buf.WriteString("described here as it was, not as it is. For the same data read live from the\n") + buf.WriteString("`.mpk`, plus the dynamic visibility rules that are not rendered here at all:\n\n") + buf.WriteString("```bash\n") + buf.WriteString("mxcli widget describe -p # e.g. datagrid, combobox\n") + buf.WriteString("mxcli widget list -p # every widget, one line each\n") + buf.WriteString("```\n\n") + buf.WriteString("Prefer `describe` when a property does not behave as this file says it should.\n") + + if len(withoutRouting) > 0 { + buf.WriteString("\n## Widgets without child-block routing\n\n") + buf.WriteString("mxcli has no MDL child-slot mapping for these, so their example is the widget\n") + buf.WriteString("line alone. For a leaf widget (a filter, an input) that is simply correct. If\n") + buf.WriteString("one of them needs a `{ … }` block, the example will not tell you — check\n") + buf.WriteString("`mxcli widget describe ` for properties of type `widgets` or `object`:\n\n") + for _, n := range withoutRouting { + buf.WriteString("- `" + strings.ToLower(n) + "`\n") + } + } + return buf.String() } + +// widgetSkillDescription builds the frontmatter description, naming as many of +// the project's widgets as fit. The names are the point: they let a reader rule +// the skill in or out without opening it. +func widgetSkillDescription(names []string) string { + const maxNames = 12 + shown := names + suffix := "" + if len(names) > maxNames { + shown = names[:maxNames] + suffix = fmt.Sprintf(" and %d more", len(names)-maxNames) + } + list := strings.Join(shown, ", ") + suffix + return fmt.Sprintf("%q", "The pluggable and custom widgets installed in THIS project and how to write them in MDL — "+ + list+". Use before placing any PLUGGABLEWIDGET or CUSTOMWIDGET on a page, or when a widget's "+ + "property names, enumeration values or child blocks need checking.") +} diff --git a/mdl/executor/widget_docs_skill_test.go b/mdl/executor/widget_docs_skill_test.go new file mode 100644 index 000000000..607eb01c9 --- /dev/null +++ b/mdl/executor/widget_docs_skill_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +// The generated widget docs are read by agents and by nothing else — no Go code +// loads them back — so their content IS the contract. These tests pin the three +// facts that were missing, each measured on a 42-widget project before the fix: +// 134 enumeration rows named 0 permitted values, 23 object rows exposed 0 +// children, and 75 descriptions were cut mid-sentence at 77 characters. + +func enumProp() mpk.PropertyDef { + return mpk.PropertyDef{ + Key: "itemSelectionMethod", Type: "enumeration", Required: true, + DefaultValue: "checkbox", EnumValues: []string{"checkbox", "rowClick"}, + Category: "General::General", Description: "Selection method", + } +} + +func objectProp() mpk.PropertyDef { + return mpk.PropertyDef{ + Key: "columns", Type: "object", Required: true, IsList: true, + Category: "General::Columns", + Children: []mpk.PropertyDef{ + {Key: "showContentAs", Type: "enumeration", Required: true, + DefaultValue: "attribute", EnumValues: []string{"attribute", "dynamicText"}}, + {Key: "header", Type: "textTemplate"}, + {Key: "hidden", Type: "boolean", IsSystem: true}, + }, + } +} + +// An enumeration that shows its default but not its alternatives cannot be +// filled in from the doc. `mxcli widget describe` reads the same PropertyDef and +// always printed `{checkbox|rowClick}`; only this renderer dropped it. +func TestWidgetDocRendersEnumerationValues(t *testing.T) { + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Y", Name: "Y", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{enumProp()}, + }, nil, "Y") + + for _, want := range []string{"`checkbox`", "`rowClick`"} { + if !strings.Contains(doc, want) { + t.Errorf("enumeration value %s missing; the reader cannot know what is allowed:\n%s", want, doc) + } + } +} + +// An `object` row that says only "object, required" leaves the reader unable to +// write a single entry of it. The children are on the same struct. +func TestWidgetDocRendersNestedObjectChildren(t *testing.T) { + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Grid", Name: "Grid", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{objectProp()}, + }, nil, "Grid") + + for _, want := range []string{"showContentAs", "header", "sub-properties below"} { + if !strings.Contains(doc, want) { + t.Errorf("%q missing from the rendered doc:\n%s", want, doc) + } + } + if !strings.Contains(doc, "↳") { + t.Error("children are not visibly nested under their parent") + } + // System properties are not authorable in MDL and were already excluded at + // the top level; the recursion must not reintroduce them. + if strings.Contains(doc, "hidden") { + t.Error("a system sub-property was rendered") + } +} + +// Descriptions were cut at 77 characters plus an ellipsis, which reliably +// removed the operative half ("Must include '%d' to denote number posit..."). +func TestWidgetDocKeepsWholeDescriptions(t *testing.T) { + long := "Must include '%d' to denote number position, and the row count is substituted at runtime before the string reaches assistive technology." + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Y", Name: "Y", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{{Key: "selectedCountTemplatePlural", Type: "textTemplate", Description: long}}, + }, nil, "Y") + + if strings.Contains(doc, "...") { + t.Error("a description was truncated") + } + if !strings.Contains(doc, "assistive technology") { + t.Error("the end of the description was lost") + } +} + +// A pipe in a description silently splits the markdown row into extra columns, +// so the table stops parsing from that row on. +func TestWidgetDocEscapesPipesInCells(t *testing.T) { + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Y", Name: "Y", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{{Key: "mode", Type: "string", Description: "one | two"}}, + }, nil, "Y") + + for _, line := range strings.Split(doc, "\n") { + if strings.Contains(line, "`mode`") { + if strings.Count(line, "|")-strings.Count(line, `\|`) != 8 { + t.Errorf("row has the wrong column count, an unescaped pipe leaked: %q", line) + } + } + } +} + +// The skill front page must be a real skill: frontmatter, and a description that +// names the project's own widgets — the thing a hand-written skill cannot do, +// and what lets a reader rule it in or out without opening it. +func TestWidgetSkillMarkdownIsADiscoverableSkill(t *testing.T) { + skill := widgetSkillMarkdown( + []string{"| `PLUGGABLEWIDGET` | [BADGE](badge.md) | `com.x.Badge` | Badge | 3 |"}, + []string{"Badge", "Data grid 2"}, nil) + + if !strings.HasPrefix(skill, "---\nname: widgets\ndescription: ") { + t.Fatalf("no Agent Skills frontmatter:\n%s", skill[:min(200, len(skill))]) + } + for _, want := range []string{"Badge", "Data grid 2"} { + if !strings.Contains(skill, want) { + t.Errorf("the description does not name %q, so the listing cannot answer what this project has", want) + } + } + // It routes onward rather than trying to be the whole reference. + if !strings.Contains(skill, "mxcli widget describe") { + t.Error("no pointer to the always-fresh command") + } + if !strings.Contains(skill, "[BADGE](badge.md)") { + t.Error("the index does not link the per-widget files, so nothing tells a reader they exist") + } +} + +// End to end: `widget docs` must write a usable skill into every skills tree, +// retire the old `_index.md`, and never leave the pre-#906 name behind. +func TestRegenerateWidgetDocsWritesSkillToBothTrees(t *testing.T) { + dir := t.TempDir() + projectPath := filepath.Join(dir, "App.mpr") + if err := os.MkdirAll(filepath.Join(dir, "widgets"), 0o755); err != nil { + t.Fatal(err) + } + for _, d := range []string{".ai-context", ".claude"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + const fixture = "../../testdata/expr-checker/widgets/Charts.mpk" + src, err := os.ReadFile(fixture) + if err != nil { + t.Skipf("Charts.mpk fixture not available: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "widgets", "Charts.mpk"), src, 0o644); err != nil { + t.Fatal(err) + } + + // A project upgraded from an older mxcli still carries the underscore index. + for _, d := range []string{".ai-context", ".claude"} { + wd := filepath.Join(dir, d, "skills", "widgets") + if err := os.MkdirAll(wd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wd, "_index.md"), []byte("# old\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := RegenerateWidgetDocs(projectPath); err != nil { + t.Fatalf("RegenerateWidgetDocs: %v", err) + } + + for _, d := range []string{".ai-context", ".claude"} { + wd := filepath.Join(dir, d, "skills", "widgets") + body, err := os.ReadFile(filepath.Join(wd, "SKILL.md")) + if err != nil { + t.Errorf("%s/SKILL.md not written: %v", d, err) + continue + } + if !strings.HasPrefix(string(body), "---\nname: widgets\n") { + t.Errorf("%s/SKILL.md has no frontmatter", d) + } + if _, err := os.Stat(filepath.Join(wd, "_index.md")); !os.IsNotExist(err) { + t.Errorf("%s/_index.md survived; a stale second index sits beside SKILL.md", d) + } + } +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 2c73160e0..cfc03059c 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -326,6 +326,23 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A } seenActivityMetadata = true + case "start": + // @start(x, y) — the StartEvent, the implicit node the flow begins + // at. Written on the FIRST statement, the one the start flows into; + // same positional shape and same reason as @merge, which positions + // the other node that has no statement of its own. (#951) + if params := ann.AnnotationParams(); params != nil { + allParams := params.(*parser.AnnotationParamsContext).AllAnnotationParam() + if len(allParams) >= 2 { + result.Start = &ast.Position{ + X: parseAnnotationParamInt(allParams[0]), + Y: parseAnnotationParamInt(allParams[1]), + } + hasAny = true + } + } + seenActivityMetadata = true + case "curve": // @curve(from: (40, -90), to: (-40, 90)) — the bezier control // vectors of the flow LEAVING this statement. Needed no grammar diff --git a/mdl/visitor/visitor_start_annotation_test.go b/mdl/visitor/visitor_start_annotation_test.go new file mode 100644 index 000000000..3014c772b --- /dev/null +++ b/mdl/visitor/visitor_start_annotation_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Tests for @start(x, y) — the StartEvent's position, written on the first +// statement because the start has no statement of its own. Same shape and same +// reason as @merge, which positions the other implicit node. +// +// The builder-side tests construct ast.ActivityAnnotations directly, so they +// pass whether or not @start survives the grammar and the visitor. This is the +// half that proves an author can actually write it. (upstream #951) +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestStartAnnotation_ReachesTheAST(t *testing.T) { + stmt := firstStatement(t, "@start(145, 200)\n@position(260, 200)\nlog info node 'App' 'hi';") + + log, ok := stmt.(*ast.LogStmt) + if !ok { + t.Fatalf("expected LogStmt, got %T", stmt) + } + if log.Annotations == nil || log.Annotations.Start == nil { + t.Fatal("@start did not reach the AST — it parses as a bare annotation name, " + + "so a missing visitor arm loses it in silence") + } + if got := *log.Annotations.Start; got.X != 145 || got.Y != 200 { + t.Errorf("@start parsed as %d;%d, want 145;200", got.X, got.Y) + } + // @start must not consume the statement's own @position. + if log.Annotations.Position == nil { + t.Fatal("@position was lost") + } + if got := *log.Annotations.Position; got.X != 260 || got.Y != 200 { + t.Errorf("@position parsed as %d;%d, want 260;200", got.X, got.Y) + } +} + +// Negative coordinates: Studio Pro's canvas origin is not a corner, so a start +// dragged up and left of the first activity is ordinary, not malformed. +func TestStartAnnotation_AcceptsNegativeCoordinates(t *testing.T) { + stmt := firstStatement(t, "@start(-40, -120)\nlog info node 'App' 'hi';") + log := stmt.(*ast.LogStmt) + if log.Annotations == nil || log.Annotations.Start == nil { + t.Fatal("@start did not reach the AST") + } + if got := *log.Annotations.Start; got.X != -40 || got.Y != -120 { + t.Errorf("@start parsed as %d;%d, want -40;-120", got.X, got.Y) + } +} + +// A known annotation name must not land in UnknownNames, or MDL059 rejects the +// very statement DESCRIBE just wrote. +func TestStartAnnotation_IsNotReportedUnknown(t *testing.T) { + stmt := firstStatement(t, "@start(145, 200)\nlog info node 'App' 'hi';") + log := stmt.(*ast.LogStmt) + if n := log.Annotations.UnknownNames; len(n) > 0 { + t.Errorf("@start recorded as unknown %v — MDL059 would reject it", n) + } +}