Skip to content

[WC-3337]: Pluggable Image Crop Widget#2235

Open
rahmanunver wants to merge 43 commits into
mainfrom
feat/image-crop-web
Open

[WC-3337]: Pluggable Image Crop Widget#2235
rahmanunver wants to merge 43 commits into
mainfrom
feat/image-crop-web

Conversation

@rahmanunver
Copy link
Copy Markdown
Contributor

Pull request type

New feature (non-breaking change which adds functionality)


Description

Adds Image Crop, a new pluggable widget that lets end-users crop an image directly inside a Mendix page and saves the result back to an Image attribute.

  • Shows the bound image inside a fixed crop area and lets the user drag a selection over it.
  • Supports rectangle or circle crop shapes.
  • Supports aspect-ratio presets (Free, 1:1, 16:9, 4:3, 3:4) plus a Custom W:H option.
  • Optional zoom with a slider and mouse-wheel (always, only with Ctrl/Cmd, or off).
  • Optional live preview at a configurable size, so users see the result before saving.
  • On click of the crop button, the result is saved into the bound Image attribute and an optional action runs.
  • Output size can match the visible crop area or the original image resolution.

rahmanunver and others added 19 commits May 21, 2026 22:54
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds getPreview structure tile (icon + caption with shape/aspect/output),
fit-and-scale canvas sizing in CropArea (no more gaps after horizontal crop),
DPR-aware PreviewPane buffer, exhaustiveness guard in aspectRatio, and
required="true" on three XML booleans. SVG asset replaces inline data URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rahmanunver rahmanunver requested a review from a team as a code owner May 27, 2026 13:59
@rahmanunver rahmanunver changed the title [WC-337]: Pluggable Image Crop Widget [WC-3337]: Pluggable Image Crop Widget May 27, 2026
rahmanunver and others added 7 commits May 27, 2026 16:03
Adds getPreview structure tile (icon + caption with shape/aspect/output),
fit-and-scale canvas sizing in CropArea (no more gaps after horizontal crop),
DPR-aware PreviewPane buffer, exhaustiveness guard in aspectRatio, and
required="true" on three XML booleans. SVG asset replaces inline data URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rahmanunver rahmanunver force-pushed the feat/image-crop-web branch from bcd4ea9 to b19bb26 Compare May 27, 2026 14:03
@github-actions

This comment was marked as outdated.

@github-actions
Copy link
Copy Markdown

AI Code Review

🔶 Changes requested — one or more medium-severity items must be addressed


What was reviewed

File Change
src/ImageCrop.xml New widget manifest — 20 properties across shape, zoom, preview, output, action groups
typings/ImageCropProps.d.ts Generated container + preview prop types
src/ImageCrop.tsx Entry-point wrapper
src/components/ImageCropContainer.tsx Main orchestration component
src/components/CropArea.tsx react-image-crop integration, wheel zoom wiring
src/components/CropButton.tsx Atlas-styled submit button
src/components/PreviewPane.tsx Canvas live-preview
src/components/ZoomSlider.tsx Range input for zoom
src/hooks/useImageCropState.ts State bundle hook
src/hooks/useWheelZoom.ts Wheel-event zoom handler
src/utils/aspectRatio.ts Aspect ratio resolver
src/utils/cropImage.ts Canvas crop + Blob export
src/ImageCrop.editorConfig.ts Studio Pro property visibility + structure preview
src/ImageCrop.editorPreview.tsx Design-time placeholder
src/ui/ImageCrop.scss Widget styles
src/__tests__/ImageCrop.spec.tsx Container integration tests
src/hooks/__tests__/useWheelZoom.spec.ts Hook unit tests
src/utils/__tests__/aspectRatio.spec.ts Util unit tests
src/utils/__tests__/cropImage.spec.ts Canvas export unit tests
jest.config.js, jest.setup.ts Test infrastructure
package.json, tsconfig.json, rollup.config.mjs, eslint.config.mjs Package scaffolding
CHANGELOG.md Initial 1.0.0 entry
.github/configs/labeler.yml PR label routing

Skipped (out of scope): pnpm-lock.yaml, *.png, crop-icon.svg


Findings

🔶 Medium — props.image.value accessed without null guard in handleCrop

File: src/components/ImageCropContainer.tsx line 52
Problem: Inside handleCrop, the early-exit guard checks props.image.status !== ValueStatus.Available but does not check !props.image.value. The typings declare value: WebImage | undefined, so props.image.value.name will throw a TypeError if the Mendix runtime returns status = Available with a null/undefined value (e.g., immediately after clearing the attribute).
Fix:

// add !props.image.value to the existing guard
if (
    !img ||
    !state.completedCrop ||
    props.image.readOnly ||
    props.image.status !== ValueStatus.Available ||
    !props.image.value   // ← add this
) {
    return;
}

🔶 Medium — No E2E tests for a new interactive widget

File: packages/pluggableWidgets/image-crop-web/ (directory)
Problem: This widget introduces drag-to-crop, zoom slider, mouse-wheel zoom, live preview, and a save action — all interactive paths that can't be verified by unit tests alone. No e2e/ directory is present. The package.json references a testProject.branchName, suggesting CI infrastructure exists but the spec files are missing.
Fix: Add a e2e/ImageCrop.spec.js that at minimum covers:

  • Widget renders after page load (.mx-name-* selector + toBeVisible)
  • Crop button becomes enabled after image loads
  • Clicking Crop button does not error (ideally verifies the action fires)
  • afterEach session logout to avoid Mendix 5-session limit

🔶 Medium — Unit tests bypass TypeScript with Partial<any> overrides

File: src/__tests__/ImageCrop.spec.tsx lines 8–52
Problem: makeImageProp(overrides: Partial<any>) and the base: any cast in makeProps mean prop overrides are not type-checked. A refactor that renames or changes a prop type will silently produce invalid test props without a compile error. The skill guidelines require builders from @mendix/widget-plugin-test-utils for Mendix data objects.
Fix: If EditableImageValue does not have a dedicated builder in widget-plugin-test-utils, type the factory tightly against the real type:

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

At minimum remove Partial<any> and type the overrides parameter.


⚠️ Low — Committed typings/ file conflicts with .gitignore

File: packages/pluggableWidgets/image-crop-web/.gitignore line 3 / typings/ImageCropProps.d.ts
Note: The package's .gitignore lists typings/ as ignored, yet typings/ImageCropProps.d.ts is committed. Once the file is tracked, git will continue tracking it, but any XML-regeneration diff will appear as a modification rather than an untracked file. This is confusing. Either remove typings/ from the gitignore (if the convention is to commit generated typings, as appears to be the case for this widget) or remove the committed file and let it be generated at build time.


⚠️ Low — marketplace.appNumber is 0

File: package.json line 22
Note: The Marketplace appNumber is a placeholder 0. This must be updated to the real Marketplace app ID before the release automation (create-gh-release, publish-marketplace, update-changelog) will work correctly. Track this as a follow-up before the first release.


Positives

  • canExecute is checked before onCropAction.execute() — correct Mendix action guard pattern
  • All three EditableValue statuses (Loading, non-Available, Available-with-no-value) are handled with distinct UI states before rendering the crop UI
  • useEffect wheel-event listener correctly returns a cleanup that removes the same listener reference, avoiding duplicate registrations on re-render
  • resolveAspectRatio and aspectLabel both use exhaustive never checks so missing enum values become compile errors
  • crossOrigin="anonymous" is set on the <img> element, enabling canvas export without tainted-canvas errors for properly CORS-configured sources — and the tainted-canvas failure path is handled with a user-readable CropError
  • jest.setup.ts documents the node-canvas/jsdom interaction problem and the rationale for each monkey-patch clearly — unusual but the explanation makes the workaround reviewable
  • cropImage correctly fills the canvas background with white before drawing when output format is JPEG, preventing black transparency artifacts

<enumerationValue key="onWithCtrl">On (hold Ctrl)</enumerationValue>
</enumerationValues>
</property>
<property key="minZoom" type="decimal" defaultValue="1" required="true">
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just an integer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decimal is intentional here. Zoom is a multiplier (1× = image fits the canvas, 2× = double, 0.5× = half-size). Real-world configurations need fractional values:

  • minZoom: 0.5 — lets users zoom out further than fit, useful when the source image is larger than the canvas and the user wants to see the whole thing before cropping.
  • maxZoom: 2.5 — caps zoom mid-step so the slider doesn't overshoot detail.

Forcing integers would round these to the nearest whole multiple, breaking the slider's natural feel and the wheel-zoom step.

<caption>Output size</caption>
<description>Resolution of the saved crop. Original is sharpest; Viewport matches the on-screen canvas size.</description>
<enumerationValues>
<enumerationValue key="viewport">Viewport (canvas dims)</enumerationValue>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dims means dimensions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will rename to dimensions

<caption>Crop button caption</caption>
<description>Label on the Crop button.</description>
<translations>
<translation lang="en_US">Crop</translation>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nl_NL translation missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding it.

Comment on lines +25 to +26
const _exhaustive: never = aspect;
return _exhaustive;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this?

Copy link
Copy Markdown
Contributor Author

@rahmanunver rahmanunver May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript exhaustiveness guard;
Imagine adding a new case, a new aspectRatio in the .xml, but don't add the new case in this switch;
After all existing cases are ruled out, since no case for the new aspect ratio exists, it will return undefined, causing the widget to act as if the setting was set to "free", and have no warnings.

A new "landscape21x9" is added as an option in the xml.
Adding a default case and setting the aspect to a value with type never:

error TS2322: Type '"landscape21x9"' is not assignable to type 'never'.
  src/utils/aspectRatio.ts:25  const _exhaustive: never = aspect;

The build fails with the error pointing at exactly this line, and the developer is forced to add the missing case "landscape21x9": return 21 / 9; before they can ship.

Comment on lines +117 to +119
<div className="widget-image-crop__error">
Image source does not allow cropping. Upload locally or configure CORS.
</div>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this error message makes any sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will rephrase

Comment on lines +108 to +113
const setImageRef = useCallback(
(img: HTMLImageElement | null) => {
imageRef.current = img;
},
[imageRef]
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it needed? Maybe we can pass imageRef as ref directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating it, stale change.

Comment on lines +124 to +130
<div
ref={containerRef}
className={classNames("widget-image-crop__canvas", {
"widget-image-crop__canvas--circle": props.circular
})}
style={{ maxWidth: props.boundaryWidth, maxHeight: props.boundaryHeight }}
>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can decouple container wrapper with zoom logic to a separate component.

Copy link
Copy Markdown
Contributor Author

@rahmanunver rahmanunver May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactoring into a ZoomContainer

Comment on lines +137 to +138
disabled={!props.resizable}
locked={!props.resizable}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it really be locked if not resizable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will drop, keep only disabled

transformOrigin: "center"
}}
onLoad={handleImageLoad}
onError={(_e: SyntheticEvent<HTMLImageElement>) => setLoadError(true)}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You likely can use empty signature () =>

const [crop, setCrop] = useState<Crop | undefined>(undefined);
const [completedCrop, setCompletedCrop] = useState<PixelCrop | undefined>(undefined);
const [zoom, setZoom] = useState<number>(initialZoom);
const imageRef = useRef<HTMLImageElement | null>(null);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useRef adds | null by default in this case. Check other places as well.

Comment on lines +5 to +8
crop: Crop | undefined;
setCrop: Dispatch<SetStateAction<Crop | undefined>>;
completedCrop: PixelCrop | undefined;
setCompletedCrop: Dispatch<SetStateAction<PixelCrop | undefined>>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between those, can we use only one instead of two?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They fire on different events (onChange per pointer move vs onComplete on release), so collapsing them either makes the selection rectangle freeze during drag or makes the preview canvas re-render dozens of times per second.

maxWidth: displaySize ? undefined : props.boundaryWidth,
maxHeight: displaySize ? undefined : props.boundaryHeight,
transform: `scale(${props.zoom})`,
transformOrigin: "center"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand it correctly that it only zooms into the center, like there is no way to zoom in and crop a corner of and image?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's for the zoom in/out either with gesture or slider, we have drag-to-crop for corner edge cropping (ss is for circle crop).
Screenshot 2026-05-28 at 15 38 48

@github-actions
Copy link
Copy Markdown

AI Code Review

🔶 Changes requested — one or more medium-severity items must be addressed


What was reviewed

File Change
src/ImageCrop.tsx Entry point component
src/ImageCrop.xml Widget manifest + properties
typings/ImageCropProps.d.ts Generated prop types
src/components/ImageCropContainer.tsx Main container logic
src/components/CropArea.tsx Crop/zoom area
src/components/CropButton.tsx Crop action button
src/components/PreviewPane.tsx Live preview canvas
src/components/ZoomContainer.tsx Wheel zoom wrapper
src/components/ZoomSlider.tsx Zoom range input
src/hooks/useImageCropState.ts State hook
src/hooks/useWheelZoom.ts Wheel event hook
src/utils/aspectRatio.ts Aspect ratio resolver
src/utils/cropImage.ts Canvas crop + export
src/__tests__/ImageCrop.spec.tsx Main widget tests
src/hooks/__tests__/useWheelZoom.spec.ts Hook tests
src/utils/__tests__/aspectRatio.spec.ts Util tests
src/utils/__tests__/cropImage.spec.ts Canvas util tests
src/ui/ImageCrop.scss Widget styles
src/ImageCrop.editorConfig.ts Studio Pro design-time config
src/ImageCrop.editorPreview.tsx Studio Pro preview component
jest.config.js, jest.setup.ts Test configuration
package.json, tsconfig.json, rollup.config.mjs Package config
CHANGELOG.md, .gitignore, LICENSE, README.md Package metadata
.github/configs/labeler.yml New label rule

Skipped (out of scope): pnpm-lock.yaml, src/ImageCrop.icon*.png, src/ImageCrop.tile*.png


Findings

🔶 Medium — Loading and empty states omit props.class and props.style

File: src/components/ImageCropContainer.tsx lines 97–103
Problem: The loading and empty early-returns render a bare <div> without applying props.class or props.style. This means Studio Pro class/style customizations are silently dropped whenever the image is loading or unavailable, which breaks widget layout configuration.

Fix:

if (props.image.status === ValueStatus.Loading) {
    return (
        <div
            className={classNames("widget-image-crop", "widget-image-crop--loading", props.class)}
            style={props.style}
            aria-busy="true"
        />
    );
}
if (props.image.status !== ValueStatus.Available || !props.image.value) {
    return (
        <div className={classNames("widget-image-crop", "widget-image-crop--empty", props.class)} style={props.style}>
            No image
        </div>
    );
}

🔶 Medium — No E2E tests for an interactive widget

File: packages/pluggableWidgets/image-crop-web/ (missing e2e/ directory)
Problem: This widget's primary value is interactive — drag-to-crop, zoom slider, wheel zoom, live preview, and the crop action writing back to a Mendix attribute. Unit tests cover utilities and isolated hooks, but they cannot exercise the full interaction loop (drag handles, the Mendix setValue call path through a real browser, CORS image loading). Without E2E coverage, regressions in the end-to-end crop workflow may go undetected.
Fix: Add a e2e/ImageCrop.spec.js with at minimum:

  • afterEach session logout (required — see guidelines)
  • A smoke test that loads the test project page, confirms the crop canvas is visible, clicks the Crop button, and asserts setValue was invoked (or that a success indicator appears).

⚠️ Low — tabIndex prop accepted but never forwarded to DOM

File: src/components/ImageCropContainer.tsx line 112
Note: ImageCropContainerProps.tabIndex is declared in the typings and passed through to ImageCropContainer, but no DOM element receives it. Studio Pro sets tabIndex for widget tab-order configuration; ignoring it breaks keyboard tab navigation for app builders who configure it.


⚠️ Low — Preview <canvas> has no accessible label

File: src/components/PreviewPane.tsx line 62
Note: The canvas renders a live crop preview but has no aria-label or role. Screen readers announce it as an unlabelled graphic. Add aria-label="Crop preview" (or equivalent) and role="img" to convey its purpose.


⚠️ Low — "Zoom" label is hardcoded English

File: src/components/ZoomSlider.tsx line 13
Note: <span className="widget-image-crop__zoom-label">Zoom</span> is a hardcoded English string. Other user-visible strings in this widget use textTemplate properties (e.g. cropButtonCaption). Consider adding a zoomLabel textTemplate property to the XML (with en_US / nl_NL translations), or at minimum document it as a known limitation for localisation.


Positives

  • canExecute is correctly checked before props.onCropAction.execute() — no unconditional executions.
  • image.status is checked for Loading and Available before accessing .value — no stale reads.
  • Exhaustive switch with const _exhaustive: never = value pattern used consistently in aspectRatio.ts and editorConfig.ts — compile-time safety for future enum additions.
  • cropImage.ts correctly pre-fills a white background before drawing for JPEG output (alpha channel handling).
  • ZoomContainer attaches the wheel listener imperatively with { passive: false } so preventDefault is effective — the React synthetic event path doesn't support this.
  • jest.config.js properly spreads the base config from @mendix/pluggable-widgets-tools/test-config/jest.config.js.
  • jest.setup.ts is well-documented: the multi-paragraph comment accurately explains the node-canvas / jest-canvas-mock conflict and the three-step fix — a genuinely non-obvious workaround worth preserving.
  • CHANGELOG.md is present and follows Keep a Changelog format for the initial release.

@rahmanunver rahmanunver force-pushed the feat/image-crop-web branch from b824245 to f376009 Compare May 28, 2026 14:21
@github-actions
Copy link
Copy Markdown

AI Code Review

🔶 Changes requested — one or more medium-severity items must be addressed


What was reviewed

File Change
src/ImageCrop.tsx Widget entry point — thin passthrough
src/components/ImageCropContainer.tsx Main container, data API usage, crop flow
src/components/CropArea.tsx react-image-crop wrapper, zoom integration
src/components/ZoomContainer.tsx Wheel event listener lifecycle
src/components/ZoomSlider.tsx Range input component
src/components/CropButton.tsx Crop trigger button
src/components/PreviewPane.tsx Canvas-based live preview
src/hooks/useImageCropState.ts State hook
src/hooks/useWheelZoom.ts Wheel zoom logic
src/utils/cropImage.ts Canvas crop + File export
src/utils/aspectRatio.ts Aspect ratio resolver
src/ImageCrop.xml Widget manifest
typings/ImageCropProps.d.ts Generated props types
src/ImageCrop.editorConfig.ts Studio Pro design panel
src/ImageCrop.editorPreview.tsx Studio Pro preview
src/ui/ImageCrop.scss Styles
src/__tests__/ImageCrop.spec.tsx Widget unit tests
src/hooks/__tests__/useWheelZoom.spec.ts Hook unit tests
src/utils/__tests__/cropImage.spec.ts Utility unit tests
src/utils/__tests__/aspectRatio.spec.ts Utility unit tests
jest.config.js, jest.setup.ts Test configuration
package.json, CHANGELOG.md Package metadata
.github/configs/labeler.yml Label config addition

Skipped (out of scope): pnpm-lock.yaml, dist/, *.png icons


Findings

🔶 Medium — handleCrop callback missing state staleness guard

File: src/components/ImageCropContainer.tsx line 34–84
Problem: handleCrop is an async function that calls props.image.setValue(file) and props.onCropAction.execute() after the await cropImage(...) resolves. There is no guard for component unmount or image source change between the time the user clicks Crop and the time the canvas resolves. If the image is swapped out mid-flight (the uri useEffect already clears completedCrop), setValue will still be called with the stale blob.
Fix: Add an isMounted / AbortController guard pattern:

const handleCrop = useCallback(async () => {
    let cancelled = false;
    // ...
    try {
        const file = await cropImage({ ... });
        if (cancelled) return;          // ← guard
        props.image.setValue(file);
        if (props.onCropAction?.canExecute) {
            props.onCropAction.execute();
        }
    } catch (err) { ... }
    return () => { cancelled = true; };   // Not applicable for useCallback — use a ref instead
}, [...]);

The idiomatic fix is a ref that is set to false during the cleanup of a wrapping useEffect, or an AbortController whose signal is checked after await.


🔶 Medium — image prop in ImageCropContainer.tsx not checked with EditableImageValue status before setThumbnailSize / setValue

File: src/components/ImageCropContainer.tsx lines 58–61
Problem: props.image.setValue(file) is called without re-verifying props.image.status === ValueStatus.Available after the async cropImage call. The status could have changed to Unavailable between the button click and the resolved promise (e.g. the page navigated away or the entity was removed). The outer guard at line 37–44 only runs at the start of the async function.
Fix: Re-check status after await:

const file = await cropImage({ ... });
if (props.image.status !== ValueStatus.Available || !props.image.value) {
    return;
}
props.image.setValue(file);

🔶 Medium — ZoomSlider label is hardcoded English string

File: src/components/ZoomSlider.tsx line 13
Problem: The label text "Zoom" is hardcoded. The cropButtonCaption uses a textTemplate with translations in the XML, but the zoom label bypasses this entirely. In a Dutch-locale Mendix app this will render "Zoom" regardless of theme.
Fix: Either add a zoomLabel textTemplate property to the XML (and pass DynamicValue<string> to ZoomSlider) or at minimum pass the label through a prop so callers can supply it. At minimum add it to ZoomSliderProps:

interface ZoomSliderProps {
    label: string;
    // ...
}

and thread it from ImageCropContainer using cropButtonCaption-style resolved value.


🔶 Medium — Missing E2E test file

File: (package-level)
Problem: This is a new interactive widget with drag/zoom/crop/save behaviour — precisely the scenario called out in the review guidelines as warranting E2E coverage. No e2e/*.spec.js file is present. A test project branch (image-crop-web) is already referenced in package.json, so infrastructure exists.
Fix: Add at minimum a Playwright spec covering:

  • Page load with a bound image — crop area renders
  • Crop button click — setValue executes (can be checked via a Mendix feedback widget)
  • The mandatory afterEach session logout to avoid exceeding the 5-session limit in CI

⚠️ Low — crossOrigin="anonymous" on <img> may silently fail for same-origin Mendix images

File: src/components/CropArea.tsx line 112
Note: Setting crossOrigin="anonymous" causes browsers to add Origin headers and requires the server to respond with Access-Control-Allow-Origin. For images served from the Mendix runtime's own /file? endpoint this usually works, but CDN-served thumbnails and some self-hosted configs will return no CORS headers, causing the image to load visually (the element fires onLoad) but the canvas toBlob will fail with a tainted-canvas error. The CropError path already handles this gracefully, but the UX is confusing — the image renders fine and the Crop button is enabled, then clicking Crop shows nothing visible (only a console.error). Consider surfacing the tainted-canvas CropError as a user-visible error state in ImageCropContainer.


⚠️ Low — useImageCropState initial zoom not updated when minZoom prop changes

File: src/hooks/useImageCropState.ts line 17
Note: useState(initialZoom) only uses initialZoom on the first render. If a Studio Pro user changes minZoom via a dynamic expression, the zoom state will not reset. The handleImageLoad callback does call setZoom(Number(props.minZoom)) on image load, which partially mitigates this, but a prop change without a new image load won't re-synchronize. Low risk for typical use, but worth documenting or handling via a useEffect.


⚠️ Low — makeImageProp in tests uses manual mock object instead of builder

File: src/__tests__/ImageCrop.spec.tsx lines 11–22
Note: The guidelines require EditableValueBuilder / test-utils builders for Mendix data mocking. makeImageProp manually constructs the EditableImageValue shape. This may miss status edge-cases that builders enforce (e.g. Unavailable with stale .value). Consider switching to new EditableImageValueBuilder<WebImage>().withValue(...).build() if it is available in @mendix/widget-plugin-test-utils, or document why a manual mock is necessary here.


Positives

  • canExecute is checked before onCropAction.execute() — correct Mendix action guard pattern.
  • All three ValueStatus branches (Loading, Available, absent) are explicitly handled in ImageCropContainer with appropriate UI states, including aria-busy on the loading skeleton.
  • ZoomContainer correctly adds the wheel listener as { passive: false } and removes it in the cleanup function, preventing memory leaks.
  • cropImage and aspectRatio utilities have thorough, fast unit tests covering all enum branches, zoom scaling math, and the tainted-canvas error path.
  • jest.config.js correctly extends @mendix/pluggable-widgets-tools/test-config/jest.config — proper inheritance.
  • Exhaustive never checks in both resolveAspectRatio and aspectLabel catch any future enum additions at compile time.
  • CHANGELOG entry present and correctly formatted for the initial release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants