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
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/image-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- 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

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion packages/pluggableWidgets/image-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@mendix/prettier-config-web-widgets": "workspace:*",
"@mendix/rollup-web-widgets": "workspace:*",
"@mendix/run-e2e": "workspace:*",
"@mendix/widget-plugin-platform": "workspace:*"
"@mendix/widget-plugin-platform": "workspace:*",
"@mendix/widget-plugin-test-utils": "workspace:*"
}
}
59 changes: 4 additions & 55 deletions packages/pluggableWidgets/image-web/src/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,9 @@
import { ValueStatus } from "mendix";
import { FunctionComponent, useCallback } from "react";
import { ImageContainerProps } from "../typings/ImageProps";
import { Image as ImageComponent, ImageType } from "./components/Image/Image";
import { Image as ImageComponent } from "./components/Image/Image";
import { constructStyleObject } from "./utils/helpers";

function getImageProps({
datasource,
imageIcon,
imageObject,
imageUrl,
defaultImageDynamic
}: ImageContainerProps): ImageType {
const fallback: ImageType = {
type: "image",
image: undefined
};
switch (datasource) {
case "image": {
if (imageObject?.status === ValueStatus.Available) {
return {
type: "image",
image: imageObject.value.uri
};
} else if (
imageObject?.status === ValueStatus.Unavailable &&
defaultImageDynamic?.status === ValueStatus.Available
) {
return {
type: "image",
image: defaultImageDynamic.value.uri
};
}
return {
type: "image",
image: undefined
};
}
case "imageUrl":
return {
type: "image",
image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined
};
case "icon": {
if (imageIcon?.status === ValueStatus.Available && imageIcon.value) {
const icon = imageIcon.value;
return {
type: icon.type,
image: icon.type === "image" ? icon.iconUrl : icon.iconClass
};
}
return fallback;
}
default:
return fallback;
}
}
import { getImageProps } from "./utils/getImageProps";

Check warning on line 6 in packages/pluggableWidgets/image-web/src/Image.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./utils/getImageProps` import should occur before import of `./utils/helpers`

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

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

return (
return image ? (
<ImageComponent
class={props.class}
style={imageStyle}
Expand All @@ -85,5 +34,5 @@
renderAsBackground={props.datasource !== "icon" && props.isBackgroundImage}
backgroundImageContent={props.children}
/>
);
) : null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { DynamicValue, ValueStatus, WebIcon, WebImage } from "mendix";
import { dynamic } from "@mendix/widget-plugin-test-utils";
import { getImageProps, GetImagePropsInput } from "../getImageProps";

const webImage = (uri: string): WebImage => ({ uri, name: "test.jpg" });

const loadingDynamic = <T>(): DynamicValue<T> =>
({ status: "loading" as ValueStatus.Loading, value: undefined }) as DynamicValue<T>;

describe("getImageProps", () => {
describe('datasource: "image"', () => {
it("returns the main image URI when main image is available", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: dynamic(webImage("https://example.com/main.jpg"))
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" });
});

it("returns the main image URI when main image is loading (uri present)", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: dynamic(webImage("https://example.com/main.jpg"), true)
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" });
});

it("returns undefined image when main image is loading (no uri yet)", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: loadingDynamic<WebImage>()
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("falls back to defaultImage when main image is unavailable and fallback is available", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: dynamic<WebImage>(),
defaultImageDynamic: dynamic(webImage("https://example.com/fallback.jpg"))
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/fallback.jpg" });
});

it("falls back to defaultImage when main image is unavailable and fallback is loading", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: dynamic<WebImage>(),
defaultImageDynamic: dynamic(webImage("https://example.com/fallback.jpg"), true)
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/fallback.jpg" });
});

it("returns undefined image when both main image and fallback are unavailable", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: dynamic<WebImage>(),
defaultImageDynamic: dynamic<WebImage>()
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("returns undefined image when imageObject and defaultImageDynamic are not provided", () => {
const input: GetImagePropsInput = { datasource: "image" };
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("does not use defaultImage when main image is available", () => {
const input: GetImagePropsInput = {
datasource: "image",
imageObject: dynamic(webImage("https://example.com/main.jpg")),
defaultImageDynamic: dynamic(webImage("https://example.com/fallback.jpg"))
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" });
});
});

describe('datasource: "imageUrl"', () => {
it("returns the URL when imageUrl is available", () => {
const input: GetImagePropsInput = {
datasource: "imageUrl",
imageUrl: dynamic("https://example.com/image.jpg")
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/image.jpg" });
});

it("returns undefined image when imageUrl is loading", () => {
const input: GetImagePropsInput = {
datasource: "imageUrl",
imageUrl: loadingDynamic<string>()
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("returns undefined image when imageUrl is unavailable", () => {
const input: GetImagePropsInput = {
datasource: "imageUrl",
imageUrl: dynamic<string>()
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("returns undefined image when imageUrl is not provided", () => {
const input: GetImagePropsInput = { datasource: "imageUrl" };
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});
});

describe('datasource: "icon"', () => {
it("returns glyph icon class when a glyph icon is available", () => {
const glyphIcon: WebIcon = { type: "glyph", iconClass: "glyphicon-star" };
const input: GetImagePropsInput = {
datasource: "icon",
imageIcon: dynamic(glyphIcon)
};
expect(getImageProps(input)).toEqual({ type: "glyph", image: "glyphicon-star" });
});

it("returns image icon URL when an image icon is available", () => {
const imageIcon: WebIcon = { type: "image", iconUrl: "https://example.com/icon.png" };
const input: GetImagePropsInput = {
datasource: "icon",
imageIcon: dynamic(imageIcon)
};
expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/icon.png" });
});

it("returns mx-icon class when a named icon is available", () => {
const namedIcon: WebIcon = { type: "icon", iconClass: "mx-icon-star" };
const input: GetImagePropsInput = {
datasource: "icon",
imageIcon: dynamic(namedIcon)
};
expect(getImageProps(input)).toEqual({ type: "icon", image: "mx-icon-star" });
});

it("returns fallback when imageIcon is loading", () => {
const input: GetImagePropsInput = {
datasource: "icon",
imageIcon: loadingDynamic<WebIcon>()
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("returns fallback when imageIcon is unavailable", () => {
const input: GetImagePropsInput = {
datasource: "icon",
imageIcon: dynamic<WebIcon>()
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("returns fallback when imageIcon value is undefined (WebIcon = undefined)", () => {
const input: GetImagePropsInput = {
datasource: "icon",
imageIcon: dynamic<WebIcon>(undefined as WebIcon)
};
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});

it("returns fallback when imageIcon is not provided", () => {
const input: GetImagePropsInput = { datasource: "icon" };
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});
});

describe("unknown datasource", () => {
it("returns fallback for an unrecognised datasource value", () => {
const input = { datasource: "unknown" as GetImagePropsInput["datasource"] };
expect(getImageProps(input)).toEqual({ type: "image", image: undefined });
});
});
});
58 changes: 58 additions & 0 deletions packages/pluggableWidgets/image-web/src/utils/getImageProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ValueStatus } from "mendix";
import { ImageContainerProps } from "../../typings/ImageProps";
import { ImageType } from "../components/Image/Image";

export type GetImagePropsInput = Pick<
ImageContainerProps,
"datasource" | "imageIcon" | "imageObject" | "imageUrl" | "defaultImageDynamic"
>;

const fallback: ImageType = { type: "image", image: undefined };

export function getImageProps({
datasource,
imageIcon,
imageObject,
imageUrl,
defaultImageDynamic: defaultImage
}: GetImagePropsInput): ImageType {
switch (datasource) {
case "image": {
// if main image is available or loading
if (imageObject?.status === ValueStatus.Available || imageObject?.status === ValueStatus.Loading) {
return {
type: "image",
image: imageObject?.value?.uri
};
}

// if main image is not available, but fallback is available or loading
if (defaultImage?.status === ValueStatus.Available || defaultImage?.status === ValueStatus.Loading) {
return {
type: "image",
image: defaultImage?.value?.uri
};
}

// if main image and fallback are not available
return { type: "image", image: undefined };
}
case "imageUrl":
return {
type: "image",
image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined
};
case "icon": {
if (imageIcon?.status === ValueStatus.Available && imageIcon.value) {
const icon = imageIcon.value;
return {
type: icon.type,
image: icon.type === "image" ? icon.iconUrl : icon.iconClass
};
}
return fallback;
}
default:
return fallback;
}
}
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading