From 41db3b6e86947cbc023ff966d2cb2bef0c2e9777 Mon Sep 17 00:00:00 2001 From: stagknee <241821830+stagknee@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:16:36 -0500 Subject: [PATCH] fix(describe): print a Data grid 2's and a Gallery's settings by their widget key (mendixlabs/mxcli#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pluggable widget takes every one of its settings under the key its package declares, and exec stores them: datagrid dg (DataSource: database Shop.Ord, configurationStorageType: localStorage, itemSelectionMethod: rowClick, refreshInterval: 30) `describe page` printed only the properties MDL has a keyword for (PageSize, Pagination, Selection, onClick), so executing the description wrote the widget's default over each of the others. Measured on Mendix 11.12.5: a grid with 22 settings changed came back with the 4 that have keywords, with `mx check` at 0 errors throughout, and a Studio Pro-authored grid lost its `onClickTrigger: double`. The describers for datagrid2 and gallery now also print every setting whose stored value differs from the default, under its key: enumerations and plain values, text templates with their `{N}` parameters, expressions, attributes, a column's own settings (minWidth, minWidthLimit, allowEventPropagation, …) and the empty-state placeholder slot. The default is read from the widget's own stored definition (Type.ObjectType, and the item type of `columns`), not from a list. It is not always the schema's DefaultValue: four text templates have '' there and store the text the widget ships for them, so a text template counts as default when it holds that text, and a grid that sets none of the settings describes byte for byte as before (16 pages of a Studio Pro-authored project and 16 of the committed PedApp: the one that differs is a marketplace page, by the setting it had been losing). An Expression prints unquoted, except an expression that is only a string literal, which would be read back as the string and stored without its quotes. An attribute prints bare where exec resolves a bare name (inside a data container) and qualified where it would not. A setting with no spelling (an attribute reached over associations) is named in a `-- NOT re-executable` comment. Selection-typed keys are carried by `Selection:` and not repeated. Co-Authored-By: Claude Sonnet 5.5 --- ...e-page-drops-datagrid-by-key-settings.json | 1 + CHANGELOG.md | 4 + docs-site/src/language/widget-types.md | 27 ++ ...1573-datagrid-by-key-settings-describe.mdl | 158 +++++++ mdl/executor/cmd_pages_describe.go | 11 + mdl/executor/cmd_pages_describe_bykey.go | 394 +++++++++++++++++ mdl/executor/cmd_pages_describe_bykey_test.go | 415 ++++++++++++++++++ mdl/executor/cmd_pages_describe_omitted.go | 13 + mdl/executor/cmd_pages_describe_output.go | 50 ++- mdl/executor/cmd_pages_describe_parse.go | 21 + mdl/executor/cmd_pages_describe_pluggable.go | 2 + 11 files changed, 1083 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json create mode 100644 mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl create mode 100644 mdl/executor/cmd_pages_describe_bykey.go create mode 100644 mdl/executor/cmd_pages_describe_bykey_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json new file mode 100644 index 000000000..5aea4bfcb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`describe page` printed a Data grid 2 as `datagrid dg (DataSource: database from Shop.Ord, PageSize: 25, Pagination: virtualScrolling)` although exec had stored `configurationStorageType: localStorage`, `itemSelectionMethod: rowClick`, `refreshInterval: 30`, `columnsHidable: false`, a column's `minWidth`, labels and the empty-state placeholder (mendixlabs/mxcli#1573). Executing the description wrote the widget's default over every one of them, with `check` and `mx check` at 0 errors; a Studio Pro-authored grid lost its `onClickTrigger: double` the same way. The Gallery had the same loss (pagination, pageSize, showEmptyPlaceholder, state storage).", "cause": "The write side takes any pluggable-widget setting by its own property key (the explicit-key pass), but the describer for the widgets it has dedicated extractors for (datagrid2, gallery, combobox, filters) read only the properties MDL has a keyword for. The generic pluggable path that does print by key (extractExplicitProperties) is skipped for those widget types (isKnownCustomWidgetType), and it has no notion of a default, so it could not simply be switched on: it would print the whole schema of every untouched widget.", "fix": "cmd_pages_describe_bykey.go: nonDefaultPropsByKey reads each property's declared type and default from the stored widget's own Type.ObjectType (and the item type of `columns`) and prints a setting only when it differs from what exec writes for it left out; wired into the datagrid2 and gallery describers, each column, and the emptyPlaceholder slot. Text templates compare against the text the widget ships (Translations), an Expression prints unquoted (quoted when it is only a string literal), an attribute prints bare only where exec resolves a bare name; a value with no MDL spelling (attribute over associations) is named in a `-- NOT re-executable` comment.", "file": "mdl/executor/cmd_pages_describe_bykey.go", "test": "mdl/executor/cmd_pages_describe_bykey_test.go (against the shipped Data grid 2 / Gallery templates); mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl", "insight": "Three traps decided the design. (1) DefaultValue is not the default exec writes: four text templates have DefaultValue '' but store their shipped text from Translations, so comparing with DefaultValue printed four labels on every grid; the untouched-grid test is built from the shipped widget template for that reason. (2) `describe -> exec` printing 'Unchanged page' proves nothing about the writer: exec keeps the stored page when the statement equals its description, so a lossy describer is a fixed point of itself; the round trip only shows after `drop page` + exec of the description, compared as BSON with ids stripped (v0.27.0 differs on all six pages of the bug-test script, the fix is identical on all six). (3) A by-key `itemSelection: Single` is stored as PrimitiveValue with Selection 'None' (the explicit pass routes a Selection-typed key as a primitive: mappedKeys holds mapping Sources, not keys), i.e. no selection at all, so the bug-test uses `Selection:`; describe skips Selection-typed keys, which keeps a describe -> exec -> BSON comparison from hiding that write-side defect."} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0798c1db7..3e7849d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`describe page` prints a Data grid 2's and a Gallery's settings that MDL has no keyword for** (mendixlabs/mxcli#1573) — a pluggable widget takes every setting by its own key (`configurationStorageType: localStorage`, `itemSelectionMethod: rowClick`, `refreshInterval: 30`, a column's `minWidth`), and `exec` stores it with `mx check` clean, but `describe page` printed only the keyword properties (`PageSize`, `Pagination`, `Selection`, `onClick`). Executing the description therefore wrote the widget's default over each of them, silently: measured on Mendix 11.12.5, a grid with 22 settings changed came back with the 4 that have keywords, and a Studio Pro-authored grid lost its `onClickTrigger: double`. `describe` now prints each setting whose stored value differs from the default, under its key — enumerations and plain values, text templates (with their `{N}` parameters), expressions, attributes (bare inside a data container, qualified where a bare name would not resolve), a column's own settings and the empty-state placeholder slot. The default is read from the widget's own stored definition, not a list, and a text template counts as default when it holds the text the widget ships for it, so a grid that sets none of them describes exactly as before. A setting with no spelling (an attribute reached over associations) is named in a `-- NOT re-executable` comment. A Gallery had the same loss and takes the same fix. + ## [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. diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 0dd3e3f3e..213461616 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -127,6 +127,33 @@ DATAGRID dgOrders (DataSource: DATABASE Sales.Order, PageSize: 20) { | `PagingPosition` | Position of paging controls | `PagingPosition: both` | | `ShowPagingButtons` | When to show paging buttons | `ShowPagingButtons: auto` | +**Any other Data grid 2 setting** can be set by its widget key — the name the +widget's own definition gives it (`DESCRIBE WIDGET TYPE datagrid` lists them with +their types, defaults and allowed values). The grid's personalization, for +example, is stored in the browser or in an attribute of the surrounding object: + +```sql +-- in the browser's local storage +DATAGRID dgLocal (DataSource: DATABASE Sales.Order, + configurationStorageType: localStorage, + storeFiltersInPersonalization: true) + +-- in an attribute of the data view the grid sits in +DATAVIEW dvPref (DataSource: $Pref) { + DATAGRID dgAttr (DataSource: DATABASE Sales.Order, + configurationStorageType: attribute, + configurationAttribute: Config) +} +``` + +`DESCRIBE PAGE` prints every such setting whose stored value differs from the +widget's default, under the same key, so describing a page and executing the +description gives back the page it started from. A setting it cannot spell +(an attribute reached over an association) is named in a `-- NOT re-executable` +comment instead of being dropped without a word. Column settings with no keyword +of their own (`minWidth`, `minWidthLimit`, `allowEventPropagation`, …) are +written and described the same way, and so are a Gallery's. + **Column properties:** | Property | Values | Default | Description | diff --git a/mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl b/mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl new file mode 100644 index 000000000..b5aa30d75 --- /dev/null +++ b/mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl @@ -0,0 +1,158 @@ +mdl 1; +-- ============================================================================ +-- Bug: DESCRIBE PAGE dropped a Data grid 2's / Gallery's settings that MDL has +-- no keyword for (mendixlabs/mxcli#1573) +-- ============================================================================ +-- +-- A pluggable widget has far more settings than MDL has keywords. exec takes +-- every one of them under the widget's own property key, stores it, and +-- `mx check` is clean — but `describe page` printed only the keyword +-- properties (PageSize, Pagination, Selection, …). A describe -> edit -> exec +-- cycle therefore wrote the widget's default back over each of them, silently: +-- the personalization storage, the selection method, the labels, the column +-- minimum width, the empty-state placeholder. +-- +-- `describe page` now prints every setting whose stored value differs from the +-- default, under its key, so describe -> exec reproduces the stored widget: +-- +-- describe page BugDgByKey.Orders_Local +-- datagrid dgLocal (DataSource: database from BugDgByKey.Ord, +-- configurationStorageType: 'localStorage', …) +-- +-- This script is the INPUT side: it must keep passing `mxcli check`, and every +-- setting below is one the description has to carry. The BSON round trip needs a +-- project; it is asserted by mdl/executor/cmd_pages_describe_bykey_test.go +-- (against the shipped widget template) and was measured on Mendix 11.12.5: +-- exec this script, drop each page, exec its description, compare the page +-- BSON with ids stripped (identical) and `mx check` (0 errors). +-- +-- Two Data grid 2 rules the settings below respect (mxcli check enforces both): +-- * a setting the widget's editor config hides must hold its default +-- (MDL-WIDGET10): itemSelectionMode needs a non-checkbox selection method, +-- showSelectAllToggle needs the checkbox method, a column's hidable needs +-- columnsHidable on; +-- * columnsFilterable: false needs columns without a filter (page E). +-- +-- An Expression-typed setting (rowClass) is covered by the Go test: the checker +-- cannot tell it is one without a project (a separate fix). +-- ============================================================================ + +CREATE MODULE BugDgByKey; + +CREATE OR MODIFY PERSISTENT ENTITY BugDgByKey.Ord ( + Code: String(20), + Amount: Decimal, + Placed: DateTime, + Open: Boolean +); + +CREATE OR MODIFY PERSISTENT ENTITY BugDgByKey.GridPref ( + Config: String(unlimited) +); + +-- A: selection, refresh, loading, paging, labels, empty-state placeholder, and +-- the column settings that have no keyword. +CREATE PAGE BugDgByKey.Orders_All (Title: 'All settings', Layout: Atlas_Core.Atlas_Default) { + datagrid dgAll ( + DataSource: database BugDgByKey.Ord, + Selection: Multi, + itemSelectionMethod: rowClick, + itemSelectionMode: toggle, + keepSelection: true, + refreshInterval: 30, + refreshIndicator: true, + loadingType: skeleton, + columnsSortable: false, + columnsResizable: false, + columnsDraggable: false, + showEmptyPlaceholder: custom, + onClickTrigger: double, + PageSize: 50, + PagingPosition: top, + ShowPagingButtons: auto, + exportDialogLabel: 'Exporting', + cancelExportLabel: 'Stop export', + selectRowLabel: 'Pick row', + selectAllRowsLabel: 'Pick all', + filterSectionTitle: 'Narrow down', + selectedCountTemplateSingular: '1 row picked', + selectedCountTemplatePlural: 'Many rows picked' + ) { + emptyplaceholder { + dynamictext noRows (Content: 'Nothing here') + } + column (Attribute: Code, Hidable: no) { textfilter tf } + column (Attribute: Amount, ShowContentAs: customContent, ColumnWidth: autoFit, minWidth: manual, minWidthLimit: 150, allowEventPropagation: false) { + dynamictext amt (Content: 'amt') + } + column (Attribute: Placed, Alignment: center, WrapText: true, Tooltip: 'when') { datefilter df } + } +}; + +-- B: "load more" paging with a row count. +CREATE PAGE BugDgByKey.Orders_LoadMore (Title: 'Load more', Layout: Atlas_Core.Atlas_Default) { + datagrid dgMore ( + DataSource: database BugDgByKey.Ord, + Pagination: loadMore, + showNumberOfRows: true, + loadMoreButtonCaption: 'Show more', + advanced: true + ) { + column (Attribute: Code) { textfilter tf } + } +}; + +-- C: the grid's personalization stored in an attribute of the surrounding object. +CREATE PAGE BugDgByKey.Orders_Attribute (Title: 'Attribute', Layout: Atlas_Core.Atlas_Default, Params: ( $Pref: BugDgByKey.GridPref )) { + dataview dvPref (DataSource: $Pref) { + datagrid dgAttr ( + DataSource: database BugDgByKey.Ord, + configurationStorageType: attribute, + configurationAttribute: Config, + storeFiltersInPersonalization: false + ) { + column (Attribute: Code) { textfilter tf } + } + } +}; + +-- D: the grid's personalization stored in the browser; a plain selection grid +-- with the columns' hide switch off. +CREATE PAGE BugDgByKey.Orders_Local (Title: 'Local', Layout: Atlas_Core.Atlas_Default) { + datagrid dgLocal ( + DataSource: database BugDgByKey.Ord, + configurationStorageType: localStorage, + storeFiltersInPersonalization: false, + columnsHidable: false, + Selection: Multi, + showSelectAllToggle: false + ) { + column (Attribute: Code) { textfilter tf } + } +}; + +-- E: no column filters, so the grid can switch filtering off. +CREATE PAGE BugDgByKey.Orders_NoFilters (Title: 'No filters', Layout: Atlas_Core.Atlas_Default) { + datagrid dgNoFilter (DataSource: database BugDgByKey.Ord, columnsFilterable: false) { + column (Attribute: Code) + } +}; + +-- F: a Gallery takes its settings by key the same way. +CREATE PAGE BugDgByKey.Orders_Gallery (Title: 'Gallery', Layout: Atlas_Core.Atlas_Default) { + gallery glOrders ( + DataSource: database BugDgByKey.Ord, + Selection: Multi, + itemSelectionMode: toggle, + pagination: loadMore, + pageSize: 5, + showEmptyPlaceholder: custom + ) { + emptyplaceholder { + dynamictext glNoRows (Content: 'Nothing here') + } + template { + dynamictext glCode (Content: 'order') + } + } +}; diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index a6a3738ce..423575fd8 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -720,6 +720,11 @@ type rawDataGridColumn struct { Visible string // expression, e.g. "true" DynamicCellClass string // expression Tooltip string // text + // ExtraProps are the column settings MDL has no keyword for (minWidth, + // allowEventPropagation, …) that differ from their defaults, printed under + // the key the widget declares. OmittedProps names those that could not be. + ExtraProps []rawExplicitProp + OmittedProps []string } // rawWidget represents a widget from raw BSON data for MDL output. @@ -870,6 +875,12 @@ type rawWidget struct { // describe -> exec round trip cannot silently delete a widget's body. // See unreconstructedContainers. OmittedContainers []string + + // OmittedProps names properties whose stored value differs from the default + // and that DESCRIBE has no spelling for (an attribute reached over + // associations, …). Emitted as a comment, like OmittedContainers, so a + // describe -> exec round trip cannot reset them without saying so. + OmittedProps []string // Data container context: entity qualified name provided by this container EntityContext string // Full widget ID (e.g. "com.mendix.widget.custom.switch.Switch") diff --git a/mdl/executor/cmd_pages_describe_bykey.go b/mdl/executor/cmd_pages_describe_bykey.go new file mode 100644 index 000000000..8ee3aec95 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_bykey.go @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "sort" + "strings" +) + +// Settings addressed by the widget's own property key. +// +// A pluggable widget has far more settings than MDL gives a keyword. The write +// side takes every one of them under the key the widget's package declares: +// +// datagrid dg (DataSource: database Shop.Ord, configurationStorageType: localStorage) +// +// DESCRIBE read only the handful it has keywords for (PageSize, Pagination, +// Selection, …), so a grid authored with the rest described back without them +// and a describe -> exec cycle put every one back to the widget's default — +// silently, with `mx check` at 0 errors. +// +// # What is printed +// +// A setting is printed when its stored value differs from what exec writes for +// a property left out, and only then. That keeps every grid that sets none of +// them describing byte for byte as it did. +// +// "What exec writes" is not always the schema's DefaultValue. A text template's +// DefaultValue is '' while the widget ships text for it ("Export progress"), and +// the writer stores that text when the property is absent; comparing against '' +// would print four labels on every grid. The schema is the widget's own, read +// from the document's stored Type — the definition the value was written +// against — so nothing here is a list of widgets or of defaults. + +// widgetPropSchema is one property type of a stored widget, as far as DESCRIBE +// needs it. +type widgetPropSchema struct { + Key string + Type string // "Boolean", "Integer", "Enumeration", "TextTemplate", … + // Default is the schema's DefaultValue. + Default string + // DataSourceProperty is the key of the datasource property this one is + // linked to; "" when it binds to the context object (an attribute property + // resolves against the datasource's entity when linked, the enclosing data + // container's when not). + DataSourceProperty string + // Translations is a text template's shipped default text per language code. + Translations map[string]string + // FirstTranslation is the text of the first shipped translation, the + // fallback for a language the widget ships no text for. + FirstTranslation string +} + +// propertySchemasByID reads the property types of a stored widget (or of a +// nested object type) into a map keyed by the property type's $ID, which is +// what a stored property's TypePointer holds. +func propertySchemasByID(propTypes []any) map[string]widgetPropSchema { + out := make(map[string]widgetPropSchema, len(propTypes)) + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + id := extractBinaryID(ptMap["$ID"]) + key := extractString(ptMap["PropertyKey"]) + if id == "" || key == "" { + continue + } + sc := widgetPropSchema{Key: key} + if vt, ok := ptMap["ValueType"].(map[string]any); ok { + sc.Type = extractString(vt["Type"]) + sc.Default = extractString(vt["DefaultValue"]) + sc.DataSourceProperty = extractString(vt["DataSourceProperty"]) + for _, tr := range getBsonArrayElements(vt["Translations"]) { + trMap, ok := tr.(map[string]any) + if !ok { + continue + } + text := extractString(trMap["Text"]) + if sc.Translations == nil { + sc.Translations = map[string]string{} + sc.FirstTranslation = text + } + sc.Translations[extractString(trMap["LanguageCode"])] = text + } + } + out[id] = sc + } + return out +} + +// widgetPropertySchemas is propertySchemasByID for a custom widget's own +// property types. +func widgetPropertySchemas(w map[string]any) map[string]widgetPropSchema { + widgetType, ok := w["Type"].(map[string]any) + if !ok { + return nil + } + objType, ok := widgetType["ObjectType"].(map[string]any) + if !ok { + return nil + } + return propertySchemasByID(getBsonArrayElements(objType["PropertyTypes"])) +} + +// objectListPropertySchemas is propertySchemasByID for the item type of the +// object-list property listKey (a data grid's `columns`). +func objectListPropertySchemas(w map[string]any, listKey string) map[string]widgetPropSchema { + widgetType, ok := w["Type"].(map[string]any) + if !ok { + return nil + } + objType, ok := widgetType["ObjectType"].(map[string]any) + if !ok { + return nil + } + for _, pt := range getBsonArrayElements(objType["PropertyTypes"]) { + ptMap, ok := pt.(map[string]any) + if !ok || extractString(ptMap["PropertyKey"]) != listKey { + continue + } + vt, ok := ptMap["ValueType"].(map[string]any) + if !ok { + return nil + } + itemType, ok := vt["ObjectType"].(map[string]any) + if !ok { + return nil + } + return propertySchemasByID(getBsonArrayElements(itemType["PropertyTypes"])) + } + return nil +} + +// byKeyOptions steers nonDefaultPropsByKey. +type byKeyOptions struct { + // Skip names the keys MDL already spells another way (PageSize, Selection, + // onClick, …) or that have a body of their own (columns, slots). + Skip map[string]bool + // AttrEntity returns the entity a bare attribute name of property sc + // resolves against on re-execution; "" when there is none. + AttrEntity func(sc widgetPropSchema) string +} + +// nonDefaultPropsByKey returns, in key order, the properties of a stored widget +// object (or object-list item) whose value differs from what exec writes for a +// property left out — each as the `key: value` it is written back with. +// +// omitted names the properties that differ and that no MDL spelling carries, so +// the caller can say so rather than lose them quietly. +func nonDefaultPropsByKey(ctx *ExecContext, objProps []any, schemas map[string]widgetPropSchema, opts byKeyOptions) (props []rawExplicitProp, omitted []string) { + for _, prop := range objProps { + propMap, ok := prop.(map[string]any) + if !ok { + continue + } + sc, ok := schemas[extractBinaryID(propMap["TypePointer"])] + if !ok || opts.Skip[sc.Key] { + continue + } + value, ok := propMap["Value"].(map[string]any) + if !ok { + continue + } + switch sc.Type { + case "Action", "DataSource", "Widgets", "Object", "Selection": + // Carried elsewhere: an action by onClick / its own key, a datasource + // by DataSource:, a widget slot or an object list by its body, and a + // selection by Selection:. + continue + case "Boolean", "Integer", "Decimal", "String", "Enumeration": + pv := extractString(value["PrimitiveValue"]) + if pv == "" || pv == sc.Default { + continue + } + props = append(props, rawExplicitProp{Key: sc.Key, Value: pv, ValueType: sc.Type}) + case "Expression": + expr := extractString(value["Expression"]) + if expr == "" || expr == sc.Default { + continue + } + props = append(props, expressionByKeyProp(ctx, sc.Key, expr)) + case "TextTemplate": + text, tt := extractTextTemplateText(value) + params := []string(nil) + if tt != nil { + params = extractTextTemplateParameters(ctx, tt) + } + if len(params) == 0 && textTemplateIsDefault(value, sc) { + continue + } + props = append(props, rawExplicitProp{Key: sc.Key, Value: text, ValueType: sc.Type, Params: params}) + case "Attribute": + attrRef, ok := value["AttributeRef"].(map[string]any) + if !ok || attrRef == nil { + continue + } + attr := extractString(attrRef["Attribute"]) + if attr == "" { + continue + } + if entityRef, ok := attrRef["EntityRef"].(map[string]any); ok && len(entityRef) > 0 { + // A path over associations has no spelling the explicit-key pass + // reads back. + omitted = append(omitted, sc.Key) + continue + } + scope := "" + if opts.AttrEntity != nil { + scope = opts.AttrEntity(sc) + } + props = append(props, rawExplicitProp{Key: sc.Key, Value: byKeyAttributeName(ctx, attr, scope), IsRef: true}) + default: + // A kind this does not know (an association, an icon, an image, a + // file): if it holds a value, say it was left out. + if valueHoldsSomething(value, "EntityRef", "Icon", "Image", "File", "AttributeRef", "Expression", "PrimitiveValue") { + omitted = append(omitted, sc.Key) + } + } + } + sort.Slice(props, func(i, j int) bool { return props[i].Key < props[j].Key }) + sort.Strings(omitted) + return props, omitted +} + +// byKeyAttributeName is the attribute as the explicit-key pass reads it back: a +// bare name, as the rest of describe writes one, when exec resolves that against +// the entity the property binds to; the qualified name when it would not — no +// data container to resolve against, or an attribute of another entity (one +// inherited from a generalization). The writer takes any name with two dots as +// qualified. +// +// The scope is a qualified entity name when the container's source is an +// entity, and only a name (a page parameter's) when it is not; the latter cannot +// be compared, and Mendix binds such a property to an attribute of that +// container's entity anyway. +func byKeyAttributeName(ctx *ExecContext, attr, scopeEntity string) string { + if ctx != nil && ctx.describeQualifyAttrs { + return attr + } + if scopeEntity == "" { + return attr + } + if strings.Contains(scopeEntity, ".") && !strings.EqualFold(attributeEntity(attr), scopeEntity) { + return attr + } + return shortAttributeName(attr) +} + +// attributeEntity is Module.Entity of Module.Entity.Attribute. +func attributeEntity(attr string) string { + if idx := strings.LastIndex(attr, "."); idx >= 0 { + return attr[:idx] + } + return "" +} + +// expressionByKeyProp renders an Expression-typed property. The explicit-key +// pass takes the unquoted form as the expression itself, so that is what is +// printed — except for an expression that is nothing but a string literal +// (`'cls'`), which unquoted would be read as the string `cls` and stored +// without its quotes; it is printed as the quoted text of the expression. +func expressionByKeyProp(ctx *ExecContext, key, expr string) rawExplicitProp { + if _, isLiteral := mendixStringLiteral(expr); isLiteral { + return rawExplicitProp{Key: key, Value: mdlQuote(ctx, expr), IsRef: true} + } + return rawExplicitProp{Key: key, Value: expr, IsRef: true} +} + +// textTemplateIsDefault reports whether a stored text template is what exec +// writes for the property left out: the text the widget ships for it, or no text +// at all. Compared per language, falling back to the first shipped text for a +// language it ships none for. +func textTemplateIsDefault(value map[string]any, sc widgetPropSchema) bool { + stored := map[string]string{} + if tt, ok := value["TextTemplate"].(map[string]any); ok && tt != nil { + if template, ok := tt["Template"].(map[string]any); ok && template != nil { + for _, item := range getBsonArrayElements(template["Items"]) { + m, ok := item.(map[string]any) + if !ok { + continue + } + if text := extractString(m["Text"]); text != "" { + stored[extractString(m["LanguageCode"])] = text + } + } + } + } + if len(stored) == 0 { + // Nothing stored: a null template, which is what a property the widget's + // editor config hides is written with, or an empty one. Neither is a + // setting MDL can restate — `key: ''` is written as the widget's text, + // the same as leaving the property out — so printing it would only + // change the description of every grid whose property is hidden. + return true + } + for lang, text := range stored { + shipped, ok := sc.Translations[lang] + if !ok { + shipped = sc.FirstTranslation + } + if text != shipped { + return false + } + } + return true +} + +// valueHoldsSomething reports whether any of the named fields of a widget value +// is set to something other than null, "" or an empty map. +func valueHoldsSomething(value map[string]any, fields ...string) bool { + for _, f := range fields { + switch v := value[f].(type) { + case nil: + case string: + if v != "" { + return true + } + case map[string]any: + if len(v) > 0 { + return true + } + default: + return true + } + } + return false +} + +// dataGridByKeySkip are the grid-level keys MDL spells another way or carries in +// a body: the paging keywords, the selection, the datasource, the columns, the +// slot widgets, and the action slots. (Action-, DataSource-, Widgets-, Object- +// and Selection-typed properties are skipped by type; these are the scalar ones +// with a keyword of their own.) +var dataGridByKeySkip = map[string]bool{ + "pageSize": true, "pagination": true, "pagingPosition": true, "showPagingButtons": true, +} + +// dataGridColumnKeySkip are the column keys extractDataGrid2Column already +// reads under their MDL names. +var dataGridColumnKeySkip = map[string]bool{ + "showContentAs": true, "attribute": true, "header": true, "dynamicText": true, + "tooltip": true, "visible": true, "sortable": true, "resizable": true, + "draggable": true, "hidable": true, "width": true, "size": true, + "alignment": true, "columnClass": true, "wrapText": true, +} + +// galleryByKeySkip is dataGridByKeySkip for a Gallery: the column counts are +// DesktopColumns / TabletColumns / PhoneColumns, and the click trigger has its +// own line. +var galleryByKeySkip = map[string]bool{ + "desktopItems": true, "tabletItems": true, "phoneItems": true, "onClickTrigger": true, +} + +// extractDataGrid2ByKeyProps returns the grid-level settings of a Data grid 2 +// that DESCRIBE has no keyword for and that differ from their defaults, and the +// keys it could not print. enclosingEntity is the entity of the data container +// the grid sits in (what an attribute property linked to no datasource binds +// to), gridEntity the grid's own datasource entity. +func extractDataGrid2ByKeyProps(ctx *ExecContext, w map[string]any, enclosingEntity, gridEntity string) ([]rawExplicitProp, []string) { + return extractWidgetByKeyProps(ctx, w, dataGridByKeySkip, enclosingEntity, gridEntity) +} + +// extractGalleryByKeyProps is extractDataGrid2ByKeyProps for a Gallery, which +// takes its settings by key the same way and described back without them the +// same way (pagination, page size, empty placeholder, state storage, …). +func extractGalleryByKeyProps(ctx *ExecContext, w map[string]any, enclosingEntity, galleryEntity string) ([]rawExplicitProp, []string) { + return extractWidgetByKeyProps(ctx, w, galleryByKeySkip, enclosingEntity, galleryEntity) +} + +func extractWidgetByKeyProps(ctx *ExecContext, w map[string]any, skip map[string]bool, enclosingEntity, ownEntity string) ([]rawExplicitProp, []string) { + obj, ok := w["Object"].(map[string]any) + if !ok { + return nil, nil + } + return nonDefaultPropsByKey(ctx, getBsonArrayElements(obj["Properties"]), widgetPropertySchemas(w), byKeyOptions{ + Skip: skip, + AttrEntity: func(sc widgetPropSchema) string { + if sc.DataSourceProperty != "" { + return ownEntity + } + return enclosingEntity + }, + }) +} + +// extractDataGrid2ColumnByKeyProps is extractDataGrid2ByKeyProps for one +// column's object. +func extractDataGrid2ColumnByKeyProps(ctx *ExecContext, colObj map[string]any, colSchemas map[string]widgetPropSchema, entityContext string) ([]rawExplicitProp, []string) { + return nonDefaultPropsByKey(ctx, getBsonArrayElements(colObj["Properties"]), colSchemas, byKeyOptions{ + Skip: dataGridColumnKeySkip, + AttrEntity: func(widgetPropSchema) string { return entityContext }, + }) +} diff --git a/mdl/executor/cmd_pages_describe_bykey_test.go b/mdl/executor/cmd_pages_describe_bykey_test.go new file mode 100644 index 000000000..ad5ce5d52 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_bykey_test.go @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: Apache-2.0 + +// DESCRIBE has to print a Data grid 2's settings that MDL has no keyword for, +// under the key the widget declares, or a describe -> exec cycle resets each of +// them to the widget's default. +// +// datagrid dg (DataSource: database Shop.Ord, configurationStorageType: localStorage) +// +// exec stores that, `describe page` printed only the keyword properties +// (PageSize, Pagination, …), and re-executing the description wrote the default +// back — silently, at `mx check` 0 errors. +// +// The fixtures here are the shipped Data grid 2 template, not a hand-written +// list of its properties, so "an untouched grid prints nothing" is checked +// against the widget's real schema and real default values. +package executor + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/widgets" +) + +const dataGridWidgetID = "com.mendix.widget.web.datagrid.Datagrid" + +// normalizeTemplateJSON turns a decoded JSON template into the shape a decoded +// BSON document has: the typed-array marker that opens every array is an int, +// not the float64 encoding/json makes of it. +func normalizeTemplateJSON(v any) any { + switch x := v.(type) { + case map[string]any: + for k, e := range x { + x[k] = normalizeTemplateJSON(e) + } + return x + case []any: + for i, e := range x { + if f, ok := e.(float64); ok && i == 0 { + x[i] = int(f) + continue + } + x[i] = normalizeTemplateJSON(e) + } + return x + } + return v +} + +// dataGridFromTemplate is an untouched Data grid 2: the shipped template's +// schema and the values exec writes for a property left out. +func dataGridFromTemplate(t *testing.T) map[string]any { + t.Helper() + tmpl, err := widgets.GetTemplate(dataGridWidgetID) + if err != nil || tmpl == nil { + t.Fatalf("Data grid 2 template: %v, %v", tmpl, err) + } + raw, err := json.Marshal(map[string]any{"Type": tmpl.Type, "Object": tmpl.Object}) + if err != nil { + t.Fatal(err) + } + var w map[string]any + if err := json.Unmarshal(raw, &w); err != nil { + t.Fatal(err) + } + normalizeTemplateJSON(w) + w["$Type"] = "CustomWidgets$CustomWidget" + w["Name"] = "dg" + return w +} + +// gridValue is the stored Value of the grid-level property key. +func gridValue(t *testing.T, w map[string]any, key string) map[string]any { + t.Helper() + ids := buildPropertyTypeKeyMap(w, false) + for _, p := range getBsonArrayElements(w["Object"].(map[string]any)["Properties"]) { + pm := p.(map[string]any) + if ids[extractBinaryID(pm["TypePointer"])] == key { + return pm["Value"].(map[string]any) + } + } + t.Fatalf("template has no property %q", key) + return nil +} + +func describeGrid(t *testing.T, w map[string]any, enclosing string) rawWidget { + t.Helper() + ctx := (&Executor{}).newExecContext(nil) + got := parseRawWidget(ctx, w, enclosing) + if len(got) != 1 { + t.Fatalf("parseRawWidget returned %d widgets", len(got)) + } + return got[0] +} + +func explicitByKey(ps []rawExplicitProp) map[string]rawExplicitProp { + m := map[string]rawExplicitProp{} + for _, p := range ps { + m[p.Key] = p + } + return m +} + +func explicitKeyList(ps []rawExplicitProp) []string { + var out []string + for _, p := range ps { + out = append(out, p.Key) + } + return out +} + +// An untouched grid describes exactly as before: every value the template holds +// is the widget's default, the text templates hold the text the widget ships +// for them, and a template hidden by the editor config is null. +func TestDataGridByKey_UntouchedGridPrintsNothing(t *testing.T) { + got := describeGrid(t, dataGridFromTemplate(t), "") + if len(got.ExplicitProperties) != 0 { + t.Errorf("untouched grid printed %v; its description must not change", explicitKeyList(got.ExplicitProperties)) + } + if len(got.OmittedProps) != 0 { + t.Errorf("untouched grid reported omitted properties %v", got.OmittedProps) + } +} + +// Every kind of setting a grid has, set away from its default, is printed under +// its own key; the ones left alone are not. +func TestDataGridByKey_PrintsEachNonDefaultKind(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationStorageType")["PrimitiveValue"] = "localStorage" // enumeration + gridValue(t, w, "columnsHidable")["PrimitiveValue"] = "false" // boolean + gridValue(t, w, "refreshInterval")["PrimitiveValue"] = "30" // integer + gridValue(t, w, "rowClass")["Expression"] = "if $currentObject/Open then 'a' else 'b'" + gridValue(t, w, "exportDialogLabel")["TextTemplate"].(map[string]any)["Template"].(map[string]any)["Items"].([]any)[1].(map[string]any)["Text"] = "Saving" + gridValue(t, w, "configurationAttribute")["AttributeRef"] = map[string]any{"Attribute": "Shop.GridPref.Config"} + + got := describeGrid(t, w, "Shop.GridPref") + props := explicitByKey(got.ExplicitProperties) + + want := map[string]string{ + "configurationStorageType": "'localStorage'", + "columnsHidable": "false", + "refreshInterval": "30", + "rowClass": "if $currentObject/Open then 'a' else 'b'", + "exportDialogLabel": "'Saving'", + "configurationAttribute": "Config", + } + ctx := (&Executor{}).newExecContext(nil) + for key, wantValue := range want { + p, ok := props[key] + if !ok { + t.Errorf("%s was not printed; got %v", key, explicitKeyList(got.ExplicitProperties)) + continue + } + if v := explicitPropValue(ctx, p); v != wantValue { + t.Errorf("%s printed as %s, want %s", key, v, wantValue) + } + } + if len(got.ExplicitProperties) != len(want) { + t.Errorf("printed %v, want exactly %d settings", explicitKeyList(got.ExplicitProperties), len(want)) + } +} + +// The attribute is written bare where exec resolves a bare name — inside a data +// container — and qualified where it would not: there is nothing to resolve a +// bare name against on a page-level grid. +func TestDataGridByKey_AttributeIsBareOnlyInsideAContainer(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationAttribute")["AttributeRef"] = map[string]any{"Attribute": "Shop.GridPref.Config"} + + if v := explicitByKey(describeGrid(t, w, "Shop.GridPref").ExplicitProperties)["configurationAttribute"].Value; v != "Config" { + t.Errorf("inside a data view over the entity: %q, want Config", v) + } + // A page parameter's data view names no entity; the property still binds to + // that container's entity. + if v := explicitByKey(describeGrid(t, w, "Pref").ExplicitProperties)["configurationAttribute"].Value; v != "Config" { + t.Errorf("inside a data view over a page parameter: %q, want Config", v) + } + if v := explicitByKey(describeGrid(t, w, "").ExplicitProperties)["configurationAttribute"].Value; v != "Shop.GridPref.Config" { + t.Errorf("on a page-level grid: %q, want the qualified name", v) + } + if v := explicitByKey(describeGrid(t, w, "Shop.Other").ExplicitProperties)["configurationAttribute"].Value; v != "Shop.GridPref.Config" { + t.Errorf("attribute of another entity than the container's: %q, want the qualified name", v) + } +} + +// What MDL already spells another way must not be printed twice: writing +// `PageSize: 25` and `pageSize: 25` would put two values on one property. +func TestDataGridByKey_SkipsWhatHasAKeywordOfItsOwn(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "pageSize")["PrimitiveValue"] = "25" + gridValue(t, w, "pagination")["PrimitiveValue"] = "virtualScrolling" + gridValue(t, w, "pagingPosition")["PrimitiveValue"] = "top" + gridValue(t, w, "showPagingButtons")["PrimitiveValue"] = "auto" + // A Selection-typed property is carried by `Selection:`; and a stray + // PrimitiveValue on it is not a setting. + gridValue(t, w, "itemSelection")["Selection"] = "Multi" + + got := describeGrid(t, w, "") + if len(got.ExplicitProperties) != 0 { + t.Errorf("printed %v for keys MDL already has a keyword for", explicitKeyList(got.ExplicitProperties)) + } + if got.PageSize != "25" || got.Pagination != "virtualScrolling" || got.Selection != "Multi" { + t.Errorf("the keyword properties were not read: PageSize=%q Pagination=%q Selection=%q", + got.PageSize, got.Pagination, got.Selection) + } +} + +// An expression that is only a string literal would, unquoted, be read back as +// the string and stored without its quotes — a different expression. It is +// printed as the quoted text of the expression instead. +func TestDataGridByKey_ExpressionThatIsAStringLiteralIsQuoted(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "rowClass")["Expression"] = "'is-open'" + + ctx := (&Executor{}).newExecContext(nil) + p := explicitByKey(describeGrid(t, w, "").ExplicitProperties)["rowClass"] + if got, want := explicitPropValue(ctx, p), `'''is-open'''`; got != want { + t.Errorf("rowClass printed as %s, want %s", got, want) + } +} + +// A path over associations has no spelling the explicit-key pass reads back. It +// must not be printed as if it had one, and must not vanish. +func TestDataGridByKey_AttributeOverAnAssociationIsNamedNotDropped(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationAttribute")["AttributeRef"] = map[string]any{ + "Attribute": "Shop.GridPref.Config", + "EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef"}, + } + + got := describeGrid(t, w, "Shop.Ord") + if _, printed := explicitByKey(got.ExplicitProperties)["configurationAttribute"]; printed { + t.Errorf("printed an attribute path the writer cannot read back") + } + if len(got.OmittedProps) != 1 || got.OmittedProps[0] != "configurationAttribute" { + t.Errorf("OmittedProps = %v, want [configurationAttribute]", got.OmittedProps) + } + + var buf bytes.Buffer + writeOmittedPropsNote(&buf, " ", got.OmittedProps) + if out := buf.String(); !strings.Contains(out, "-- NOT re-executable: configurationAttribute") { + t.Errorf("note does not name the property:\n%s", out) + } +} + +// A column's own settings follow the same rule, minus the keys describe already +// reads under their MDL names. +func TestDataGridByKey_ColumnSettings(t *testing.T) { + w := dataGridFromTemplate(t) + + schemas := objectListPropertySchemas(w, "columns") + if len(schemas) == 0 { + t.Fatal("no column schema in the template") + } + // The template's columns object-list holds one default column. + var column map[string]any + for _, p := range getBsonArrayElements(w["Object"].(map[string]any)["Properties"]) { + pm := p.(map[string]any) + if objs := getBsonArrayElements(pm["Value"].(map[string]any)["Objects"]); len(objs) > 0 { + column = objs[0].(map[string]any) + } + } + if column == nil { + t.Skip("template carries no default column") + } + + if props, omitted := extractDataGrid2ColumnByKeyProps(nil, column, schemas, "Shop.Ord"); len(props) != 0 || len(omitted) != 0 { + t.Errorf("untouched column printed %v / omitted %v", explicitKeyList(props), omitted) + } + + setColumn := func(key, field string, value any) { + ids := map[string]string{} + for id, sc := range schemas { + ids[id] = sc.Key + } + for _, p := range getBsonArrayElements(column["Properties"]) { + pm := p.(map[string]any) + if ids[extractBinaryID(pm["TypePointer"])] == key { + pm["Value"].(map[string]any)[field] = value + return + } + } + t.Fatalf("column has no property %q", key) + } + setColumn("minWidth", "PrimitiveValue", "manual") + setColumn("minWidthLimit", "PrimitiveValue", "150") + setColumn("allowEventPropagation", "PrimitiveValue", "false") + // Read under their own names by extractDataGrid2Column: not repeated. + setColumn("alignment", "PrimitiveValue", "right") + setColumn("size", "PrimitiveValue", "3") + + props, _ := extractDataGrid2ColumnByKeyProps(nil, column, schemas, "Shop.Ord") + got := explicitByKey(props) + for key, want := range map[string]string{"minWidth": "manual", "minWidthLimit": "150", "allowEventPropagation": "false"} { + if got[key].Value != want { + t.Errorf("column %s = %q, want %q (printed %v)", key, got[key].Value, want, explicitKeyList(props)) + } + } + for _, key := range []string{"alignment", "size"} { + if _, dup := got[key]; dup { + t.Errorf("column %s printed by key as well as under its MDL name", key) + } + } + + // …and DESCRIBE of the whole grid carries them on the column. + described := false + for _, col := range describeGrid(t, w, "Shop.Ord").DataGridColumns { + if explicitByKey(col.ExtraProps)["minWidth"].Value == "manual" { + described = true + } + } + if !described { + t.Error("describe of the grid does not carry the column's minWidth") + } +} + +// The description has to parse, and carry each value under the key it was +// printed with — otherwise it is text that merely looks right. +func TestDataGridByKey_DescriptionReparses(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationStorageType")["PrimitiveValue"] = "localStorage" + gridValue(t, w, "columnsHidable")["PrimitiveValue"] = "false" + gridValue(t, w, "exportDialogLabel")["TextTemplate"].(map[string]any)["Template"].(map[string]any)["Items"].([]any)[1].(map[string]any)["Text"] = "It's saving" + + raw := describeGrid(t, w, "") + raw.DataSource = &rawDataSource{Type: "database", Reference: "Shop.Ord"} + raw.Name = "dg" + + var buf bytes.Buffer + ctx := (&Executor{}).newExecContext(nil) + ctx.Output = &buf + outputWidgetMDLV3(ctx, raw, 1) + described := buf.String() + + prog, errs := visitor.Build("create page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n" + described + "};") + if len(errs) > 0 { + t.Fatalf("description does not parse: %v\n%s", errs[0], described) + } + _ = prog + for _, want := range []string{ + "configurationStorageType: 'localStorage'", + "columnsHidable: false", + "exportDialogLabel: 'It''s saving'", + } { + if !strings.Contains(described, want) { + t.Errorf("description lacks %q:\n%s", want, described) + } + } + if errs := checkErrors(t, "create page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n"+described+"};"); len(errs) > 0 { + t.Errorf("check refused the description:\n %s\n%s", strings.Join(errs, "\n "), described) + } +} + +// The empty-state placeholder is a widget slot the grid declares. It is +// printed, because showEmptyPlaceholder: custom is printed and means "look in +// the slot". +func TestDataGridByKey_EmptyPlaceholderSlotIsKept(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "showEmptyPlaceholder")["PrimitiveValue"] = "custom" + gridValue(t, w, "emptyPlaceholder")["Widgets"] = []any{2, map[string]any{ + "$Type": "Forms$DynamicText", "Name": "none_here", + "Content": map[string]any{"Template": map[string]any{"Items": []any{2, + map[string]any{"LanguageCode": "en_US", "Text": "Nothing here"}}}}, + }} + + got := describeGrid(t, w, "") + if len(got.ChildSlots) != 1 || got.ChildSlots[0].Keyword != "emptyplaceholder" { + t.Fatalf("ChildSlots = %+v, want the emptyplaceholder slot only (the control bar has its own block)", got.ChildSlots) + } +} + +// A Gallery takes its settings by key the same way, and had the same loss. Its +// column counts and click trigger already have MDL names and are not repeated. +func TestGalleryByKey_PrintsNonDefaultSettingsOnly(t *testing.T) { + tmpl, err := widgets.GetTemplate("com.mendix.widget.web.gallery.Gallery") + if err != nil || tmpl == nil { + t.Fatalf("Gallery template: %v, %v", tmpl, err) + } + raw, _ := json.Marshal(map[string]any{"Type": tmpl.Type, "Object": tmpl.Object}) + var w map[string]any + if err := json.Unmarshal(raw, &w); err != nil { + t.Fatal(err) + } + normalizeTemplateJSON(w) + w["$Type"] = "CustomWidgets$CustomWidget" + w["Name"] = "gl" + + if got := describeGrid(t, w, ""); len(got.ExplicitProperties) != 0 || len(got.OmittedProps) != 0 { + t.Errorf("untouched gallery printed %v / omitted %v", explicitKeyList(got.ExplicitProperties), got.OmittedProps) + } + + gridValue(t, w, "pagination")["PrimitiveValue"] = "loadMore" + gridValue(t, w, "pageSize")["PrimitiveValue"] = "5" + gridValue(t, w, "desktopItems")["PrimitiveValue"] = "3" + gridValue(t, w, "onClickTrigger")["PrimitiveValue"] = "double" + + got := describeGrid(t, w, "") + props := explicitByKey(got.ExplicitProperties) + for key, want := range map[string]string{"pagination": "loadMore", "pageSize": "5"} { + if props[key].Value != want { + t.Errorf("gallery %s = %q, want %q (printed %v)", key, props[key].Value, want, explicitKeyList(got.ExplicitProperties)) + } + } + for _, key := range []string{"desktopItems", "onClickTrigger"} { + if _, dup := props[key]; dup { + t.Errorf("gallery %s printed by key as well as under its MDL name", key) + } + } + if got.DesktopColumns != "3" || got.OnClickTrigger != "double" { + t.Errorf("MDL-named properties not read: DesktopColumns=%q OnClickTrigger=%q", got.DesktopColumns, got.OnClickTrigger) + } +} diff --git a/mdl/executor/cmd_pages_describe_omitted.go b/mdl/executor/cmd_pages_describe_omitted.go index 00ad66a39..4d97cc06b 100644 --- a/mdl/executor/cmd_pages_describe_omitted.go +++ b/mdl/executor/cmd_pages_describe_omitted.go @@ -138,3 +138,16 @@ func writeOmittedContainerNote(out io.Writer, prefix string, omitted []string) { fmt.Fprintf(out, "%s-- reproduce. Re-running this script would DROP it. Inspect the widget with\n", prefix) fmt.Fprintf(out, "%s-- `mxcli widget describe ` and re-add the block by hand.\n", prefix) } + +// writeOmittedPropsNote emits, as a comment, the properties whose stored value +// differs from the default and that DESCRIBE has no spelling for — the property +// counterpart of writeOmittedContainerNote. The output still parses and +// re-executes; it simply does not carry them, and says so. +func writeOmittedPropsNote(out io.Writer, prefix string, omitted []string) { + if len(omitted) == 0 { + return + } + fmt.Fprintf(out, "%s-- NOT re-executable: %s — stored with a non-default value that DESCRIBE has\n", + prefix, strings.Join(omitted, ", ")) + fmt.Fprintf(out, "%s-- no spelling for. Re-running this script would reset it to the widget's default.\n", prefix) +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 22e5fb132..9dca52320 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -100,6 +100,21 @@ func looksNumeric(s string) bool { return body != "." } +// appendExplicitProps appends a pluggable widget's settings printed under their +// own key: `key: value`, and a text template's `{N}` bindings as the +// `Params` companion — a `{1}` re-executed without its parameter is CE0720, +// so the companion travels with the text it belongs to (#575). +func appendExplicitProps(ctx *ExecContext, props []string, explicit []rawExplicitProp) []string { + for _, ep := range explicit { + props = append(props, fmt.Sprintf("%s: %s", ep.Key, explicitPropValue(ctx, ep))) + if len(ep.Params) > 0 { + props = append(props, fmt.Sprintf("%sParams: (%s)", + ep.Key, strings.Join(formatParametersV3(ep.Params), ", "))) + } + } + return props +} + // appendDataGridPagingProps appends non-default paging properties for DataGrid2. func appendDataGridPagingProps(props []string, w rawWidget) []string { if w.PageSize != "" && w.PageSize != "20" { @@ -734,12 +749,17 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = appendNamedActionProps(props, w) // Add paging properties if non-default props = appendDataGridPagingProps(props, w) + // Every other setting that differs from its default, under the + // widget's own key (configurationStorageType, columnsHidable, …). + props = appendExplicitProps(ctx, props, w.ExplicitProperties) props = appendAppearanceProps(ctx, props, w) - // Output CONTROLBAR and columns as children - hasContent := len(w.ControlBar) > 0 || len(w.DataGridColumns) > 0 + // Output CONTROLBAR, the widget slots and columns as children + hasContent := len(w.ControlBar) > 0 || len(w.DataGridColumns) > 0 || + len(w.ChildSlots) > 0 || len(w.OmittedProps) > 0 if hasContent { formatWidgetProps(ctx.Output, prefix, header, props, " {\n") outputDataContainerContext(ctx.Output, prefix+" ", w.Name, w.EntityContext, true) + writeOmittedPropsNote(ctx.Output, prefix+" ", w.OmittedProps) // Output CONTROLBAR section if control bar widgets present if len(w.ControlBar) > 0 { fmt.Fprintf(ctx.Output, "%s controlbar {\n", prefix) @@ -748,6 +768,8 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { } fmt.Fprintf(ctx.Output, "%s }\n", prefix) } + // The empty-state placeholder, and any other slot the grid declares. + outputChildSlots(ctx, w.ChildSlots, prefix+" ", indent+1) // Output columns. A DataGrid 2 column has no name in the model, // so none is written: the one derived from its attribute gave two // columns over one attribute the same name (#749). ALTER PAGE @@ -787,12 +809,17 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = append(props, actionProp("onClick", w.OnClick)) } props = appendNamedActionProps(props, w) + // Every other setting that differs from its default, under the + // widget's own key (pagination, pageSize, …). + props = appendExplicitProps(ctx, props, w.ExplicitProperties) props = appendAppearanceProps(ctx, props, w) // Output filter and content widgets - hasContent := len(w.Children) > 0 || len(w.FilterWidgets) > 0 + hasContent := len(w.Children) > 0 || len(w.FilterWidgets) > 0 || + len(w.ChildSlots) > 0 || len(w.OmittedProps) > 0 if hasContent { formatWidgetProps(ctx.Output, prefix, header, props, " {\n") outputDataContainerContext(ctx.Output, prefix+" ", w.Name, w.EntityContext, true) + writeOmittedPropsNote(ctx.Output, prefix+" ", w.OmittedProps) // Output FILTER section if filter widgets present if len(w.FilterWidgets) > 0 { fmt.Fprintf(ctx.Output, "%s filter {\n", prefix) @@ -809,6 +836,8 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { } fmt.Fprintf(ctx.Output, "%s }\n", prefix) } + // The empty-state placeholder, and any other slot the gallery declares. + outputChildSlots(ctx, w.ChildSlots, prefix+" ", indent+1) fmt.Fprintf(ctx.Output, "%s}\n", prefix) } else { formatWidgetProps(ctx.Output, prefix, header, props, "\n") @@ -853,15 +882,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { // the datasource is gone. The DESCRIBE text was byte-identical before // and after, so only mx check separated them (#956). props = appendWidgetDataSources(ctx, props, w) - for _, ep := range w.ExplicitProperties { - props = append(props, fmt.Sprintf("%s: %s", ep.Key, explicitPropValue(ctx, ep))) - // A `{1}` re-executed without its parameter is CE0720, so the - // companion travels with the text it belongs to (#575). - if len(ep.Params) > 0 { - props = append(props, fmt.Sprintf("%sParams: (%s)", - ep.Key, strings.Join(formatParametersV3(ep.Params), ", "))) - } - } + props = appendExplicitProps(ctx, props, w.ExplicitProperties) // onClick action (ledger #67 — reported on CustomChart) if w.OnClick != "" { props = append(props, actionProp("onClick", w.OnClick)) @@ -1273,6 +1294,8 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol if col.Tooltip != "" { props = append(props, fmt.Sprintf("Tooltip: %s", mdlQuote(ctx, col.Tooltip))) } + // Any other column setting that differs from its default, by its own key. + props = appendExplicitProps(ctx, props, col.ExtraProps) // No name: Mendix stores none on a DataGrid 2 column (#749). header := "column" @@ -1280,11 +1303,12 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol // the builder routes a filter back to its own slot by widget type. Emitting // only the content widgets deleted the filter of every custom-content column // on a describe→exec round trip (ako/mxcli#489). - hasContent := len(col.ContentWidgets) > 0 || len(col.FilterWidgets) > 0 + hasContent := len(col.ContentWidgets) > 0 || len(col.FilterWidgets) > 0 || len(col.OmittedProps) > 0 if hasContent { // Output column with content block formatWidgetProps(ctx.Output, prefix, header, props, " {\n") + writeOmittedPropsNote(ctx.Output, prefix+" ", col.OmittedProps) for _, widget := range col.ContentWidgets { outputWidgetMDLV3(ctx, widget, len(prefix)/2+1) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 15eacb00e..972100e4e 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -491,6 +491,17 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } widget.DataGridColumns = extractDataGrid2Columns(ctx, w, widget.EntityContext) widget.ControlBar = extractDataGrid2ControlBar(ctx, w) + // Every other grid setting that differs from its default, by the + // widget's own key, and the widget slots other than the control bar + // (the empty-state placeholder). Without them a describe -> exec + // cycle reset the personalization, selection, paging and label + // settings to the widget's defaults. + widget.ExplicitProperties, widget.OmittedProps = extractDataGrid2ByKeyProps(ctx, w, inheritedCtx, widget.EntityContext) + for _, slot := range extractChildSlots(ctx, w, widget.EntityContext) { + if slot.PropertyKey != "filtersPlaceholder" { + widget.ChildSlots = append(widget.ChildSlots, slot) + } + } // onClick action (ledger #67): read the client action back with full // parameter mappings so a describe round-trip re-emits the onClick. // By SOURCE, not by the literal key — the widget may store it as @@ -517,6 +528,16 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s return extractGalleryContent(ctx, w, widget.EntityContext) }) widget.FilterWidgets = extractGalleryFilters(ctx, w) + // Pagination, page size, state storage and the rest of the gallery's + // settings that differ from their defaults, by the widget's own key, + // and the slots other than `template` and `filter` (the empty-state + // placeholder). + widget.ExplicitProperties, widget.OmittedProps = extractGalleryByKeyProps(ctx, w, inheritedCtx, widget.EntityContext) + for _, slot := range extractChildSlots(ctx, w, widget.EntityContext) { + if slot.PropertyKey != "content" && slot.PropertyKey != "filtersPlaceholder" { + widget.ChildSlots = append(widget.ChildSlots, slot) + } + } // The row click and the other two action slots, read the way the // data grid's are: the click by SOURCE, the rest by the widget's own // key. Without them the write fix for #842 was one-way — describe → diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 9a688efe5..8b6ed80e6 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -244,12 +244,14 @@ func extractDataGrid2Columns(ctx *ExecContext, w map[string]any, entityContext . entCtx = entityContext[0] } var columns []rawDataGridColumn + colSchemas := objectListPropertySchemas(w, "columns") for _, colObj := range objects { colMap, ok := colObj.(map[string]any) if !ok { continue } col := extractDataGrid2Column(ctx, colMap, colPropKeyMap, entCtx) + col.ExtraProps, col.OmittedProps = extractDataGrid2ColumnByKeyProps(ctx, colMap, colSchemas, entCtx) // Every object of the resolved `columns` list is a column. The // actions column has no attribute and an empty header, and keeping // only columns with one of those dropped it with all its widgets,