Skip to content

Commit 61babca

Browse files
committed
fix: do not render image when the image is not available
1 parent d376dbf commit 61babca

6 files changed

Lines changed: 246 additions & 58 deletions

File tree

packages/pluggableWidgets/image-web/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- 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.
12+
913
## [1.5.1] - 2025-10-29
1014

1115
### Fixed

packages/pluggableWidgets/image-web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@mendix/prettier-config-web-widgets": "workspace:*",
5555
"@mendix/rollup-web-widgets": "workspace:*",
5656
"@mendix/run-e2e": "workspace:*",
57-
"@mendix/widget-plugin-platform": "workspace:*"
57+
"@mendix/widget-plugin-platform": "workspace:*",
58+
"@mendix/widget-plugin-test-utils": "workspace:*"
5859
}
5960
}
Lines changed: 4 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,9 @@
11
import { ValueStatus } from "mendix";
22
import { FunctionComponent, useCallback } from "react";
33
import { ImageContainerProps } from "../typings/ImageProps";
4-
import { Image as ImageComponent, ImageType } from "./components/Image/Image";
4+
import { Image as ImageComponent } from "./components/Image/Image";
55
import { constructStyleObject } from "./utils/helpers";
6-
7-
function getImageProps({
8-
datasource,
9-
imageIcon,
10-
imageObject,
11-
imageUrl,
12-
defaultImageDynamic
13-
}: ImageContainerProps): ImageType {
14-
const fallback: ImageType = {
15-
type: "image",
16-
image: undefined
17-
};
18-
switch (datasource) {
19-
case "image": {
20-
if (imageObject?.status === ValueStatus.Available) {
21-
return {
22-
type: "image",
23-
image: imageObject.value.uri
24-
};
25-
} else if (
26-
imageObject?.status === ValueStatus.Unavailable &&
27-
defaultImageDynamic?.status === ValueStatus.Available
28-
) {
29-
return {
30-
type: "image",
31-
image: defaultImageDynamic.value.uri
32-
};
33-
}
34-
return {
35-
type: "image",
36-
image: undefined
37-
};
38-
}
39-
case "imageUrl":
40-
return {
41-
type: "image",
42-
image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined
43-
};
44-
case "icon": {
45-
if (imageIcon?.status === ValueStatus.Available && imageIcon.value) {
46-
const icon = imageIcon.value;
47-
return {
48-
type: icon.type,
49-
image: icon.type === "image" ? icon.iconUrl : icon.iconClass
50-
};
51-
}
52-
return fallback;
53-
}
54-
default:
55-
return fallback;
56-
}
57-
}
6+
import { getImageProps } from "./utils/getImageProps";
587

598
export const Image: FunctionComponent<ImageContainerProps> = props => {
609
const onClick = useCallback(() => props.onClick?.execute(), [props.onClick]);
@@ -65,7 +14,7 @@ export const Image: FunctionComponent<ImageContainerProps> = props => {
6514

6615
const imageStyle = { ...props.style, ...styleObject };
6716

68-
return (
17+
return image ? (
6918
<ImageComponent
7019
class={props.class}
7120
style={imageStyle}
@@ -85,5 +34,5 @@ export const Image: FunctionComponent<ImageContainerProps> = props => {
8534
renderAsBackground={props.datasource !== "icon" && props.isBackgroundImage}
8635
backgroundImageContent={props.children}
8736
/>
88-
);
37+
) : null;
8938
};
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { DynamicValue, ValueStatus, WebIcon, WebImage } from "mendix";
2+
import { dynamic } from "@mendix/widget-plugin-test-utils";
3+
import { getImageProps, GetImagePropsInput } from "../getImageProps";
4+
5+
const webImage = (uri: string): WebImage => ({ uri, name: "test.jpg" });
6+
7+
const loadingDynamic = <T>(): DynamicValue<T> =>
8+
({ status: "loading" as ValueStatus.Loading, value: undefined }) as DynamicValue<T>;
9+
10+
describe("getImageProps", () => {
11+
describe('datasource: "image"', () => {
12+
it("returns the main image URI when main image is available", () => {
13+
const input: GetImagePropsInput = {
14+
datasource: "image",
15+
imageObject: dynamic(webImage("https://example.com/main.jpg"))
16+
};
17+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" });
18+
});
19+
20+
it("returns the main image URI when main image is loading (uri present)", () => {
21+
const input: GetImagePropsInput = {
22+
datasource: "image",
23+
imageObject: dynamic(webImage("https://example.com/main.jpg"), true)
24+
};
25+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" });
26+
});
27+
28+
it("returns undefined image when main image is loading (no uri yet)", () => {
29+
const input: GetImagePropsInput = {
30+
datasource: "image",
31+
imageObject: loadingDynamic<WebImage>()
32+
};
33+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
34+
});
35+
36+
it("falls back to defaultImage when main image is unavailable and fallback is available", () => {
37+
const input: GetImagePropsInput = {
38+
datasource: "image",
39+
imageObject: dynamic<WebImage>(),
40+
defaultImageDynamic: dynamic(webImage("https://example.com/fallback.jpg"))
41+
};
42+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/fallback.jpg" });
43+
});
44+
45+
it("falls back to defaultImage when main image is unavailable and fallback is loading", () => {
46+
const input: GetImagePropsInput = {
47+
datasource: "image",
48+
imageObject: dynamic<WebImage>(),
49+
defaultImageDynamic: dynamic(webImage("https://example.com/fallback.jpg"), true)
50+
};
51+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/fallback.jpg" });
52+
});
53+
54+
it("returns undefined image when both main image and fallback are unavailable", () => {
55+
const input: GetImagePropsInput = {
56+
datasource: "image",
57+
imageObject: dynamic<WebImage>(),
58+
defaultImageDynamic: dynamic<WebImage>()
59+
};
60+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
61+
});
62+
63+
it("returns undefined image when imageObject and defaultImageDynamic are not provided", () => {
64+
const input: GetImagePropsInput = { datasource: "image" };
65+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
66+
});
67+
68+
it("does not use defaultImage when main image is available", () => {
69+
const input: GetImagePropsInput = {
70+
datasource: "image",
71+
imageObject: dynamic(webImage("https://example.com/main.jpg")),
72+
defaultImageDynamic: dynamic(webImage("https://example.com/fallback.jpg"))
73+
};
74+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" });
75+
});
76+
});
77+
78+
describe('datasource: "imageUrl"', () => {
79+
it("returns the URL when imageUrl is available", () => {
80+
const input: GetImagePropsInput = {
81+
datasource: "imageUrl",
82+
imageUrl: dynamic("https://example.com/image.jpg")
83+
};
84+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/image.jpg" });
85+
});
86+
87+
it("returns undefined image when imageUrl is loading", () => {
88+
const input: GetImagePropsInput = {
89+
datasource: "imageUrl",
90+
imageUrl: loadingDynamic<string>()
91+
};
92+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
93+
});
94+
95+
it("returns undefined image when imageUrl is unavailable", () => {
96+
const input: GetImagePropsInput = {
97+
datasource: "imageUrl",
98+
imageUrl: dynamic<string>()
99+
};
100+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
101+
});
102+
103+
it("returns undefined image when imageUrl is not provided", () => {
104+
const input: GetImagePropsInput = { datasource: "imageUrl" };
105+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
106+
});
107+
});
108+
109+
describe('datasource: "icon"', () => {
110+
it("returns glyph icon class when a glyph icon is available", () => {
111+
const glyphIcon: WebIcon = { type: "glyph", iconClass: "glyphicon-star" };
112+
const input: GetImagePropsInput = {
113+
datasource: "icon",
114+
imageIcon: dynamic(glyphIcon)
115+
};
116+
expect(getImageProps(input)).toEqual({ type: "glyph", image: "glyphicon-star" });
117+
});
118+
119+
it("returns image icon URL when an image icon is available", () => {
120+
const imageIcon: WebIcon = { type: "image", iconUrl: "https://example.com/icon.png" };
121+
const input: GetImagePropsInput = {
122+
datasource: "icon",
123+
imageIcon: dynamic(imageIcon)
124+
};
125+
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/icon.png" });
126+
});
127+
128+
it("returns mx-icon class when a named icon is available", () => {
129+
const namedIcon: WebIcon = { type: "icon", iconClass: "mx-icon-star" };
130+
const input: GetImagePropsInput = {
131+
datasource: "icon",
132+
imageIcon: dynamic(namedIcon)
133+
};
134+
expect(getImageProps(input)).toEqual({ type: "icon", image: "mx-icon-star" });
135+
});
136+
137+
it("returns fallback when imageIcon is loading", () => {
138+
const input: GetImagePropsInput = {
139+
datasource: "icon",
140+
imageIcon: loadingDynamic<WebIcon>()
141+
};
142+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
143+
});
144+
145+
it("returns fallback when imageIcon is unavailable", () => {
146+
const input: GetImagePropsInput = {
147+
datasource: "icon",
148+
imageIcon: dynamic<WebIcon>()
149+
};
150+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
151+
});
152+
153+
it("returns fallback when imageIcon value is undefined (WebIcon = undefined)", () => {
154+
const input: GetImagePropsInput = {
155+
datasource: "icon",
156+
imageIcon: dynamic<WebIcon>(undefined as WebIcon)
157+
};
158+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
159+
});
160+
161+
it("returns fallback when imageIcon is not provided", () => {
162+
const input: GetImagePropsInput = { datasource: "icon" };
163+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
164+
});
165+
});
166+
167+
describe("unknown datasource", () => {
168+
it("returns fallback for an unrecognised datasource value", () => {
169+
const input = { datasource: "unknown" as GetImagePropsInput["datasource"] };
170+
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
171+
});
172+
});
173+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { ValueStatus } from "mendix";
2+
import { ImageContainerProps } from "../../typings/ImageProps";
3+
import { ImageType } from "../components/Image/Image";
4+
5+
export type GetImagePropsInput = Pick<
6+
ImageContainerProps,
7+
"datasource" | "imageIcon" | "imageObject" | "imageUrl" | "defaultImageDynamic"
8+
>;
9+
10+
const fallback: ImageType = { type: "image", image: undefined };
11+
12+
export function getImageProps({
13+
datasource,
14+
imageIcon,
15+
imageObject,
16+
imageUrl,
17+
defaultImageDynamic: defaultImage
18+
}: GetImagePropsInput): ImageType {
19+
switch (datasource) {
20+
case "image": {
21+
// if main image is available or loading
22+
if (imageObject?.status === ValueStatus.Available || imageObject?.status === ValueStatus.Loading) {
23+
return {
24+
type: "image",
25+
image: imageObject?.value?.uri
26+
};
27+
}
28+
29+
// if main image is not available, but fallback is available or loading
30+
if (defaultImage?.status === ValueStatus.Available || defaultImage?.status === ValueStatus.Loading) {
31+
return {
32+
type: "image",
33+
image: defaultImage?.value?.uri
34+
};
35+
}
36+
37+
// if main image and fallback are not available
38+
return { type: "image", image: undefined };
39+
}
40+
case "imageUrl":
41+
return {
42+
type: "image",
43+
image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined
44+
};
45+
case "icon": {
46+
if (imageIcon?.status === ValueStatus.Available && imageIcon.value) {
47+
const icon = imageIcon.value;
48+
return {
49+
type: icon.type,
50+
image: icon.type === "image" ? icon.iconUrl : icon.iconClass
51+
};
52+
}
53+
return fallback;
54+
}
55+
default:
56+
return fallback;
57+
}
58+
}

pnpm-lock.yaml

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)