Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/commands/mendix/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ mxcli lint -p app.mpr --exclude System --exclude Administration
| MPR006 | quality | EmptyContainer - Empty layout containers |
| MPR007 | security | PageNavigationSecurity - Navigation pages need allowed roles (CE0557); every user role must be able to open its home page (at Production security, else CE2729 per widget) |
| MPR012 | correctness | LegacyImageWidget - staticimage/dynamicimage are unsupported by the React client (CE0582) |
| MPR013 | correctness | SnippetCallAppearance - Class/Style/DynamicClasses/design properties on a snippet call never reach the page (it renders no element) |
| SEC001 | security | NoEntityAccessRules - Persistent entities need access rules |
| SEC002 | security | WeakPasswordPolicy - Password minimum length should be 8+ |
| SEC003 | security | DemoUsersActive - Demo users should be off at Production security |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`snippetcall sc (snippet: M.S, class: 'x')` / `alter page M.P { set (Class: 'x') on sc }` passed check, exec and mx check (0 errors) and describe showed the class, but nothing in the running app carried it (mendixlabs/mxcli#1511)", "cause": "Not an mxcli defect: a Forms$SnippetCallWidget renders no DOM element - the snippet's widgets are inlined into the parent - so its Appearance (Class, Style, DynamicClasses, DesignProperties) has nothing to land on. Measured on 11.15.0 with run --local (hsqldb) + playwright check: .probe-x / .probe-dyn / the red border / pull-right / hide-tablet = 0 elements; the snippet's own container was a direct child of the enclosing container; a container on the same page with the same four settings carried all of them (control)", "file": "`mdl/executor/validate_snippetcall_appearance.go` (MDL-WIDGET48: `validateSnippetCallAppearance` in the widget-tree walk for CREATE / ALTER INSERT / REPLACE; `validateStoredSnippetCallAppearance` in the reference pass for ALTER PAGE SET and ALTER STYLING, typed via `widgetStorageTyper`), `mdl/linter/rules/snippet_call_appearance.go` (MPR013: catalog Class/Style/DynamicClasses + design properties read from the raw unit)", "insight": "**Valid BSON, a clean mx check and a correct describe prove nothing about whether an appearance setting renders** - only the DOM does. The positive control (same settings on a container on the same page) is what makes a 0-count meaningful: without it a stale build or a wrong selector reads the same. ALTER ... SET names a STORED widget, so whether it is a snippet call is only answerable against the document; `widgetStorageTyper` (pagemutator) already gives the $Type, so the check runs in the reference pass and appends a warning (never blocks). Clearing a class on a snippet call is the fix and must stay silent.", "refs": ["mendixlabs/mxcli#1511", "ako/mxcli#1393"], "rules": ["MDL-WIDGET48", "MPR013"]}
22 changes: 12 additions & 10 deletions .claude/skills/mendix/create-page/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ description: "CREATE PAGE syntax reference — parameters, variables, layouts, a
|---|---|
| 28-43 | Reference files |
| 44-46 | Overview |
| 47-196 | Syntax |
| 197-267 | Basic Examples |
| 268-289 | Modifying Existing Pages |
| 290-393 | Conditional Visibility and Editability |
| 394-558 | Known Limitations |
| 559-567 | Tips |
| 568-594 | Related Commands |
| 595-619 | PLUGGABLEWIDGET Escape Hatch |
| 620-623 | See Also |
| 47-198 | Syntax |
| 199-269 | Basic Examples |
| 270-291 | Modifying Existing Pages |
| 292-395 | Conditional Visibility and Editability |
| 396-560 | Known Limitations |
| 561-569 | Tips |
| 570-596 | Related Commands |
| 597-621 | PLUGGABLEWIDGET Escape Hatch |
| 622-625 | See Also |

<!-- /skill-index -->
# CREATE PAGE - MDL Syntax Guide
Expand Down Expand Up @@ -130,7 +130,9 @@ create page MyModule.OrderDetail folder 'Orders/Details'

### Styling: Class, Style, and DesignProperties

Three styling mechanisms can be applied to any widget:
Three styling mechanisms can be applied to any widget — except a `snippetcall`,
which renders no element of its own, so its styling never reaches the page
(MDL-WIDGET48 / lint MPR013); style a container around or inside it instead:

**CSS Class** — Atlas UI utility classes or custom CSS classes:
```sql
Expand Down
14 changes: 14 additions & 0 deletions .claude/skills/mendix/create-page/reference/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,20 @@ snippetcall snippetName (snippet: Module.SnippetName)
snippetcall actions (snippet: Module.EntityActions, params: (entity = $Param))
```

**A snippet call renders no element of its own** — the snippet's widgets are
inlined into the parent. So `Class`, `Style`, `DynamicClasses` and
`DesignProperties` on a `snippetcall` never reach the page: valid, `mx check`
clean, and silently useless (measured on Mendix 11.15). `mxcli check` warns
**MDL-WIDGET48** (also for `alter page … { set (Class: …) on <snippetcall> }` and
`alter styling` on one) and `mxcli lint` reports existing ones as **MPR013**.
Style a container instead — inside the snippet, or around the call:

```sql
container cNav (class: 'sidebar') {
snippetcall scNav (snippet: Module.NavigationMenu)
}
```

**A parameter satisfied by the enclosing data context takes NO mapping.** Mendix
has no variable meaning "the surrounding context" — the correct model is the
*absence* of a mapping, which is what Studio Pro writes. So inside a DataView
Expand Down
1 change: 1 addition & 0 deletions cmd/mxcli/cmd_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ func builtinLintRules() []linter.Rule {
rules.NewValidationFeedbackRule(),
rules.NewImageSourceRule(),
rules.NewLegacyImageWidgetRule(),
rules.NewSnippetCallAppearanceRule(),
rules.NewEmptyContainerRule(),
rules.NewGallerySelectionListenerRule(),
rules.NewDataViewLayoutGridRule(),
Expand Down
2 changes: 1 addition & 1 deletion cmd/mxcli/syntax/features_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ LIST IMPACT OF htmlelement;
"snippet", "snippets", "reusable", "snippetcall",
"page fragment", "component",
},
Syntax: "CREATE SNIPPET Module.Name [FOLDER 'path']\n [( Params: ( $P: Module.Entity ) )] -- parameters are entities only\n {\n -- widgets (same as page)\n }\n\n-- Embed in a page:\nSNIPPETCALL scName (Snippet: Module.SnippetName)",
Syntax: "CREATE SNIPPET Module.Name [FOLDER 'path']\n [( Params: ( $P: Module.Entity ) )] -- parameters are entities only\n {\n -- widgets (same as page)\n }\n\n-- Embed in a page:\nSNIPPETCALL scName (Snippet: Module.SnippetName)\n-- A snippet call renders no element: Class / Style / DynamicClasses /\n-- DesignProperties on it never reach the page (MDL-WIDGET48). Style a\n-- container around the call, or inside the snippet, instead.",
Example: "CREATE SNIPPET MyModule.CustomerInfo (\n Params: ( $Customer: MyModule.Customer )\n)\n{\n DATAVIEW dv (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n TEXTBOX txtEmail (Label: 'Email', Attribute: Email)\n }\n};",
SeeAlso: []string{"snippet.create", "snippet.alter", "page"},
})
Expand Down
14 changes: 14 additions & 0 deletions docs-site/src/language/widget-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,20 @@ Embeds a snippet (reusable page fragment) into the page:
SNIPPETCALL scNav (Snippet: MyModule.NavigationMenu)
```

**A snippet call renders no element of its own** — the snippet's widgets are
inlined into the parent. So `Class`, `Style`, `DynamicClasses` and
`DesignProperties` on a `snippetcall` never reach the page: valid, `mx check`
clean, and silently useless (measured on Mendix 11.15). `mxcli check` warns
**MDL-WIDGET48** (also for `alter page … { set (Class: …) on <snippetcall> }` and
`alter styling` on one) and `mxcli lint` reports existing ones as **MPR013**.
Style a container instead — inside the snippet, or around the call:

```sql
container cNav (class: 'sidebar') {
snippetcall scNav (snippet: MyModule.NavigationMenu)
}
```

## Navigation Widgets

### NAVIGATIONLIST
Expand Down
49 changes: 49 additions & 0 deletions mdl-examples/bug-tests/1511-snippetcall-class-style.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
mdl 1;
-- ============================================================================
-- mendixlabs/mxcli#1511 — Class / Style on a snippet call never reach the page
-- ============================================================================
--
-- A snippet call renders no element of its own: the snippet's widgets are
-- inlined into the parent. So the Class, Style, DynamicClasses and design
-- properties on `scStyled` are valid, `mx check` clean, and silently useless —
-- measured on Mendix 11.15.0 (run --local + playwright check): no element
-- carries `.probe-x`, `.probe-dyn`, the red border, `pull-right` or
-- `hide-tablet`, while container `ctl` with the same settings carries them all.
--
-- Expected: `mxcli check` warns MDL-WIDGET48 once, on `scStyled` only (not on
-- `cWrap` or `ctl`, which are the fix and the control). Applied, `mxcli lint`
-- reports MPR013 on `scStyled`. After applying, a separate script with
-- alter page Bug1511.ProbePage { set (Class: 'x') on scPlain };
-- warns MDL-WIDGET48 under `check -p … --references` (the target's type lives
-- in the stored page, so only the reference pass can tell it is a snippet call).

create module Bug1511;

create snippet Bug1511.ProbeSnippet {
container scInner { dynamictext scTxt (Content: 'Inside the snippet') }
};

create page Bug1511.ProbePage ( Title: 'Probe', Layout: Atlas_Core.Atlas_Default, Url: 'probe1511' ) {
container cOuter (Class: 'probe-outer') {
snippetcall scStyled (
Snippet: Bug1511.ProbeSnippet,
Class: 'probe-x',
Style: 'border: 3px solid red',
DynamicClasses: 'probe-dyn',
DesignProperties: ('Align self': 'Right', 'Hide on': ('Tablet': on))
)
}
-- the fix: style a container around the call
container cWrap (Class: 'probe-wrap') {
snippetcall scPlain (Snippet: Bug1511.ProbeSnippet)
}
-- the control: the same settings on a container do render
container ctl (
Class: 'probe-ctl',
Style: 'border: 3px solid blue',
DynamicClasses: 'probe-ctl-dyn',
DesignProperties: ('Align self': 'Left', 'Hide on': ('Desktop': on))
) {
dynamictext ctlTxt (Content: 'Control container')
}
};
2 changes: 2 additions & 0 deletions mdl/executor/cmd_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func execLint(ctx *ExecContext, s *ast.LintStmt) error {
rules.NewValidationFeedbackRule(),
rules.NewImageSourceRule(),
rules.NewLegacyImageWidgetRule(),
rules.NewSnippetCallAppearanceRule(),
rules.NewMissingTranslationsRule(),
rules.NewRequiredCaptionDefaultLanguageRule(),
rules.NewGallerySelectionListenerRule(),
Expand Down Expand Up @@ -153,6 +154,7 @@ func listLintRules(ctx *ExecContext) error {
lint.AddRule(rules.NewValidationFeedbackRule())
lint.AddRule(rules.NewImageSourceRule())
lint.AddRule(rules.NewLegacyImageWidgetRule())
lint.AddRule(rules.NewSnippetCallAppearanceRule())
lint.AddRule(rules.NewMissingTranslationsRule())
lint.AddRule(rules.NewRequiredCaptionDefaultLanguageRule())
lint.AddRule(rules.NewGallerySelectionListenerRule())
Expand Down
4 changes: 4 additions & 0 deletions mdl/executor/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,10 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error,
// widget that is already stored, so its property can only be resolved
// against the document — which is why it passed check and failed exec.
errors = append(errors, validateAlterSetProperties(ctx, prog, sc)...)
// A SET or ALTER STYLING that styles a stored snippet call: valid, and
// useless — a snippet call renders no element (mendixlabs/mxcli#1511).
// What the target IS lives only in the document. Warns, never blocks.
validateStoredSnippetCallAppearance(ctx, prog, sc)
// The entity an ALTER's INSERT / REPLACE binds against is in the stored
// document, not the statement. Where it has none (a missing flow source, or
// no data container at all) a bare binding is written bare and the project
Expand Down
202 changes: 202 additions & 0 deletions mdl/executor/validate_snippetcall_appearance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"fmt"
"sort"
"strings"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/mdl/backend"
"github.com/mendixlabs/mxcli/mdl/linter"
"github.com/mendixlabs/mxcli/model"
)

// A snippet call renders no element of its own (mendixlabs/mxcli#1511).
//
// The snippet's widgets are inlined into the parent, so the Class, Style,
// DynamicClasses and design properties a Forms$SnippetCallWidget carries have
// nothing to land on. Measured on Mendix 11.15.0 with `run --local` and
// `playwright check`: a snippetcall carrying Class 'probe-x', Style
// 'border: 3px solid red', DynamicClasses 'probe-dyn' and the design properties
// 'Align self': 'Right' / 'Hide on': Tablet produced no element with any of
// them — the snippet's own container was a DIRECT child of the enclosing
// container — while a container on the same page with the same four settings
// carried every one. check, exec and mx check (0 errors) were all clean.
//
// A warning, not an error: the model is valid and Studio Pro stores it, the
// setting is only useless. The lint rule MPR013 reports the same for a project
// already holding such a call.

const snippetCallAppearanceRule = "MDL-WIDGET48"

const snippetCallAppearanceSuggestion = "Put the class or style on a container inside the snippet, " +
"or wrap the snippet call in a container and style that container."

// snippetCallAppearanceMessage is the one wording the CREATE and the stored
// (SET / ALTER STYLING) paths share.
func snippetCallAppearanceMessage(widget string, fields []string) string {
return fmt.Sprintf("snippet call `%s` sets %s, but a snippet call renders no element of its own "+
"(the snippet's widgets are inlined into the parent), so the setting never reaches the page",
widget, strings.Join(fields, ", "))
}

// validateSnippetCallAppearance reports a snippetcall the script writes with
// appearance settings — CREATE PAGE / SNIPPET and ALTER PAGE INSERT / REPLACE.
func validateSnippetCallAppearance(w *ast.WidgetV3, locationPrefix string) []linter.Violation {
if w == nil || !strings.EqualFold(w.Type, "snippetcall") {
return nil
}
var fields []string
if strings.TrimSpace(w.GetClass()) != "" {
fields = append(fields, "Class")
}
if strings.TrimSpace(w.GetStyle()) != "" {
fields = append(fields, "Style")
}
if strings.TrimSpace(w.GetDynamicClasses()) != "" {
fields = append(fields, "DynamicClasses")
}
for _, dp := range w.GetDesignProperties() {
fields = append(fields, fmt.Sprintf("design property '%s'", dp.Key))
}
if len(fields) == 0 {
return nil
}
return []linter.Violation{{
RuleID: snippetCallAppearanceRule,
Severity: linter.SeverityWarning,
Message: locationPrefix + ": " + snippetCallAppearanceMessage(w.Name, fields),
Suggestion: snippetCallAppearanceSuggestion,
}}
}

// validateStoredSnippetCallAppearance is the same finding for the statements
// that name a widget already STORED — `alter page … set (Class: …) on sc` (the
// reported case) and `alter styling … widget sc set …`. Whether the target is
// a snippet call lives only in the document, so this runs in the reference pass
// and adds warnings; it never blocks. A document that will not open, or a
// target that does not resolve, is silent: those are other passes' findings.
func validateStoredSnippetCallAppearance(ctx *ExecContext, prog *ast.Program, sc *scriptContext) {
if prog == nil || sc == nil || !ctx.Connected() {
return
}
h, err := getHierarchy(ctx)
if err != nil || h == nil {
return
}
opened := map[model.ID]backend.PageMutator{}
open := func(unitID model.ID) backend.PageMutator {
m, seen := opened[unitID]
if !seen {
m, _ = ctx.Backend.OpenPageForMutation(unitID)
opened[unitID] = m
}
return m
}

for _, stmt := range prog.Statements {
switch s := stmt.(type) {
case *ast.AlterPageStmt:
if !hasSetPropertyOp(s) || alterTargetComesFromScript(sc, s) {
continue
}
unitID, _, kind, err := resolveAlterPageUnit(ctx, s, h)
if err != nil {
continue
}
m := open(unitID)
if m == nil {
continue
}
for _, op := range s.Operations {
set, ok := op.(*ast.SetPropertyOp)
if !ok || set.Target.Widget == "" || set.Target.IsColumn() || !storedIsSnippetCall(m, set.Target.Widget) {
continue
}
var fields []string
for name, value := range set.Properties {
if setsSnippetCallAppearance(ctx, m, set.Target.Widget, name, value) {
fields = append(fields, name)
}
}
warnSnippetCallAppearance(sc, fmt.Sprintf("alter %s %s", kind, s.PageName.String()), set.Target.Widget, fields)
}
case *ast.AlterStylingStmt:
kind := strings.ToLower(s.ContainerType)
var unitID model.ID
switch kind {
case "page":
if sc.pages[s.ContainerName.String()] {
continue
}
pg, err := findPageByName(ctx, s.ContainerName, h)
if err != nil {
continue
}
unitID = pg.ID
case "snippet":
if sc.snippets[s.ContainerName.String()] {
continue
}
snp, _, err := findSnippetByName(ctx, s.ContainerName, h)
if err != nil {
continue
}
unitID = snp.ID
default:
continue
}
m := open(unitID)
if m == nil || !storedIsSnippetCall(m, s.WidgetName) {
continue
}
var fields []string
for _, a := range s.Assignments {
if a.Property == "" || (a.IsCSS && strings.TrimSpace(a.Value) == "") ||
(a.IsToggle && !a.ToggleOn) {
continue
}
if a.IsCSS {
fields = append(fields, a.Property)
} else {
fields = append(fields, fmt.Sprintf("design property '%s'", a.Property))
}
}
warnSnippetCallAppearance(sc, fmt.Sprintf("alter styling on %s %s", kind, s.ContainerName.String()), s.WidgetName, fields)
}
}
}

func warnSnippetCallAppearance(sc *scriptContext, label, widget string, fields []string) {
if len(fields) == 0 {
return
}
sort.Strings(fields)
sc.warnings = append(sc.warnings, fmt.Sprintf("%s: %s [%s] — %s",
label, snippetCallAppearanceMessage(widget, fields), snippetCallAppearanceRule, snippetCallAppearanceSuggestion))
}

// storedIsSnippetCall reports whether the stored widget is a snippet call.
func storedIsSnippetCall(m backend.PageMutator, widget string) bool {
typer, ok := m.(widgetStorageTyper)
if !ok {
return false
}
bsonType, _ := typer.WidgetStorageType(widget)
return bsonType == "Forms$SnippetCallWidget"
}

// setsSnippetCallAppearance reports whether one SET property writes a
// non-empty appearance setting: Class, Style, DynamicClasses, or a design
// property the theme declares for the stored widget. Clearing one — the fix
// this warning asks for — is not reported.
func setsSnippetCallAppearance(ctx *ExecContext, m backend.PageMutator, widget, name string, value any) bool {
switch strings.ToLower(name) {
case "class", "style", "dynamicclasses":
s, ok := value.(string)
return !ok || strings.TrimSpace(s) != ""
}
return designPropertyForStoredWidget(ctx.GetThemeRegistry(), m, widget, name) != nil
}
Loading
Loading