Skip to content

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

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

[WC-3337]: Pluggable Image Crop Widget#2235
rahmanunver wants to merge 44 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 5 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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

AI Code Review

⚠️ Approved with suggestions — low-severity items only, safe to merge


What was reviewed

File Change
packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tsx Root component entry point
packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml Widget manifest — properties and enumerations
packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx Main container: state wiring, applyCrop, Mendix data handling
packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx ReactCrop wrapper with image sizing
packages/pluggableWidgets/image-cropper-web/src/components/ZoomContainer.tsx Wheel-zoom event binding
packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx Range slider for zoom
packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx Canvas live-preview pane
packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts Debounce/armed pattern for auto-save
packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts Local state bag
packages/pluggableWidgets/image-cropper-web/src/hooks/useWheelZoom.ts Mouse-wheel zoom handler
packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts Aspect ratio resolver
packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts Canvas-based crop-to-File utility
packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts Studio Pro design-time config
packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx Editor preview component
packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss Widget styles
packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx Container unit tests
packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useWheelZoom.spec.ts Hook unit tests
packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts Utility unit tests
packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/aspectRatio.spec.ts Utility unit tests
packages/pluggableWidgets/image-cropper-web/CHANGELOG.md Initial 1.0.0 entry present
.github/configs/labeler.yml Added image-cropper label routing (whitespace-only reformat)

Skipped (out of scope): pnpm-lock.yaml, dist/, icon PNG/tile images


Findings

⚠️ Low — zoomEnabled prop ignored in CropArea / ZoomContainer wheel handler

File: packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx line 142 and ZoomContainer.tsx line 31

Note: The ZoomSlider is correctly hidden when zoomEnabled is false, but ZoomContainer still registers the wheel listener via useWheelZoom, and CropArea still receives setZoom. If zoomEnabled=false the user can still zoom with the mouse wheel (unless wheelZoomMode is also set to "off"). Consider passing mode="off" to ZoomContainer when !props.zoomEnabled, or skipping the wheel attachment entirely:

// ImageCropperContainer.tsx — pass through zoomEnabled
<CropArea
    ...
    wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"}
    ...
/>

⚠️ Low — ZoomSlider label is hard-coded English text

File: packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx line 13

Note: The label "Zoom" is a plain string literal. Other Mendix widgets use DynamicValue<string> or Studio Pro caption overrides for user-visible text. Even if internationalisation is out of scope for v1, the aria-label/aria-valuetext for the range input is absent — screen readers will announce the numeric value with no context. Consider adding aria-label="Zoom" to the <input> (the wrapping <label> already provides implicit labelling, so this is low priority but worth noting).


⚠️ Low — crossOrigin="anonymous" always set; may break same-origin images that redirect through a CDN without CORS headers

File: packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx line 112

Note: Setting crossOrigin="anonymous" unconditionally forces a CORS preflight for all images. If the Mendix runtime serves images through a CDN or redirect that lacks Access-Control-Allow-Origin, the image will silently fail to load. The loadError state handles the visible error, but the constraint is invisible to Studio Pro configurers. Consider documenting this in the widget description or the helpUrl, or making crossOrigin conditional on the output format requiring cross-origin access (canvas taint only matters when actually exporting).


⚠️ Low — Missing E2E tests for an interactive widget

File: packages/pluggableWidgets/image-cropper-web/ (no e2e/ directory)

Note: This is a new interactive widget with drag-to-crop, zoom, and image-save behaviour. Per repo conventions (see docs/requirements/e2e-test-guidelines.md), interactive widgets should have Playwright E2E tests covering at least the golden path. No e2e/ directory is present. Acceptable for v1 if a test project branch (image-cropper) is tracked, but worth adding before the widget ships to the Marketplace.


Positives

  • applyCrop correctly checks props.image.readOnly, props.image.status !== ValueStatus.Available, and props.image.value before mutating — all three Mendix data API guards are in place.
  • onCropAction.canExecute is checked before execute() (line 53 of ImageCropperContainer.tsx).
  • The armed/debounce pattern in useAutoApplyCrop elegantly prevents auto-saving on initial load while still giving immediate feedback on crop-release — and the test coverage proves the contract at each branch.
  • cropImage handles the tainted-canvas case (CORS + toBlob returning null) and surfaces a user-friendly CropError rather than an unhandled rejection.
  • jest.config.js correctly extends @mendix/pluggable-widgets-tools/test-config/jest.config and the jest.setup.ts properly patches CanvasRenderingContext2D and toBlob for jsdom — thoughtful test infrastructure.
  • editorConfig.ts hides irrelevant properties dynamically (e.g. customAspectWidth/Height when not custom, outputQuality when not JPEG) — clean Studio Pro UX.
  • All CSS classes use the widget-image-cropper prefix consistently and no Atlas core classes are overridden.

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