diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index cf43be8ebc..d19707e4a9 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -470,21 +470,27 @@ Property names resolve against the keys the installed widget declares, so both the schema key and mxcli's MDL alias work (`DynamicCellClass` and `ColumnClass` both reach `columnClass`). An unknown name lists what *is* settable on that grid. -**Expression-valued properties take a Mendix expression, not a literal.** -`DynamicCellClass` and `Visible` are expressions, so a literal CSS class has to be -a quoted string *inside* the expression — doubled quotes in MDL: +**`DynamicCellClass` (and a widget's `DynamicClasses`) take a Mendix expression, +written as-is.** A quoted value is a Mendix string, so a literal CSS class is just +the quoted class name, and a computed one is the expression itself: ```mdl --- WRONG: the expression becomes a bare identifier, mxbuild reports CE0117 +-- a literal class: the string 'highlight' alter page Mod.P { SET DynamicCellClass = 'highlight' ON dg1.Label } --- correct: the expression is the string literal 'highlight' -alter page Mod.P { SET DynamicCellClass = '''highlight''' ON dg1.Label } +-- a computed class +alter page Mod.P { SET DynamicCellClass = if $currentObject/Price > 100 then 'highlight' else '' ON dg1.Label } + +-- WRONG: a bare name is an identifier, not a string — mxbuild reports CE0117 +alter page Mod.P { SET DynamicCellClass = highlight ON dg1.Label } ``` -This applies equally to `create page`; the two paths behave identically. A bare -identifier is not a valid Mendix expression, and mxbuild reports CE0117 against -the column. +The old spelling — the expression's text in quotes, `'if … then ''a'' else '''''` +— is refused as MDL-WIDGET33, because it would now store that text as a class +name. This applies equally to `create page`; the two paths behave identically. + +A column's pluggable `Visible` expression is not converted yet: there a quoted +value is still the expression's text, so a literal needs the doubled quotes. Properties holding a **structured** value — `attribute`, `filter`, `content`, actions — cannot be set by ALTER at all. It refuses them and points at diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index d4d7d232ed..c1a4432d3a 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -134,26 +134,29 @@ actionbutton btn (caption: 'Save', designproperties: ['Size': 'Large', 'Full wid ``` **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 (`''`): +class list (applied on top of the static `class`). Write the expression as-is — +no outer quotes, no doubled ones — and root attributes in `$currentObject`. A +quoted value is a Mendix string: `dynamicclasses: 'is-featured'` is the class +`is-featured`. ```sql dynamictext ovChip ( content: 'chip', class: 'ss-chip', - dynamicclasses: 'if $currentObject/VesselClass = Mod.BoatClass.Astute then ''ss-chip--astute'' else ''''' + dynamicclasses: if $currentObject/VesselClass = Mod.BoatClass.Astute then 'ss-chip--astute' else '' ) ``` -Write it quoted, not in brackets: `dynamicclasses: [ … ]` (and a column's -`DynamicCellClass: [ … ]`) parses as a list, which no writer reads — `check` -reports it as MDL-WIDGET32 rather than letting the value be dropped. +Not in brackets: `dynamicclasses: [ … ]` (and a column's `DynamicCellClass: [ … ]`) +parses as a list, which no writer reads — `check` reports it as MDL-WIDGET32. And +not the old quoted spelling `'if … then ''a'' else '''''`, which would now store +the expression's text as a class name — `check` reports it as MDL-WIDGET33. **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 ''''', + dynamicclasses: if $currentObject/Featured then 'is-featured' else '', designproperties: ['Spacing top': 'Large', 'Full width': on] ) { dynamictext txtTitle (content: 'Styled Container', rendermode: H3) diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 61b50ddbdd..256bb57d31 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -340,7 +340,7 @@ column colPrice ( Sortable: false, Resizable: false, Hidable: hidden, ColumnWidth: manual, Size: 150, - DynamicCellClass: 'if($currentObject/Price > 100) then ''highlight'' else '''' ', + DynamicCellClass: if($currentObject/Price > 100) then 'highlight' else '' , tooltip: 'Price in USD' ) ``` diff --git a/.claude/skills/mendix/migrate-design-prototype/SKILL.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md index 568e419180..423769afbd 100644 --- a/.claude/skills/mendix/migrate-design-prototype/SKILL.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -501,13 +501,13 @@ top of** `Class:`. ```sql container heatCell ( Class: 'ss-heat-cell', - DynamicClasses: 'if $currentObject/M01 >= 100 then ''ss-heat--over'' - else if $currentObject/M01 >= 80 then ''ss-heat--warn'' - else ''ss-heat--ok''' + DynamicClasses: if $currentObject/M01 >= 100 then 'ss-heat--over' + else if $currentObject/M01 >= 80 then 'ss-heat--warn' + else 'ss-heat--ok' ) ``` -(Note the doubled single-quotes for string literals inside an MDL expression.) +(Written as-is: plain single quotes inside, no outer quotes around the expression.) ### Computed dimensions — the bucket-class idiom @@ -526,7 +526,7 @@ bucket and generate one class per bucket**: ``` 3. Select the class from the bucket: - `DynamicClasses: '''ss-pb-'' + toString($currentObject/PctBucket)'`. + `DynamicClasses: 'ss-pb-' + toString($currentObject/PctBucket)`. Trade-off worth noting: this adds one bucket attribute per animated dimension to the domain model. Pick a bucket count that matches the visual precision you need (20 → 5% diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e278d325..3ac4a90451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **`DynamicClasses` and a column's `DynamicCellClass` are written as Mendix expressions** (mendixlabs/mxcli#750) — the expression is written as-is, so the doubled-quote spelling is gone: `dynamicclasses: if $currentObject/Featured then 'is-featured' else ''`, and `dynamicclasses: 'is-featured'` is the string — the class `is-featured`. The same rule as the OData client's credentials. `create page`, `alter page … set` and `describe` all use it, and a describe → exec round trip stores identical values (measured on a Mendix 11.14.0 project). **Migrating a script:** the old spelling, the expression's text in quotes (`'if … then ''a'' else '''''`), still parses but would now store that text as a class name, so `check` and `exec` refuse it as **MDL-WIDGET33** and give the unquoted expression. An expression in any other widget property is an error rather than an empty value; a pluggable property whose schema kind is Expression (a column's `Visible`, for one) keeps the quoted form until a following change. - **An OData client's credentials and header values are written as Mendix expressions** (mendixlabs/mxcli#750) — `HttpUsername`, `HttpPassword`, `ClientCertificate` and every `headers (…)` value hold an expression, and MDL now writes it as-is: `HttpUsername: 'admin'` is the string `'admin'`, `@Module.Const` reads a constant, and `'Bearer ' + @Module.Token` concatenates. Before, a quoted value was the expression's *text*, so `'admin'` stored the identifier `admin` and a string needed `'''admin'''`. `describe` prints the stored expression as-is, so Studio Pro's `'abc'` now reads `HttpUsername: 'abc'`; measured against a Studio Pro-authored client, and a describe → exec round trip stores identical values. **Migrating a script:** `'''admin'''` becomes `'admin'`, and a quoted constant `'@Module.Const'` becomes `@Module.Const` — both old forms still parse but would now store something else, so `check` and `exec` refuse them as **MDL-ODATA07**. A compound expression in any other OData property (`Path: 'a' + 'b'`) is an error rather than an empty value. `ServiceUrl` is a constant reference, not an expression — see the next entry. - **An OData client's `ServiceUrl` names a constant, like `ProxyHost`** (mendixlabs/mxcli#750) — Studio Pro picks the service URL as a constant and stores it as `@Module.Name`. `ServiceUrl: Module.Location` is now accepted alongside `@Module.Location` and `'@Module.Location'` (the bare name used to be refused as "not a constant reference"); all three store the same value, and `describe` prints the bare name, as it does for the proxy references. A literal URL is still refused (CE6825). diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index ecd42e9915..f8f1e46fce 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -317,7 +317,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "Reach for ALTER STYLING rather than CREATE OR REPLACE PAGE whenever only\n" + "the look changes: replacing the page rewrites every widget in it, so the\n" + "diff is the whole document and anything MDL cannot yet spell is lost.", - Example: "CONTAINER ctn (\n Class: 'my-card',\n DynamicClasses: 'if $currentObject/Priority = ''High'' then ''card-danger'' else ''card-normal'''\n) {\n DYNAMICTEXT txt (Content: 'Styled text')\n}\n\n" + + Example: "CONTAINER ctn (\n Class: 'my-card',\n DynamicClasses: if $currentObject/Priority = 'High' then 'card-danger' else 'card-normal'\n) {\n DYNAMICTEXT txt (Content: 'Styled text')\n}\n\n" + "-- Restyle one widget on a page that already exists\n" + "alter styling on page Sales.OrderOverview widget btnSave\n" + " set Class = 'btn-primary', 'Spacing top' = 'Large';\n\n" + diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 24e4914f8f..30f8116625 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -359,7 +359,7 @@ MDL uses explicit property declarations for pages: | 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;')` | -| Dynamic classes | `DynamicClasses: 'expr'` | `CONTAINER c (DynamicClasses: 'if $currentObject/IsActive then ''is-active'' else ''''')` — runtime-computed; stacks on `Class` | +| Dynamic classes | `DynamicClasses: 'expr'` | `CONTAINER c (DynamicClasses: if $currentObject/IsActive then 'is-active' else '')` — runtime-computed; stacks on `Class` | | Design properties | `DesignProperties: [...]` | `CONTAINER c (DesignProperties: ['Spacing top': 'Large', 'Full width': ON])` | | Width (pixels) | `Width: integer` | `IMAGE img (Width: 200)` | | Height (pixels) | `Height: integer` | `IMAGE img (Height: 150)` | @@ -383,7 +383,7 @@ MDL uses explicit property declarations for pages: | `ColumnWidth` | `autoFill`, `autoFit`, `manual` | `autoFill` | `ColumnWidth: manual` | | `Size` | integer (px) | `1` | `Size: 200` | | `Visible` | expression string | `true` | `Visible: '$showColumn'` (page variable, not $currentObject) | -| `DynamicCellClass` | expression string | (empty) | `DynamicCellClass: 'if(...) then ... else ...'` | +| `DynamicCellClass` | expression string | (empty) | `DynamicCellClass: if(...) then ... else ...` | | `Tooltip` | text string | (empty) | `Tooltip: 'Price in USD'` | **Page Example:** diff --git a/docs-site/src/language/alter-page.md b/docs-site/src/language/alter-page.md index 819f3cb13a..8e4898af7a 100644 --- a/docs-site/src/language/alter-page.md +++ b/docs-site/src/language/alter-page.md @@ -43,7 +43,7 @@ ALTER PAGE Module.EditPage { | `ButtonStyle` | Button visual style | `SET ButtonStyle = Danger ON btnDelete` | | `Class` | CSS class names | `SET Class = 'card p-3' ON cMain` | | `Style` | Inline CSS | `SET Style = 'margin: 8px;' ON cBox` | -| `DynamicClasses` | Runtime-computed CSS classes | `SET DynamicClasses = 'if $currentObject/IsActive then ''is-active'' else ''''' ON cMain` | +| `DynamicClasses` | Runtime-computed CSS classes | `SET DynamicClasses = if $currentObject/IsActive then 'is-active' else '' ON cMain` | | `Editable` | Editability mode | `SET Editable = ReadOnly ON txtEmail` | | `Visible` | Visibility expression | `SET Visible = '$showField' ON txtPhone` | | `Name` | Widget name | `SET Name = 'txtFullName' ON txtName` | diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index e697de7953..8e13a2248e 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -69,7 +69,7 @@ CONTAINER cCard (Class: 'card mx-spacing-top-large') { |----------|-------------|---------| | `Class` | CSS class names | `Class: 'card p-3'` | | `Style` | Inline CSS styles | `Style: 'padding: 16px;'` | -| `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: 'if $currentObject/IsActive then ''is-active'' else '''''` | +| `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: if $currentObject/IsActive then 'is-active' else ''` | | `DesignProperties` | Design property values | `DesignProperties: ['Spacing top': 'Large']` | ### CUSTOMCONTAINER @@ -566,7 +566,7 @@ These properties are shared across many widget types: |----------|-------------|---------| | `Class` | CSS class names | `Class: 'card p-3'` | | `Style` | Inline CSS styles | `Style: 'margin-top: 8px;'` | -| `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: 'if $currentObject/IsActive then ''is-active'' else '''''` | +| `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: if $currentObject/IsActive then 'is-active' else ''` | | `DesignProperties` | Atlas design properties | `DesignProperties: ['Spacing top': 'Large', 'Full width': ON]` | | `Visible` | Visibility expression | `Visible: '$showSection'` | | `Editable` | Editability mode | `Editable: ReadOnly` | diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index 4cbd52c384..6c27500945 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -190,7 +190,7 @@ These properties are available on most widget types: |----------|-------------|---------| | `Class` | CSS class names | `Class: 'card mx-spacing-top-large'` | | `Style` | Inline CSS | `Style: 'padding: 16px;'` | -| `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: 'if $currentObject/IsActive then ''is-active'' else '''''` | +| `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: if $currentObject/IsActive then 'is-active' else ''` | | `Editable` | Edit control | `Editable: NEVER` or `Editable: ALWAYS` | | `Visible` | Visibility expression | `Visible: '$showField'` | | `DesignProperties` | Atlas design properties | `DesignProperties: ['Spacing top': 'Large']` | diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 3ecf990031..6bff72d057 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1455,7 +1455,7 @@ MDL uses explicit property declarations for pages: | Association source ("data from context") | `datasource: $currentObject/Module.Assoc` | nested `dataview dvCust (datasource: $currentObject/Order_Customer)` shows the to-one referenced object; a list widget shows the to-many collection | | CSS class | `class: 'classes'` | `container c (class: 'card mx-spacing-top-large')` | | Inline style | `style: 'css'` | `container c (style: 'padding: 16px;')` | -| Dynamic classes | `dynamicclasses: 'expr'` | `container c (dynamicclasses: 'if $currentObject/IsActive then ''is-active'' else ''''')` — runtime-computed classes; stacks on `class` | +| Dynamic classes | `dynamicclasses: 'expr'` | `container c (dynamicclasses: if $currentObject/IsActive then 'is-active' else '')` — runtime-computed classes; stacks on `class` | | Design properties | `designproperties: [...]` | `container c (designproperties: ['Spacing top': 'Large', 'full width': on])` | | Width (pixels) | `width: integer` | `image img (width: 200)` | | Height (pixels) | `height: integer` | `image img (height: 150)` | @@ -1521,7 +1521,7 @@ MDL uses explicit property declarations for pages: | `ColumnWidth` | `autofill`, `autoFit`, `manual` | `autofill` | `ColumnWidth: manual` | | `Size` | integer (px) | `1` | `Size: 200` | | `visible` | expression string | `true` | `visible: '$showColumn'` (page variable, not $currentObject) | -| `DynamicCellClass` | expression string | (empty) | `DynamicCellClass: 'if(...) then ... else ...'` | +| `DynamicCellClass` | expression string | (empty) | `DynamicCellClass: if(...) then ... else ...` | | `tooltip` | text string | (empty) | `tooltip: 'Price in USD'` | **Page Example:** diff --git a/docs/11-proposals/PROPOSAL_first_class_expressions.md b/docs/11-proposals/PROPOSAL_first_class_expressions.md index 09f4e4b0a4..220c2d1c4c 100644 --- a/docs/11-proposals/PROPOSAL_first_class_expressions.md +++ b/docs/11-proposals/PROPOSAL_first_class_expressions.md @@ -232,6 +232,12 @@ proposals compose rather than compete. otherwise quote. Confirm this output variance is acceptable. 4. **Pluggable expression properties are resolved by schema, not by name.** + **Decided 2026-09-25: (a)**, shipped in two steps. First the named + properties, `DynamicClasses` and a column's `DynamicCellClass`: written + as-is, a quoted value is a string (the Q5 rule), and the old quoted-text + spelling is refused as MDL-WIDGET33. Then the schema-driven extension to every + pluggable property whose kind is Expression. + Slice 2 either (a) lets any generic property take a bare expression and rejects it at check time when the widget schema says the slot is not `expression`-typed, or (b) adds the bare form only for the named properties @@ -376,6 +382,10 @@ quoted output with the bare form. **Slice 2 — expression family, widget slots.** +*Named-property step done (2026-09-25):* `DynamicClasses` and `DynamicCellClass` +take the expression as written through `create page`, `alter page … set` and +`describe`. The table below is the schema-driven remainder. + | File | Change | |---|---| | `mdl/grammar/domains/MDLPage.g4` | `widgetPropertyV3`: add `(IDENTIFIER \| keyword) COLON expression` **after** every existing generic branch, so `'text'`, numbers, booleans, qualified names and `[ … ]` keep their current parse and only what those reject (`if …`, `$v/Attr + …`, calls) reaches it. `make grammar`; watch for new ambiguity reports. | diff --git a/mdl-examples/bug-tests/750-dynamicclasses-legacy-quoted-expression.fail.mdl b/mdl-examples/bug-tests/750-dynamicclasses-legacy-quoted-expression.fail.mdl new file mode 100644 index 0000000000..18a6a1acae --- /dev/null +++ b/mdl-examples/bug-tests/750-dynamicclasses-legacy-quoted-expression.fail.mdl @@ -0,0 +1,16 @@ +-- mendixlabs/mxcli#750, PROPOSAL_first_class_expressions.md slice 2 (named +-- properties): DynamicClasses and a datagrid column's DynamicCellClass hold a +-- Mendix expression written as-is. +-- +-- dynamicclasses: if $currentObject/Featured then 'is-featured' else '' +-- dynamicclasses: 'is-featured' -- the string: the class is-featured +-- +-- The old spelling — the expression's text in quotes, with its own quotes +-- doubled — still parses, and would now store that text as a class name. check +-- and exec refuse it as MDL-WIDGET33 and name the unquoted expression. +-- +-- This file must FAIL `mxcli check`. + +create page Legacy.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + container c1 (dynamicclasses: 'if $currentObject/Featured then ''is-featured'' else ''''') { } +} diff --git a/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl b/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl index 0faf6077cb..4d2ab2b30b 100644 --- a/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl +++ b/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl @@ -55,7 +55,7 @@ create or replace page MyFirstModule.P_LowerSet -- Every one of these used to hard-error "widget has no pluggable Object". alter page MyFirstModule.P_LowerSet { set class = 'fl-topbar' on topBar; - set dynamicclasses = 'if $currentObject/Name != '''' then ''is-named'' else ''''' on topBar; + set dynamicclasses = if $currentObject/Name != '' then 'is-named' else '' on topBar; set class = 'fl-badge' on rowBadge; set caption = 'Shortcuts' on btnKeys; } diff --git a/mdl-examples/bug-tests/bug10-dynamic-css-classes.mdl b/mdl-examples/bug-tests/bug10-dynamic-css-classes.mdl index 526707f2fb..a75cde2e67 100644 --- a/mdl-examples/bug-tests/bug10-dynamic-css-classes.mdl +++ b/mdl-examples/bug-tests/bug10-dynamic-css-classes.mdl @@ -39,7 +39,7 @@ create page BugTests.Bug10 ( dynamictext t1 ( content: 'hi', class: 'base', - dynamicclasses: 'if 1 = 1 then ''mod-a'' else ''mod-b''' + dynamicclasses: if 1 = 1 then 'mod-a' else 'mod-b' ) -- 10a: DataGrid2 column DynamicCellClass must persist as an expression datagrid dg1 ( @@ -49,7 +49,7 @@ create page BugTests.Bug10 ( column colStatus ( Attribute: FullName, Caption: 'Name', - DynamicCellClass: 'if $currentObject/FullName = ''x'' then ''ea-ok'' else ''ea-draft''' + DynamicCellClass: if $currentObject/FullName = 'x' then 'ea-ok' else 'ea-draft' ) } } diff --git a/mdl-examples/bug-tests/widget-dynamicclasses.mdl b/mdl-examples/bug-tests/widget-dynamicclasses.mdl index 6268f91525..82ce02a553 100644 --- a/mdl-examples/bug-tests/widget-dynamicclasses.mdl +++ b/mdl-examples/bug-tests/widget-dynamicclasses.mdl @@ -40,12 +40,12 @@ create or replace page MyFirstModule.P_DynClass dynamictext ovClass ( Content: 'chip', Class: 'ss-chip', - DynamicClasses: 'if $currentObject/Name = ''Astute'' then ''ss-chip--astute'' else ''''' + DynamicClasses: if $currentObject/Name = 'Astute' then 'ss-chip--astute' else '' ) -- DynamicClasses on a container container ovBox ( Class: 'ss-box', - DynamicClasses: 'if $currentObject/Name = '''' then ''ss-box--empty'' else ''''' + DynamicClasses: if $currentObject/Name = '' then 'ss-box--empty' else '' ) { dynamictext boxLabel (content: 'inner') } @@ -58,7 +58,7 @@ create or replace page MyFirstModule.P_DynClass -- hard error "widget has no pluggable Object" on core widgets). alter page MyFirstModule.P_DynClass { set Class = 'ss-later' on ovLater; - set DynamicClasses = 'if $currentObject/Name != '''' then ''ss-later--named'' else ''''' on ovLater; + set DynamicClasses = if $currentObject/Name != '' then 'ss-later--named' else '' on ovLater; } describe page MyFirstModule.P_DynClass; diff --git a/mdl-examples/bug-tests/widget-unknown-property.mdl b/mdl-examples/bug-tests/widget-unknown-property.mdl index afde96beaa..f33cb726aa 100644 --- a/mdl-examples/bug-tests/widget-unknown-property.mdl +++ b/mdl-examples/bug-tests/widget-unknown-property.mdl @@ -25,7 +25,7 @@ create or replace page MyFirstModule.P_UnknownProp ( Title: 'Unknown prop', Layout: Atlas_Core.Atlas_Default ) { -- all recognized → no warning - dynamictext ok ( Content: 'hi', Class: 'c', DynamicClasses: 'if true then ''a'' else ''''', RenderMode: H1 ) + dynamictext ok ( Content: 'hi', Class: 'c', DynamicClasses: if true then 'a' else '', RenderMode: H1 ) -- typo of a real property → MDL-WIDGET07 "did you mean `Content`?" dynamictext typo ( Content: 'hi', Contnet: 'oops' ) -- genuinely unknown property → MDL-WIDGET07 (no suggestion) diff --git a/mdl-examples/doctype-tests/03-page-examples.mdl b/mdl-examples/doctype-tests/03-page-examples.mdl index cf2c5da237..992c21e960 100644 --- a/mdl-examples/doctype-tests/03-page-examples.mdl +++ b/mdl-examples/doctype-tests/03-page-examples.mdl @@ -2327,7 +2327,7 @@ create page PgTest.P033b_DataGrid_ColumnProperties attribute: Stock, caption: 'In Stock', Alignment: center, visible: '$showStockColumn', - DynamicCellClass: 'if($currentObject/Stock < 10) then ''text-danger'' else '''' ' + DynamicCellClass: if($currentObject/Stock < 10) then 'text-danger' else '' ) -- Non-hidable column with auto-fit width diff --git a/mdl-examples/doctype-tests/12-styling-examples.mdl b/mdl-examples/doctype-tests/12-styling-examples.mdl index 1938bc80b0..b443cb7f38 100644 --- a/mdl-examples/doctype-tests/12-styling-examples.mdl +++ b/mdl-examples/doctype-tests/12-styling-examples.mdl @@ -165,17 +165,17 @@ create page StyleTest.P002b_Dynamic_Classes dynamictext dcStatus ( content: 'Status', class: 'badge', - dynamicclasses: 'if $currentObject/IsActive then ''badge-success'' else ''badge-muted''' + dynamicclasses: if $currentObject/IsActive then 'badge-success' else 'badge-muted' ) -- runtime class on a container from a string attribute container dcCard ( class: 'card', - dynamicclasses: 'if $currentObject/Department = ''Sales'' then ''card-sales'' else ''card-default''' + dynamicclasses: if $currentObject/Department = 'Sales' then 'card-sales' else 'card-default' ) { dynamictext dcName ( content: 'Name', - dynamicclasses: 'if $currentObject/Name = '''' then ''is-empty'' else ''''' + dynamicclasses: if $currentObject/Name = '' then 'is-empty' else '' ) } } @@ -506,7 +506,7 @@ update widgets -- ALTER PAGE ... SET DynamicClasses ON is the surgical alternative to a -- bulk update — set (or change) a single widget's runtime class expression in place. alter page StyleTest.P002b_Dynamic_Classes { - set DynamicClasses = 'if $currentObject/Department = ''HR'' then ''card-hr'' else ''''' on dcCard; + set DynamicClasses = if $currentObject/Department = 'HR' then 'card-hr' else '' on dcCard; } -- MARK: Roundtrip diff --git a/mdl-examples/doctype-tests/29-datagrid-examples.mdl b/mdl-examples/doctype-tests/29-datagrid-examples.mdl index db8e119ae3..f77cb08b3a 100644 --- a/mdl-examples/doctype-tests/29-datagrid-examples.mdl +++ b/mdl-examples/doctype-tests/29-datagrid-examples.mdl @@ -377,7 +377,7 @@ create page DgTest.DG07_Column_Properties ( -- Status column with dynamic cell class based on value column colStatus ( attribute: Status, caption: 'Status', - DynamicCellClass: 'if ($currentObject/Status = DgTest.Status.Active) then ''text-success'' else ''text-muted''' + DynamicCellClass: if ($currentObject/Status = DgTest.Status.Active) then 'text-success' else 'text-muted' ) } }; diff --git a/mdl/backend/pagemutator/expression_list_value_test.go b/mdl/backend/pagemutator/expression_list_value_test.go index 8b1100974d..e5ee2c919c 100644 --- a/mdl/backend/pagemutator/expression_list_value_test.go +++ b/mdl/backend/pagemutator/expression_list_value_test.go @@ -37,8 +37,8 @@ func TestSetWidgetProperty_DynamicClassesRefusesAList(t *testing.T) { if err == nil { t.Fatal("a bracketed list was accepted for DynamicClasses and reported as success") } - if !strings.Contains(err.Error(), "quoted") { - t.Errorf("error = %q, want it to name the quoted spelling that works", err) + if !strings.Contains(err.Error(), "without brackets") { + t.Errorf("error = %q, want it to name the spelling that works", err) } app := bsonnav.DGetDoc(findBsonWidget(rawData, "ctn1").widget, "Appearance") if got := bsonnav.DGetString(app, "DynamicClasses"); got != stored { @@ -58,8 +58,8 @@ func TestSetColumnProperty_ExpressionRefusesAList(t *testing.T) { if err == nil { t.Fatal("a bracketed list was accepted for DynamicCellClass and reported as success") } - if !strings.Contains(err.Error(), "quoted") { - t.Errorf("error = %q, want it to name the quoted spelling that works", err) + if !strings.Contains(err.Error(), "without brackets") { + t.Errorf("error = %q, want it to name the spelling that works", err) } if got := fieldOf(t, col, idClass, "Expression"); got != "'kept'" { t.Errorf("Expression = %v after a refused set, want 'kept' unchanged", got) diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index ae7c5be3fb..32eaa2bbce 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3205,7 +3205,7 @@ func (m *Mutator) lookupParameter(name string) (entity string, isSnippetParam bo func errExpressionNotAString(propName string, _ any) error { return fmt.Errorf( "property %q takes a single value, but was given a bracketed list — "+ - "write an expression as a quoted string, doubling the quotes inside it: "+ - "set %s = 'if $currentObject/Featured then ''a'' else ''b'''", + "write the expression itself, without brackets: "+ + "set %s = if $currentObject/Featured then 'a' else 'b'", propName, propName) } diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 7d7a64acfe..4ee83ba432 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -181,7 +181,7 @@ func appendAppearanceProps(props []string, w rawWidget) []string { props = append(props, fmt.Sprintf("Style: %s", mdlQuote(w.Style))) } if w.DynamicClasses != "" { - props = append(props, fmt.Sprintf("DynamicClasses: %s", mdlQuote(w.DynamicClasses))) + props = append(props, fmt.Sprintf("DynamicClasses: %s", w.DynamicClasses)) // an expression, printed as-is } if len(w.DesignProperties) > 0 { props = append(props, formatDesignPropertiesMDL(w.DesignProperties)) @@ -1173,7 +1173,7 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix, colName string, col rawDa props = append(props, fmt.Sprintf("Visible: %s", mdlQuote(col.Visible))) } if col.DynamicCellClass != "" { - props = append(props, fmt.Sprintf("DynamicCellClass: %s", mdlQuote(col.DynamicCellClass))) + props = append(props, fmt.Sprintf("DynamicCellClass: %s", col.DynamicCellClass)) // an expression, printed as-is } if col.Tooltip != "" { props = append(props, fmt.Sprintf("Tooltip: %s", mdlQuote(col.Tooltip))) diff --git a/mdl/executor/validate_widget_expression_list.go b/mdl/executor/validate_widget_expression_list.go index 732172e56f..d51895cc58 100644 --- a/mdl/executor/validate_widget_expression_list.go +++ b/mdl/executor/validate_widget_expression_list.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "regexp" "sort" "strings" @@ -58,8 +59,8 @@ func validateExpressionPropertyLists(w *ast.WidgetV3, locationPrefix string) []l "the value is discarded on write", locationPrefix, w.Name, key), Suggestion: fmt.Sprintf( - "write the expression as a quoted string, doubling the quotes inside it: "+ - "%s: 'if $currentObject/Featured then ''is-featured'' else '''''", key), + "write the expression itself, without brackets: "+ + "%s: if $currentObject/Featured then 'is-featured' else ''", key), }) } return out @@ -73,3 +74,83 @@ func isListValuedExpressionProp(key string) bool { } return false } + +// legacyExpressionTextRe recognises the content of the OLD spelling of an +// expression property: a quoted string holding the expression's text. A class +// name or class list never contains a `$` (a variable) or a quote character, and +// does not start with `if`; the old expression text nearly always does one of +// the three. +var legacyExpressionTextRe = regexp.MustCompile(`\$|'|^\s*if\b`) + +// validateLegacyExpressionText (MDL-WIDGET33) reports the old spelling of +// DynamicClasses / DynamicCellClass: +// +// dynamicclasses: 'if $currentObject/F then ''a'' else ''''' -- old +// dynamicclasses: if $currentObject/F then 'a' else '' -- now +// +// The property holds a Mendix expression written as-is, so a quoted value is a +// Mendix string (PROPOSAL_first_class_expressions.md slice 2, the same rule as +// the OData client's credentials). The old spelling still parses and would now +// store the expression's TEXT as a class-name string — valid, silent, and never +// the class the author meant. An error, so exec refuses it too; the suggestion +// is the expression with the quoting removed. +func validateLegacyExpressionText(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + if w == nil || len(w.Properties) == 0 { + return nil + } + keys := make([]string, 0, len(w.Properties)) + for k := range w.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + var out []linter.Violation + for _, key := range keys { + if !isListValuedExpressionProp(key) { + continue + } + s, ok := w.Properties[key].(string) + if !ok { + continue + } + if v, bad := legacyExpressionTextViolation(fmt.Sprintf("%s: widget `%s`", locationPrefix, w.Name), key, s); bad { + out = append(out, v) + } + } + return out +} + +func legacyExpressionTextViolation(where, key, expr string) (linter.Violation, bool) { + content, isLiteral := mendixStringLiteral(expr) + if !isLiteral || !legacyExpressionTextRe.MatchString(content) { + return linter.Violation{}, false + } + return linter.Violation{ + RuleID: "MDL-WIDGET33", + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s property `%s` is a quoted string holding an expression — %s now takes the expression itself, "+ + "so this would store the text as a class name", where, key, key), + Suggestion: fmt.Sprintf("write the expression without the outer quotes and the doubled ones: %s: %s", key, content), + }, true +} + +// validateAlterSetLegacyExpressionText is MDL-WIDGET33 for ALTER PAGE … SET. +func validateAlterSetLegacyExpressionText(op *ast.SetPropertyOp, locationPrefix string) []linter.Violation { + keys := make([]string, 0, len(op.Properties)) + for k := range op.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + var out []linter.Violation + for _, key := range keys { + s, ok := op.Properties[key].(string) + if !ok || !isListValuedExpressionProp(key) { + continue + } + where := fmt.Sprintf("%s: set on `%s`", locationPrefix, op.Target.Widget) + if v, bad := legacyExpressionTextViolation(where, key, s); bad { + out = append(out, v) + } + } + return out +} diff --git a/mdl/executor/validate_widget_expression_list_test.go b/mdl/executor/validate_widget_expression_list_test.go index 09d4d81733..9f5749ffb6 100644 --- a/mdl/executor/validate_widget_expression_list_test.go +++ b/mdl/executor/validate_widget_expression_list_test.go @@ -46,19 +46,19 @@ func TestMDLWIDGET32_ExpressionPropertyWrittenAsList(t *testing.T) { want: 1, }, { - // The quoted expression is how the property is written today, and + // The expression written as-is is how the property is spelled, and // is what reaches storage: the control. - name: "control: quoted expression", + name: "control: the expression", src: `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { - container c1 (dynamicclasses: 'if $currentObject/Featured then ''is-featured'' else ''''') { } + container c1 (dynamicclasses: if $currentObject/Featured then 'is-featured' else '') { } }`, want: 0, }, { - name: "control: quoted column expression", + name: "control: the column expression", src: `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { datagrid dg (datasource: database M.Thing) { - column c1 (attribute: Name, caption: 'N', DynamicCellClass: 'if $currentObject/Featured then ''hot'' else ''''') + column c1 (attribute: Name, caption: 'N', DynamicCellClass: if $currentObject/Featured then 'hot' else '') } }`, want: 0, @@ -82,9 +82,9 @@ func TestMDLWIDGET32_ExpressionPropertyWrittenAsList(t *testing.T) { if tc.want == 0 { return } - // The message has to carry its own remedy: the quoted spelling that + // The message has to carry its own remedy: the unbracketed spelling that // does reach storage. - for _, s := range []string{"discarded", "quoted"} { + for _, s := range []string{"discarded", "without brackets"} { if !strings.Contains(got[0].Message+got[0].Suggestion, s) { t.Errorf("message should mention %q: %s / %s", s, got[0].Message, got[0].Suggestion) } diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index aac5dfa73c..9ef2ee5c3a 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -124,6 +124,8 @@ func ValidateWidgetPropertiesForStatement(stmt ast.Statement, registry *WidgetRe out = append(out, validateWidgetSubtree(o.Widgets, registry, "alter "+s.PageName.String())...) case *ast.ReplaceWidgetOp: out = append(out, validateWidgetSubtree(o.NewWidgets, registry, "alter "+s.PageName.String())...) + case *ast.SetPropertyOp: + out = append(out, validateAlterSetLegacyExpressionText(o, "alter "+s.PageName.String())...) } } return out @@ -181,6 +183,9 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // An expression property written in brackets — the spelling #750 // proposes — parses as a list and was discarded on write. out = append(out, validateExpressionPropertyLists(w, locationPrefix)...) + // …and the OLD spelling, a quoted string holding the expression's text, + // which now stores that text as a class name (MDL-WIDGET33). + out = append(out, validateLegacyExpressionText(w, locationPrefix)...) // #1062: an action slot holding something that is not an action, which // used to check clean, exec clean, build clean and render dead. Runs for // every widget kind and needs no definition, for the same reason as the diff --git a/mdl/executor/widget_expression_props_test.go b/mdl/executor/widget_expression_props_test.go new file mode 100644 index 0000000000..d16cb9325f --- /dev/null +++ b/mdl/executor/widget_expression_props_test.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// PROPOSAL_first_class_expressions.md slice 2, named properties: DynamicClasses +// (any widget) and a datagrid column's DynamicCellClass hold ONE Mendix +// expression, and MDL writes it as-is. A quoted value is a Mendix string — the +// same rule as the OData client's credentials (#676) — so the doubled-quote +// spelling `'if … then ''a'' else '''''` is no longer needed, and is refused. + +func pageWidgets(t *testing.T, src string) map[string]*ast.WidgetV3 { + t.Helper() + out := map[string]*ast.WidgetV3{} + var walk func(ws []*ast.WidgetV3) + walk = func(ws []*ast.WidgetV3) { + for _, w := range ws { + out[w.Name] = w + walk(w.Children) + } + } + for _, s := range parseMDL(t, src).Statements { + if p, ok := s.(*ast.CreatePageStmtV3); ok { + walk(p.Widgets) + } + } + return out +} + +func TestWidgetExpressionProps_StoreTheExpressionAsWritten(t *testing.T) { + ws := pageWidgets(t, `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + container c1 (dynamicclasses: if $currentObject/Featured then 'is-featured' else '') { } + container c2 (dynamicclasses: 'is-featured') { } + container c3 (DynamicClasses: $currentObject/Style + ' card') { } + datagrid dg (datasource: database M.Thing) { + column col1 (attribute: Name, caption: 'N', DynamicCellClass: if $currentObject/Price > 100 then 'highlight' else '') + } +}`) + for name, want := range map[string]string{ + "c1": "if $currentObject/Featured then 'is-featured' else ''", + "c2": "'is-featured'", + "c3": "$currentObject/Style + ' card'", + "col1": "if $currentObject/Price > 100 then 'highlight' else ''", + } { + w := ws[name] + if w == nil { + t.Fatalf("widget %s not parsed", name) + } + got := w.GetDynamicClasses() + if name == "col1" { + got = w.GetStringProp("DynamicCellClass") + } + if got != want { + t.Errorf("%s stored %q, want the expression %q", name, got, want) + } + } +} + +func TestWidgetExpressionProps_AlterStoresTheExpression(t *testing.T) { + prog := parseMDL(t, `alter page M.P { set DynamicClasses = if $currentObject/Featured then 'a' else 'b' on c1 };`) + stmt := prog.Statements[0].(*ast.AlterPageStmt) + var got any + for _, op := range stmt.Operations { + if set, ok := op.(*ast.SetPropertyOp); ok { + got = set.Properties["DynamicClasses"] + } + } + if got != "if $currentObject/Featured then 'a' else 'b'" { + t.Errorf("set DynamicClasses stored %#v", got) + } +} + +// Widening the value rule for two properties must not open a silent empty value +// for every other one. +func TestWidgetExpressionInAPlainProperty_IsAnError(t *testing.T) { + for _, src := range []string{ + `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { combobox cb (emptyOptionText: 'a' + 'b') }`, + `alter page M.P { set Caption = 'Save' + ' now' on btn1 };`, + } { + _, errs := visitor.Build(src) + if len(errs) == 0 { + t.Errorf("accepted an expression in a plain-value property: %s", src) + continue + } + if !strings.Contains(errs[0].Error(), "expression") { + t.Errorf("error %q should say the property does not take an expression", errs[0]) + } + } +} + +// MDL-WIDGET33: the old spelling — a quoted string holding the expression's +// text — now stores a string, so it is refused and the message names the +// unquoted expression. +func TestMDLWIDGET33_LegacyQuotedExpression(t *testing.T) { + cases := []struct { + name, value string + want int + }{ + {"legacy if-expression", `'if $currentObject/Featured then ''is-featured'' else '''''`, 1}, + {"legacy attribute concatenation", `'$currentObject/Style + '' card'''`, 1}, + {"control: a class-name string", `'is-featured'`, 0}, + {"control: a class list", `'btn btn-lg'`, 0}, + {"control: the unquoted expression", `if $currentObject/Featured then 'is-featured' else ''`, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + container c1 (dynamicclasses: ` + tc.value + `) { } + datagrid dg (datasource: database M.Thing) { + column col1 (attribute: Name, caption: 'N', DynamicCellClass: ` + tc.value + `) + } +}` + got := widgetViolations(t, src, "MDL-WIDGET33") + if len(got) != 2*tc.want { + t.Fatalf("MDL-WIDGET33: got %d, want %d: %#v", len(got), 2*tc.want, got) + } + if tc.want > 0 && !strings.Contains(got[0].Suggestion, "if $currentObject") && + !strings.Contains(got[0].Suggestion, "$currentObject/Style") { + t.Errorf("suggestion should give the unquoted expression: %s", got[0].Suggestion) + } + }) + } +} + +func TestMDLWIDGET33_LegacyQuotedExpressionOnAlter(t *testing.T) { + prog := parseMDL(t, `alter page M.P { set DynamicClasses = 'if $currentObject/F then ''a'' else ''b''' on c1 };`) + var got []string + for _, v := range ValidateWidgetProperties(prog, "") { + if v.RuleID == "MDL-WIDGET33" { + got = append(got, v.Message) + } + } + if len(got) != 1 { + t.Fatalf("MDL-WIDGET33 on ALTER: got %v, want one", got) + } +} + +// describe prints the stored expression as-is, and that output re-stores it. +func TestDescribeWidgetExpressionProps_RoundTrip(t *testing.T) { + const expr = "if $currentObject/Featured then 'is-featured' else ''" + props := appendAppearanceProps(nil, rawWidget{DynamicClasses: expr}) + var line string + for _, p := range props { + if strings.HasPrefix(p, "DynamicClasses:") { + line = p + } + } + if line != "DynamicClasses: "+expr { + t.Fatalf("describe printed %q, want the expression unquoted", line) + } + ws := pageWidgets(t, `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + container c1 (`+line+`) { } +}`) + if got := ws["c1"].GetDynamicClasses(); got != expr { + t.Errorf("re-exec of describe output stores %q, want %q", got, expr) + } + + var out bytes.Buffer + outputDataGrid2ColumnV3(&ExecContext{Output: &out}, "", "col1", rawDataGridColumn{DynamicCellClass: expr}) + if !strings.Contains(out.String(), "DynamicCellClass: "+expr) { + t.Errorf("column describe should print the expression unquoted, got:\n%s", out.String()) + } +} diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 2d5836a5c8..fc2f79d57e 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -328,6 +328,7 @@ alterPageAssignment | identifierOrKeyword EQUALS actionExprV3 // createFileAction = MICROFLOW Module.MF | identifierOrKeyword EQUALS propertyValueV3 // Caption = 'Save' | STRING_LITERAL EQUALS propertyValueV3 // 'showLabel' = false + | identifierOrKeyword EQUALS expression // DynamicClasses = if $x/F then 'a' else '' (see widgetPropertyV3) ; alterPageInsert diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index e717507a53..39dc786373 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -538,6 +538,13 @@ widgetPropertyV3 | (IDENTIFIER | keyword) COLON actionExprV3 | IDENTIFIER COLON propertyValueV3 // Generic: any other property | keyword COLON propertyValueV3 // Generic: keyword as property name (for pluggable widgets) + // A Mendix expression, written as-is: `dynamicclasses: if $currentObject/F + // then 'a' else ''`. LAST, so every value form above keeps its parse and only + // what they all reject reaches it. The visitor accepts it only for the + // expression-typed properties (DynamicClasses, a column's DynamicCellClass) + // and refuses it elsewhere, so no plain property can read it as empty + // (PROPOSAL_first_class_expressions.md, slice 2). + | (IDENTIFIER | keyword) COLON expression ; diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index a45aecc065..f8d8015846 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -124,6 +124,20 @@ func (b *Builder) buildAlterPageSetLayout(ctx *parser.AlterPageSetContext) *ast. // buildAlterPageAssignment extracts property name and value from an assignment context. func (b *Builder) buildAlterPageAssignment(ctx *parser.AlterPageAssignmentContext) (string, interface{}) { + // An expression-typed property takes the expression as written, from + // whichever value alternative matched (visitor_widget_expression.go). + if id := ctx.IdentifierOrKeyword(); id != nil && ctx.STRING_LITERAL() == nil { + name := identifierOrKeywordText(id) + if isWidgetExpressionProp(name) { + if v := lastRuleChild(ctx); v != nil { + return name, widgetExpressionValue(v) + } + } + if expr := ctx.Expression(); expr != nil { + b.addError(widgetExpressionNotAllowed(name, expr)) + return "", nil + } + } // DataSource = dataSourceExprV3 if dsCtx := ctx.DataSourceExprV3(); dsCtx != nil { return "DataSource", buildDataSourceV3(dsCtx) diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 94ffd8be4e..238b0bbc34 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -868,6 +868,20 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Generic property: Identifier: value if id := propCtx.IDENTIFIER(); id != nil { + // An expression-typed property takes the expression as written, from + // whichever value alternative matched (visitor_widget_expression.go). + if isWidgetExpressionProp(id.GetText()) { + if v := lastRuleChild(propCtx); v != nil { + widget.Properties[id.GetText()] = widgetExpressionValue(v) + } + return + } + if expr := propCtx.Expression(); expr != nil { + if b != nil { + b.addError(widgetExpressionNotAllowed(id.GetText(), expr)) + } + return + } // `Params: [{1} = Attr]` — the parameters of a text-template // sub-property whose name belongs to the WIDGET rather than to MDL (a // File Uploader custom button's ButtonCaptionParams). ContentParams and @@ -901,6 +915,18 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Generic property with keyword name: keyword: value (for pluggable widget property keys // that happen to be MDL keywords, e.g., type, datasource, content) if kw := propCtx.Keyword(); kw != nil { + if isWidgetExpressionProp(kw.GetText()) { + if v := lastRuleChild(propCtx); v != nil { + widget.Properties[kw.GetText()] = widgetExpressionValue(v) + } + return + } + if expr := propCtx.Expression(); expr != nil { + if b != nil { + b.addError(widgetExpressionNotAllowed(kw.GetText(), expr)) + } + return + } if plCtx := propCtx.ParamListV3(); plCtx != nil { widget.Properties[kw.GetText()] = buildParamListV3(plCtx) return diff --git a/mdl/visitor/visitor_widget_expression.go b/mdl/visitor/visitor_widget_expression.go new file mode 100644 index 0000000000..a0590ecdec --- /dev/null +++ b/mdl/visitor/visitor_widget_expression.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "strings" + + "github.com/antlr4-go/antlr/v4" + + "github.com/mendixlabs/mxcli/mdl/grammar/parser" +) + +// Widget properties that hold ONE Mendix expression, written as-is: +// +// dynamicclasses: if $currentObject/Featured then 'is-featured' else '' +// dynamicclasses: 'is-featured' -- the string: the class is-featured +// +// A quoted value is a Mendix string, as for the OData client's credentials, not +// the expression's text — the old spelling, a quoted string holding the +// expression with its own quotes doubled, is +// refused at check time (MDL-WIDGET33). This is the named-property slice of +// PROPOSAL_first_class_expressions.md §6.2 slice 2; pluggable properties whose +// schema kind is Expression follow in the schema-driven slice. +var widgetExpressionProps = map[string]bool{ + "dynamicclasses": true, // any widget's Appearance.DynamicClasses + "dynamiccellclass": true, // a datagrid column's columnClass +} + +func isWidgetExpressionProp(name string) bool { + return widgetExpressionProps[strings.ToLower(name)] +} + +// widgetExpressionValue returns an expression property's value: the source text +// of whatever the grammar matched after the `:` or `=`, whitespace kept. Which +// alternative matched is not a signal — `$x/Cls + ' x'` may be claimed by the +// datasource or action alternatives, which also start with a variable — so the +// text is taken from the value node itself. A bracketed list is the one +// exception: it is returned as the list the other readers build, so MDL-WIDGET27 +// (empty) and MDL-WIDGET32 (non-empty) still report it. +func widgetExpressionValue(valueNode antlr.ParserRuleContext) any { + if pv, ok := valueNode.(*parser.PropertyValueV3Context); ok && (pv.LBRACKET() != nil || pv.ObjectEntryListV3() != nil) { + return buildPropertyValueV3(pv) + } + return ruleSourceText(valueNode) +} + +// lastRuleChild is the rule node after `name :` / `name =` — the value. +func lastRuleChild(ctx antlr.ParserRuleContext) antlr.ParserRuleContext { + children := ctx.GetChildren() + for i := len(children) - 1; i >= 0; i-- { + if rc, ok := children[i].(antlr.ParserRuleContext); ok { + return rc + } + } + return nil +} + +func widgetExpressionNotAllowed(name string, expr parser.IExpressionContext) error { + return fmt.Errorf( + "property %s takes a plain value, not an expression: %s — "+ + "only DynamicClasses and a column's DynamicCellClass take an expression", + name, ruleSourceText(expr.(antlr.ParserRuleContext))) +}