diff --git a/packages/pluggableWidgets/image-web/CHANGELOG.md b/packages/pluggableWidgets/image-web/CHANGELOG.md index 8bd533181d..4bfb130fbc 100644 --- a/packages/pluggableWidgets/image-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-web/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/image-web/package.json b/packages/pluggableWidgets/image-web/package.json index 9c644692a6..bf4bcc3cac 100644 --- a/packages/pluggableWidgets/image-web/package.json +++ b/packages/pluggableWidgets/image-web/package.json @@ -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:*" } } diff --git a/packages/pluggableWidgets/image-web/src/Image.tsx b/packages/pluggableWidgets/image-web/src/Image.tsx index d6745017b2..82cea0d4ff 100644 --- a/packages/pluggableWidgets/image-web/src/Image.tsx +++ b/packages/pluggableWidgets/image-web/src/Image.tsx @@ -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"; export const Image: FunctionComponent = props => { const onClick = useCallback(() => props.onClick?.execute(), [props.onClick]); @@ -65,7 +14,7 @@ export const Image: FunctionComponent = props => { const imageStyle = { ...props.style, ...styleObject }; - return ( + return image ? ( = props => { renderAsBackground={props.datasource !== "icon" && props.isBackgroundImage} backgroundImageContent={props.children} /> - ); + ) : null; }; diff --git a/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts b/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts new file mode 100644 index 0000000000..55bd642020 --- /dev/null +++ b/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts @@ -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 = (): DynamicValue => + ({ status: "loading" as ValueStatus.Loading, value: undefined }) as DynamicValue; + +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() + }; + 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(), + 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(), + 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(), + defaultImageDynamic: dynamic() + }; + 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() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns undefined image when imageUrl is unavailable", () => { + const input: GetImagePropsInput = { + datasource: "imageUrl", + imageUrl: dynamic() + }; + 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() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns fallback when imageIcon is unavailable", () => { + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic() + }; + 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(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 }); + }); + }); +}); diff --git a/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts b/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts new file mode 100644 index 0000000000..59734686a2 --- /dev/null +++ b/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts @@ -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; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf32de8c14..bceb2a51fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -413,7 +413,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1748,7 +1748,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1761,6 +1761,9 @@ importers: '@mendix/widget-plugin-platform': specifier: workspace:* version: link:../../shared/widget-plugin-platform + '@mendix/widget-plugin-test-utils': + specifier: workspace:* + version: link:../../shared/widget-plugin-test-utils packages/pluggableWidgets/language-selector-web: dependencies: