From c9421b8077f5d1c831f35ba94a12001eb431c09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Go=C5=82embiewski?= Date: Tue, 13 Oct 2026 20:57:59 +0200 Subject: [PATCH] feat: bind the image widget to an image object in context (mendixlabs/mxcli#1476) MDL had no way to show an uploaded image (an object of a System.Image specialisation) on a React-client page. `Image:` on the pluggable Image widget could only name an image collection entry. The legacy `dynamicimage` is CE0582 in React. `image i (Image: $currentObject)` failed check with MDL-WIDGET22 and MDL-WIDGET42. Describe of a Studio Pro page bound this way printed a bare `image i`, which re-executes into "No image selected." `Image: $currentObject` now binds the widget to the enclosing data view's, list view's or gallery's object. The stored shape is Studio Pro's. Measured on 11.12.1 with Image widget 1.5.0 by setting "Image source" to a data view's object and decoding the unit before and after, exactly two values change: imageObject Image '' and EntityRef DirectEntityRef{Entity} defaultImageDynamic EntityRef DirectEntityRef{Entity} `DefaultImage:` is the fallback on `defaultImageDynamic`, written as a plain collection image. The Studio Pro page set no fallback, so this shape was measured in the running app, on an object whose HasContents is false. With the EntityRef beside the image, the widget renders hidden with no src. Without it, the fallback renders. Both shapes pass mx check. Results: - Written by mxcli on the same page, the unit is identical to Studio Pro's once $IDs and TypePointers are stripped. mx check: 0 errors. - describe -> exec reports "Unchanged page". - In a running app, an image uploaded through the File Uploader renders as /file?guid=... with naturalWidth 40 for a 40-pixel PNG. With its contents cleared, the fallback renders instead. These are refused before the write: - an entity that is not an image (mxbuild: CE7247, once per bound property); - a widget with no enclosing data container; - a page parameter or an association path, whose stored shapes have not been measured. Describe prints the binding back. A stored shape MDL cannot spell gets a NOT re-executable note. --- ...widget-could-not-bind-an-image-object.json | 1 + .claude/skills/mendix/create-page/SKILL.md | 21 +- .../mendix/create-page/reference/widgets.md | 7 +- CHANGELOG.md | 4 + cmd/mxcli/syntax/features_page.go | 8 + docs-site/src/language/widget-types.md | 11 + docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../widgets-1476-image-object-binding.mdl | 65 +++++ .../doctype-tests/03-page-examples.mdl | 26 ++ mdl/backend/mcp/widget.go | 10 + mdl/backend/mutation.go | 5 + mdl/backend/widgetobj/builder.go | 44 +++ mdl/backend/widgetobj/builder_image_test.go | 58 ++++ mdl/executor/cmd_pages_describe.go | 6 + mdl/executor/cmd_pages_describe_image_test.go | 49 ++++ mdl/executor/cmd_pages_describe_output.go | 18 +- mdl/executor/cmd_pages_describe_pluggable.go | 51 ++++ mdl/executor/helpers.go | 2 +- .../validate_widget_expression_value.go | 4 + mdl/executor/validate_widget_image.go | 67 ++++- .../validate_widget_image_ref_collect_test.go | 15 + mdl/executor/validate_widget_image_test.go | 51 ++++ mdl/executor/widget_engine.go | 35 ++- mdl/executor/widget_image_object.go | 104 +++++++ mdl/executor/widget_image_object_test.go | 261 ++++++++++++++++++ modelsdk/widgets/definitions/image.def.json | 5 + sdk/widgets/definitions/image.def.json | 5 + 27 files changed, 923 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-13-image-widget-could-not-bind-an-image-object.json create mode 100644 mdl-examples/bug-tests/widgets-1476-image-object-binding.mdl create mode 100644 mdl/executor/widget_image_object.go create mode 100644 mdl/executor/widget_image_object_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-image-widget-could-not-bind-an-image-object.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-image-widget-could-not-bind-an-image-object.json new file mode 100644 index 0000000000..5a6a6f96ca --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-image-widget-could-not-bind-an-image-object.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-13", "symptom": "No MDL form showed an uploaded image (a System.Image specialisation) on a page in a React-client app. `image i (Image: $currentObject)` failed check with MDL-WIDGET22 (\"names no image\") and MDL-WIDGET42 (\"cannot hold the expression $currentObject\"); `image i (ImageType: dynamicImage)` passed check and mx check but is not a binding (`dynamicImage` is not one of the widget's datasource values); the legacy `dynamicimage` is CE0582 in React. `describe page` of a Studio Pro page bound this way printed `image i` with no source, which re-executes into \"No image selected.\" Agents worked around it with a JavaScript action computing the file URL into a String attribute.", "cause": "The pluggable Image widget's `imageObject` is of type `image`, which Studio Pro can set to a collection image OR to the image object in context; the `image` operation only ever wrote the first (WidgetValue.Image), and `defaultImageDynamic` had no mapping at all. Measured on Studio Pro 11.12.1 (Image widget 1.5.0) by setting \"Image source\" to the data view's object and decoding the unit before and after: exactly two values change — imageObject gets EntityRef DirectEntityRef{Entity: the data view's entity} and Image '', defaultImageDynamic gets the same EntityRef; no SourceVariable. Fix: `Image: $currentObject` (already parsed, as a variable-led data source) resolves to the enclosing data container's entity and is written as that EntityRef on every image-typed mapping of the widget that names no image of its own (SetImageObject on WidgetObjectBuilder); DefaultImage maps to defaultImageDynamic as a plain collection image, without the EntityRef; describe reads the EntityRef back.", "file": "`mdl/executor/widget_image_object.go`, `mdl/executor/widget_engine.go` (image operation), `mdl/backend/widgetobj/builder.go` (setImageObjectValue), `sdk/widgets/definitions/image.def.json` + `modelsdk/…`, `mdl/executor/cmd_pages_describe_pluggable.go` (extractImageObjectBinding), `mdl/executor/validate_widget_image.go` (MDL-WIDGET22)", "insight": "**A widget property's TYPE says what it can hold; the mapping only says what mxcli wrote.** `imageObject` is `type=\"image\"`, and Mendix's image type is static-or-dynamic — the editorConfig even branches on `imageObject.type === \"static\"` to hide `defaultImageDynamic`. Reading the widget XML and editorConfig first would have shown the gap without an agent spending six minutes on three variants. **The decisive measurement was one Studio Pro edit on an mxcli-written page, diffed as decoded BSON with sorted keys and $IDs stripped**: the before/after diff was 20 lines and named both properties, including the non-obvious EntityRef on the fallback property; the same diff run between Studio Pro's unit and mxcli's was empty, which is a stronger round-trip proof than the describe text. mxbuild's own error for a non-image entity (CE7247, once per bound property) also confirmed that BOTH properties are checked against the entity. The page-parameter and association-path forms Studio Pro also offers were not measured and are refused rather than guessed. **The Studio Pro diff is not the whole contract: it covered only the unset fallback.** Writing the same EntityRef beside a chosen `DefaultImage` passed `mx check` and rendered nothing in the browser (the widget hid itself for an object with HasContents=false); without it the fallback rendered. Only a runtime check with an empty image object told the two apart. And when re-testing after the change, ALTER REPLACE kept the stale EntityRef: #1247's keep-unstated merge treats what describe cannot tell apart as unstated — test a writer change on a fresh copy, not on a page the previous build wrote.", "refs": ["mendixlabs/mxcli#1476", "mendixlabs/mxcli#1057", "mendixlabs/mxcli#1149"]} diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 3a9a892b80..022b20b39f 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -16,11 +16,11 @@ description: "CREATE PAGE syntax reference — parameters, variables, layouts, a | 196-266 | Basic Examples | | 267-288 | Modifying Existing Pages | | 289-376 | Conditional Visibility and Editability | -| 377-526 | Known Limitations | -| 527-535 | Tips | -| 536-562 | Related Commands | -| 563-587 | PLUGGABLEWIDGET Escape Hatch | -| 588-591 | See Also | +| 377-537 | Known Limitations | +| 538-546 | Tips | +| 547-573 | Related Commands | +| 574-598 | PLUGGABLEWIDGET Escape Hatch | +| 599-602 | See Also | # CREATE PAGE - MDL Syntax Guide @@ -428,6 +428,17 @@ image imgRemote (ImageType: imageUrl, ImageUrl: 'https://example.com/logo.svg') image imgIcon (ImageType: icon) ``` +An image a **user uploaded** — an object of an entity that extends `System.Image` — +is the object of the enclosing data view, list view or gallery. Bind the widget to +it with `Image: $currentObject`; `DefaultImage:` is shown while the object holds no +image. This is the form for a React-client app, where `dynamicimage` is CE0582: + +```sql +dataview dvPhoto (DataSource: $Photo) { + image imgPhoto (Image: $currentObject, DefaultImage: 'MyFirstModule.Images._1') +} +``` + ### A button's icon is one of three elements Mendix stores **three different icon elements**, and the keyword picks which: diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index a1226aaa58..8fd5933944 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -731,7 +731,12 @@ Display images on pages: image imgLogo (width: 200, height: 100) staticimage imgBanner (Image: 'MyModule.Images.banner', width: 400, height: 120) --- Dynamic image (from entity data source, e.g. inside a DataView) +-- An uploaded image: the object of the enclosing data view, list view or +-- gallery, whose entity extends System.Image (mendixlabs/mxcli#1476) +image imgPhoto (Image: $currentObject, DefaultImage: 'MyModule.Images.placeholder') + +-- Legacy dynamic image (from entity data source, e.g. inside a DataView); +-- CE0582 in a React-client app, where the `image` form above is the one to use dynamicimage imgProduct (width: 300, height: 200) -- Image without explicit dimensions diff --git a/CHANGELOG.md b/CHANGELOG.md index ab8083df83..952a18f0e7 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] +### Added + +- **`image` shows an uploaded image: `Image: $currentObject`** (mendixlabs/mxcli#1476) — the pluggable Image widget can now be bound to the object of the enclosing data view, list view or gallery, an entity that extends `System.Image`, with an optional `DefaultImage: 'Module.Collection.Image'` shown while that object has no image. Before, `Image:` could only name an image collection entry, and the legacy `dynamicimage` is CE0582 in a React-client app, so MDL had no way to show an uploaded photo on a Mendix 11 page. The stored shape is Studio Pro 11.12.1's, measured by setting "Image source" to a data view's object and decoding the unit before and after: a `DirectEntityRef` to the entity on both `imageObject` and `defaultImageDynamic`, and an empty collection image. A `DefaultImage` is written as a plain collection image on `defaultImageDynamic`: with the `DirectEntityRef` beside it the running widget showed nothing for an object without contents, and without it the widget shows the fallback. A page mxcli writes this way is identical to Studio Pro's apart from `$ID`s, passes `mx check` with 0 errors, re-executes from `describe page` as `Unchanged page`, and renders the uploaded file in the browser (`/file?guid=…`, natural width 40 for a 40-pixel PNG), or the `DefaultImage` when the object has no contents. `describe page` prints the binding back, which it dropped before. An entity that is not an image is refused before the write (mxbuild: CE7247 "Widget expects entity of type System.Image."), as is a page parameter or an association path, whose stored shapes have not been measured + ## [0.26.0] - 2026-10-11 Headline: **Documents mxcli writes now survive Studio Pro's merge and diff: every element carries exactly the property names Studio Pro writes for the project's Mendix version.** Before, a key mxcli left out, or wrote for a newer version, made a document unmergeable the first time Studio Pro saved it, although `mx check`, the build and the runtime stayed green. `mxcli fix property-sets` repairs projects an older mxcli wrote. Alongside it: a Claude Code companion mod with a Project pane, `describe` → `exec` round trips that keep loop back-edges, shared error handlers and page design properties, `check -p` resolving script sets and security references the way `exec` does, `mxcli run --local` as a background service, and a run of Windows fixes for the local dev loop. The [language poster](https://www.mxcli.org/poster/mxcli-language-poster.html) puts all of mxcli and MDL on one page. diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index c961a73faf..3e05a76506 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -176,6 +176,14 @@ LIST IMPACT OF htmlelement; "-- reports that at check time, and a name that does not resolve is reported by\n" + "-- `check --references` rather than failing the build with CE1613.\n" + "-- The alternatives are the URL form above, or `ImageType: icon`.\n" + + "-- To show an image a user uploaded (an object of an entity extending\n" + + "-- System.Image), bind it to the object of the enclosing data view, list view\n" + + "-- or gallery. DefaultImage is shown while that object holds no image:\n" + + "DATAVIEW dvPhoto (DataSource: $Photo) {\n" + + " IMAGE imgPhoto (Image: $currentObject, DefaultImage: 'Module.Collection.ImageName')\n" + + "}\n" + + "-- This is the React-client way to show an image object; DYNAMICIMAGE below is\n" + + "-- CE0582 there (mendixlabs/mxcli#1476).\n" + "-- A text-template property (ImageUrl, AlternativeText, a pluggable widget's\n" + "-- headerCaption/title/…) takes TEXT, so a bare value renders the same string\n" + "-- on every row. Bind it with the property's own `Params` companion:\n" + diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 67e9c83995..97ff4b9373 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -367,8 +367,19 @@ DYNAMICIMAGE imgPhoto (DataSource: $Photo, Width: 200, Height: 150) -- Generic image widget IMAGE imgBanner (Width: 800, Height: 200) + +-- An uploaded image: the object of the enclosing data view, list view or +-- gallery (entity must extend System.Image). Works in a React-client app, +-- where DYNAMICIMAGE is refused with CE0582 +DATAVIEW dvPhoto (DataSource: $Photo) { + IMAGE imgPhoto (Image: $currentObject, DefaultImage: 'MyModule.Images.placeholder') +} ``` +`Image:` on `IMAGE` is either a three-part image collection entry +(`'Module.Collection.ImageName'`) or `$currentObject`. `DefaultImage:` is the +fallback for the second, shown while the object holds no image. + **Image properties:** | Property | Description | Example | diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 4744e9ea5c..e074ebb748 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1127,6 +1127,7 @@ Respond in {{Language}}.$$, | Create or modify | `create or modify image collection Module.Name [...];` | Preserves UUID — preferred for AI agents | | Drop collection | `drop image collection [if exists] Module.Name;` | Removes collection and all embedded images | | Show an image on a page | `image imgLogo (Image: 'Module.Collection.ImageName');` | Three-part name, like an icon reference. `describe image collection` lists the names | +| Show an uploaded image | `image imgPhoto (Image: $currentObject, DefaultImage: 'Module.Collection.ImageName');` | Inside a data view, list view or gallery over an entity extending `System.Image`; `DefaultImage` is optional (mendixlabs/mxcli#1476) | An `image` widget's default source **is** an image collection entry, so a bare `image imgLogo (...)` with no `Image:` builds into a model mxbuild refuses ("No diff --git a/mdl-examples/bug-tests/widgets-1476-image-object-binding.mdl b/mdl-examples/bug-tests/widgets-1476-image-object-binding.mdl new file mode 100644 index 0000000000..4435f45a9c --- /dev/null +++ b/mdl-examples/bug-tests/widgets-1476-image-object-binding.mdl @@ -0,0 +1,65 @@ +mdl 1; +-- mendixlabs/mxcli#1476: no MDL form bound the pluggable Image widget to an +-- image object, so a page in a React-client app could not show an image a user +-- uploaded. The legacy `dynamicimage` is refused there: +-- +-- [error] [CE0582] "Widget dynamic image is not supported in React client." +-- +-- and `Image:` could only name an image collection entry. +-- +-- `Image: $currentObject` binds the widget to the object of the enclosing data +-- view, list view or gallery, whose entity must specialise System.Image. +-- The stored shape is Studio Pro's, measured on 11.12.1 with the Image widget +-- 1.5.0 by setting "Image source" to the data view's object and decoding the +-- unit before and after: +-- +-- imageObject Image 'Atlas_Core.Content.Mendix' -> '' +-- EntityRef null -> DirectEntityRef 'Module.Photo' +-- defaultImageDynamic EntityRef null -> DirectEntityRef 'Module.Photo' +-- +-- Nothing else in the unit changes. `DefaultImage:` is the fallback shown while +-- the object holds no image. The reference page set none, so its shape is +-- measured in the running app instead: written as a collection image on +-- `defaultImageDynamic` WITHOUT the EntityRef it renders (an object whose +-- HasContents is false shows the fallback); with the EntityRef beside it the +-- widget renders hidden with no src. Both shapes pass mx check. +-- +-- Measured on a blank Mendix 11.12.1 app (React client), mxbuild 11.12.1: +-- +-- before `image (Image: $currentObject)` check: MDL-WIDGET22 + MDL-WIDGET42 +-- after exec + mx check 0 errors +-- after describe -> exec Unchanged page (both pages) +-- after a non-image entity refused by exec; mxbuild would +-- say CE7247 "Widget expects entity +-- of type System.Image." twice + +create persistent entity MyFirstModule.Photo extends System.Image ( + Caption: string(200) +); + +-- The data view's object. +create or modify page MyFirstModule.Photo_View ( + Title: 'Photo', + Layout: Atlas_Core.PopupLayout, + Params: ( $Photo: MyFirstModule.Photo ) +) { + dataview Photo_View_Form (DataSource: $Photo) { + image Photo_View_Image (Image: $currentObject) + } +}; + +-- A list's row object, with a fallback for a row that has no image yet. +create or modify page MyFirstModule.Photo_Overview ( + Title: 'Photos', + Layout: Atlas_Core.Atlas_Default +) { + listview Photo_Overview_List (DataSource: database from MyFirstModule.Photo) { + image Photo_Overview_Image ( + Image: $currentObject, + DefaultImage: 'Atlas_Core.Content.Mendix', + WidthUnit: pixels, + Width: 120 + ) + dynamictext Photo_Overview_Caption (Content: '{1}', ContentParams: ({1} = Caption)) + } +}; diff --git a/mdl-examples/doctype-tests/03-page-examples.mdl b/mdl-examples/doctype-tests/03-page-examples.mdl index ba51fd4eea..51033029d4 100644 --- a/mdl-examples/doctype-tests/03-page-examples.mdl +++ b/mdl-examples/doctype-tests/03-page-examples.mdl @@ -2009,6 +2009,32 @@ create page PgTest.P042_Image } }; +-- The image object in context (mendixlabs/mxcli#1476): an image a user +-- uploaded, held by an object whose entity extends System.Image. `Image: +-- $currentObject` binds the widget to the enclosing data view's or list's +-- object; `DefaultImage:` is shown while that object holds no image. The entity +-- is stored on `imageObject` (and on an unset `defaultImageDynamic`, as Studio +-- Pro does), and mxbuild refuses an entity that is not an image (CE7247). + +create persistent entity PgTest.ProductPhoto extends System.Image ( + Caption: String(200) +); + +create page PgTest.P042b_ImageObject +( + title: 'Image Object', + layout: Atlas_Core.Atlas_Default +) +{ + listview lvPhotos (DataSource: database from PgTest.ProductPhoto) { + image imgPhoto ( + Image: $currentObject, + DefaultImage: Atlas_Core.Layout.logo, + WidthUnit: pixels, Width: 120 + ) + } +}; + -- MARK: Custom Container -- ============================================================================ diff --git a/mdl/backend/mcp/widget.go b/mdl/backend/mcp/widget.go index 05a0eb1d50..20e35577b7 100644 --- a/mdl/backend/mcp/widget.go +++ b/mdl/backend/mcp/widget.go @@ -200,6 +200,16 @@ func (w *mcpWidgetBuilder) SetImage(propertyKey, imageQN string) { w.object[propertyKey] = imageQN } +// SetImageObject: the MCP widget payload's image-object shape has not been +// measured, so the binding is reported unsupported rather than sent in a form +// Studio Pro might store differently. +func (w *mcpWidgetBuilder) SetImageObject(propertyKey, entityName string) { + if entityName == "" { + return + } + w.note("SetImageObject(" + propertyKey + ")") +} + func (w *mcpWidgetBuilder) SetDataSource(propertyKey string, ds pages.DataSource) { if src := customWidgetXPathSource(ds); src != nil { w.object[propertyKey] = src diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 7d0b76dba1..211d872608 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -386,6 +386,11 @@ type WidgetObjectBuilder interface { // `imageObject`) at an image collection entry, by its three-part qualified // name `Module.Collection.Image`. SetImage(propertyKey string, imageQN string) + // SetImageObject binds an image-typed property to the image object in + // context — the enclosing data container's object, an entity that + // specialises System.Image — by naming that entity. Studio Pro stores it as + // a DirectEntityRef on the property's WidgetValue with no collection image. + SetImageObject(propertyKey string, entityName string) SetExpression(propertyKey string, value string) SetDataSource(propertyKey string, ds pages.DataSource) SetChildWidgets(propertyKey string, children []pages.Widget) diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index dd5de05857..53ae6d416c 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -127,6 +127,24 @@ func (ob *Builder) SetImage(propertyKey string, imageQN string) { }) } +// SetImageObject binds an image-typed property to the image object in context +// (mendixlabs/mxcli#1476). Measured on Studio Pro 11.12.1 with Image widget +// 1.5.0: setting "Image source" to the data view's object stores, on the +// property's WidgetValue, an EntityRef that is a DirectEntityRef to the data +// view's entity and an empty Image. +// +// The EntityRef slot comes from the template; a template without one is left +// alone rather than given a key the document schema may not declare, the same +// rule setImageValue follows. +func (ob *Builder) SetImageObject(propertyKey string, entityName string) { + if entityName == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setImageObjectValue(val, entityName) + }) +} + func (ob *Builder) SetSelection(propertyKey string, value string) { if value == "" { return @@ -1200,6 +1218,32 @@ func setImageValue(val bson.D, imageQN string) bson.D { return result } +// setImageObjectValue writes the image-object binding onto a WidgetValue: a +// DirectEntityRef to entityName under EntityRef, and an empty Image, which is +// what Studio Pro leaves when a property moves from a collection image to the +// object in context. +func setImageObjectValue(val bson.D, entityName string) bson.D { + if entityName == "" { + return val + } + result := make(bson.D, 0, len(val)) + for _, elem := range val { + switch elem.Key { + case "EntityRef": + result = append(result, bson.E{Key: "EntityRef", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, + {Key: "Entity", Value: entityName}, + }}) + case "Image": + result = append(result, bson.E{Key: "Image", Value: ""}) + default: + result = append(result, elem) + } + } + return result +} + func setDataSource(val bson.D, ds pages.DataSource) bson.D { result := make(bson.D, 0, len(val)) for _, elem := range val { diff --git a/mdl/backend/widgetobj/builder_image_test.go b/mdl/backend/widgetobj/builder_image_test.go index 1f0e273ee6..e309cca2ad 100644 --- a/mdl/backend/widgetobj/builder_image_test.go +++ b/mdl/backend/widgetobj/builder_image_test.go @@ -102,3 +102,61 @@ func TestSetImageValue_EmptyNameLeavesTheValueAlone(t *testing.T) { t.Errorf("Image = %v, want the existing value untouched", got) } } + +// mendixlabs/mxcli#1476: the image object in context. Studio Pro 11.12.1 +// stores it on the WidgetValue as a DirectEntityRef to the data view's entity, +// with an empty Image: +// +// EntityRef: {$Type: 'DomainModels$DirectEntityRef', Entity: 'MyFirstModule.Photo'} +// Image: '' +func imageObjectValue() bson.D { + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "EntityRef", Value: nil}, + {Key: "Image", Value: "Atlas_Core.Content.Mendix"}, + {Key: "PrimitiveValue", Value: ""}, + } +} + +func TestSetImageObjectValue_WritesADirectEntityRefAndClearsTheImage(t *testing.T) { + got := setImageObjectValue(imageObjectValue(), "MyFirstModule.Photo") + + ref, ok := get(got, "EntityRef").(bson.D) + if !ok { + t.Fatalf("EntityRef = %v, want a document", get(got, "EntityRef")) + } + if v := get(ref, "$Type"); v != "DomainModels$DirectEntityRef" { + t.Errorf("EntityRef $Type = %v, want DomainModels$DirectEntityRef", v) + } + if v := get(ref, "Entity"); v != "MyFirstModule.Photo" { + t.Errorf("EntityRef Entity = %v, want MyFirstModule.Photo", v) + } + if get(ref, "$ID") == nil { + t.Error("EntityRef has no $ID") + } + if v := get(got, "Image"); v != "" { + t.Errorf("Image = %v, want empty — Studio Pro clears the collection image", v) + } +} + +// Same key set, same order: only the two values change. +func TestSetImageObjectValue_LeavesEverythingElseAlone(t *testing.T) { + in := imageObjectValue() + got := setImageObjectValue(in, "MyFirstModule.Photo") + if len(got) != len(in) { + t.Fatalf("key count changed: %d -> %d", len(in), len(got)) + } + for i := range in { + if got[i].Key != in[i].Key { + t.Errorf("key %d: %q -> %q — order changed", i, in[i].Key, got[i].Key) + } + } +} + +// An empty entity name is "not bound": the value stays as it was. +func TestSetImageObjectValue_EmptyEntityLeavesTheValueAlone(t *testing.T) { + got := setImageObjectValue(imageObjectValue(), "") + if get(got, "EntityRef") != nil || get(got, "Image") != "Atlas_Core.Content.Mendix" { + t.Errorf("value changed for an empty entity: %v", got) + } +} diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index bcd276241e..6ed85472d2 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -821,6 +821,12 @@ type rawWidget struct { // is not an image collection, or when none is selected. Without it a // describe -> exec copy loses the image (mxcli-formula1 FINDINGS §142). ImageObject string + // ImageObjectEntity is the entity of the image object in context the + // pluggable Image widget is bound to, described as `Image: $currentObject` + // (mendixlabs/mxcli#1476). ImageObjectUnreadable says why a stored binding + // has no MDL spelling, when it has none. + ImageObjectEntity string + ImageObjectUnreadable string // DefaultImage is a DYNAMIC image's fallback, shown when the bound object // carries none — Forms$ImageViewer.DefaultImage, a by-name reference to // Images$Image, so the same three-part Module.Collection.Image name as diff --git a/mdl/executor/cmd_pages_describe_image_test.go b/mdl/executor/cmd_pages_describe_image_test.go index dc1f8e2a3a..f05407fabf 100644 --- a/mdl/executor/cmd_pages_describe_image_test.go +++ b/mdl/executor/cmd_pages_describe_image_test.go @@ -111,3 +111,52 @@ func TestDescribeImageWidget_OmitsAnAbsentReference(t *testing.T) { } } } + +// mendixlabs/mxcli#1476: the image object in context as Studio Pro stores it — +// a DirectEntityRef on `imageObject`, no collection image, no source variable. +func imageObjectWidget(value map[string]any) map[string]any { + w := imagePropWidget("imageObject", "") + props := w["Object"].(map[string]any)["Properties"].([]any) + stored := props[0].(map[string]any)["Value"].(map[string]any) + for k, v := range value { + stored[k] = v + } + return w +} + +func TestExtractImageObjectBinding_ReadsTheContextEntity(t *testing.T) { + w := imageObjectWidget(map[string]any{ + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "MyFirstModule.Photo"}, + }) + entity, unreadable := extractImageObjectBinding(w, "imageObject") + if entity != "MyFirstModule.Photo" || unreadable != "" { + t.Errorf("got (%q, %q), want (MyFirstModule.Photo, \"\")", entity, unreadable) + } +} + +// Shapes MDL cannot spell are reported, never printed as `$currentObject`. +func TestExtractImageObjectBinding_UnmeasuredShapesAreUnreadable(t *testing.T) { + for name, value := range map[string]map[string]any{ + "association": {"EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef"}}, + "parameter": { + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "MyFirstModule.Photo"}, + "SourceVariable": map[string]any{"$Type": "Forms$PageVariable", "PageParameter": "Photo"}, + }, + } { + entity, unreadable := extractImageObjectBinding(imageObjectWidget(value), "imageObject") + if entity != "" || unreadable == "" { + t.Errorf("%s: got (%q, %q), want no entity and a reason", name, entity, unreadable) + } + } +} + +func TestDescribeImageWidget_EmitsTheImageObjectBinding(t *testing.T) { + w := rawWidget{Name: "Photo_View_Image", ImageObjectEntity: "MyFirstModule.Photo", DefaultImage: "Atlas_Core.Content.Mendix"} + got := strings.Join(describeImageWidgetProps(nil, w), ", ") + if !strings.Contains(got, "Image: $currentObject") { + t.Errorf("describe did not emit the binding: %s", got) + } + if !strings.Contains(got, "DefaultImage: 'Atlas_Core.Content.Mendix'") { + t.Errorf("describe did not emit the default image: %s", got) + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index ee8dbd87b0..1e3dd18b5a 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -788,6 +788,11 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { } } else if widgetType == "image" { header := fmt.Sprintf("image %s", mdlIdent(w.Name)) + if w.ImageObjectUnreadable != "" { + fmt.Fprintf(ctx.Output, "%s-- NOT re-executable: image %s shows %s; MDL binds only the "+ + "enclosing data container's object, `Image: $currentObject` (mendixlabs/mxcli#1476)\n", + prefix, mdlIdent(w.Name), w.ImageObjectUnreadable) + } props := describeImageWidgetProps(ctx, w) // appendAppearanceProps prints the visibility and editability; the // conditional settings appended a second time before it printed each @@ -2051,9 +2056,20 @@ func describeImageWidgetProps(ctx *ExecContext, w rawWidget) []string { if w.ImageType != "" && w.ImageType != "image" { props = append(props, fmt.Sprintf("ImageType: %s", w.ImageType)) } - if w.ImageObject != "" { + switch { + case w.ImageObjectEntity != "": + // The image object in context: what Studio Pro stores as a + // DirectEntityRef on `imageObject` (mendixlabs/mxcli#1476). Exec derives + // the entity from the enclosing data container again. + props = append(props, "Image: $currentObject") + case w.ImageObject != "": props = append(props, fmt.Sprintf("Image: %s", mdlQuote(ctx, w.ImageObject))) } + // The default image is the image object's fallback; it is stored, and + // written, only beside a binding. + if w.DefaultImage != "" && w.ImageObjectEntity != "" { + props = append(props, fmt.Sprintf("DefaultImage: %s", mdlQuote(ctx, w.DefaultImage))) + } if w.ImageUrl != "" { props = append(props, fmt.Sprintf("ImageUrl: %s", mdlQuote(ctx, w.ImageUrl))) if len(w.ImageUrlParams) > 0 { diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 17d70bd806..094ae1ed2a 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -1050,6 +1050,55 @@ func extractCustomWidgetPropertyImage(ctx *ExecContext, w map[string]any, proper return "" } +// extractImageObjectBinding reads an image-typed property's binding to the +// image object in context (mendixlabs/mxcli#1476): the entity of the +// DirectEntityRef Studio Pro stores on the WidgetValue when "Image source" is +// the data view's object. +// +// unreadable is set, with entity empty, for a binding stored in a shape MDL +// has no spelling for — over an association, or through a page parameter (a +// SourceVariable). Neither has been measured from Studio Pro; DESCRIBE says so +// rather than printing `$currentObject`, which would re-execute into a binding +// to a different object. +func extractImageObjectBinding(w map[string]any, propertyKey string) (entity, unreadable string) { + value := customWidgetPropertyValue(w, propertyKey) + if value == nil { + return "", "" + } + ref, ok := value["EntityRef"].(map[string]any) + if !ok || ref == nil { + return "", "" + } + if sv, ok := value["SourceVariable"].(map[string]any); ok && sv != nil { + return "", "an image object read through a page or snippet parameter" + } + if t, _ := ref["$Type"].(string); t != "DomainModels$DirectEntityRef" { + return "", "an image object reached over an association" + } + entity, _ = ref["Entity"].(string) + return entity, "" +} + +// customWidgetPropertyValue returns the stored WidgetValue of a CustomWidget's +// property, resolved through its TypePointer, or nil. +func customWidgetPropertyValue(w map[string]any, propertyKey string) map[string]any { + obj, ok := w["Object"].(map[string]any) + if !ok { + return nil + } + propTypeKeyMap := buildPropertyTypeKeyMap(w, false) + for _, prop := range getBsonArrayElements(obj["Properties"]) { + propMap, ok := prop.(map[string]any) + if !ok || propTypeKeyMap[extractBinaryID(propMap["TypePointer"])] != propertyKey { + continue + } + if value, ok := propMap["Value"].(map[string]any); ok { + return value + } + } + return nil +} + // extractCustomWidgetPropertyExpression reads an expression-typed property's // stored Expression, or "" when it is unset. func extractCustomWidgetPropertyExpression(w map[string]any, propertyKey string) string { @@ -1268,6 +1317,8 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit func extractImageProperties(ctx *ExecContext, w map[string]any, widget *rawWidget) { widget.ImageType = extractCustomWidgetPropertyString(ctx, w, "datasource") widget.ImageObject = extractCustomWidgetPropertyImage(ctx, w, "imageObject") + widget.ImageObjectEntity, widget.ImageObjectUnreadable = extractImageObjectBinding(w, "imageObject") + widget.DefaultImage = extractCustomWidgetPropertyImage(ctx, w, "defaultImageDynamic") widget.ImageUrl, widget.ImageUrlParams = extractCustomWidgetPropertyTextTemplate(ctx, w, "imageUrl") widget.AlternativeText, widget.AlternativeTextParams = extractCustomWidgetPropertyTextTemplate(ctx, w, "alternativeText") widget.ImageWidth = extractCustomWidgetPropertyString(ctx, w, "width") diff --git a/mdl/executor/helpers.go b/mdl/executor/helpers.go index b2a127eee8..da90def46b 100644 --- a/mdl/executor/helpers.go +++ b/mdl/executor/helpers.go @@ -1052,7 +1052,7 @@ func buildConstantQualifiedNames(ctx *ExecContext) (map[string]bool, bool) { // ones `validate_widgets.go` already accepts for these widgets and DESCRIBE // already emits, so the two lists say the same thing about the same property. var imageRefProps = map[string][]string{ - "image": {"Image"}, + "image": {"Image", "DefaultImage"}, "staticimage": {"Image"}, "dynamicimage": {"DefaultImage"}, } diff --git a/mdl/executor/validate_widget_expression_value.go b/mdl/executor/validate_widget_expression_value.go index c4d9244a9b..1431f0993a 100644 --- a/mdl/executor/validate_widget_expression_value.go +++ b/mdl/executor/validate_widget_expression_value.go @@ -65,6 +65,10 @@ func validateWidgetExpressionValues(w *ast.WidgetV3, def *WidgetDefinition, item case !isExpr && !scalarOperations[op]: // `$var/Assoc` in a datasource or action slot is exactly that. continue + case !isExpr && op == "image" && isImageContextRef(w.Properties[key].(*ast.DataSourceV3)): + // `Image: $currentObject` binds the image to the object in context + // (mendixlabs/mxcli#1476); MDL-WIDGET22 judges where it may appear. + continue } kind := "a plain-value property" if op != "" { diff --git a/mdl/executor/validate_widget_image.go b/mdl/executor/validate_widget_image.go index 8885af42ff..7d3903bda3 100644 --- a/mdl/executor/validate_widget_image.go +++ b/mdl/executor/validate_widget_image.go @@ -44,15 +44,54 @@ var imageSourceNeedsImage = map[string]bool{ } // validateImageSource reports an IMAGE widget whose configured source has no -// value to render. +// value to render, or whose image-object binding cannot be written. +// +// The image may also be the object in context, `Image: $currentObject` +// (mendixlabs/mxcli#1476). That form names no collection image and is complete +// on its own; `DefaultImage:` is its fallback, and only its — Studio Pro hides +// the default image unless the image is bound to an object. func validateImageSource(w *ast.WidgetV3, locationPrefix string) []linter.Violation { if w == nil || !strings.EqualFold(w.Type, "image") { return nil } source := strings.TrimSpace(w.GetStringProp("ImageType")) + binding, bound := imageObjectProp(w) + hasDefault := strings.TrimSpace(w.GetStringProp("DefaultImage")) != "" + + if bound && !isImageContextRef(binding) { + return []linter.Violation{{ + RuleID: imageSourceRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: widget `%s` (image) binds its image to %s — only `$currentObject`, the object of "+ + "the enclosing data view or list, can be bound", + locationPrefix, w.Name, formatDataSourceV3(binding)), + Location: linter.Location{DocumentType: "page"}, + Suggestion: fmt.Sprintf( + "Place the widget in a data view over that object and bind it there: "+ + "`dataview v (DataSource: $Photo) { image %s (Image: $currentObject) }`.", w.Name), + }} + } switch { case imageSourceNeedsImage[strings.ToLower(source)]: + if bound { + return nil // the image object in context — the widget is complete + } + if hasDefault { + return []linter.Violation{{ + RuleID: imageSourceRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: widget `%s` (image) has a `DefaultImage` but shows a collection image — the "+ + "default image is the fallback for an image object, and Studio Pro hides it otherwise", + locationPrefix, w.Name), + Location: linter.Location{DocumentType: "page"}, + Suggestion: fmt.Sprintf( + "Bind the image to the object in context, `image %s (Image: $currentObject, "+ + "DefaultImage: 'Module.Collection.ImageName')`, or drop `DefaultImage`.", w.Name), + }} + } if strings.TrimSpace(w.GetStringProp("Image")) != "" { return nil // an entry is named — the widget is complete } @@ -68,10 +107,23 @@ func validateImageSource(w *ast.WidgetV3, locationPrefix string) []linter.Violat Suggestion: fmt.Sprintf( "Name the entry: `image %s (Image: 'Module.Collection.ImageName')` — "+ "`show image collections` lists them, and `describe image collection "+ - "Module.Collection` lists the images inside one. Or switch source: "+ + "Module.Collection` lists the images inside one. Or show the object in context, "+ + "inside a data view over an entity that specialises System.Image: "+ + "`Image: $currentObject`. Or switch source: "+ "`ImageType: imageUrl, ImageUrl: 'https://…'`, or `ImageType: icon`.", w.Name), }} + case bound || hasDefault: + return []linter.Violation{{ + RuleID: imageSourceRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: widget `%s` (image) has `ImageType: %s` and an image object or `DefaultImage` — "+ + "both belong to `ImageType: image`, and Studio Pro hides them for any other source", + locationPrefix, w.Name, source), + Location: linter.Location{DocumentType: "page"}, + Suggestion: "Drop `ImageType` (it defaults to `image`), or drop `Image`/`DefaultImage`.", + }} case strings.EqualFold(source, "imageUrl") && strings.TrimSpace(w.GetStringProp("ImageUrl")) == "": return []linter.Violation{{ RuleID: imageSourceRule, @@ -86,3 +138,14 @@ func validateImageSource(w *ast.WidgetV3, locationPrefix string) []linter.Violat } return nil } + +// imageObjectProp returns the widget's `Image:` value when it is an object +// rather than a collection image's name. +func imageObjectProp(w *ast.WidgetV3) (*ast.DataSourceV3, bool) { + v, ok := lookupProperty(w.Properties, "Image") + if !ok { + return nil, false + } + ds, isDS := v.(*ast.DataSourceV3) + return ds, isDS +} diff --git a/mdl/executor/validate_widget_image_ref_collect_test.go b/mdl/executor/validate_widget_image_ref_collect_test.go index 3839b68c31..039533c826 100644 --- a/mdl/executor/validate_widget_image_ref_collect_test.go +++ b/mdl/executor/validate_widget_image_ref_collect_test.go @@ -165,3 +165,18 @@ func TestWidgetRefs_ImagePropOnAnotherWidgetIsNotAnImageReference(t *testing.T) t.Errorf("a non-image widget's Image property was resolved as an image collection entry: %v", errs) } } + +// mendixlabs/mxcli#1476 gave the pluggable `image` widget the same fallback, +// `DefaultImage:` beside `Image: $currentObject`; it is resolved like the +// dynamic image's, or a mistyped name is CE1613 at build time. +func TestWidgetRefs_ImageWidgetDefaultImageIsResolved(t *testing.T) { + ctx := imageCollectionCtx(t) + + errs := validateWidgetReferences(ctx, + imageRefWidget("image", "DefaultImage", "MyFirstModule.Images.NoSuchImage"), + newScriptContext()) + if len(errs) != 1 || !strings.Contains(errs[0], "NoSuchImage") { + t.Fatalf("an image widget's fallback naming an image the collection does not hold was "+ + "accepted; got %v", errs) + } +} diff --git a/mdl/executor/validate_widget_image_test.go b/mdl/executor/validate_widget_image_test.go index 0674444950..e15b344db1 100644 --- a/mdl/executor/validate_widget_image_test.go +++ b/mdl/executor/validate_widget_image_test.go @@ -144,3 +144,54 @@ func TestImageWidget_EmptyOrBlankImageIsStillReported(t *testing.T) { } } } + +// mendixlabs/mxcli#1476: `Image: $currentObject` binds the image to the object +// in context. It names no collection image and is complete — MDL-WIDGET22 must +// not ask for one, and MDL-WIDGET42 must not call it an expression. +func currentObjectRef() *ast.DataSourceV3 { + return &ast.DataSourceV3{Type: "parameter", Reference: "$currentObject"} +} + +func TestImageWidget_BoundToTheObjectInContextIsClean(t *testing.T) { + for _, props := range []map[string]any{ + {"Image": currentObjectRef()}, + {"Image": currentObjectRef(), "ImageType": "image"}, + {"Image": currentObjectRef(), "DefaultImage": "Atlas_Core.Content.Mendix"}, + } { + if got := imageViolations(validateImageSource(imageWidget(props), "page P")); len(got) != 0 { + t.Errorf("%v reported: %v", props, got) + } + } +} + +func TestImageWidget_BindingIsNotAnExpression(t *testing.T) { + reg := LoadWidgetRegistry("") + w := imageWidget(map[string]any{"Image": currentObjectRef()}) + w.ValueSource = map[string]string{"Image": "$currentObject"} + for _, v := range validateWidgetExpressionValues(w, reg.byMDLName["IMAGE"], nil, false, "page P") { + t.Errorf("reported as an expression: %s", v.Message) + } +} + +// Anything but the context object is refused: its stored shape is unmeasured. +func TestImageWidget_BindingToAnotherObjectIsReported(t *testing.T) { + w := imageWidget(map[string]any{"Image": &ast.DataSourceV3{Type: "parameter", Reference: "$Photo"}}) + got := imageViolations(validateImageSource(w, "page P")) + if len(got) != 1 || !strings.Contains(got[0].Message, "$currentObject") { + t.Errorf("got %v, want one MDL-WIDGET22 naming $currentObject", got) + } +} + +// The default image is the fallback for an image object; Studio Pro hides it +// beside a collection image, and with any other image type. +func TestImageWidget_DefaultImageWithoutABindingIsReported(t *testing.T) { + for _, props := range []map[string]any{ + {"Image": "Atlas_Core.Content.Mendix", "DefaultImage": "Atlas_Core.Content.Mendix"}, + {"ImageType": "imageUrl", "ImageUrl": "https://example.com/x.png", "DefaultImage": "Atlas_Core.Content.Mendix"}, + {"ImageType": "icon", "Image": currentObjectRef()}, + } { + if got := imageViolations(validateImageSource(imageWidget(props), "page P")); len(got) != 1 { + t.Errorf("%v: got %d violations, want 1", props, len(got)) + } + } +} diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 8473e4c102..299a22c15c 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -241,7 +241,11 @@ type BuildContext struct { // SourceVariable names the parameter an attribute or association binding // reads (`$Param.Attr`); nil for one read from the enclosing object. SourceVariable *pages.WidgetVariable - pageBuilder *pageBuilder + // ImageObjectEntity is the entity of the image object in context when the + // widget binds its image to it (`Image: $currentObject`), for every + // image-typed mapping of that widget; "" otherwise (mendixlabs/mxcli#1476). + ImageObjectEntity string + pageBuilder *pageBuilder } // ============================================================================= @@ -293,6 +297,11 @@ type PluggableWidgetEngine struct { // always been; more than one means it names nothing in particular, and the // keys here are what the refusal offers instead. nil outside a build. currentDataSourceKeys []string + + // imageObjectEntity is the entity of the image object the widget being + // built binds its image to (`Image: $currentObject`), or "". Set once per + // build, before the mappings run; see imageObjectBinding. + imageObjectEntity string } // NewPluggableWidgetEngine creates a new engine with the given backend and page builder. @@ -366,6 +375,11 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* return nil, err } e.currentDataSourceKeys = dataSourceMappingKeys(mappings) + oldImageObjectEntity := e.imageObjectEntity + defer func() { e.imageObjectEntity = oldImageObjectEntity }() + if e.imageObjectEntity, err = e.imageObjectBinding(mappings, w); err != nil { + return nil, err + } if err := refuseLinkedDataSourceMapping(def, mappings, propertyTypeIDs); err != nil { return nil, err } @@ -915,6 +929,16 @@ func (e *PluggableWidgetEngine) applyOperation(builder backend.WidgetObjectBuild case "selection": builder.SetSelection(propKey, ctx.PrimitiveVal) case "image": + // The image object in context, when the widget is bound to one: Studio + // Pro writes its entity on every image-typed property of the widget that + // holds no image of its own, the unset fallback included + // (mendixlabs/mxcli#1476). A fallback that names a collection image is + // written as that static image alone: with the EntityRef beside it the + // runtime treats the fallback as the (empty) object too and shows + // nothing — measured on Image widget 1.5.0, see widget_image_object.go. + if ctx.PrimitiveVal == "" { + builder.SetImageObject(propKey, ctx.ImageObjectEntity) + } // An image collection entry, by its three-part qualified name. The // value arrives through the generic source branch, so it is in // PrimitiveVal like any other scalar — only the key it lands on differs. @@ -1567,6 +1591,15 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W } default: + if mapping.Operation == "image" { + ctx.ImageObjectEntity = e.imageObjectEntity + if isImageObjectValue(w, source) { + // The binding itself, not a collection image: there is no text + // to store, and scalarPropertyText would refuse it as an + // expression in a scalar property. + return ctx, nil + } + } val := w.GetStringProp(source) if val == "" { // Non-string AST values (e.g. `DesktopColumns: 3`, parsed as an int) diff --git a/mdl/executor/widget_image_object.go b/mdl/executor/widget_image_object.go new file mode 100644 index 0000000000..82b980f75a --- /dev/null +++ b/mdl/executor/widget_image_object.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// The Image widget's image can be the image object in context — a data view's +// or a list's object whose entity specialises System.Image — and not only an +// entry of an image collection. MDL spells it `Image: $currentObject` +// (mendixlabs/mxcli#1476). +// +// Measured on Studio Pro 11.12.1 with the Image widget 1.5.0: setting "Image +// source" to the data view's object changes exactly two values in the stored +// page. `imageObject` loses its collection image (Image '') and gains an +// EntityRef, a DirectEntityRef to the data view's entity; `defaultImageDynamic` +// gains the same EntityRef. No source variable is stored. The entity is +// therefore the enclosing data container's, which is what `$currentObject` +// already means everywhere else in a page. +// +// `DefaultImage:` is not part of that measurement: the reference page set none. +// It is written as a plain collection image on `defaultImageDynamic`, WITHOUT +// the EntityRef. Measured in the running app (Image widget 1.5.0, an object +// whose HasContents is false): with the EntityRef beside the collection image +// the widget renders hidden with no src; without it, it shows the collection +// image. Both pass mx check. +// +// Before this, MDL could only point `imageObject` at a collection image, so a +// React-client page had no way to show an uploaded image: the legacy +// `dynamicimage` is refused there with CE0582. + +// isImageContextRef reports whether ds is the context object, `$currentObject`. +func isImageContextRef(ds *ast.DataSourceV3) bool { + return ds != nil && ds.Type == "parameter" && + strings.EqualFold(strings.TrimPrefix(ds.Reference, "$"), "currentObject") +} + +// isImageObjectValue reports whether the widget's property key holds an +// object rather than a collection image's name — any variable-led value, the +// supported `$currentObject` and the forms imageObjectBinding refuses alike. +func isImageObjectValue(w *ast.WidgetV3, key string) bool { + v, ok := lookupProperty(w.Properties, key) + if !ok { + return false + } + _, isDS := v.(*ast.DataSourceV3) + return isDS +} + +// imageObjectBinding returns the entity of the image object the widget binds +// an image-typed property to, or "" when every image-typed property names a +// collection image or nothing. +// +// Only the enclosing data container's object is accepted. Studio Pro also +// offers a page parameter and, presumably, an associated object; their stored +// shapes have not been measured, so they are refused here rather than written +// in a form Studio Pro might not read back. +func (e *PluggableWidgetEngine) imageObjectBinding(mappings []PropertyMapping, w *ast.WidgetV3) (string, error) { + for _, m := range mappings { + if m.Operation != "image" || m.Source == "" { + continue + } + v, ok := lookupProperty(w.Properties, m.Source) + if !ok { + continue + } + ds, isDS := v.(*ast.DataSourceV3) + if !isDS { + continue + } + if !isImageContextRef(ds) { + return "", mdlerrors.NewValidationf( + "widget `%s` property `%s`: %s — an image object is bound only as `$currentObject`, "+ + "the object of the enclosing data view or list; place the widget in a data view "+ + "over the object you want to show (`dataview v (DataSource: $Photo) { image i (Image: $currentObject) }`)", + widgetLabelForError(w), m.Source, formatDataSourceV3(ds)) + } + entity := e.outerEntityContext + if entity == "" { + return "", mdlerrors.NewValidationf( + "widget `%s` property `%s`: `$currentObject` names the object of the enclosing data view "+ + "or list, and this widget has none — place it inside a data view, list view or gallery "+ + "whose entity specialises System.Image", + widgetLabelForError(w), m.Source) + } + // mxbuild refuses any other entity with CE7247 "Widget expects entity of + // type System.Image.", once per bound property — measured on 11.12.1. + if !e.pageBuilder.isSpecializationOf(entity, systemImageEntity) { + return "", mdlerrors.NewValidationf( + "widget `%s` property `%s`: `$currentObject` is a %s, which does not specialise %s — "+ + "the Image widget shows only an image object (mxbuild: CE7247 \"Widget expects entity "+ + "of type System.Image.\"); bind it inside a data view over an entity that extends %s", + widgetLabelForError(w), m.Source, entity, systemImageEntity, systemImageEntity) + } + return entity, nil + } + return "", nil +} + +const systemImageEntity = "System.Image" diff --git a/mdl/executor/widget_image_object_test.go b/mdl/executor/widget_image_object_test.go new file mode 100644 index 0000000000..f64b46a402 --- /dev/null +++ b/mdl/executor/widget_image_object_test.go @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1476: no MDL form bound the Image widget to an image object, +// so a React-client page could not show an uploaded image (the legacy +// `dynamicimage` is CE0582 there). +// +// The reference is a page Studio Pro 11.12.1 wrote (Image widget 1.5.0): an +// image inside `dataview (DataSource: $Photo)`, where Photo extends +// System.Image, with "Image source" set to the data view's object. Decoding the +// unit before and after that one change gives exactly two differences: +// +// imageObject Image 'Atlas_Core.Content.Mendix' -> '' +// EntityRef null -> {DomainModels$DirectEntityRef, Entity: 'MyFirstModule.Photo'} +// defaultImageDynamic EntityRef null -> {DomainModels$DirectEntityRef, Entity: 'MyFirstModule.Photo'} +// +// MDL spells the binding `Image: $currentObject`. + +// imageObjectEngine returns an engine building inside a data view over +// contextEntity, over a domain model in which Sales.Photo extends System.Image, +// Sales.Thumbnail extends Sales.Photo, and Sales.Room extends nothing. +func imageObjectEngine(t *testing.T, contextEntity string) *PluggableWidgetEngine { + t.Helper() + real := &modelsdkbackend.Backend{} + mod := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-sales")}, Name: "Sales"} + b := &mock.MockBackend{ + LoadWidgetTemplateFunc: real.LoadWidgetTemplate, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ + BaseElement: model.BaseElement{ID: model.ID("dm-sales")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: model.ID("e-photo")}, Name: "Photo", GeneralizationRef: "System.Image"}, + {BaseElement: model.BaseElement{ID: model.ID("e-thumb")}, Name: "Thumbnail", GeneralizationRef: "Sales.Photo"}, + {BaseElement: model.BaseElement{ID: model.ID("e-room")}, Name: "Room"}, + }, + }}, nil + }, + } + pb := &pageBuilder{ + backend: b, + entityContext: contextEntity, + paramEntityNames: map[string]string{}, + widgetScope: map[string]model.ID{}, + } + e := NewPluggableWidgetEngine(b, pb) + pb.pluggableEngine = e + return e +} + +func imageObjectDef(t *testing.T) *WidgetDefinition { + t.Helper() + reg := LoadWidgetRegistry("") + if reg == nil || reg.byMDLName["IMAGE"] == nil { + t.Fatal("built-in IMAGE definition not available") + } + return reg.byMDLName["IMAGE"] +} + +// builtImageValue returns the stored WidgetValue of one property of a built +// widget, found by its TypePointer as Studio Pro resolves it. +func builtImageValue(t *testing.T, w pages.Widget, propertyKey string) bson.D { + t.Helper() + cw, ok := w.(*pages.CustomWidget) + if !ok { + t.Fatalf("Build returned %T, want *pages.CustomWidget", w) + } + entry, ok := cw.PropertyTypeIDMap[propertyKey] + if !ok { + t.Fatalf("template has no property %q", propertyKey) + } + want := strings.ReplaceAll(entry.PropertyTypeID, "-", "") + for _, e := range cw.RawObject { + if e.Key != "Properties" { + continue + } + for _, item := range e.Value.(bson.A) { + prop, ok := item.(bson.D) + if !ok { + continue + } + var id string + var value bson.D + for _, f := range prop { + switch f.Key { + case "TypePointer": + if bin, ok := f.Value.(primitive.Binary); ok { + id = strings.ReplaceAll(types.BlobToUUID(bin.Data), "-", "") + } + case "Value": + value, _ = f.Value.(bson.D) + } + } + if id == want { + return value + } + } + } + t.Fatalf("built widget has no value for %q", propertyKey) + return nil +} + +func imageValueField(d bson.D, key string) any { + for _, e := range d { + if e.Key == key { + return e.Value + } + } + return nil +} + +// directEntity returns the Entity of a DirectEntityRef, or "" for anything else. +func directEntity(v any) string { + ref, ok := v.(bson.D) + if !ok || imageValueField(ref, "$Type") != "DomainModels$DirectEntityRef" { + return "" + } + s, _ := imageValueField(ref, "Entity").(string) + return s +} + +func boundImageWidget(extra map[string]any) *ast.WidgetV3 { + props := map[string]any{"Image": &ast.DataSourceV3{Type: "parameter", Reference: "$currentObject"}} + for k, v := range extra { + props[k] = v + } + return &ast.WidgetV3{Name: "Photo_View_Image", Type: "image", Properties: props} +} + +// The fix: both image-typed properties carry the data view's entity as a +// DirectEntityRef, and the image object's collection image is empty — the +// shape Studio Pro stores. +func TestImageObject_BindsBothImagePropertiesToTheContextEntity(t *testing.T) { + e := imageObjectEngine(t, "Sales.Photo") + widget, err := e.Build(imageObjectDef(t), boundImageWidget(nil)) + if err != nil { + t.Fatalf("Build: %v", err) + } + for _, key := range []string{"imageObject", "defaultImageDynamic"} { + v := builtImageValue(t, widget, key) + if got := directEntity(imageValueField(v, "EntityRef")); got != "Sales.Photo" { + t.Errorf("%s: EntityRef = %v, want a DirectEntityRef to Sales.Photo", key, imageValueField(v, "EntityRef")) + } + if got := imageValueField(v, "Image"); got != "" { + t.Errorf("%s: Image = %v, want empty", key, got) + } + if got := imageValueField(v, "SourceVariable"); got != nil { + t.Errorf("%s: SourceVariable = %v, want none — Studio Pro stores none for the data view's object", key, got) + } + } + if got := imageValueField(builtImageValue(t, widget, "datasource"), "PrimitiveValue"); got != "image" { + t.Errorf("datasource = %v, want image", got) + } +} + +// The fallback is a collection image on defaultImageDynamic, and only that: in +// the running app (Image widget 1.5.0) an EntityRef beside it made the widget +// render nothing for an object without contents, and without it the widget +// showed the fallback. +func TestImageObject_DefaultImageLandsOnTheFallbackProperty(t *testing.T) { + e := imageObjectEngine(t, "Sales.Photo") + widget, err := e.Build(imageObjectDef(t), boundImageWidget(map[string]any{"DefaultImage": "Atlas_Core.Content.Mendix"})) + if err != nil { + t.Fatalf("Build: %v", err) + } + def := builtImageValue(t, widget, "defaultImageDynamic") + if got := imageValueField(def, "Image"); got != "Atlas_Core.Content.Mendix" { + t.Errorf("defaultImageDynamic Image = %v, want the fallback", got) + } + if got := imageValueField(def, "EntityRef"); got != nil { + t.Errorf("defaultImageDynamic EntityRef = %v, want none beside a collection image — the runtime then shows no fallback", got) + } + if got := directEntity(imageValueField(builtImageValue(t, widget, "imageObject"), "EntityRef")); got != "Sales.Photo" { + t.Errorf("imageObject EntityRef = %v, want Sales.Photo", got) + } + if got := imageValueField(builtImageValue(t, widget, "imageObject"), "Image"); got != "" { + t.Errorf("imageObject Image = %v — the fallback leaked onto the image itself", got) + } +} + +// A specialisation of a specialisation is still an image object. +func TestImageObject_IndirectSpecialisationIsAccepted(t *testing.T) { + e := imageObjectEngine(t, "Sales.Thumbnail") + if _, err := e.Build(imageObjectDef(t), boundImageWidget(nil)); err != nil { + t.Fatalf("Build: %v", err) + } +} + +// CONTROL: a collection image is written as before, with no EntityRef. +func TestImageObject_CollectionImageWritesNoEntityRef(t *testing.T) { + e := imageObjectEngine(t, "Sales.Photo") + w := &ast.WidgetV3{Name: "Logo", Type: "image", Properties: map[string]any{"Image": "Atlas_Core.Content.Mendix"}} + widget, err := e.Build(imageObjectDef(t), w) + if err != nil { + t.Fatalf("Build: %v", err) + } + v := builtImageValue(t, widget, "imageObject") + if got := imageValueField(v, "Image"); got != "Atlas_Core.Content.Mendix" { + t.Errorf("imageObject Image = %v, want the collection image", got) + } + for _, key := range []string{"imageObject", "defaultImageDynamic"} { + if got := imageValueField(builtImageValue(t, widget, key), "EntityRef"); got != nil { + t.Errorf("%s: EntityRef = %v, want none for a collection image", key, got) + } + } +} + +// mxbuild answers an entity that is not an image with CE7247 "Widget expects +// entity of type System.Image." (measured, once per bound property); refused +// before anything is written. +func TestImageObject_NonImageEntityIsRefused(t *testing.T) { + e := imageObjectEngine(t, "Sales.Room") + _, err := e.Build(imageObjectDef(t), boundImageWidget(nil)) + if err == nil || !strings.Contains(err.Error(), "System.Image") { + t.Fatalf("err = %v, want a refusal naming System.Image", err) + } +} + +// Outside a data container `$currentObject` names nothing. +func TestImageObject_NoContextIsRefused(t *testing.T) { + e := imageObjectEngine(t, "") + _, err := e.Build(imageObjectDef(t), boundImageWidget(nil)) + // The refusal must say there is no object, not blame an entity: with no + // context there is no entity to judge. + if err == nil || !strings.Contains(err.Error(), "this widget has none") { + t.Fatalf("err = %v, want a refusal saying the widget has no enclosing object", err) + } +} + +// A page parameter or an association path is a binding whose stored shape has +// not been measured; refused rather than written as the context object. +func TestImageObject_OtherObjectsAreRefused(t *testing.T) { + for _, ds := range []*ast.DataSourceV3{ + {Type: "parameter", Reference: "$Photo"}, + {Type: "association", ContextVariable: "currentObject", Reference: "Sales.Room_Photo"}, + } { + e := imageObjectEngine(t, "Sales.Photo") + w := &ast.WidgetV3{Name: "img", Type: "image", Properties: map[string]any{"Image": ds}} + if _, err := e.Build(imageObjectDef(t), w); err == nil { + t.Errorf("%s: built, want a refusal", formatDataSourceV3(ds)) + } + } +} diff --git a/modelsdk/widgets/definitions/image.def.json b/modelsdk/widgets/definitions/image.def.json index 55e12e71f6..de922c31fd 100644 --- a/modelsdk/widgets/definitions/image.def.json +++ b/modelsdk/widgets/definitions/image.def.json @@ -15,6 +15,11 @@ "source": "Image", "operation": "image" }, + { + "propertyKey": "defaultImageDynamic", + "source": "DefaultImage", + "operation": "image" + }, { "propertyKey": "imageUrl", "source": "ImageUrl", diff --git a/sdk/widgets/definitions/image.def.json b/sdk/widgets/definitions/image.def.json index 55e12e71f6..de922c31fd 100644 --- a/sdk/widgets/definitions/image.def.json +++ b/sdk/widgets/definitions/image.def.json @@ -15,6 +15,11 @@ "source": "Image", "operation": "image" }, + { + "propertyKey": "defaultImageDynamic", + "source": "DefaultImage", + "operation": "image" + }, { "propertyKey": "imageUrl", "source": "ImageUrl",