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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area":"mdl/executor","date":"2026-10-17","symptom":"A quoted `Editable: '$currentObject/Locked = false'` on a checkbox or text box passes check and exec (\"Created page\") and is stored `Editable: Always` with no ConditionalEditabilitySettings, so a field meant to be locked stays editable; `describe page` prints the widget without any Editable. The quoted `Visible: '<expr>'` beside it is stored as a conditional visibility, and the unquoted `Editable: $currentObject/Locked = false` works. `Editable: false` on an input was dropped the same way","cause":"`applyConditionalSettings` (cmd_pages_builder_v3.go) consulted only `pages.CanonicalEditability`, which accepts exactly Always|Never|Conditional and answers (\"\", false) for any other string, so the `else if` did not fire and the default Always stayed. `Visible` goes through `pages.StaticVisibleExpression`, whose default branch hands any other non-empty string back as the expression. MDL-WIDGET19 let every bool/string through for both keys (\"a static form StaticVisibleExpression consumes\"), true for Visible and false for Editable; a bool never reached the builder at all because it read the value with GetStringProp, which is \"\" for a bool","file":"`sdk/pages/pages_widgets.go` (new `StaticEditability`), `mdl/executor/widget_editable_static.go` (new: `staticEditability`, `editableCondition`, `editableValueProblem`), `cmd_pages_builder_v3.go` (`applyConditionalSettings`), `pluggable_system_props.go` (`widgetAsksEditability`), `validate_widgets.go` (MDL-WIDGET19, MDL-WIDGET13), `validate_page_button_context.go` (MDL-BUTTON02), `validate_widget_editability.go` (MDL-WIDGET20), `validate_listview_editable_inputs.go` (MDL-WIDGET31)","insight":"Two sibling properties with the same grammar slot but different consumers: one takes any string as an expression, the other only an enum word and skips the rest silently. A skipped write must be a refusal, so the fix is one rule for both keys (a string that is not the property's plain value is its condition) PLUS a check that names what still cannot be stored (a lone word such as `Neever`, text that is not an expression, `Conditional` with no condition). Every reader of `EditableIf` is a second consumer of the same value and has to see the quoted spelling too: MDL-WIDGET13 (association step, CE0117), MDL-BUTTON02 (own container name), MDL-WIDGET41 (pluggable Editability gate) and MDL-WIDGET20; fixing only the builder leaves them blind. Not every widget stores the enum: a list view and a grid column have a plain `Editable bool` (ako/mxcli#510), so a boolean there must NOT be read as Never/Always (`plainEditableWidgetTypes`). A quoted string reaches the validators unparsed, so a malformed quoted condition needs `visitor.ParsesAsExpression`; the bare spelling fails in the grammar. Quoted Visible still gets none of those checks (WIDGET13, BUTTON02, a syntax check) — left as is, same family. ALTER PAGE `set (Editable: '<expr>')` keeps its loud refusal (pagemutator setWidgetEditableMut); accepting it is about five lines (settings element plus the Conditional enum), not one. Control: same script on the pre-fix binary stores Always/null for the quoted widgets; the fix stores Conditional + expression, byte-equal (ids stripped) to the unquoted spelling, and describe -> exec reports `Unchanged page`. Side find, not fixed: a data view stores its static editability from its own ReadOnly flag (Forms$DataView.Editability), which the builder never sets from `Editable:`, so `DATAVIEW (Editable: Never)` is still stored Always (measured: bson dump shows Editability Always for Never, false and true alike); only the condition (ConditionalEditabilitySettings) is carried, and the quoted spelling now matches the bare one there","refs":[],"rules":["MDL-WIDGET19","MDL-WIDGET13","MDL-BUTTON02","MDL-WIDGET20","MDL-WIDGET41"], "refs": ["mendixlabs/mxcli#1567"]}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed

- **A quoted `Editable: '<expression>'` is stored as a conditional editability instead of being dropped** (mendixlabs/mxcli#1567) — `CHECKBOX cb (Attribute: Flag, Editable: '$currentObject/Locked = false')` passed `check`, `exec` said "Created page", and the widget was stored `Editable: Always` with no condition, so a field meant to be locked stayed editable (`mx check` also reported 0 errors). `Visible: '<expr>'` beside it was stored as a conditional visibility, and the unquoted `Editable: $currentObject/Locked = false` worked: the page builder read `Editable` only as one of `Always`/`Never`/`Conditional` and skipped every other string without a word. A string that is not one of those is now the condition, exactly as for `Visible`, and `describe` prints it back bare, so a describe → exec round trip is stable. `Editable: false` / `true` on an input widget were dropped the same way and now store `Never` / `Always` (a list view and a grid column keep their own plain boolean, and a data view keeps its existing handling). `check` refuses what still cannot be stored — a misspelt `Editable: Neever`, a quoted text that is not an expression, `Editable: Conditional` with no condition — as MDL-WIDGET19 naming the accepted spellings, and a quoted condition now gets the checks the bare one gets (MDL-WIDGET13 association steps, MDL-BUTTON02, MDL-WIDGET41 on a pluggable widget that declares no Editability, MDL-WIDGET20, and MDL-WIDGET31 no longer warns about an input marked `editable: false`). `alter page … set (Editable: '<expr>')` still refuses a quoted condition with its existing message.

## [0.27.0] - 2026-10-15

Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories.
Expand Down
3 changes: 3 additions & 0 deletions cmd/mxcli/syntax/features_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ LIST IMPACT OF htmlelement;
"-- written (name attributes as $currentObject/Attr). A plain value is static.\n" +
"TEXTBOX name (Attribute: Attr, Visible: $currentObject/IsActive, Editable: $currentObject/Status != 'Closed')\n" +
"TEXTBOX name (Attribute: Attr, Visible: false, Editable: Never)\n" +
"-- A condition may also be a quoted string holding the same expression; Editable:\n" +
"-- false / true mean Never / Always.\n" +
"TEXTBOX name (Attribute: Attr, Editable: '$currentObject/Status != ''Closed''')\n" +
"-- The bracketed Visible: [IsActive] (attributes rooted for you) is the deprecated\n" +
"-- spelling, MDL-DEPR081.\n\n" +
"-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE CHANGES, ButtonStyle: Primary)\nLINKBUTTON name (Caption: 'C', Action: ...)\n\n" +
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/language/alter-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ ALTER PAGE Module.EditPage {
| `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` |
| `Editable` | Editability mode | `SET (Editable: ReadOnly) ON txtEmail` |
| `Editable` | Editability: `Always`, `Never`, or a bare condition expression (the quoted form is refused in `SET`) | `SET (Editable: Never) ON txtEmail` |
| `Visible` | Visibility expression | `SET (Visible: '$showField') ON txtPhone` |
| `Name` | Widget name | `SET (Name: 'txtFullName') ON txtName` |

Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/language/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ Input widgets can be conditionally editable:
TEXTBOX txtStatus (Label: 'Status', Attribute: Status, Editable: $currentObject/Status != 'Closed')
```

Static values: `Editable: Never`, `Editable: Always`.
Static values: `Editable: Never`, `Editable: Always`; `Editable: false` and `Editable: true` mean `Never` and `Always`.

The condition may also be written as a quoted string, exactly as for `Visible:` — `Editable: '$currentObject/Status != ''Closed'''` stores the same condition as the bare form above (a quote inside the string is doubled). Any other value, such as a misspelt `Neever`, is refused by `mxcli check` (`MDL-WIDGET19`) instead of leaving the field editable.

### Pluggable Widgets

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/language/widget-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ All input widgets share common properties:
|----------|-------------|---------|
| `Label` | Field label text | `Label: 'Customer Name'` |
| `Attribute` | Entity attribute to bind to | `Attribute: Name` |
| `Editable` | Editability mode | `Editable: ReadOnly` |
| `Editable` | Editability: `Always`, `Never`, `true`/`false`, or a condition expression (bare or quoted) | `Editable: Never` |
| `Visible` | Visibility expression | `Visible: '$showField'` |

Each input widget takes only some attribute types. Measured with mxbuild 11.14.0,
Expand Down Expand Up @@ -641,7 +641,7 @@ These properties are shared across many widget types:
| `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` |
| `Editable` | Editability: `Always`, `Never`, `true`/`false`, or a condition expression (bare or quoted) | `Editable: Never` |

## See Also

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/reference/page/create-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,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 ''` |
| `Editable` | Edit control | `Editable: NEVER` or `Editable: ALWAYS` |
| `Editable` | Edit control: `Always`, `Never`, or a condition expression (bare or quoted) | `Editable: NEVER` or `Editable: $currentObject/Locked = false` |
| `Visible` | Visibility expression | `Visible: '$showField'` |
| `DesignProperties` | Atlas design properties | `DesignProperties: ('Spacing top': 'Large')` |

Expand Down
28 changes: 28 additions & 0 deletions mdl-examples/bug-tests/1567-quoted-editable-expression.fail.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
mdl 1;
-- Negative companion of 1567-quoted-editable-expression.mdl: `check` must refuse every
-- Editable value that cannot be stored, naming the accepted spellings, instead of
-- letting the widget stay editable. Each statement below is refused on its own.
-- mendixlabs/mxcli#1567.

create entity if not exists QEdit.Item (Code: String(50), Locked: Boolean default false);

-- A misspelt enum word (MDL-WIDGET19).
create or modify page QEdit.Bad_Word (Title: 'Bad', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: QEdit.Item )) {
layoutgrid lg { row { column (DesktopWidth: AutoFill) {
dataview dv (Datasource: $Item) { textbox txtA (Attribute: Code, Editable: 'Neever') }
} } }
};

-- A quoted expression that is not an expression (MDL-WIDGET19).
create or modify page QEdit.Bad_Syntax (Title: 'Bad', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: QEdit.Item )) {
layoutgrid lg { row { column (DesktopWidth: AutoFill) {
dataview dv (Datasource: $Item) { textbox txtB (Attribute: Code, Editable: '$currentObject/Locked = = false') }
} } }
};

-- A quoted expression that follows an association (MDL-WIDGET13, CE0117).
create or modify page QEdit.Bad_Assoc (Title: 'Bad', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: QEdit.Item )) {
layoutgrid lg { row { column (DesktopWidth: AutoFill) {
dataview dv (Datasource: $Item) { textbox txtC (Attribute: Code, Editable: '$currentObject/QEdit.Item_Item/Code = ''x''') }
} } }
};
55 changes: 55 additions & 0 deletions mdl-examples/bug-tests/1567-quoted-editable-expression.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
mdl 1;
-- A quoted `Editable: '<expression>'` was dropped on write (mendixlabs/mxcli#1567).
--
-- `Visible: '$currentObject/Locked = false'` was stored as a conditional
-- visibility, because pages.StaticVisibleExpression returns any string that is not
-- true/false as the condition. Editable shares the grammar slot but not the
-- consumer: pages.CanonicalEditability knew only Always | Never | Conditional and
-- the builder skipped every other string without a word. The widget was stored
-- `Editable: Always` with no ConditionalEditabilitySettings, so a field meant to
-- be locked stayed editable -- `check` clean, `exec` reporting "Created page",
-- `mx check` at 0 errors.
--
-- Now the quoted spelling is the bare expression's twin: both store
-- `Editable: Conditional` plus the condition, and `describe page` prints either
-- as the bare form. `Editable: false` / `true` (also dropped before) store
-- Never / Always.
--
-- Verify: exec this script, then `describe page QEdit.Item_Edit` -- the first four
-- inputs print their condition bare (`Editable: $currentObject/Locked = false`,
-- the `!= 'x'` form, ...), txtNever and txtFalse print `Editable: Never`, and the
-- stored page passes `mx check`. Values that cannot be stored are refused by
-- `check` instead: see 1567-quoted-editable-expression.fail.mdl.

create entity if not exists QEdit.Item (
Code: String(50),
Note: String(200),
Locked: Boolean default false
);

create or modify page QEdit.Item_Edit (
Title: 'Item',
Layout: Atlas_Core.Atlas_Default,
Params: ( $Item: QEdit.Item )
) {
layoutgrid lg {
row {
column (DesktopWidth: AutoFill) {
dataview dv (Datasource: $Item) {
-- Quoted and bare spellings of the same condition store the same thing.
textbox txtQuoted (Attribute: Code, Editable: '$currentObject/Locked = false')
textbox txtBare (Attribute: Code, Editable: $currentObject/Locked = false)
-- A string literal inside the quoted expression has its quotes doubled.
textarea txtLiteral (Attribute: Note, Editable: '$currentObject/Code != ''x''')
checkbox cbLocked (Attribute: Locked, Editable: '$currentObject/Code = empty')
-- The enum words keep their meaning, quoted or not.
textbox txtNever (Attribute: Code, Editable: 'Never')
textbox txtFalse (Attribute: Code, Editable: false)
textbox txtAlways (Attribute: Code, Editable: 'Always')
}
}
}
}
};

describe page QEdit.Item_Edit;
21 changes: 12 additions & 9 deletions mdl/executor/cmd_pages_builder_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,22 +589,25 @@ func applyConditionalSettings(widget pages.Widget, w *ast.WidgetV3) {
}

if editableIf := w.GetStringProp("EditableIf"); editableIf != "" {
bw.ConditionalEditability = &pages.ConditionalEditabilitySettings{
BaseElement: model.BaseElement{
ID: model.ID(types.GenerateID()),
TypeName: "Forms$ConditionalEditabilitySettings",
},
Expression: editableIf,
}
} else if editable, ok := pages.CanonicalEditability(w.GetStringProp("Editable")); ok {
bw.ConditionalEditability = newConditionalEditability(editableIf)
} else if enum, expr, ok := staticEditability(w); ok {
// `Editable: Never` — parsed and validated (MDL-WIDGET20 checks the widget
// TYPE) and then dropped, because nothing carried it to the writers, which
// hardcoded "Always". Same shape as the `Visible: false` case above.
//
// A string that is not an enum word is the condition, exactly as for
// Visible: `Editable: '$currentObject/Locked = false'` is the quoted
// spelling of the bare expression. It used to be skipped here, so the
// widget stayed Always.
//
// EDITABLE IF wins when both are given: the conditional settings element is
// what makes the enum "Conditional", so honouring a plain `Editable` too
// would write an enum contradicting the element beside it.
bw.Editable = editable
if expr != "" {
bw.ConditionalEditability = newConditionalEditability(expr)
} else {
bw.Editable = enum
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions mdl/executor/pluggable_system_props.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ func widgetAsksEditability(w *ast.WidgetV3) bool {
if w.GetStringProp("EditableIf") != "" {
return true
}
e, ok := pages.CanonicalEditability(w.GetStringProp("Editable"))
return ok && e != "Always"
enum, expr, ok := staticEditability(w)
return ok && (expr != "" || enum != "Always")
}

// systemPropsOfType returns the system properties a CustomWidgetType declares,
Expand Down
4 changes: 3 additions & 1 deletion mdl/executor/validate_listview_editable_inputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ func firstEditableInput(widgets []*ast.WidgetV3, registry *WidgetRegistry) *ast.
if typ == "listview" {
continue
}
never := strings.EqualFold(c.GetStringProp("Editable"), "Never")
// `editable: false` is Never too, quoted or not (pages.StaticEditability).
enum, _, hasStatic := staticEditability(c)
never := hasStatic && enum == "Never"
if editableWidgetTypes[typ] && typ != "dataview" && !never {
return c
}
Expand Down
22 changes: 22 additions & 0 deletions mdl/executor/validate_listview_editable_inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ func TestMDLWIDGET31_ListViewInputsNotEditable(t *testing.T) {
}`,
want: 0,
},
{
// `editable: false` and the quoted spellings are Never as well: the
// author already made these inputs read-only, so there is nothing to warn about.
name: "control: inputs marked editable false or 'Never'",
src: `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {
listview lv (datasource: database M.Thing) {
textbox t (label: 'N', attribute: Name, editable: false)
textbox u (label: 'M', attribute: Name, editable: 'Never')
}
}`,
want: 0,
},
{
// A condition is not Never: the input can still be editable, so the list view still decides.
name: "an input with a quoted condition is still warned about",
src: `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {
listview lv (datasource: database M.Thing) {
textbox t (label: 'N', attribute: Name, editable: '$currentObject/Name = empty')
}
}`,
want: 1,
},
{
// A nested list view's inputs are governed by the NESTED list
// view's Editable, so the outer one is not the one to report.
Expand Down
6 changes: 6 additions & 0 deletions mdl/executor/validate_page_button_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ func checkOwnContainerName(w *ast.WidgetV3, nearest, locationPrefix string) []li
flag(p.written+" expression", v, "CE0117")
}
}
// A quoted `Editable: '<expr>'` is the same condition as the bare one.
if _, routed := w.Properties["EditableIf"]; !routed {
if expr, ok := editableCondition(w); ok {
flag("Editable expression", expr, "CE0117")
}
}
return out
}

Expand Down
6 changes: 5 additions & 1 deletion mdl/executor/validate_widget_editability.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ func validateWidgetEditability(w *ast.WidgetV3, locationPrefix string) []linter.
// (lowered to EditableIf, and riding on ConditionalEditabilitySettings these
// types do not have) is not. Only the second is worth reporting.
if plainEditableWidgetTypes[typ] && !strings.EqualFold(key, "editableif") {
return nil
// A quoted condition is the bracket form's twin: it is the part of
// `Editable:` these types have no place for.
if _, expr, ok := staticEditability(w); !ok || expr == "" {
return nil
}
}
return []linter.Violation{{
RuleID: "MDL-WIDGET20",
Expand Down
Loading
Loading