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",