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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-07-23
39 changes: 39 additions & 0 deletions openspec/changes/fix-image-allow-upload-deprecation/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## Why

The Image widget's `imageObject` and `defaultImageDynamic` properties are declared as `type="image"` without `allowUpload`. Mendix has deprecated this form of the image/file property type — `DynamicValue<WebImage>` will be removed in Mendix 12 — and apps using the widget on Studio Pro 11.8+ already see a deprecation warning at design time. The fix is to set `allowUpload="true"` on both properties, per Mendix's own migration guidance.

## What Changes

- `allowUpload="true"` added to `imageObject` and `defaultImageDynamic` in `Image.xml`.
- Generated typings (`ImageProps.d.ts`) change `imageObject`/`defaultImageDynamic` from `DynamicValue<WebImage>` to `EditableImageValue<ImageValue>`.
- `Image.tsx` reads `.status` and `.value.uri` off both props today — `EditableImageValue` exposes the same shape (extends the same status/value base as `DynamicValue`), so no logic change is required. Widget stays display-only; no upload UI is added.
- `package.json` `marketplace.minimumMXVersion` bumped to `11.12.0` (LTS, confirmed with ticket reporter — 11.11 has native `allowUpload` support but 11.12 is the intended floor).
- CHANGELOG entry added under `[Unreleased]`. Widget version stays unbumped for now — version bumps happen at release time per repo convention; this change requires a major bump (2.0.0) once released, since `minimumMXVersion` is raised.

Verified in Studio Pro (11.12, fresh test app) that `allowUpload="true"` does not remove the "Static" image-source option — both Static and Dynamic configuration remain available in the property editor, so existing apps configuring `imageObject`/`defaultImageDynamic` as a static asset are not broken by this change.

## Capabilities

### New Capabilities

<!-- none -->

### Modified Capabilities

<!-- none -->

## Root cause

Widget XML predates the `allowUpload` attribute (introduced Mendix 11.8, native widget support 11.11). Not fixed proactively when the attribute was introduced; ticket WC-3471 flags the resulting deprecation warning.

## Impact

- **Files**:
- `packages/pluggableWidgets/image-web/src/Image.xml`
- `packages/pluggableWidgets/image-web/typings/ImageProps.d.ts` (regenerated)
- `packages/pluggableWidgets/image-web/package.json` (version, minimumMXVersion)
- `packages/pluggableWidgets/image-web/CHANGELOG.md`
- **Behavior**: none — widget remains display-only, no upload UI added, no runtime logic change
- **Studio Pro UX**: none — Static/Dynamic image-source configuration both remain available (manually verified)
- **Breaking change**: minimum Mendix version raised from 9.24.0 to 11.12.0 — apps on older Mendix versions cannot upgrade to this widget version once released
- **Affected widget**: `@mendix/image-web` — requires a major version bump (2.0.0) at release time; not bumped in this change
17 changes: 17 additions & 0 deletions openspec/changes/fix-image-allow-upload-deprecation/tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- [x] **Container renders the bound image when imageObject is Available**
- **Type:** unit
- **Given:** `ImageContainerProps` with `datasource: "image"`, `imageObject` as an `EditableImageValue<ImageValue>`-shaped object with `status: ValueStatus.Available` and `value.uri: "https://example.com/a.png"`
- **When:** `Image` (src/Image.tsx container) is rendered
- **Then:** the rendered `<img>` `src` equals `"https://example.com/a.png"`

- [x] **Container falls back to defaultImageDynamic when imageObject is Unavailable**
- **Type:** unit
- **Given:** `imageObject` with `status: ValueStatus.Unavailable`, `defaultImageDynamic` as `EditableImageValue<ImageValue>`-shaped with `status: ValueStatus.Available` and `value.uri: "https://example.com/default.png"`
- **When:** `Image` is rendered
- **Then:** the rendered `<img>` `src` equals `"https://example.com/default.png"`

- [x] **Container renders nothing bound when both imageObject and defaultImageDynamic are unavailable**
- **Type:** unit
- **Given:** `imageObject` with `status: ValueStatus.Unavailable`, `defaultImageDynamic` undefined
- **When:** `Image` is rendered
- **Then:** the rendered image element has no meaningful `src` (placeholder/empty state), matching current behavior before this change
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"d3-color@<3.1.0": ">=3.1.0",
"glob@10": "^10.5.0",
"fast-uri": "3.1.4",
"mendix": "11.10.0",
"react": ">=18.0.0 <19.0.0",
"react-big-calendar@1>clsx": "2.1.1",
"react-dom": ">=18.0.0 <19.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Big } from "big.js";
import { ValueStatus } from "mendix";
import { Ref } from "react";
import type { Crop, PixelCrop } from "react-image-crop";
import { actionValue, dynamic } from "@mendix/widget-plugin-test-utils";
import { actionValue, dynamic, editableImage } from "@mendix/widget-plugin-test-utils";
import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps";

// Capture the container's callbacks via a mocked CropArea. Real ReactCrop only fires
Expand Down Expand Up @@ -67,13 +67,7 @@ const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 };

function makeImageProp(overrides: Partial<ImageProp> = {}): ImageProp {
return {
status: ValueStatus.Available,
value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage,
readOnly: false,
validation: undefined,
setValidator: jest.fn(),
setValue: jest.fn(),
setThumbnailSize: jest.fn(),
...editableImage.with<WebImage>({ uri: "http://localhost/img.png", name: "img.png" }),
...overrides
} as ImageProp;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { Big } from "big.js";
import { ValueStatus } from "mendix";
import { Ref } from "react";
import type { Crop, PixelCrop } from "react-image-crop";
import { actionValue } from "@mendix/widget-plugin-test-utils";
import { actionValue, editableImage } from "@mendix/widget-plugin-test-utils";
import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps";

// Integration test: proves grayscale reversibility after rotate.
Expand Down Expand Up @@ -76,15 +75,7 @@ const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 10
const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 };

function makeImageProp(): ImageProp {
return {
status: ValueStatus.Available,
value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage,
readOnly: false,
validation: undefined,
setValidator: jest.fn(),
setValue: jest.fn(),
setThumbnailSize: jest.fn()
} as ImageProp;
return editableImage.with<WebImage>({ uri: "http://localhost/img.png", name: "img.png" }) as ImageProp;
}

function makeProps(overrides: Partial<ImageCropperContainerProps> = {}): ImageCropperContainerProps {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { act, render } from "@testing-library/react";
import { Big } from "big.js";
import { ValueStatus } from "mendix";
import { Ref } from "react";
import type { Crop, PixelCrop } from "react-image-crop";
import { actionValue } from "@mendix/widget-plugin-test-utils";
import { actionValue, editableImage } from "@mendix/widget-plugin-test-utils";
import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps";

// Multi-instance integration test: verifies that auto-commit only fires for the
Expand Down Expand Up @@ -68,15 +67,7 @@ const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 10
const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 };

function makeImageProp(): ImageProp {
return {
status: ValueStatus.Available,
value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage,
readOnly: false,
validation: undefined,
setValidator: jest.fn(),
setValue: jest.fn(),
setThumbnailSize: jest.fn()
} as ImageProp;
return editableImage.with<WebImage>({ uri: "http://localhost/img.png", name: "img.png" }) as ImageProp;
}

function makeProps(overrides: Partial<ImageCropperContainerProps> = {}): ImageCropperContainerProps {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { Big } from "big.js";
import { ValueStatus } from "mendix";
import { Ref } from "react";
import type { Crop, PixelCrop } from "react-image-crop";
import { actionValue } from "@mendix/widget-plugin-test-utils";
import { actionValue, editableImage } from "@mendix/widget-plugin-test-utils";
import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps";

// Integration test: proves the rotate/grayscale actions reach the right util with the right args.
Expand Down Expand Up @@ -85,15 +84,7 @@ const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 10
const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 };

function makeImageProp(): ImageProp {
return {
status: ValueStatus.Available,
value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage,
readOnly: false,
validation: undefined,
setValidator: jest.fn(),
setValue: jest.fn(),
setThumbnailSize: jest.fn()
} as ImageProp;
return editableImage.with<WebImage>({ uri: "http://localhost/img.png", name: "img.png" }) as ImageProp;
}

function makeProps(overrides: Partial<ImageCropperContainerProps> = {}): ImageCropperContainerProps {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Big } from "big.js";
import { ValueStatus } from "mendix";
import { action, makeObservable, observable } from "mobx";
import type { Crop, PixelCrop } from "react-image-crop";
import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { editableImage } from "@mendix/widget-plugin-test-utils";
import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps";

// The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts
Expand Down Expand Up @@ -33,13 +33,7 @@ type WebImage = NonNullable<ImageProp["value"]>;

function makeImageProp(overrides: Partial<ImageProp> = {}): ImageProp {
return {
status: ValueStatus.Available,
value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage,
readOnly: false,
validation: undefined,
setValidator: jest.fn(),
setValue: jest.fn(),
setThumbnailSize: jest.fn(),
...editableImage.with<WebImage>({ uri: "http://localhost/img.png", name: "img.png" }),
...overrides
} as unknown as ImageProp;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/pluggableWidgets/image-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- We fixed an issue where previews in Design and Structure modes did not respect the configured minimum and maximum image height.

- We fixed a deprecation warning by migrating the image source properties to the `allowUpload` property type. This raises the minimum supported Mendix version to 11.12.0.

- We fixed an issue where the Image widget would briefly render in an empty or broken state while the image data was still loading. The widget now waits until the image is ready before displaying it.

## [1.5.1] - 2025-10-29
Expand Down
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/image-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"packagePath": "com.mendix.widget.web",
"marketplace": {
"minimumMXVersion": "9.24.0",
"minimumMXVersion": "11.12.0",
"appNumber": 118579,
"appName": "Image",
"reactReady": true
Expand Down
4 changes: 2 additions & 2 deletions packages/pluggableWidgets/image-web/src/Image.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
<enumerationValue key="icon">Icon</enumerationValue>
</enumerationValues>
</property>
<property key="imageObject" type="image" required="false">
<property key="imageObject" type="image" required="false" allowUpload="true">
<caption>Image source</caption>
<description />
</property>
<property key="defaultImageDynamic" type="image" required="false">
<property key="defaultImageDynamic" type="image" required="false" allowUpload="true">
<caption>Default image</caption>
<description>This is the image that is displayed if no image is uploaded.</description>
</property>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render } from "@testing-library/react";
import { editableImage } from "@mendix/widget-plugin-test-utils";
import { ImageContainerProps } from "../../typings/ImageProps";
import { Image } from "../Image";

function makeProps(overrides: Partial<ImageContainerProps> = {}): ImageContainerProps {
return {
name: "image1",
class: "",
style: undefined,
tabIndex: 0,
datasource: "image",
isBackgroundImage: false,
onClickType: "action",
widthUnit: "auto",
width: 100,
heightUnit: "auto",
height: 100,
minHeightUnit: "none",
minHeight: 0,
maxHeightUnit: "none",
maxHeight: 0,
iconSize: 14,
displayAs: "fullImage",
responsive: true,
...overrides
} as ImageContainerProps;
}

describe("Image container", () => {
it("renders the bound image when imageObject is Available", () => {
const { getByRole } = render(
<Image {...makeProps({ imageObject: editableImage.with({ uri: "https://example.com/a.png" }) })} />
);
const image = getByRole("img") as HTMLImageElement;
expect(image.src).toBe("https://example.com/a.png");
});

it("falls back to defaultImageDynamic when imageObject is Unavailable", () => {
const { getByRole } = render(
<Image
{...makeProps({
imageObject: editableImage(b => b.isUnavailable().build()),
defaultImageDynamic: editableImage.with({ uri: "https://example.com/default.png" })
})}
/>
);
const image = getByRole("img") as HTMLImageElement;
expect(image.src).toBe("https://example.com/default.png");
});

it("renders no image src when both imageObject and defaultImageDynamic are unavailable", () => {
const { container } = render(
<Image
{...makeProps({
imageObject: editableImage(b => b.isUnavailable().build())
})}
/>
);
const image = container.querySelector("img");
expect(image?.getAttribute("src")).toBeFalsy();
});
});
6 changes: 3 additions & 3 deletions packages/pluggableWidgets/image-web/typings/ImageProps.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* WARNING: All changes made to this file will be overwritten
* @author Mendix Widgets Framework Team
*/
import { ActionValue, DynamicValue, WebIcon, WebImage } from "mendix";
import { ActionValue, DynamicValue, EditableImageValue, WebIcon, WebImage } from "mendix";
import { ComponentType, CSSProperties, ReactNode } from "react";

export type DatasourceEnum = "image" | "imageUrl" | "icon";
Expand All @@ -26,8 +26,8 @@ export interface ImageContainerProps {
style?: CSSProperties;
tabIndex?: number;
datasource: DatasourceEnum;
imageObject?: DynamicValue<WebImage>;
defaultImageDynamic?: DynamicValue<WebImage>;
imageObject?: EditableImageValue<WebImage>;
defaultImageDynamic?: EditableImageValue<WebImage>;
imageUrl?: DynamicValue<string>;
imageIcon?: DynamicValue<WebIcon>;
isBackgroundImage: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/filter-commons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"test": "jest"
},
"dependencies": {
"mendix": "^10.24.75382"
"mendix": "*"
},
"peerDependencies": {
"mobx": "6.12.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@mendix/widget-plugin-hooks": "workspace:*",
"@mendix/widget-plugin-mobx-kit": "workspace:^",
"downshift": "^9.0.9",
"mendix": "^10.24.75382",
"mendix": "*",
"mobx": "6.12.3",
"mobx-react-lite": "4.0.7"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/widget-plugin-filtering/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"@mendix/widget-plugin-mobx-kit": "workspace:^",
"@mendix/widget-plugin-platform": "workspace:*",
"downshift": "^9.0.8",
"mendix": "10.24.75382",
"mendix": "*",
"mobx": "6.12.3",
"mobx-react-lite": "4.0.7"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/widget-plugin-test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"test": "jest"
},
"dependencies": {
"mendix": "10.24.75382"
"mendix": "*"
},
"devDependencies": {
"@mendix/eslint-config-web-widgets": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { EditableImageValue, ImageValue } from "mendix";
import { Status } from "../constants.js";
import { Writable } from "./type-utils.js";

export class EditableImageValueBuilder<T extends ImageValue> {
private readonly editableImageValue: Writable<EditableImageValue<T>> = {
value: undefined,
status: Status.Available,
readOnly: false,
validation: undefined,
setValidator: jest.fn(),
setValue: jest.fn((value?: T) => this.withValue(value)),
setThumbnailSize: jest.fn()
};

withValue(value?: T): EditableImageValueBuilder<T> {
this.editableImageValue.value = value;
return this;
}

isReadOnly(): EditableImageValueBuilder<T> {
this.editableImageValue.readOnly = true;
return this;
}

isLoading(): EditableImageValueBuilder<T> {
this.editableImageValue.status = Status.Loading;
return this.isReadOnly();
}

isUnavailable(): EditableImageValueBuilder<T> {
this.editableImageValue.status = Status.Unavailable;
return this.isReadOnly();
}

withValidation(validation?: string): EditableImageValueBuilder<T> {
this.editableImageValue.validation = validation;
return this;
}

build(): EditableImageValue<T> {
return this.editableImageValue;
}
}
Loading
Loading