Skip to content

Conversation

Magnusrm
Copy link
Contributor

@Magnusrm Magnusrm commented Sep 2, 2025

Description

New component ImageUpload. The main functionality of this component is to let the user upload an image and crop it before saving it to the instance. When it is saved, user will be displayed the result.
Validations are added: the file must be of type image and the size can't exceed 10MB. If user adds an image type that can contain animation such as gif, apng or webp, we inform user that only the first frame is displayed.

The logic behind cropping the image, which is also used in other third-party libraries, uses the web API Canvas. It can draw the uploaded image according to our specifications, such as which part of the image should be drawn and kept inside the crop area.

The content of the crop area is determined by various utilities that calculate the position of the image (x, y) relative to the canvas, the size of the image relative to the canvas, the center of the crop area, clamped positions (restricting the user from dragging the crop area outside the image), etc. Most of these calculation functions can be found in the utility file.

This new component has some specific configuration options:

  • Users can choose between two types of crop shapes: cropShape: enum["square", "circle"], where "circle" is the default.
  • cropWidth: the width of the crop area in pixels. Default is 250px.
  • cropHeight: the height of the crop area in pixels. Default is also 250px.

These are optional props. If the user chooses not to set them, they will default to 250px and "circle".

Studio also supports this new component and is currently behind the feature flag: imageUpload.

This component also supports keyboard users.

Altinn docs is updated.

Here is how the component will behave for the user:

imageupload.mp4

Related Issue(s)

  • closes #{issue number}

Verification/QA

  • Manual functionality testing
    • I have tested these changes manually
    • Creator of the original issue (or service owner) has been contacted for manual testing (or will be contacted when released in alpha)
    • No testing done/necessary
  • Automated tests
    • Unit test(s) have been added/updated
    • Cypress E2E test(s) have been added/updated
    • No automatic tests are needed here (no functional changes/additions)
    • I want someone to help me make some tests
  • UU/WCAG (follow these guidelines until we have our own)
    • I have tested with a screen reader/keyboard navigation/automated wcag validator
    • No testing done/necessary (no DOM/visual changes)
    • I want someone to help me perform accessibility testing
  • User documentation @ altinn-studio-docs
  • Support in Altinn Studio
    • Issue(s) created for support in Studio
    • This change/feature does not require any changes to Altinn Studio
  • Sprint board
    • The original issue (or this PR itself) has been added to the Team Apps project and to the current sprint board
    • I don't have permissions to do that, please help me out
  • Labels
    • I have added a kind/* and backport* label to this PR for proper release notes grouping
    • I don't have permissions to add labels, please help me out

Summary by CodeRabbit

  • New Features

    • Full Image Upload workflow: drag-or-pick upload, in-app cropping canvas with pan/zoom, save/change/delete, and summary preview.
  • Behavior

    • Image-only uploads with keyboard/accessibility support, drag-active feedback, replace-before-save flow, and client-side 10MB validation.
  • Localization

    • Added EN/NB/NN translations for image upload UI, buttons and error messages.
  • Style

    • Adjusted media-card spacing and added styling for canvas, controls, slider, dropzone and action buttons.
  • Tests

    • New unit and end-to-end tests covering upload, crop, validation, save/delete and summaries.

Copy link

coderabbitai bot commented Sep 2, 2025

📝 Walkthrough

Walkthrough

Adds a new ImageUpload feature (dropzone, canvas cropper, controllers, utils, hook, config, summaries, tests), updates Dropzone API and FileUpload integration, adjusts Card/Cards styling and AppCard API, and adds i18n keys plus E2E helpers.

Changes

Cohort / File(s) Summary of changes
ImageUpload feature
src/layout/ImageUpload/*ImageUploadComponent.tsx, ImageCropper.tsx, ImageCanvas.tsx, ImageControllers.tsx, ImageDropzone.tsx, ImageUploadSummary2.tsx, useImageFile.tsx, imageUploadUtils.ts, config.ts, index.tsx, ImageCanvas.module.css, ImageControllers.module.css, ImageDropzone.module.css, ImageUploadComponent.test.tsx, ImageUploadSummary2.test.tsx, imageUploadUtils.test.tsx, ImageUploadSummary2.module.css
New image upload implementation: Dropzone UI, canvas-based cropper with pan/zoom/keyboard/wheel handling, controller UI (zoom/save/delete/reset), utilities for crop/placement/zoom/validation, hook for attachment persistence, layout config/registration, summary renderer, CSS modules, unit tests, and e2e wiring.
Dropzone API & FileUpload integration
src/app-components/Dropzone/Dropzone.tsx, src/layout/FileUpload/FileUploadComponent.tsx
Renamed exported Dropzone type to IDropzoneProps; made onClick optional; added onDragActiveChange and acceptedFiles props; useDropzone now uses acceptedFiles; FileUploadComponent now computes and passes acceptedFiles (replacing previous custom-extension props).
Cards / Card API & CSS
src/layout/Cards/Cards.module.css, src/layout/Cards/Cards.tsx, src/app-components/Card/Card.module.css, src/app-components/Card/Card.tsx
Added .mediaCard CSS and applied it in Cards; removed margin-bottom: -7px from Card.module.css; extended AppCard props with optional className and ref and forwarded them to underlying Card.
File table support
src/layout/FileUpload/FileUploadTable/FileTable.tsx
Extended useItemWhenType usage to include 'ImageUpload' so FileTable recognizes ImageUpload entries.
i18n
src/language/texts/en.ts, src/language/texts/nb.ts, src/language/texts/nn.ts
Added image_upload_component.* translation keys (warnings, change/delete/save buttons, zoom/slider labels, reset, error messages, valid file types) and minor NN/NB label tweak.
Expression shared test
src/features/expressions/shared-tests/functions/displayValue/type-ImageUpload.json
Added shared test verifying displayValue for ImageUpload using instance attachment filename.
E2E helpers & tests
test/e2e/integration/component-library/image-upload.ts, test/e2e/support/apps/component-library/uploadImageAndVerify.ts
Added Cypress e2e tests and helpers to upload images, verify canvas pixel output, and exercise upload/crop/save/delete/replace flows.
Summary & UI prop typings
src/layout/ImageUpload/ImageUploadSummary2.tsx, src/layout/ImageUpload/ImageUploadSummary2.module.css, src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx, src/layout/Option/OptionSummary.tsx
New ImageUpload summary component and styles; broadened displayData prop to `string
Tests / Expectation updates
src/layout/FileUpload/FileUploadComponent.test.tsx
Updated test expectation text to match changed UI label ("let etter fil" → "finn fil").

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120+ minutes

Possibly related PRs

Suggested reviewers

  • Magnusrm

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning The Related Issue(s) section still uses a placeholder instead of an actual issue number and needs a valid issue reference for proper linking. Please replace the placeholder in the Related Issue(s) section with the actual issue number (e.g., closes #123) so that the PR correctly links to the issue.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly identifies the addition of the ImageUpload component, clearly matching the primary feature introduced in this pull request.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/image-upload-component

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Magnusrm Magnusrm added kind/product-feature Pull requests containing new features backport-ignore This PR is a new feature and should not be cherry-picked onto release branches labels Sep 2, 2025
lassopicasso and others added 21 commits September 2, 2025 12:39
…tsimplify mouse dragging and support touch dragging (#3656)
* reuse dropzonecomponent and display only when imgsrc is not defined

* bring back the reset button

* move controller section to its own file
* move image cropper into card wip

* working cropping component in card

* conforrm to styling naming in repo

* canvas height adjustment

---------

Co-authored-by: lassopicasso <[email protected]>
* clean up css and style closer to figma design
…fn) (#3668)

* added save and cancel buttons with functionality (some left for save fn)

* Adding AttachmentsPlugin to ImageUpload component (it needs to be defined as a form component for this to work, which is more fitting)

* save cropped image to backend

* feedback

* small fix

---------

Co-authored-by: Ole Martin Handeland <[email protected]>
* added save and cancel buttons with functionality (some left for save fn)

* Adding AttachmentsPlugin to ImageUpload component (it needs to be defined as a form component for this to work, which is more fitting)

* save cropped image to backend

* redesign slider + buttons

---------

Co-authored-by: Ole Martin Handeland <[email protected]>
* wip

* revert unneccesary change

* clean up
* add hook that handles the saved image and controllers buttons when saved
* support custom config

* support mismatch of  height and width if type is circle
* remove dropzone icon and refactor css

* replace look of placeholder

* remove unused css

* remove focus for now
* cursor change to grabbing when active in canvas

* make change image button support keyboard
* add validationmessages for imageupload size and types

* clear validationmessages on cancel
* add language texts and use them throughout the component

* change wording slightly and remove unused text

* make file-endings the same
@lassopicasso
Copy link
Contributor

Not sure if I should do changes that SnoardCloud warns about. Have used hooks in the index file for the component, and I see that is done in other components as well.

Regarding cypress, I have tested this locally and it works, but the tests fails here because of image upload isn't in the app that is tested in github actions. I can update it, but then other PR's will fail. Don't know what the best approach is.

@lassopicasso
Copy link
Contributor

/publish

Copy link
Contributor

github-actions bot commented Oct 3, 2025

PR release:

  • <link rel="stylesheet" type="text/css" href="https://altinncdn.no/toolkits/altinn-app-frontend/4.22.0-pr.2600.image-upload-component.20c479ce/altinn-app-frontend.css">
  • <script src="https://altinncdn.no/toolkits/altinn-app-frontend/4.22.0-pr.2600.image-upload-component.20c479ce/altinn-app-frontend.js"></script>

⚙️ Building...
✅ Done!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (8)
src/layout/ImageUpload/imageUploadUtils.ts (4)

34-45: Use naturalWidth/naturalHeight and guard against zero.

The function uses image.width/image.height (CSS pixels) instead of image.naturalWidth/image.naturalHeight (actual image pixels), which can cause incorrect calculations. Also needs guards against zero dimensions to avoid Infinity or NaN in clamp calculations.

Apply this diff to fix:

 export function constrainToArea({ image, zoom, position, cropArea }: ConstrainToAreaParams): Position {
-  const scaledWidth = image.width * zoom;
-  const scaledHeight = image.height * zoom;
+  const iw = image.naturalWidth || image.width || 1;
+  const ih = image.naturalHeight || image.height || 1;
+  const scaledWidth = iw * zoom;
+  const scaledHeight = ih * zoom;

   const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / 2 : 0;
   const clampY = scaledHeight > cropArea.height ? (scaledHeight - cropArea.height) / 2 : 0;

   const newX = Math.max(-clampX, Math.min(position.x, clampX));
   const newY = Math.max(-clampY, Math.min(position.y, clampY));

   return { x: newX, y: newY };
 }

Based on past review comments.


54-61: Use naturalWidth/naturalHeight for pixel-accurate placement.

Same issue as constrainToArea: should use naturalWidth/naturalHeight with zero guards for accurate canvas calculations.

Apply this diff:

 export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
-  const scaledWidth = img.width * zoom;
-  const scaledHeight = img.height * zoom;
+  const iw = img.naturalWidth || img.width || 1;
+  const ih = img.naturalHeight || img.height || 1;
+  const scaledWidth = iw * zoom;
+  const scaledHeight = ih * zoom;
   const imgX = (canvas.width - scaledWidth) / 2 + position.x;
   const imgY = (canvas.height - scaledHeight) / 2 + position.y;

   return { imgX, imgY, scaledWidth, scaledHeight };
 };

Based on past review comments.


98-115: Guard zoom calculations against invalid inputs and clamp outputs.

Math.log with non-positive values yields -Infinity or NaN. The functions lack guards for minZoom/maxZoom ≤ 0 and value ≤ 0 in logToNormalZoom. Also, the normalized result should be clamped to [0, 100].

Apply this diff:

 function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
+  if (minZoom <= 0 || maxZoom <= 0) {
+    throw new Error('minZoom and maxZoom must be > 0');
+  }
   const logMin = Math.log(minZoom);
   const logMax = Math.log(maxZoom);
   return { logScale: (logMax - logMin) / 100, logMin };
 }

 export function normalToLogZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
-  return Math.exp(logMin + logScale * value);
+  const zoom = Math.exp(logMin + logScale * value);
+  return Math.min(maxZoom, Math.max(minZoom, zoom));
 }

 export function logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
   if (logScale === 0) {
     return 0;
   }
-  return (Math.log(value) - logMin) / logScale;
+  const safeValue = Math.max(value, Number.EPSILON);
+  const normalized = (Math.log(safeValue) - logMin) / logScale;
+  return Math.min(100, Math.max(0, normalized));
 }

Based on past review comments.


118-119: Use naturalWidth/naturalHeight and guard against zero in calculateMinZoom.

Same issue: should use naturalWidth/naturalHeight and guard against zero dimensions to avoid division by zero or Infinity.

Apply this diff:

-export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) =>
-  Math.max(cropArea.width / img.width, cropArea.height / img.height);
+export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => {
+  const iw = img.naturalWidth || img.width || 1;
+  const ih = img.naturalHeight || img.height || 1;
+  return Math.max(cropArea.width / iw, cropArea.height / ih);
+};

Based on past review comments.

src/layout/ImageUpload/ImageDropzone.tsx (1)

19-26: Fix ARIA: move dragLabelId to labelId prop.

dragLabelId should be passed to Dropzone via the labelId prop (for aria-labelledby) rather than included in describedBy (for aria-describedby). This ensures proper semantic ARIA relationships.

Apply this diff:

-  const ariaDescribedBy = [descriptionId, dragLabelId, formatLabelId].filter(Boolean).join(' ');
+  const ariaDescribedBy = [descriptionId, formatLabelId].filter(Boolean).join(' ') || undefined;

And add labelId={dragLabelId} to the Dropzone props (see next comment).

Based on past review comments.

src/layout/ImageUpload/ImageCropper.tsx (3)

21-24: Add readOnly prop to support form state.

The component lacks a readOnly prop, so the upload UI can't be disabled based on form state (e.g., when viewing submitted data).

Apply this diff:

 interface ImageCropperProps {
   baseComponentId: string;
   cropArea: CropArea;
+  readOnly?: boolean;
 }

Then thread readOnly through to ImageDropzone and disable interactions in ImageCanvas/ImageControllers when true.

Based on past review comments.


91-112: Fix memory leak: clean up Image and FileReader on unmount or new upload.

The Image and FileReader created in handleFileUpload are not cleaned up if the component unmounts or a new file is uploaded while loading. This causes memory leaks and potential stale callbacks.

Apply this diff:

+  const loadingImageRef = useRef<HTMLImageElement | null>(null);
+  const readerRef = useRef<FileReader | null>(null);
+
   const handleFileUpload = (file: File) => {
     const validationErrors = validateFile(file);
     setValidationErrors(validationErrors);
     if (validationErrors.length > 0) {
       return;
     }

+    // Clean up any previous loading
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+      loadingImageRef.current = null;
+    }
+    if (readerRef.current) {
+      readerRef.current.abort();
+      readerRef.current = null;
+    }
+
     const reader = new FileReader();
+    readerRef.current = reader;
     reader.onload = (event) => {
       const result = event.target?.result;

       if (typeof result === 'string') {
         const img = new Image();
+        loadingImageRef.current = img;
         img.id = file.name;
         img.onload = () => {
+          if (loadingImageRef.current === img) {
             updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+            loadingImageRef.current = null;
+          }
         };
         img.src = result;
       }
     };
     reader.readAsDataURL(file);
   };
+
+  // Clean up on unmount
+  useEffect(() => {
+    return () => {
+      if (loadingImageRef.current) {
+        loadingImageRef.current.onload = null;
+        loadingImageRef.current.src = '';
+      }
+      if (readerRef.current) {
+        readerRef.current.abort();
+      }
+    };
+  }, []);

Based on past review comments.


114-143: Add error handling for toBlob failures.

The toBlob callback only handles null blob with an early return but doesn't provide user feedback or logging when blob creation fails.

Apply this diff:

     cropCanvas.toBlob((blob) => {
       if (!blob) {
+        console.error('Failed to create image blob');
+        setValidationErrors([{ key: 'image_upload_component.error_save_failed' }]);
         return;
       }
       const fileName = img.id;
       const imageFile = new File([blob], fileName, { type: 'image/png' });
       saveImage(imageFile);
       setValidationErrors(null);
     }, 'image/png');

Note: You'll need to add the error translation key to the language files.

Based on past review comments.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 221082c and 20c479c.

📒 Files selected for processing (7)
  • src/language/texts/en.ts (1 hunks)
  • src/language/texts/nb.ts (1 hunks)
  • src/language/texts/nn.ts (1 hunks)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/language/texts/nn.ts
  • src/layout/ImageUpload/ImageControllers.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/language/texts/nb.ts
  • src/layout/ImageUpload/ImageDropzone.tsx
  • src/layout/ImageUpload/imageUploadUtils.ts
  • src/language/texts/en.ts
  • src/layout/ImageUpload/ImageCropper.tsx
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageDropzone.tsx (4)
src/app-components/Dropzone/Dropzone.tsx (2)
  • IDropzoneProps (15-27)
  • Dropzone (31-99)
src/hooks/useDeviceWidths.ts (1)
  • useIsMobileOrTablet (33-35)
src/components/label/Label.tsx (1)
  • getDescriptionId (135-141)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (9)
  • CropArea (8-8)
  • Position (1-1)
  • ErrorTypes (122-122)
  • calculateMinZoom (118-119)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • validateFile (123-144)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (19-59)
src/app-components/Card/Card.tsx (1)
  • AppCard (20-54)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (24-148)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Install
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (5)
src/language/texts/nb.ts (1)

212-219: LGTM — Translation keys added correctly.

The eight new Norwegian translation keys for the image upload component are well-formed and consistent with the existing translation structure.

src/language/texts/en.ts (1)

211-219: LGTM — English translations added correctly.

The translation keys are clear and align with the Norwegian translations. The error messages and UI labels are user-friendly.

src/layout/ImageUpload/imageUploadUtils.ts (2)

10-25: LGTM — Crop area logic is correct.

The function properly defaults to a 250×250 circle crop and enforces square dimensions for circular crops by using the minimum dimension.


121-144: LGTM — File validation logic is correct.

The validation properly checks file size (10MB limit) and file extensions using case-insensitive suffix matching. Error handling is clear and user-friendly.

src/layout/ImageUpload/ImageCropper.tsx (1)

176-209: LGTM — AppCard usage is correct.

The component properly renders AppCard with ImageCanvas as media and ImageControllers as children. The previous ref forwarding issue has been resolved.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)

121-122: Consider exporting MAX_FILE_SIZE_MB for better maintainability.

The validation limit is hard-coded here but not exposed. If the limit changes, both this constant and any related translation strings must be updated. Exporting MAX_FILE_SIZE_MB (and optionally MAX_FILE_SIZE_BYTES) allows the UI to dynamically display the limit in error messages and documentation.

Apply this diff:

-const MAX_FILE_SIZE_MB = 10;
-const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
+export const MAX_FILE_SIZE_MB = 10;
+export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 20c479c and 019ea8f.

📒 Files selected for processing (1)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/imageUploadUtils.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install
  • GitHub Check: Analyze (javascript)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 019ea8f and d0993c6.

📒 Files selected for processing (7)
  • src/language/texts/en.ts (1 hunks)
  • src/language/texts/nb.ts (1 hunks)
  • src/language/texts/nn.ts (1 hunks)
  • src/layout/ImageUpload/ImageUploadSummary2.module.css (1 hunks)
  • src/layout/ImageUpload/ImageUploadSummary2.tsx (1 hunks)
  • src/layout/Option/OptionSummary.tsx (1 hunks)
  • src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/layout/Option/OptionSummary.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/layout/ImageUpload/ImageUploadSummary2.tsx
  • src/language/texts/nn.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx
  • src/language/texts/nb.ts
  • src/language/texts/en.ts
**/*.module.css

📄 CodeRabbit inference engine (CLAUDE.md)

Use CSS Modules for component styling and follow existing patterns in *.module.css files

Files:

  • src/layout/ImageUpload/ImageUploadSummary2.module.css
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Install
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (4)
src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx (2)

15-15: LGTM! Type broadening enables richer summary content.

The change from string to string | React.ReactNode correctly supports displaying complex content (like images) in summaries, which aligns with the ImageUpload feature.


47-47: Verify falsy check behavior with ReactNode edge cases.

With displayData now accepting React.ReactNode, the falsy check !displayData on lines 47 and 55 may produce unexpected results for edge cases:

  • null: valid ReactNode, currently treated as empty (likely desired)
  • 0: valid ReactNode that renders "0", currently treated as empty (may be unintended)
  • false: valid ReactNode, currently treated as empty (likely desired)

For typical use cases (strings, JSX elements), the logic works correctly. However, if numeric values like 0 are ever displayed in summaries, they would incorrectly show the empty field text.

Consider this more explicit check if numeric zero should be treated as valid content:

-          [classes.emptyValue]: !displayData,
+          [classes.emptyValue]: displayData === undefined || displayData === null || displayData === '',
-          {!displayData && <Lang id={emptyFieldText ?? 'general.empty_summary'} />}
+          {(displayData === undefined || displayData === null || displayData === '') && <Lang id={emptyFieldText ?? 'general.empty_summary'} />}

Also applies to: 55-56

src/language/texts/nb.ts (1)

212-217: LGTM!

The Norwegian translations are clear, accurate, and properly aligned with the component's functionality (10MB limit, supported file formats).

Also applies to: 219-220

src/language/texts/en.ts (1)

211-220: LGTM!

The English translations are clear, concise, and accurately reflect the component's constraints (10MB limit, supported file formats).

@lassopicasso
Copy link
Contributor

/publish

Copy link
Contributor

github-actions bot commented Oct 7, 2025

PR release:

  • <link rel="stylesheet" type="text/css" href="https://altinncdn.no/toolkits/altinn-app-frontend/4.22.0-pr.2628.image-upload-component.057189ad/altinn-app-frontend.css">
  • <script src="https://altinncdn.no/toolkits/altinn-app-frontend/4.22.0-pr.2628.image-upload-component.057189ad/altinn-app-frontend.js"></script>

⚙️ Building...
✅ Done!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (4)
src/layout/ImageUpload/imageUploadUtils.ts (4)

34-45: Use natural image dimensions; handle zero to avoid Infinity/NaN.

This issue was flagged in a previous review: HTMLImageElement.width/height returns CSS pixels, but pixel-accurate canvas calculations require naturalWidth/naturalHeight. Additionally, guard against zero dimensions to prevent Infinity/NaN in clamp calculations.

Refer to the previous review comment for the complete fix.


54-61: Use natural image dimensions; handle zero to avoid Infinity/NaN.

This function has the same issue flagged in the previous review: use naturalWidth/naturalHeight instead of width/height for accurate canvas calculations, and guard against zero dimensions.

Refer to the previous review comment for the complete fix.


98-115: Guard zoom math against invalid ranges; clamp and avoid NaN/∞.

This issue was flagged in a previous review: Math.log with non-positive minZoom/maxZoom/value yields -Infinity/NaN. The code needs input validation and output clamping to keep the slider domain stable.

Refer to the previous review comment for the complete fix.


118-119: Use natural image dimensions; handle zero to avoid Infinity/NaN.

This function has the same issue flagged in the previous review: use naturalWidth/naturalHeight instead of width/height, and guard against zero dimensions to prevent returning Infinity.

Refer to the previous review comment for the complete fix.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d0993c6 and 057189a.

📒 Files selected for processing (9)
  • src/language/texts/en.ts (1 hunks)
  • src/language/texts/nb.ts (2 hunks)
  • src/language/texts/nn.ts (2 hunks)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadSummary2.test.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.test.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/language/texts/nn.ts
  • src/layout/ImageUpload/ImageUploadSummary2.test.tsx
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/ImageControllers.tsx
  • src/layout/ImageUpload/ImageDropzone.tsx
  • src/language/texts/nb.ts
  • src/layout/ImageUpload/imageUploadUtils.ts
  • src/layout/ImageUpload/imageUploadUtils.test.tsx
  • src/language/texts/en.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

In tests, use renderWithProviders from src/test/renderWithProviders.tsx to supply required form layout context

Files:

  • src/layout/ImageUpload/imageUploadUtils.test.tsx
🧬 Code graph analysis (4)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (8)
  • CropArea (8-8)
  • Position (1-1)
  • calculateMinZoom (118-119)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • validateFile (121-144)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/app-components/Card/Card.tsx (1)
  • AppCard (20-54)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (25-155)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/imageUploadUtils.ts (3)
  • normalToLogZoom (104-107)
  • isAnimationFile (146-149)
  • logToNormalZoom (109-115)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageDropzone.tsx (4)
src/app-components/Dropzone/Dropzone.tsx (2)
  • IDropzoneProps (15-27)
  • Dropzone (31-99)
src/hooks/useDeviceWidths.ts (1)
  • useIsMobileOrTablet (33-35)
src/components/label/Label.tsx (1)
  • getDescriptionId (135-141)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/imageUploadUtils.test.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (10)
  • getCropArea (10-25)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
  • normalToLogZoom (104-107)
  • logToNormalZoom (109-115)
  • calculateMinZoom (118-119)
  • validateFile (121-144)
  • isAnimationFile (146-149)
🔇 Additional comments (6)
src/layout/ImageUpload/imageUploadUtils.ts (6)

1-25: LGTM! Clean type definitions and getCropArea logic.

The type definitions are precise, and the getCropArea function correctly enforces the circle constraint (width = height) by using the minimum dimension when the type is Circle.


66-70: LGTM! Correct centering calculation.

The function correctly centers the crop area within the canvas.


79-87: LGTM! Correct canvas drawing for both crop shapes.

The function properly handles both circle (using arc) and square (using rect) crop areas.


121-144: LGTM! File validation logic is correct.

The validation correctly checks:

  • File presence
  • Size limit (10 MB)
  • Image type verification

The constants MAX_FILE_SIZE_MB and MAX_FILE_SIZE_BYTES are currently scoped inside the function. If these need to be referenced externally (as suggested by the AI summary), consider exporting them at the module level.


146-149: LGTM! Animation detection logic is correct.

The function properly detects animation mime types with case-insensitive matching.


1-1: Note: Past review comment about getAcceptedFiles doesn't apply to this file.

One of the past review comments mentions a getAcceptedFiles function with accept string validation issues. This function is not present in imageUploadUtils.ts. The comment may refer to a different file or the function may have been moved elsewhere.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (4)
src/layout/ImageUpload/ImageCropper.tsx (2)

99-114: Clean up pending Image/FileReader to avoid leaks and stale callbacks
When replacing files or unmounting mid‑load, the previous Image can continue loading. Track and cancel previous loaders; guard onload against stale updates.

+  const loadingImageRef = useRef<HTMLImageElement | null>(null);
+  const fileReaderRef = useRef<FileReader | null>(null);
   const handleFileUpload = (file: File) => {
     const validationErrors = validateFile(file);
     setValidationErrors(validationErrors);
     if (validationErrors.length > 0) {
       return;
     }
-
-    const reader = new FileReader();
+    // Cleanup any in-flight loaders
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+      loadingImageRef.current = null;
+    }
+    if (fileReaderRef.current && 'readyState' in fileReaderRef.current) {
+      try { fileReaderRef.current.abort?.(); } catch {}
+    }
+    const reader = new FileReader();
+    fileReaderRef.current = reader;
     reader.onload = (event) => {
       const result = event.target?.result;
 
       if (typeof result === 'string') {
         const img = new Image();
+        loadingImageRef.current = img;
         img.id = file.name;
         imageTypeRef.current = file.type;
         img.onload = () => {
-          updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+          if (loadingImageRef.current === img) {
+            updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+            loadingImageRef.current = null;
+          }
         };
         img.src = result;
       }
     };
     reader.readAsDataURL(file);
   };
+  React.useEffect(() => () => {
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+    }
+    try { fileReaderRef.current?.abort?.(); } catch {}
+  }, []);

136-144: Handle canvas.toBlob failure and report to user

If toBlob returns null, provide feedback; also wrap File creation to catch exceptions.

-    cropCanvas.toBlob((blob) => {
-      if (!blob) {
-        return;
-      }
-      const fileName = img.id;
-      const imageFile = new File([blob], fileName, { type: 'image/png' });
-      saveImage(imageFile);
-      setValidationErrors(null);
-    }, 'image/png');
+    cropCanvas.toBlob((blob) => {
+      if (!blob) {
+        console.error('Failed to create image blob');
+        setValidationErrors(['image_upload_component.error_generate_image_failed']);
+        return;
+      }
+      try {
+        const fileName = img.id;
+        const imageFile = new File([blob], fileName, { type: 'image/png' });
+        saveImage(imageFile);
+        setValidationErrors(null);
+      } catch (e) {
+        console.error('Failed to save image', e);
+        setValidationErrors(['image_upload_component.error_generate_image_failed']);
+      }
+    }, 'image/png');
src/layout/ImageUpload/ImageControllers.tsx (2)

45-53: Fix zoom slider normalization (broken zoom and thumb position)

Utils expect 0–1, slider is 0–100. Divide input by 100; multiply value by 100.

   const handleSliderZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
     const logarithmicZoomValue = normalToLogZoom({
-      value: Number.parseFloat(e.target.value),
+      value: Number.parseFloat(e.target.value) / 100,
       minZoom,
       maxZoom,
     });
@@
           <input
             id={zoomId}
             type='range'
             min='0'
             max='100'
             step='0.5'
-            value={logToNormalZoom({ value: zoom, minZoom, maxZoom })}
+            value={logToNormalZoom({ value: zoom, minZoom, maxZoom }) * 100}
             onChange={handleSliderZoom}
             className={classes.zoomSlider}
           />

Also applies to: 89-96


99-109: Respect readOnly and add accessible name for Reset; simplify “Change” to a real button

  • Disable Save/Reset/Change/Cancel when readOnly.
  • Provide aria-label for the icon-only Reset button.
  • Avoid asChild with a Label; use a real Button that clicks the input ref so disabled works.
    [accessibility]
-          <Button
-            onClick={onReset}
-            variant='tertiary'
-            icon={true}
-          >
-            <ArrowUndoIcon
-              title={langAsString('image_upload_component.reset')}
-              className={classes.resetButton}
-            />
-          </Button>
+          <Button
+            onClick={onReset}
+            variant='tertiary'
+            icon
+            aria-label={langAsString('image_upload_component.button_reset')}
+            disabled={readOnly}
+          >
+            <ArrowUndoIcon className={classes.resetButton} />
+          </Button>
@@
         <Button
           onClick={onSave}
           data-size='sm'
           data-color='accent'
+          disabled={readOnly}
         >
           <Lang id='image_upload_component.button_save' />
         </Button>
@@
-        <Button
-          asChild
-          data-size='sm'
-          variant='secondary'
-          data-color='accent'
-          tabIndex={0}
-          onKeyDown={(e) => {
-            if (e.key === 'Enter' || e.key === ' ') {
-              e.preventDefault();
-              fileInputRef?.current?.click();
-            }
-          }}
-        >
-          <Label htmlFor={inputId}>
-            <UploadIcon />
-            <Lang id='image_upload_component.button_change' />
-          </Label>
-        </Button>
+        <Button
+          data-size='sm'
+          variant='secondary'
+          data-color='accent'
+          onClick={() => fileInputRef.current?.click()}
+          disabled={readOnly}
+        >
+          <UploadIcon />
+          <Lang id='image_upload_component.button_change' />
+        </Button>
         <Button
           data-size='sm'
           variant='tertiary'
           onClick={onCancel}
           data-color='accent'
+          disabled={readOnly}
         >
           <Lang id='general.cancel' />
         </Button>

Also applies to: 112-118, 128-145, 146-153

🧹 Nitpick comments (3)
src/layout/ImageUpload/ImageUploadComponent.tsx (2)

33-36: Remove unnecessary type cast on getCropArea input

The object passed to getCropArea matches its expected params; the as CropArea cast is unnecessary and misleads types. Drop the cast.
[typescript] [As per coding guidelines]

-          cropArea={getCropArea({ width: cropWidth, height: cropHeight, type: cropShape } as CropArea)}
+          cropArea={getCropArea({ width: cropWidth, height: cropHeight, type: cropShape })}

21-36: Verify that Label htmlFor matches the interactive control id

Label uses htmlFor={id}, while ImageDropzone sets id={baseComponentId}. In repeating groups, these can differ (indexed id). Ensure the same id is used for both to keep label association and a11y intact (pass the actual control id down to ImageCropper/ImageDropzone if needed).

If misaligned, prefer passing the indexed id from useLabel/useIndexedId to Dropzone.

src/layout/ImageUpload/ImageControllers.tsx (1)

123-125: Align accepted file types with policy (extensions/types)

Restrict accept to the allowed types from the PR: png, jpg/jpeg, heic, webp.

-          accept='image/*'
+          accept='.png,.jpg,.jpeg,.heic,.webp,image/png,image/jpeg,image/heic,image/webp'

Please confirm HEIC support in your target browsers/environments.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 057189a and 5f0dbcf.

📒 Files selected for processing (4)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.test.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/layout/ImageUpload/ImageUploadComponent.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/ImageControllers.tsx
  • src/layout/ImageUpload/ImageUploadComponent.tsx
🧬 Code graph analysis (3)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (8)
  • CropArea (8-8)
  • Position (1-1)
  • calculateMinZoom (118-119)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • validateFile (121-144)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/app-components/Card/Card.tsx (1)
  • AppCard (20-54)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-157)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/imageUploadUtils.ts (3)
  • normalToLogZoom (104-107)
  • isAnimationFile (146-149)
  • logToNormalZoom (109-115)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageUploadComponent.tsx (6)
src/layout/index.ts (1)
  • PropsFromGenericComponent (29-33)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
src/utils/layout/useLabel.tsx (1)
  • useLabel (13-72)
src/layout/ComponentStructureWrapper.tsx (1)
  • ComponentStructureWrapper (20-48)
src/layout/ImageUpload/ImageCropper.tsx (1)
  • ImageCropper (29-213)
src/layout/ImageUpload/imageUploadUtils.ts (2)
  • getCropArea (10-25)
  • CropArea (8-8)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install
  • GitHub Check: Analyze (javascript)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/layout/ImageUpload/ImageUploadComponent.tsx (1)

35-35: Consider simplifying boolean coercion.

The !!readOnly double negation is defensive but unnecessary if readOnly is already typed as boolean | undefined and ImageCropper accepts readOnly?: boolean. Simply passing readOnly would be cleaner.

Apply this diff if the types align:

-          readOnly={!!readOnly}
+          readOnly={readOnly}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f0dbcf and 50f6b68.

📒 Files selected for processing (2)
  • src/layout/FileUpload/FileUploadComponent.test.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/FileUpload/FileUploadComponent.test.tsx
  • src/layout/ImageUpload/ImageUploadComponent.tsx
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

In tests, use renderWithProviders from src/test/renderWithProviders.tsx to supply required form layout context

Files:

  • src/layout/FileUpload/FileUploadComponent.test.tsx
🧬 Code graph analysis (1)
src/layout/ImageUpload/ImageUploadComponent.tsx (6)
src/layout/index.ts (1)
  • PropsFromGenericComponent (29-33)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
src/utils/layout/useLabel.tsx (1)
  • useLabel (13-72)
src/layout/ComponentStructureWrapper.tsx (1)
  • ComponentStructureWrapper (20-48)
src/layout/ImageUpload/ImageCropper.tsx (1)
  • ImageCropper (29-213)
src/layout/ImageUpload/imageUploadUtils.ts (1)
  • getCropArea (10-25)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Install
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (2)
src/layout/FileUpload/FileUploadComponent.test.tsx (1)

476-476: LGTM! Translation refinement.

The test expectation has been correctly updated to reflect the refined Norwegian text from "let etter fil" to "finn fil". This aligns with the updated UI text in the Dropzone component.

src/layout/ImageUpload/ImageUploadComponent.tsx (1)

12-16: LGTM! readOnly now propagated correctly.

The component now correctly destructures readOnly from useItemWhenType and passes it through to ImageCropper (line 35), addressing the previous review concern.

Copy link

sonarqubecloud bot commented Oct 7, 2025

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@lassopicasso lassopicasso added the squad/utforming Issues that belongs to the named squad. label Oct 7, 2025
@framitdavid framitdavid self-assigned this Oct 9, 2025
Copy link
Contributor

@olemartinorg olemartinorg left a comment

Choose a reason for hiding this comment

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

Fantastic stuff!! 🚀 This is a solid delivery. I have some comments, but nothing major.

'image_upload_component.summary_empty': "You haven't uploaded an image",
'image_upload_component.reset': 'Reset position and zoom',
'image_upload_component.error_invalid_file_type': 'Invalid file format. Please upload an image file.',
'image_upload_component.error_file_size_exceeded': 'File size exceeds 10MB limit.',
Copy link
Contributor

Choose a reason for hiding this comment

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

I checked, and I have some JPEGs straight out of my camera that are larger than this. Is there any reason for this limit? Won't this image become way smaller after cropping and resolution-limiting anyway?

Copy link
Contributor

@lassopicasso lassopicasso Oct 9, 2025

Choose a reason for hiding this comment

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

You are correct, the cropped image that is saved becomes way smaller. But it seems like the browser doesn't handle it well when reading larger files, such as 50mbs. This happens when javascript tries to decode the full image into memory and allocate a texture in gpu memory when drawing the image in canvas that is used to display the cropping area. And during this both versions is stored temporarily.

const handleFileUpload = (file: File) => {
    const validationErrors = validateFile(file);
    setValidationErrors(validationErrors);
    if (validationErrors.length > 0) {
      return;
    }

    const reader = new FileReader();
    reader.onload = (event) => {
      const result = event.target?.result;

      if (typeof result === 'string') {
        const img = new Image();
        img.id = file.name;
        imageTypeRef.current = file.type;
        img.onload = () => {
          updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
        };
        img.src = result;
      }
    };
    reader.readAsDataURL(file);
  };

After the load. the browser keeps struggling. So we ended up setting a limit on 10mb to ensure a good user experience on different browsers. With this context in mind, profile images, we thought that most images would probably would be below 10mb. But I tested a bit more, and it seem it maybe can handle up to 20mbs as well with a bit delay at the beginning.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I see! I thought it might just be an arbitrary limit. It really comes down to your device then. Will it destroy performance even after cropping and uploading (or clearing the image), or is it permanent? If it's slow forever after I'm in favor of a limit, but otherwise maybe an alert is fine (something like the alert for animations)?

Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like it works fine after I manage to save it, but before that everything lags enormously 😄 Panning the cropping area or using zoom can be extremely slow, and one time I got a browser error. I tested with 50mb from https://examplefile.com/image/jpg.
I also tried to upload this kind of image on linkedin, which uses canvas too, and I experienced the same problem there. On linkedin the image hasn't been displayed yet after more than 5 minutes. So our solution handles it better 🥳

As you mention, it could be an good option to display an alert message similar to animations if it exceeds 10mbs (something like: "Bildet er større enn 10 MB. Du kan oppleve at redigering/nettleseren går saktere enn vanlig.").

I’m still a bit in favor of limiting the size, since the UX overall is pretty bad until the user manages to save, but I can sleep on it.

.addProperty(
new CG.prop(
'cropShape',
new CG.enum('square', 'circle')
Copy link
Contributor

Choose a reason for hiding this comment

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

The 'square' setting can also be a rectangle, so that might be a better naming. It's only a square if you set cropWidth and cropHeight to the same values, but if you specify different values you'll make a rectangle, not a square.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree, will change it.

Comment on lines +31 to +42
.addProperty(
new CG.prop(
'cropWidth',
new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Optional width of the cropping area'),
),
)
.addProperty(
new CG.prop(
'cropHeight',
new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Optional height of the cropping area'),
),
)
Copy link
Contributor

Choose a reason for hiding this comment

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

You might want to add the detail that for 'circle' only the smallest of these two actually have an effect. Thus, maybe 'circle' is not as great as a default value - since these two options have weird effects? You could potentially also add a union type that removes the gotchas in this config, such as only allowing objects:

"crop": {
  "shape": "circle",
  "diameter": 250
}
"crop": {
  "shape": "rectangle",
  "width": 250,
  "height": 250
}

Example of how to do this in config.ts:

Config.addProperty(
  new CG.prop(
    'crop',
    new CG.union(
      new CG.obj(
        new CG.prop('shape', new CG.const('circle').setTitle('Shape').setDescription('Circular cropping area')),
        new CG.prop(
          'diameter',
          new CG.num().optional({ default: 250 }).setTitle('Diameter').setDescription('Diameter of the circle'),
        ),
      ).exportAs('CropConfigCircle'),
      new CG.obj(
        new CG.prop('shape', new CG.const('rectangle').setTitle('Shape').setDescription('Rectangular cropping area')),
        new CG.prop(
          'width',
          new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Width of the rectangle'),
        ),
        new CG.prop(
          'height',
          new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Height of the rectangle'),
        ),
      ).exportAs('CropConfigRect'),
    )
      .setUnionType('discriminated')
      .optional({ default: { shape: 'circle', diameter: 250 } })
      .exportAs('CropConfig'),
  ),
)

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice suggestion! 👍 I will take a look at it, and see how we can display this in studio designer as well. For now we have a selector for shape and 2 input fields for width and height.

const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
onZoomChange(zoom - e.deltaY * 0.001);
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, I found this to be less useful than I hoped. The first scroll 'click' zooms in waay too much, but later clicks zoom in less and less. I didn't need all that finesse when looking at huge pixels, but I wanted more finesse at first.. 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, you mean it should be less zoom sensitive in the first part of the zoom scale when working with huge pixels?

Copy link
Contributor

Choose a reason for hiding this comment

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

haha, I see what you mean 😆 Here I scroll:

zoom.mp4

Copy link
Contributor

Choose a reason for hiding this comment

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

Yup, exactly!

const imageFile = new File([blob], fileName, { type: 'image/png' });
saveImage(imageFile);
setValidationErrors(null);
}, 'image/png');
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, nice! Looks very easy to add an option to upload as jpeg as well (seems supported in all major browsers). I see lots of apps have added limitations in the allowedContentTypes property for the data element type in applicationmetadata, so maybe we should check that and upload as image/jpeg if image/png is not allowed by the app. Aaaand throw an error early if none of the supported image types are allowed, I guess.

Copy link
Contributor

Choose a reason for hiding this comment

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

Good point, will look into that 🙂

Comment on lines +2 to +18
background-image:
linear-gradient(
45deg,
rgba(204, 204, 204, 0.5) 25%,
transparent 25%,
transparent 75%,
rgba(204, 204, 204, 0.5) 75%,
rgba(204, 204, 204, 0.5) 100%
),
linear-gradient(
45deg,
rgba(204, 204, 204, 0.5) 25%,
rgba(255, 255, 255, 0.5) 25%,
rgba(255, 255, 255, 0.5) 75%,
rgba(204, 204, 204, 0.5) 75%,
rgba(204, 204, 204, 0.5) 100%
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧙‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

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

hehe, i know. I'm not sure how to make that simpler 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

No need! It was pretty cool! 🥇

}

useDataModelBindingValidation(baseComponentId: string, bindings: IDataModelBindings<'ImageUpload'>): string[] {
return useFileUploaderDataBindingsValidation(baseComponentId, bindings);
Copy link
Contributor

Choose a reason for hiding this comment

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

That hook should probably also be updated to allow ImageUpload bindings as an input

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice catch!

}

renderSummary(_props: SummaryRendererProps): JSX.Element | null {
throw new Error('Method not implemented.'); // Use renderSummary2, will probably not be supported
Copy link
Contributor

Choose a reason for hiding this comment

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

Best to write that comment in the error thrown as well - the comment won't be displayed to app developers, but the exception text will

Copy link
Contributor

Choose a reason for hiding this comment

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

Indeed! 👍 Thx!

Copy link
Contributor

@framitdavid framitdavid left a comment

Choose a reason for hiding this comment

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

Solid work! Few comments to be considered.

}

.previewBackground {
background-color: #f4f5f6; /* Following does not exist in v1: var(--ds-color-neutral-background-subtle); */
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we use a different color here? If this color already exists elsewhere, we should align with that and reference a shared CSS variable/design token. If it’s specific to this component only, consider introducing a v1 component-scoped token instead of hard-coding the value.

Comment on lines +130 to +147
if (storedImage) {
return (
<div className={classes.previewBackground}>
{storedImage.uploaded ? (
<img
src={imageUrl}
alt={storedImage.data?.filename}
/>
) : (
<Spinner
aria-hidden='true'
data-size='lg'
aria-label={langAsString('general.loading')}
/>
)}
</div>
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we extract the JSX inside this if into a separate component, e.g. UploadedImagePreview?

Suggested change
if (storedImage) {
return (
<div className={classes.previewBackground}>
{storedImage.uploaded ? (
<img
src={imageUrl}
alt={storedImage.data?.filename}
/>
) : (
<Spinner
aria-hidden='true'
data-size='lg'
aria-label={langAsString('general.loading')}
/>
)}
</div>
);
}
if (storedImage) {
return <UploadedImagePreview
storedImage={storedImage}
imageUrl={imageUrl}
/>;
}

height={CANVAS_HEIGHT}
width={CANVAS_WIDTH}
className={classes.canvas}
aria-label='Image cropping area'
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should support multiple languages for this aria-label? 🤔

@@ -0,0 +1,161 @@
import React, { useCallback, useEffect } from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

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

Great work on this component! This isn’t a blocker, but it currently mixes concerns, Single Responsibility Principle. It handles rendering, event handling, drag-and-drop, zoom, keyboard navigation, and canvas drawing in one place. Consider splitting these out to improve readability and testability.

An alternative is to split responsibilities into separate modules. I’m not suggesting we scatter logic—maintaining cohesion matters—but we can keep everything under the component’s folder and factor it into focused files.

 ImageCanvas.tsx 
 ├── hooks/
 │   ├──hooks.something.ts
 ├── utils/
 │   ├── SomeUtiLS

The ImageCanvas could look like the example below. We’re not writing unit tests with Vitest or Jest right now, so most coverage sits in Cypress so its easy to test as is.

 export function ImageCanvas({
    imageRef,
    zoom,
    position,
    cropArea,
    baseComponentId,
    onPositionChange,
    onZoomChange,
    canvasRef,
  }: ImageCanvasProps) {
    const { storedImage, imageUrl } = useImageFile(baseComponentId);

    // extract canvas and zoom into custom hooks. 
    useCanvasDraw({ canvasRef, imageRef, zoom, position, cropArea });
    useZoomInteraction(canvasRef, zoom, onZoomChange);
    
    const { handlePointerDown } = useDragInteraction(canvasRef, position, onPositionChange);
    
    
    const { handleKeyDown } = useKeyboardNavigation(position, onPositionChange);
    
    // Ref. previous comment, the code below seperated the preview to explicit component.
    if (storedImage) {
      return <UploadedImagePreview storedImage={storedImage} imageUrl={imageUrl} />;
    }

    return (
      <canvas
        onPointerDown={handlePointerDown}
        onKeyDown={handleKeyDown}
        tabIndex={0}
        ref={canvasRef}
        height={CANVAS_HEIGHT}
        width={CANVAS_WIDTH}
        className={classes.canvas}
        aria-label='Image cropping area' // <-- Ref. previous comment, this should support multiple langues.
      />
    );
  }

Comment on lines +23 to +45
/* ✅ Chrome / Safari / Edge */
.zoomSlider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--ds-size-8);
height: var(--ds-size-8);
border-radius: var(--ds-border-radius-full);
border: 1px solid var(--ds-color-border-strong);
background: var(--ds-color-base-contrast-default);
cursor: pointer;
}

/* ✅ Firefox */
.zoomSlider::-moz-range-thumb {
width: var(--ds-size-8);
height: var(--ds-size-8);
border-radius: var(--ds-border-radius-full);
border: 1px solid var(--ds-color-border-strong);
background: var(--ds-color-base-contrast-default);
cursor: pointer;
}

/* ✅ Firefox track (to override default blue bar) */
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to have emojis in our comments here? 😊

Comment on lines +135 to +138
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInputRef?.current?.click();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: should this be extracted to a function instead of inline? 😊

box-sizing: border-box;
}

.blueUnderLine {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: "blue" is more implementation detail, not that important for a classname?

Suggested change
.blueUnderLine {
.underLine {

Comment on lines +54 to +87
export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
const scaledWidth = img.width * zoom;
const scaledHeight = img.height * zoom;
const imgX = (canvas.width - scaledWidth) / 2 + position.x;
const imgY = (canvas.height - scaledHeight) / 2 + position.y;

return { imgX, imgY, scaledWidth, scaledHeight };
};

type CropAreaPlacementParams = { canvas: HTMLCanvasElement; cropArea: CropArea };
type CropAreaPlacement = { cropAreaX: number; cropAreaY: number };

export const cropAreaPlacement = ({ canvas, cropArea }: CropAreaPlacementParams): CropAreaPlacement => {
const cropAreaX = (canvas.width - cropArea.width) / 2;
const cropAreaY = (canvas.height - cropArea.height) / 2;
return { cropAreaX, cropAreaY };
};

interface DrawCropAreaParams {
ctx: CanvasRenderingContext2D;
cropArea: CropArea;
x?: number;
y?: number;
}

export function drawCropArea({ ctx, x = 0, y = 0, cropArea }: DrawCropAreaParams) {
const { width, height, type } = cropArea;
ctx.beginPath();
if (type === CropForm.Circle) {
ctx.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
} else {
ctx.rect(x, y, width, height);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we extract the magic numbers into named constants? I notice the value 2 appears multiple times—do these instances represent the same domain rule, or are they unrelated?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backport-ignore This PR is a new feature and should not be cherry-picked onto release branches kind/product-feature Pull requests containing new features squad/utforming Issues that belongs to the named squad.
Projects
Status: 🔎 In review
Development

Successfully merging this pull request may close these issues.

4 participants