diff --git a/.claude/commands/mendix/lint.md b/.claude/commands/mendix/lint.md index 6263a6896c..8c2def0ef9 100644 --- a/.claude/commands/mendix/lint.md +++ b/.claude/commands/mendix/lint.md @@ -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 | diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-snippetcall-appearance-never-renders.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-snippetcall-appearance-never-renders.json new file mode 100644 index 0000000000..4a557a6658 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-snippetcall-appearance-never-renders.json @@ -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"]} diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 51f834dcf0..767ba259da 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -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 | # CREATE PAGE - MDL Syntax Guide @@ -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 diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 7c18f4c4f6..a8fddacc31 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -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 }` 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 diff --git a/cmd/mxcli/cmd_lint.go b/cmd/mxcli/cmd_lint.go index fb145b0bcb..996d4b54ec 100644 --- a/cmd/mxcli/cmd_lint.go +++ b/cmd/mxcli/cmd_lint.go @@ -417,6 +417,7 @@ func builtinLintRules() []linter.Rule { rules.NewValidationFeedbackRule(), rules.NewImageSourceRule(), rules.NewLegacyImageWidgetRule(), + rules.NewSnippetCallAppearanceRule(), rules.NewEmptyContainerRule(), rules.NewGallerySelectionListenerRule(), rules.NewDataViewLayoutGridRule(), diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index d746a7fcfe..7b1d11757d 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -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"}, }) diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 8c819ce0b7..ed65caf642 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -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 }` 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 diff --git a/mdl-examples/bug-tests/1511-snippetcall-class-style.mdl b/mdl-examples/bug-tests/1511-snippetcall-class-style.mdl new file mode 100644 index 0000000000..23bfc89200 --- /dev/null +++ b/mdl-examples/bug-tests/1511-snippetcall-class-style.mdl @@ -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') + } +}; diff --git a/mdl/executor/cmd_lint.go b/mdl/executor/cmd_lint.go index 634db86eb5..d19b7dc296 100644 --- a/mdl/executor/cmd_lint.go +++ b/mdl/executor/cmd_lint.go @@ -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(), @@ -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()) diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 5775f890ba..0cabf9586c 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -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 diff --git a/mdl/executor/validate_snippetcall_appearance.go b/mdl/executor/validate_snippetcall_appearance.go new file mode 100644 index 0000000000..54461ec0fd --- /dev/null +++ b/mdl/executor/validate_snippetcall_appearance.go @@ -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 +} diff --git a/mdl/executor/validate_snippetcall_appearance_test.go b/mdl/executor/validate_snippetcall_appearance_test.go new file mode 100644 index 0000000000..596e6f64a9 --- /dev/null +++ b/mdl/executor/validate_snippetcall_appearance_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/backend/pagemutator" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1511. A snippet call renders no element of its own: the +// snippet's widgets are inlined into the parent. Measured on Mendix 11.15.0 +// (`run --local`, `playwright check`): a snippetcall carrying Class 'probe-x', +// Style 'border: 3px solid red', DynamicClasses 'probe-dyn' and the design +// properties 'Align self' / 'Hide on' put NONE of them in the DOM — the +// snippet's container was a direct child of the enclosing container — while a +// container on the same page with the same four settings carried all of them. +// check, exec and mx check were all clean, so nothing told the author. + +func snippetCallRuleCount(t *testing.T, src string) int { + t.Helper() + n := 0 + for _, v := range ValidateWidgetProperties(parseMDL(t, src), "") { + if v.RuleID == "MDL-WIDGET48" { + n++ + } + } + return n +} + +func TestCheck1511_SnippetCallAppearanceWarns(t *testing.T) { + cases := map[string]string{ + "class": `class: 'probe-x'`, + "style": `style: 'border: 3px solid red'`, + "dynamic classes": `dynamicclasses: '''probe-dyn'''`, + "design property": `designproperties: ('Align self': 'Right')`, + "several at once": `class: 'a', style: 'color: red'`, + } + for name, props := range cases { + t.Run(name, func(t *testing.T) { + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + container c1 { snippetcall sc1 (snippet: M.S, ` + props + `) } +};` + if got := snippetCallRuleCount(t, src); got != 1 { + t.Errorf("MDL-WIDGET48 reported %d times, want 1", got) + } + }) + } +} + +// The message has to say why and what to do instead, or it is one more +// warning to ignore. +func TestCheck1511_SnippetCallAppearanceMessage(t *testing.T) { + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + snippetcall sc1 (snippet: M.S, class: 'x', style: 'color: red') +};` + for _, v := range ValidateWidgetProperties(parseMDL(t, src), "") { + if v.RuleID != "MDL-WIDGET48" { + continue + } + for _, want := range []string{"sc1", "Class", "Style", "renders no element"} { + if !strings.Contains(v.Message, want) { + t.Errorf("message %q lacks %q", v.Message, want) + } + } + if !strings.Contains(v.Suggestion, "container") { + t.Errorf("suggestion %q does not point at a container", v.Suggestion) + } + return + } + t.Fatal("no MDL-WIDGET48 reported") +} + +// The controls: a bare snippet call, and the same settings on a container, +// must stay silent. A rule that fires on the fix it recommends is worse than +// no rule. +func TestCheck1511_SnippetCallAppearanceControls(t *testing.T) { + for name, src := range map[string]string{ + "bare snippet call": `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + snippetcall sc1 (snippet: M.S) +};`, + "styled container around the call": `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + container c1 (class: 'probe-x', style: 'border: 1px solid', designproperties: ('Align self': 'Right')) { + snippetcall sc1 (snippet: M.S) + } +};`, + } { + t.Run(name, func(t *testing.T) { + if got := snippetCallRuleCount(t, src); got != 0 { + t.Errorf("MDL-WIDGET48 reported %d times, want 0", got) + } + }) + } +} + +// ALTER PAGE INSERT / REPLACE carry the widget, so they are checked without a +// project, like CREATE. +func TestCheck1511_SnippetCallAppearanceInAlterInsert(t *testing.T) { + src := `alter page M.P { insert after c1 { snippetcall sc2 (snippet: M.S, class: 'x') } };` + if got := snippetCallRuleCount(t, src); got != 1 { + t.Errorf("MDL-WIDGET48 reported %d times, want 1", got) + } +} + +// --------------------------------------------------------------------------- +// ALTER PAGE … SET and ALTER STYLING name a STORED widget: what it is lives +// only in the document, so these are judged in the reference pass. +// --------------------------------------------------------------------------- + +func storedSnippetCallPage() bson.D { + appearance := func() bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$Appearance"}, + {Key: "Class", Value: ""}, + {Key: "DesignProperties", Value: bson.A{int32(3)}}, + {Key: "DynamicClasses", Value: ""}, + {Key: "Style", Value: ""}, + } + } + call := bson.D{ + {Key: "$Type", Value: "Forms$SnippetCallWidget"}, + {Key: "Name", Value: "snippetCall1"}, + {Key: "Appearance", Value: appearance()}, + {Key: "FormCall", Value: bson.D{{Key: "$Type", Value: "Forms$SnippetCall"}}}, + } + div := bson.D{ + {Key: "$Type", Value: "Forms$DivContainer"}, + {Key: "Name", Value: "c1"}, + {Key: "Appearance", Value: appearance()}, + {Key: "Widgets", Value: bson.A{int32(2), call}}, + } + return bson.D{ + {Key: "$Type", Value: "Forms$Page"}, + {Key: "FormCall", Value: bson.D{ + {Key: "Arguments", Value: bson.A{ + int32(2), + bson.D{{Key: "Widgets", Value: bson.A{int32(2), div}}}, + }}, + }}, + } +} + +func snippetCallPageCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("M") + pg := mkPage(mod.ID, "P") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{pg}, nil }, + OpenPageForMutationFunc: func(unitID model.ID) (backend.PageMutator, error) { + return pagemutator.New(storedSnippetCallPage(), unitID, &countingDeps{}), nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + ctx.ThemeRegistry = atlasListViewTheme() + return ctx +} + +func storedSnippetCallWarnings(t *testing.T, src string) []string { + t.Helper() + prog := parseMDL(t, src) + sc := newScriptContext() + sc.collectDefinitions(prog) + validateStoredSnippetCallAppearance(snippetCallPageCtx(t), prog, sc) + var out []string + for _, w := range sc.warnings { + if strings.Contains(w, "MDL-WIDGET48") { + out = append(out, w) + } + } + return out +} + +// The reported statement. +func TestCheck1511_AlterSetClassOnStoredSnippetCall(t *testing.T) { + got := storedSnippetCallWarnings(t, `alter page M.P { set (Class: 'x') on snippetCall1 };`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET48 warning, got %v", got) + } + if !strings.Contains(got[0], "snippetCall1") || !strings.Contains(got[0], "Class") { + t.Errorf("warning %q does not name the widget and the property", got[0]) + } +} + +func TestCheck1511_AlterSetStyleAndDesignPropertyOnStoredSnippetCall(t *testing.T) { + got := storedSnippetCallWarnings(t, + `alter page M.P { set (Style: 'color: red', DynamicClasses: '''d''', 'Align self': 'Right') on snippetCall1 };`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET48 warning, got %v", got) + } + for _, want := range []string{"Style", "DynamicClasses", "Align self"} { + if !strings.Contains(got[0], want) { + t.Errorf("warning %q does not name %s", got[0], want) + } + } +} + +func TestCheck1511_AlterStylingOnStoredSnippetCall(t *testing.T) { + got := storedSnippetCallWarnings(t, `alter styling on page M.P widget snippetCall1 set Class = 'x';`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET48 warning, got %v", got) + } +} + +// Controls: the same SET on the container around the call, and a SET of a +// property that is not appearance, are silent. +func TestCheck1511_StoredControlsAreSilent(t *testing.T) { + for _, src := range []string{ + `alter page M.P { set (Class: 'x', Style: 'color: red') on c1 };`, + `alter styling on page M.P widget c1 set Class = 'x';`, + `alter page M.P { set (Class: '') on snippetCall1 };`, + } { + if got := storedSnippetCallWarnings(t, src); len(got) != 0 { + t.Errorf("%s: want no MDL-WIDGET48, got %v", src, got) + } + } +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 8138073c11..6c10f1aaea 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -280,6 +280,9 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // reference MDL cannot author. See validate_widget_image.go. out = append(out, validateImageSource(w, locationPrefix)...) out = append(out, validateStaticWidget(w, locationPrefix)...) + // #1511: a snippet call renders no element, so its Class / Style / + // DynamicClasses / design properties never reach the page. + out = append(out, validateSnippetCallAppearance(w, locationPrefix)...) out = append(out, validateDynamicTextFormatting(w, locationPrefix)...) out = append(out, validateInputFormatting(w, locationPrefix)...) // A text input's SubmitBehaviour and a layout grid's Width (ako/mxcli-ledger FINDINGS #170). diff --git a/mdl/linter/rules/snippet_call_appearance.go b/mdl/linter/rules/snippet_call_appearance.go new file mode 100644 index 0000000000..b16aa3c38c --- /dev/null +++ b/mdl/linter/rules/snippet_call_appearance.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// SnippetCallAppearanceRule reports a snippet call that carries Class, Style, +// DynamicClasses or design properties (mendixlabs/mxcli#1511). +// +// A snippet call renders no element of its own — the snippet's widgets are +// inlined into the parent — so those settings have nothing to land on. +// Measured on Mendix 11.15.0 (`run --local`, `playwright check`): a snippetcall +// with Class 'probe-x', Style 'border: 3px solid red', DynamicClasses +// 'probe-dyn' and the design properties 'Align self' / 'Hide on' produced no +// element carrying any of them; 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 every one (the control). `mx check` reports nothing, so +// without this the setting is silently useless. +// +// `mxcli check` warns the same as MDL-WIDGET48 when a script writes one; this +// rule finds the ones a project already holds, including those authored in +// Studio Pro, which offers the fields on a snippet call. +type SnippetCallAppearanceRule struct{} + +// NewSnippetCallAppearanceRule creates a new snippet call appearance rule. +func NewSnippetCallAppearanceRule() *SnippetCallAppearanceRule { + return &SnippetCallAppearanceRule{} +} + +func (r *SnippetCallAppearanceRule) ID() string { return "MPR013" } +func (r *SnippetCallAppearanceRule) Name() string { return "SnippetCallAppearance" } +func (r *SnippetCallAppearanceRule) Category() string { return "correctness" } +func (r *SnippetCallAppearanceRule) DefaultSeverity() linter.Severity { return linter.SeverityWarning } + +// RequiredCatalogMode: ctx.Widgets() reads widgets_data, which only a full +// catalog build fills. +func (r *SnippetCallAppearanceRule) RequiredCatalogMode() linter.CatalogMode { + return linter.CatalogFull +} + +func (r *SnippetCallAppearanceRule) Description() string { + return "Checks for snippet calls with Class, Style, DynamicClasses or design properties, which never reach the page (a snippet call renders no element)" +} + +const snippetCallWidgetType = "Forms$SnippetCallWidget" + +// Check reports one violation per styled snippet call. Class, Style and +// DynamicClasses come from the catalog; design properties, which the catalog +// does not record, from the stored document when a reader is available. +func (r *SnippetCallAppearanceRule) Check(ctx *linter.LintContext) []linter.Violation { + type call struct { + w linter.Widget + fields []string + } + var calls []*call + byContainer := map[string]map[string]*call{} + + for w := range ctx.Widgets() { + if ctx.IsExcluded(w.ModuleName) || w.WidgetType != snippetCallWidgetType { + continue + } + c := &call{w: w} + if strings.TrimSpace(w.Class) != "" { + c.fields = append(c.fields, "Class") + } + if strings.TrimSpace(w.Style) != "" { + c.fields = append(c.fields, "Style") + } + if strings.TrimSpace(w.DynamicClasses) != "" { + c.fields = append(c.fields, "DynamicClasses") + } + calls = append(calls, c) + if byContainer[w.ContainerID] == nil { + byContainer[w.ContainerID] = map[string]*call{} + } + byContainer[w.ContainerID][w.Name] = c + } + + if reader := ctx.Reader(); reader != nil { + for containerID, named := range byContainer { + raw, err := reader.GetRawUnit(model.ID(containerID)) + if err != nil || raw == nil { + continue + } + for name, keys := range findSnippetCallDesignProperties(raw) { + if c := named[name]; c != nil { + for _, k := range keys { + c.fields = append(c.fields, fmt.Sprintf("design property '%s'", k)) + } + } + } + } + } + + var violations []linter.Violation + for _, c := range calls { + if len(c.fields) == 0 { + continue + } + docType := "page" + if c.w.ContainerType == "SNIPPET" { + docType = "snippet" + } + violations = append(violations, linter.Violation{ + RuleID: r.ID(), + Severity: r.DefaultSeverity(), + Message: fmt.Sprintf("snippet call '%s' in %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", + c.w.Name, c.w.ContainerQualifiedName, strings.Join(c.fields, ", ")), + Location: linter.Location{ + Module: c.w.ModuleName, + DocumentType: docType, + DocumentName: docNameFromQualified(c.w.ContainerQualifiedName), + DocumentID: c.w.ContainerID, + }, + Suggestion: "Put the class or style on a container inside the snippet, " + + "or wrap the snippet call in a container and style that container", + }) + } + return violations +} + +// findSnippetCallDesignProperties walks a raw page or snippet document and +// returns, per snippet call name, the design-property keys it stores. +// +// It visits every nested document rather than following named child slots: a +// snippet call can sit in any widget's children (layout grid columns, tab +// pages, pluggable widget slots, list view templates), and the one thing that +// identifies it is its $Type. +func findSnippetCallDesignProperties(raw map[string]any) map[string][]string { + out := map[string][]string{} + var walk func(v any) + walk = func(v any) { + switch n := v.(type) { + case map[string]any: + if extractStr(n["$Type"]) == snippetCallWidgetType { + if app, ok := n["Appearance"].(map[string]any); ok { + for _, dp := range getBsonArray(app["DesignProperties"]) { + if m, ok := dp.(map[string]any); ok { + if k := extractStr(m["Key"]); k != "" { + name := extractStr(n["Name"]) + out[name] = append(out[name], k) + } + } + } + } + } + for _, child := range n { + walk(child) + } + case []any: + for _, child := range n { + walk(child) + } + } + } + walk(raw) + return out +} diff --git a/mdl/linter/rules/snippet_call_appearance_test.go b/mdl/linter/rules/snippet_call_appearance_test.go new file mode 100644 index 0000000000..48cb1dbaed --- /dev/null +++ b/mdl/linter/rules/snippet_call_appearance_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1511. A snippet call renders no element of its own, so the +// Class / Style / DynamicClasses / design properties it carries never reach the +// DOM — measured on Mendix 11.15.0 with run --local + playwright check, against +// a container on the same page carrying the same settings as the control. + +// snippetCallCatalog is a real catalog holding the given widgets_data rows +// (Id, Name, WidgetType, ContainerId, ContainerQualifiedName, ContainerType, +// ModuleName, Class, Style, DynamicClasses) in module M. +func snippetCallCatalog(t *testing.T, rows ...string) *catalog.Catalog { + t.Helper() + cat, err := catalog.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cat.Close() }) + stmts := []string{ + `INSERT INTO modules_data (Id, Name, QualifiedName, ModuleName, Source) VALUES ('m1', 'M', 'M', 'M', '')`, + } + for _, r := range rows { + stmts = append(stmts, `INSERT INTO widgets_data (Id, Name, WidgetType, ContainerId, ContainerQualifiedName, + ContainerType, ModuleName, Class, Style, DynamicClasses) VALUES `+r) + } + for _, st := range stmts { + if _, err := cat.CatalogDB().Exec(st); err != nil { + t.Fatalf("%s: %v", st, err) + } + } + return cat +} + +func TestSnippetCallAppearance_ReportsClassStyleDynamicClasses(t *testing.T) { + cat := snippetCallCatalog(t, + `('w1', 'scStyled', 'Forms$SnippetCallWidget', 'p1', 'M.P', 'PAGE', 'M', 'probe-x', 'border: 3px solid red', '''d''')`, + // controls: a bare snippet call, and a styled container + `('w2', 'scBare', 'Forms$SnippetCallWidget', 'p1', 'M.P', 'PAGE', 'M', '', '', '')`, + `('w3', 'c1', 'Forms$DivContainer', 'p1', 'M.P', 'PAGE', 'M', 'probe-x', 'border: 1px', '')`, + ) + got := NewSnippetCallAppearanceRule().Check(linter.NewLintContext(cat, nil)) + if len(got) != 1 { + t.Fatalf("want exactly the styled snippet call reported, got %d: %+v", len(got), got) + } + v := got[0] + for _, want := range []string{"scStyled", "Class", "Style", "DynamicClasses", "renders no element"} { + if !strings.Contains(v.Message, want) { + t.Errorf("message %q lacks %q", v.Message, want) + } + } + if v.Location.DocumentName != "P" || v.Location.DocumentType != "page" { + t.Errorf("location = %+v, want page P", v.Location) + } + if !strings.Contains(v.Suggestion, "container") { + t.Errorf("suggestion %q does not point at a container", v.Suggestion) + } +} + +// Design properties are not in the catalog; they are read from the stored +// document when a reader is available. +func TestFindSnippetCallDesignProperties(t *testing.T) { + dp := func(key string) map[string]any { + return map[string]any{"$Type": "Forms$DesignPropertyValue", "Key": key} + } + call := func(name string, props ...any) map[string]any { + return map[string]any{ + "$Type": "Forms$SnippetCallWidget", + "Name": name, + "Appearance": map[string]any{ + "DesignProperties": append([]any{int32(3)}, props...), + }, + } + } + raw := map[string]any{ + "FormCall": map[string]any{"Arguments": []any{int32(2), map[string]any{ + "Widgets": []any{int32(2), map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "c1", + // a styled container is the control + "Appearance": map[string]any{"DesignProperties": []any{int32(3), dp("Align self")}}, + "Widgets": []any{int32(2), + call("scStyled", dp("Align self"), dp("Hide on")), + call("scBare"), + }, + }}, + }}}, + } + got := findSnippetCallDesignProperties(raw) + if len(got) != 1 || strings.Join(got["scStyled"], ",") != "Align self,Hide on" { + t.Fatalf("got %v, want only scStyled with Align self, Hide on", got) + } +} + +// rawUnitReader is dataview_layout_grid_test.go's. + +func TestSnippetCallAppearance_ReportsDesignPropertiesFromTheDocument(t *testing.T) { + cat := snippetCallCatalog(t, + `('w1', 'scDP', 'Forms$SnippetCallWidget', 'p1', 'M.P', 'PAGE', 'M', '', '', '')`, + ) + reader := rawUnitReader{units: map[model.ID]map[string]any{ + "p1": {"FormCall": map[string]any{"Arguments": []any{int32(2), map[string]any{ + "Widgets": []any{int32(2), map[string]any{ + "$Type": "Forms$SnippetCallWidget", + "Name": "scDP", + "Appearance": map[string]any{"DesignProperties": []any{int32(3), + map[string]any{"$Type": "Forms$DesignPropertyValue", "Key": "Align self"}}}, + }}, + }}}}, + }} + got := NewSnippetCallAppearanceRule().Check(linter.NewLintContext(cat, reader)) + if len(got) != 1 || !strings.Contains(got[0].Message, "Align self") { + t.Fatalf("want one violation naming the design property, got %+v", got) + } +} + +func TestSnippetCallAppearanceRule_Identity(t *testing.T) { + r := NewSnippetCallAppearanceRule() + if r.ID() != "MPR013" { + t.Errorf("ID = %q, want MPR013", r.ID()) + } + if r.RequiredCatalogMode() != linter.CatalogFull { + t.Error("ctx.Widgets() needs the full catalog; under the fast build the rule finds nothing") + } + if r.Name() == "" || r.Description() == "" { + t.Error("a rule with no name or description cannot be configured or explained") + } +}