Skip to content

Conversation

Magnusrm
Copy link
Contributor

@Magnusrm Magnusrm commented Sep 2, 2025

Description

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
    • Has been added/updated
    • No functionality has been changed/added, so no documentation is needed
    • I will do that later/have created an issue
  • 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: responsive dropzone, canvas-based cropper (circle/square), pan/zoom controls, change/save/delete flows, client-side type & 10MB validation, save/delete hook, configurable crop settings, and summary rendering.
  • Style

    • Adjusted media-card vertical spacing and new visuals/styles for image canvas, controllers, and dropzone; updated i18n texts (en/nb/nn).
  • API

    • Component APIs extended to allow custom styling hooks and drag-active/accepted-format handling for upload controls.
  • Tests

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

Copy link

coderabbitai bot commented Sep 2, 2025

📝 Walkthrough

Walkthrough

Adds a new ImageUpload feature (components, hooks, utils, styles, tests, and CodeGen config), updates Dropzone props/accept handling, extends FileUpload summary recognition, adjusts Card/Cards styling and AppCard props, and adds i18n keys for image upload.

Changes

Cohort / File(s) Summary of changes
Card styling & API
src/app-components/Card/Card.module.css, src/app-components/Card/Card.tsx, src/layout/Cards/Cards.module.css, src/layout/Cards/Cards.tsx
Removed margin-bottom: -7px from .mediaCard in Card.module.css; added .mediaCard in Cards.module.css and applied it in Cards.tsx; extended AppCard props with className?: string and ref?: React.Ref<HTMLDivElement> and forwarded them.
Dropzone updates
src/app-components/Dropzone/Dropzone.module.css, src/app-components/Dropzone/Dropzone.tsx
Appended duplicate .active and .validationError CSS rules; renamed IDropzoneComponentPropsIDropzoneProps; made onClick optional; added onDragActiveChange?: (isDragActive: boolean) => void; switched useDropzone accept handling to acceptedFiles; added effect to call onDragActiveChange.
ImageUpload core components & wiring
src/layout/ImageUpload/ImageCanvas.tsx, src/layout/ImageUpload/ImageControllers.tsx, src/layout/ImageUpload/ImageCropper.tsx, src/layout/ImageUpload/ImageDropzone.tsx, src/layout/ImageUpload/ImageUploadComponent.tsx, src/layout/ImageUpload/index.tsx, src/layout/ImageUpload/config.ts
Added ImageUpload feature: canvas-based ImageCanvas, ImageControllers, ImageCropper flow, ImageDropzone, layout ImageUploadComponent, CodeGen Config, and ImageUpload class integrating render/display/binding helpers.
ImageUpload styles
src/layout/ImageUpload/ImageCanvas.module.css, src/layout/ImageUpload/ImageControllers.module.css, src/layout/ImageUpload/ImageDropzone.module.css
New CSS modules for canvas behavior, controller layout/slider styling, and dropzone placeholder/drag visuals.
ImageUpload utilities & hook
src/layout/ImageUpload/imageUploadUtils.ts, src/layout/ImageUpload/useImageFile.tsx
New utilities/types for crop area, placement, drawing, zoom math, file validation, accepted-files mapping, plus useImageFile(baseComponentId) exposing storedImage, imageUrl, saveImage, deleteImage.
Language texts
src/language/texts/en.ts, src/language/texts/nb.ts, src/language/texts/nn.ts
Added image upload translation keys (change/delete/save, zoom/reset, invalid type/size, valid file types) in English, Bokmål, and Nynorsk.
Tests & shared expression / e2e helpers
src/layout/ImageUpload/ImageUploadComponent.test.tsx, src/features/expressions/shared-tests/functions/displayValue/type-ImageUpload.json, test/e2e/integration/component-library/image-upload.ts, test/e2e/support/apps/component-library/uploadImageAndVerify.ts
Added unit tests for ImageUploadComponent (render, delete, size validation), shared displayValue test for ImageUpload, e2e tests for upload/crop/save/delete, and Cypress helpers to create/upload test image files and validate canvas pixels.
FileUpload summary integration
src/layout/FileUpload/FileUploadTable/FileTable.tsx
Extended useItemWhenType usage to include 'ImageUpload' so ImageUpload fields are recognized by FileTable summary logic.
ImageUpload summary component
src/layout/ImageUpload/ImageUploadSummary2.tsx
Added ImageUploadSummary2 to render Summary2 layout (attachments listing, empty states, mobile/pdf handling).
FileUpload accepted files change
src/layout/FileUpload/FileUploadComponent.tsx
Added mapping from validFileEndingsfilesToAccept (extension→MIME) and pass acceptedFiles to Dropzone, replacing prior hasCustomFileEndings/validFileEndings usage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Magnusrm

Pre-merge checks and finishing touches

❌ Failed checks (3 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The pull request title “Feat/image-upload-component” uses a prefix and slash convention instead of a concise, descriptive sentence and does not clearly summarize the main change in a readable format. Please update the title to a clear, self-contained sentence such as “Add Image Upload component” so that it succinctly conveys the primary feature being introduced.
Description Check ⚠️ Warning The pull request description remains largely unfilled from the template, with the “## Description” section left as a placeholder comment, the Related Issue(s) placeholder not replaced with an actual issue number, and no non-technical summary or documentation links provided. Fill in the “## Description” section with a clear, non-technical summary of the changes, replace the placeholder in “Related Issue(s)” with the actual issue number, and include or link any relevant user documentation to fully satisfy the repository’s template requirements.
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.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • 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
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 (2)
src/layout/ImageUpload/ImageCanvas.tsx (2)

98-101: Redraw when image finishes loading to avoid blank canvas

Current draw runs before image completes; add a load listener.

   useEffect(() => {
     draw();
   }, [draw, canvasWidth]);
+
+  // Redraw once the image finishes loading
+  useEffect(() => {
+    const img = imageRef.current;
+    if (!img) return;
+    const onLoad = () => draw();
+    if (!img.complete) {
+      img.addEventListener('load', onLoad, { once: true });
+    }
+    return () => img.removeEventListener('load', onLoad);
+  }, [imageRef, draw]);

103-125: Harden drag: filter pointerId, clamp movement, and ensure cleanup

Prevents multi-pointer interference and unconstrained panning; adds better cleanup.

   const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
     e.preventDefault();
     const canvas = canvasRef.current;
     if (!canvas) {
       return;
     }
     canvas.setPointerCapture(e.pointerId);
     const startDrag = { x: e.clientX - position.x, y: e.clientY - position.y };
+    const activePointerId = e.pointerId;
 
-    const handlePointerMove = (moveEvent: PointerEvent) => {
-      onPositionChange({
-        x: moveEvent.clientX - startDrag.x,
-        y: moveEvent.clientY - startDrag.y,
-      });
-    };
-    const handlePointerUp = () => {
-      canvas.releasePointerCapture(e.pointerId);
-      document.removeEventListener('pointermove', handlePointerMove);
-      document.removeEventListener('pointerup', handlePointerUp);
-    };
-    document.addEventListener('pointermove', handlePointerMove);
-    document.addEventListener('pointerup', handlePointerUp);
+    const handlePointerMove = (moveEvent: PointerEvent) => {
+      if (moveEvent.pointerId !== activePointerId) return;
+      const next = {
+        x: moveEvent.clientX - startDrag.x,
+        y: moveEvent.clientY - startDrag.y,
+      };
+      const img = imageRef.current;
+      onPositionChange(img ? constrainToArea({ image: img, zoom, position: next, cropArea }) : next);
+    };
+    const handlePointerUp = (upEvent: PointerEvent) => {
+      if (upEvent.pointerId !== activePointerId) return;
+      canvas.releasePointerCapture(activePointerId);
+      document.removeEventListener('pointermove', handlePointerMove);
+    };
+    document.addEventListener('pointermove', handlePointerMove, { passive: true });
+    document.addEventListener('pointerup', handlePointerUp, { once: true });
+    canvas.addEventListener('lostpointercapture', handlePointerUp, { once: true });
   };
🧹 Nitpick comments (7)
src/layout/ImageUpload/ImageCanvas.tsx (7)

7-7: Import constrainToArea to enable clamped panning

Needed for both pointer and keyboard panning constraints.

-import { cropAreaPlacement, drawCropArea, imagePlacement } from 'src/layout/ImageUpload/imageUploadUtils';
+import { cropAreaPlacement, drawCropArea, imagePlacement, constrainToArea } from 'src/layout/ImageUpload/imageUploadUtils';

If constrainToArea isn’t available, I can generate a minimal helper for you.


24-28: Clamp zoom to sane bounds to avoid negative/oversized scales

Prevents broken rendering and numeric drift.

 const CANVAS_HEIGHT = 320;
 const CANVAS_WIDTH = 800;
 const MOBILE_CANVAS_WIDTH = 400;
 const CONTAINER_WIDTH = 450;
+const MIN_ZOOM = 0.1;
+const MAX_ZOOM = 4;
   const handleWheel = useCallback(
     (e: WheelEvent) => {
       e.preventDefault();
-      onZoomChange(zoom - e.deltaY * 0.001);
+      const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom - e.deltaY * 0.001));
+      onZoomChange(next);
     },
     [zoom, onZoomChange],
   );

If product requires the image to always cover the crop area, we should compute minZoom dynamically from image/crop sizes. Want me to wire that?

Also applies to: 128-134


147-160: Constrain keyboard panning to keep crop area covered

Reuses the same clamp as pointer drag.

   const handleKeyDown = (e: React.KeyboardEvent<HTMLCanvasElement>) => {
     const moveAmount = 10;
-    const keyMap: Record<string, () => void> = {
-      ArrowUp: () => onPositionChange({ ...position, y: position.y - moveAmount }),
-      ArrowDown: () => onPositionChange({ ...position, y: position.y + moveAmount }),
-      ArrowLeft: () => onPositionChange({ ...position, x: position.x - moveAmount }),
-      ArrowRight: () => onPositionChange({ ...position, x: position.x + moveAmount }),
-    };
+    const move = (dx: number, dy: number) => {
+      const img = imageRef.current;
+      const next = { x: position.x + dx, y: position.y + dy };
+      onPositionChange(img ? constrainToArea({ image: img, zoom, position: next, cropArea }) : next);
+    };
+    const keyMap: Record<string, () => void> = {
+      ArrowUp: () => move(0, -moveAmount),
+      ArrowDown: () => move(0, moveAmount),
+      ArrowLeft: () => move(-moveAmount, 0),
+      ArrowRight: () => move(moveAmount, 0),
+    };

172-176: Spinner a11y: aria-hidden conflicts with aria-label

Expose status to AT; remove aria-hidden and set role/status.

-          <Spinner
-            aria-hidden='true'
-            data-size='lg'
-            aria-label={langAsString('general.loading')}
-          />
+          <Spinner
+            data-size='lg'
+            role='status'
+            aria-live='polite'
+            aria-label={langAsString('general.loading')}
+          />

183-192: Canvas a11y: declare role and shortcuts

Improves discoverability of keyboard support.

     <canvas
+      role='img'
       onPointerDown={handlePointerDown}
       onKeyDown={handleKeyDown}
       tabIndex={0}
       ref={canvasRef}
       height={CANVAS_HEIGHT}
       width={canvasWidth}
       className={classes.canvas}
       aria-label='Image cropping area'
+      aria-keyshortcuts='ArrowUp,ArrowDown,ArrowLeft,ArrowRight'
     />

61-72: Crisp rendering on high‑DPI displays

Scale the backing store to devicePixelRatio; keep CSS size unchanged.

   const draw = useCallback(() => {
     const canvas = canvasRef.current;
     const ctx = canvas?.getContext('2d');
     const img = imageRef.current;
 
     if (!canvas || !img?.complete || !ctx) {
       return;
     }
 
+    // High-DPI backing store
+    const dpr = window.devicePixelRatio || 1;
+    const targetW = canvasWidth * dpr;
+    const targetH = CANVAS_HEIGHT * dpr;
+    if (canvas.width !== targetW || canvas.height !== targetH) {
+      canvas.width = targetW;
+      canvas.height = targetH;
+    }
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
     ctx.clearRect(0, 0, canvas.width, canvas.height);
       ref={canvasRef}
       height={CANVAS_HEIGHT}
       width={canvasWidth}
+      style={{ width: `${canvasWidth}px`, height: `${CANVAS_HEIGHT}px` }}
       className={classes.canvas}
-  }, [zoom, position, cropArea, imageRef, canvasRef]);
+  }, [zoom, position, cropArea, imageRef, canvasRef, canvasWidth]);

Also applies to: 183-191, 96-96


49-53: Avoid non‑null assertion in updateWidth

Minor safety/readability improvement.

-    const updateWidth = () => {
-      const width = containerRef.current!.offsetWidth > CONTAINER_WIDTH ? CANVAS_WIDTH : MOBILE_CANVAS_WIDTH;
-      setCanvasWidth(width);
-    };
+    const updateWidth = () => {
+      const el = containerRef.current;
+      if (!el) return;
+      const width = el.offsetWidth > CONTAINER_WIDTH ? CANVAS_WIDTH : MOBILE_CANVAS_WIDTH;
+      setCanvasWidth(width);
+    };
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cfd3a11 and 1f948f4.

📒 Files selected for processing (2)
  • src/layout/ImageUpload/ImageCanvas.tsx (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.module.css (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/layout/ImageUpload/ImageDropzone.module.css
🧰 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/ImageCanvas.tsx
🧬 Code graph analysis (1)
src/layout/ImageUpload/ImageCanvas.tsx (3)
src/layout/ImageUpload/imageUploadUtils.ts (5)
  • Position (1-1)
  • CropArea (10-10)
  • imagePlacement (56-63)
  • cropAreaPlacement (68-72)
  • drawCropArea (81-89)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)

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 (1)
src/layout/ImageUpload/ImageDropzone.tsx (1)

46-51: Make “Find” text activate the file input.

Wrap it in a label linked to the input id to improve keyboard/a11y.

-              <span className={classes.blueUnderLine}>
-                {' '}
-                <Lang id='form_filler.file_uploader_find' />
-              </span>
+              <label
+                className={classes.blueUnderLine}
+                htmlFor={componentId}
+              >
+                {' '}
+                <Lang id='form_filler.file_uploader_find' />
+              </label>
🧹 Nitpick comments (2)
src/layout/ImageUpload/ImageDropzone.tsx (2)

41-45: Minor semantics: prefer strong over b.

Use strong for emphasis; b is purely presentational.

-        <b id={dragLabelId}>
+        <strong id={dragLabelId}>
@@
-        </b>
+        </strong>

55-56: Optional: clarify valid types in text.

Consider passing customTextParameters to include extensions (e.g., “.jpg, .png, .gif”) for clearer guidance.

Example:

<Lang
  id='image_upload_component.valid_file_types'
  customTextParameters={{ types: VALID_FILE_ENDINGS.join(', ') }}
/>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1f948f4 and be47527.

📒 Files selected for processing (2)
  • src/app-components/Dropzone/Dropzone.tsx (4 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app-components/Dropzone/Dropzone.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/ImageDropzone.tsx
🧬 Code graph analysis (1)
src/layout/ImageUpload/ImageDropzone.tsx (4)
src/app-components/Dropzone/Dropzone.tsx (2)
  • IDropzoneProps (16-29)
  • Dropzone (33-103)
src/hooks/useDeviceWidths.ts (1)
  • useIsMobileOrTablet (33-35)
src/layout/ImageUpload/imageUploadUtils.ts (1)
  • VALID_FILE_ENDINGS (7-7)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
🔇 Additional comments (1)
src/layout/ImageUpload/ImageDropzone.tsx (1)

30-34: Good: unique id and default click behavior preserved.

Using componentId avoids collisions, and not overriding onClick keeps the file picker on click.

@Magnusrm
Copy link
Contributor Author

/publish

Copy link
Contributor

github-actions bot commented Sep 18, 2025

PR release:

  • <link rel="stylesheet" type="text/css" href="https://altinncdn.no/toolkits/altinn-app-frontend/4.21.0-pr.2353.image-upload-component.0a02587c/altinn-app-frontend.css">
  • <script src="https://altinncdn.no/toolkits/altinn-app-frontend/4.21.0-pr.2353.image-upload-component.0a02587c/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: 2

🧹 Nitpick comments (10)
src/language/texts/nb.ts (1)

205-214: Text polish: align indefinites, spacing, and list phrasing (minor).

Small Bokmål consistency nits and UX clarity. Also consider parameterizing size like other messages.

Apply minimal fixes:

-    'image_upload_component.button_delete': 'Slett bildet',
+    'image_upload_component.button_delete': 'Slett bilde',
-    'image_upload_component.slider_zoom': 'Tilpass bildet',
+    'image_upload_component.slider_zoom': 'Tilpass bilde',
-      'Feil filformat. Last opp en fil med bildeformat (jpg, jpeg, png, gif).',
+      'Feil filformat. Last opp en fil i et av følgende bildeformater: jpg, jpeg, png og gif.',
-    'image_upload_component.error_file_size_exceeded': 'Filen er for stor. Største tillatte filstørrelse er 10MB.',
+    'image_upload_component.error_file_size_exceeded': 'Filen er for stor. Største tillatte filstørrelse er 10 MB.',

Optional (if UI can supply the limit): parameterize max size to match existing patterns like form_filler.file_uploader_max_size_mb:

-    'image_upload_component.error_file_size_exceeded': 'Filen er for stor. Største tillatte filstørrelse er 10 MB.',
+    'image_upload_component.error_file_size_exceeded': 'Filen er for stor. Største tillatte filstørrelse er {0} MB.',

Side note: You may reuse existing generic texts (e.g., 'form_filler.file_uploader_valid_file_format') to avoid divergence between file/image upload messages.

test/e2e/integration/component-library/image-upload.ts (6)

34-37: Reduce flakiness: assert saved image before asserting canvas removal

Wait for the saved image to appear first; then assert the cropping canvas is gone.

-  cy.findByRole('button', { name: /Lagre/i }).click();
-  cy.get('canvas').should('not.exist');
-  cy.findByRole('img', { name: /uploadThis1.jpg/ }).should('be.visible');
+  cy.findByRole('button', { name: /Lagre/i }).click();
+  cy.findByRole('img', { name: /uploadThis1.png/i }).should('be.visible');
+  cy.get('canvas').should('not.exist');

36-36: Update assertion to match corrected filename

-    cy.findByRole('img', { name: /uploadThis1.jpg/ }).should('be.visible');
+    cy.findByRole('img', { name: /uploadThis1.png/i }).should('be.visible');

46-46: Use accessible, specific selector for the saved image

Avoid broad 'img' selector; assert by accessible name.

-    cy.get('img').should('be.visible');
+    cy.findByRole('img', { name: /uploadThis1.png/i }).should('be.visible');

48-48: Update deletion assertion to match corrected filename

-    cy.findByRole('img', { name: /uploadThis1.jpg/ }).should('not.exist');
+    cy.findByRole('img', { name: /uploadThis1.png/i }).should('not.exist');

26-26: Normalize data attribute casing for consistency

Use lowercase attribute name everywhere to match the rest of the suite.

-    cy.get('[data-componentId="ImageUploadPage-ImageUpload"]').should('be.visible');
+    cy.get('[data-componentid="ImageUploadPage-ImageUpload"]').should('be.visible');

Also applies to: 50-50


14-15: Consider de-coupling tests from locale-specific labels

Search shows many hard-coded Norwegian UI texts across e2e tests, making them brittle to locale/text changes — prefer stable selectors (data-testid), route-based navigation, or centralized pageobject selectors instead of UI text.

  • Examples to update: test/e2e/integration/component-library/image-upload.ts (cy.gotoNavPage('Bildeopplasting') + 'Avbryt'/'Lagre'/'Slett bildet'), test/e2e/support/formFiller.ts (findByRole button 'Lagre'), test/e2e/pageobjects/app-frontend.ts (button:contains("Print / Lagre PDF")), test/e2e/integration/frontend-test/components.ts and group.ts (many 'Avbryt' / 'Lagre og lukk' / 'Slett' uses), plus multiple component-library tests (repeating-group, attachments-in-group, multiple-select, list, checkboxes).

Action: replace UI-text-based selectors with data-testid or route/pageobject selectors and centralize reusable selectors in pageobjects.

test/e2e/support/apps/component-library/uploadImageAndVerify.ts (3)

9-13: Normalize selector casing and scope the file input to the component

Avoid repeating the long selector and keep attribute casing consistent.

-export const uploadImageAndVerify = (fileName: string) => {
-  cy.get('[data-componentId="ImageUploadPage-ImageUpload"]').should('be.visible');
-
-  cy.get('[data-componentId="ImageUploadPage-ImageUpload"]')
-    .find('input[type="file"]')
-    .selectFile(makeTestFile(fileName), { force: true });
+export const uploadImageAndVerify = (fileName: string) => {
+  const componentSel = '[data-componentid="ImageUploadPage-ImageUpload"]';
+  cy.get(componentSel).should('be.visible');
+  cy.get(componentSel)
+    .find('input[type="file"]')
+    .selectFile(makeTestFile(fileName), { force: true });

16-27: Avoid 'as' cast; add null-guard; more robust pixel check

Use Cypress generics to type the element, guard context, and check alpha channel for any drawn pixel.

-  cy.get('canvas').should('be.visible');
-  cy.get('canvas').then(($canvas) => {
-    const canvas = $canvas[0] as HTMLCanvasElement;
-    const ctx = canvas.getContext('2d');
-    const data = ctx?.getImageData(0, 0, canvas.width, canvas.height).data;
-    // Count non-transparent pixels
-    const hasImage =
-      data &&
-      Array.from(data).some(
-        (value, index) => index % 4 !== 3 && value !== 0, // not alpha channel, and not 0
-      );
-    expect(hasImage).to.be.true;
-  });
+  cy.get<HTMLCanvasElement>('canvas').should('be.visible');
+  cy.get<HTMLCanvasElement>('canvas').then(($canvas) => {
+    const canvas = $canvas[0];
+    const ctx = canvas.getContext('2d');
+    expect(ctx, '2D context').to.not.be.null;
+    if (!ctx) return;
+    const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
+    let hasOpaquePixel = false;
+    for (let i = 3; i < data.length; i += 4) {
+      if (data[i] > 0) {
+        hasOpaquePixel = true;
+        break;
+      }
+    }
+    expect(hasOpaquePixel, 'canvas contains drawn image data').to.be.true;
+  });

8-13: Export a shared selector constant for the ImageUpload component

Define and export the selector and replace all hard-coded occurrences with it. Use the exact casing used in the code:

export const IMAGE_UPLOAD_COMPONENT = '[data-componentId="ImageUploadPage-ImageUpload"]';

Replace cy.get('[data-componentId="ImageUploadPage-ImageUpload"]') with cy.get(IMAGE_UPLOAD_COMPONENT) in:

  • test/e2e/support/apps/component-library/uploadImageAndVerify.ts (lines ~9,11)
  • test/e2e/integration/component-library/image-upload.ts (lines ~26,50)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 753184f and 6f79243.

📒 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/ImageCanvas.module.css (1 hunks)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • test/e2e/integration/component-library/image-upload.ts (1 hunks)
  • test/e2e/support/apps/component-library/uploadImageAndVerify.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/language/texts/nn.ts
  • src/layout/ImageUpload/ImageCanvas.module.css
  • src/language/texts/en.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:

  • test/e2e/integration/component-library/image-upload.ts
  • src/language/texts/nb.ts
  • test/e2e/support/apps/component-library/uploadImageAndVerify.ts
🧬 Code graph analysis (1)
test/e2e/integration/component-library/image-upload.ts (2)
test/e2e/pageobjects/app-frontend.ts (1)
  • AppFrontend (3-371)
test/e2e/support/apps/component-library/uploadImageAndVerify.ts (1)
  • uploadImageAndVerify (8-28)
⏰ 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: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install
🔇 Additional comments (2)
src/language/texts/nb.ts (1)

205-214: Approve — locale keys are in parity and used.

  • en/nb/nn each contain 448 keys; no key diffs found between nb↔en and nb↔nn.
  • All image_upload_component keys exist in en/nb/nn and are referenced from src/layout/ImageUpload/{ImageDropzone.tsx, imageUploadUtils.ts, ImageControllers.tsx}.
  • nb.ts and nn.ts use satisfies FixedLanguageList; en.ts does not — optional: add satisfies FixedLanguageList to en.ts for consistency.
test/e2e/integration/component-library/image-upload.ts (1)

8-52: Solid scenario coverage

Happy path, cancel, save, and delete flows are all covered and use accessible roles. Once the filename/mime fixes land, this suite looks good.

Magnusrm and others added 3 commits September 19, 2025 08:29
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* support summary2

* small adjustments
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 (5)
src/layout/ImageUpload/imageUploadUtils.ts (4)

36-47: Use natural image dimensions; guard zero to avoid bad clamping.

CSS image.width/height can differ from pixels; zero gives degenerate clamps. Use naturalWidth/Height with a >=1 floor.

Apply:

 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 || 0;
+  const ih = image.naturalHeight || image.height || 0;
+  const scaledWidth = Math.max(1, iw) * zoom;
+  const scaledHeight = Math.max(1, ih) * zoom;

56-63: Same: natural dimensions for placement.

Apply:

 export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
-  const scaledWidth = img.width * zoom;
-  const scaledHeight = img.height * zoom;
+  const iw = img.naturalWidth || img.width || 0;
+  const ih = img.naturalHeight || img.height || 0;
+  const scaledWidth = Math.max(1, iw) * zoom;
+  const scaledHeight = Math.max(1, ih) * zoom;

100-117: Zoom math can produce NaN/∞; add validation and clamping.

If min/max/value ≤ 0 or min===max, logs break; normalized range should be [0,100].

Apply:

-function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
-  const logMin = Math.log(minZoom);
-  const logMax = Math.log(maxZoom);
-  return { logScale: (logMax - logMin) / 100, logMin };
-}
+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 logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
   if (logScale === 0) {
     return 0;
   } // Avoid division by zero if minZoom equals maxZoom
-  return (Math.log(value) - logMin) / logScale;
+  const safe = Math.max(value, Number.EPSILON);
+  const normalized = (Math.log(safe) - logMin) / logScale;
+  return Math.min(100, Math.max(0, normalized));
 }

119-122: calculateMinZoom should use natural dimensions and guard zero.

Apply:

-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 || 0;
+  const ih = img.naturalHeight || img.height || 0;
+  if (iw <= 0 || ih <= 0) return 1;
+  return Math.max(cropArea.width / iw, cropArea.height / ih);
+};
src/layout/ImageUpload/index.tsx (1)

38-40: renderSummary throws; crashes summary rendering. Return null or implement.

Apply:

-  renderSummary(_props: SummaryRendererProps): JSX.Element | null {
-    throw new Error('Method not implemented.'); // TODO
-  }
+  renderSummary(_props: SummaryRendererProps): JSX.Element | null {
+    return null; // TODO: implement legacy summary
+  }
🧹 Nitpick comments (4)
src/layout/ImageUpload/imageUploadUtils.ts (2)

12-27: Validate and normalize crop area dimensions (avoid zero/negative).

Width/height can be zero/negative, leading to broken math later. Clamp to at least 1.

Apply:

 export const getCropArea = (cropArea?: CropAreaParams): CropArea => {
   const defaultSize = 250;

-  let width = cropArea?.width ?? defaultSize;
-  let height = cropArea?.height ?? defaultSize;
+  let width = cropArea?.width ?? defaultSize;
+  let height = cropArea?.height ?? defaultSize;
+  width = Math.max(1, Math.floor(width));
+  height = Math.max(1, Math.floor(height));

81-89: Guard drawCropArea against invalid sizes.

No-op or throw on non-positive sizes to avoid confusing canvas state.

Apply:

 export function drawCropArea({ ctx, x = 0, y = 0, cropArea }: DrawCropAreaParams) {
   const { width, height, type } = cropArea;
+  if (width <= 0 || height <= 0) {
+    return;
+  }
   ctx.beginPath();
src/layout/ImageUpload/ImageUploadSummary2.tsx (1)

16-24: Nit: pluralize variable for clarity.

Rename attachment to attachments to match array usage and improve readability.

-export function ImageUploadSummary2({ targetBaseComponentId }: Summary2Props) {
-  const attachment = useUploaderSummaryData(targetBaseComponentId);
+export function ImageUploadSummary2({ targetBaseComponentId }: Summary2Props) {
+  const attachments = useUploaderSummaryData(targetBaseComponentId);
@@
-  const isEmpty = attachment.length === 0;
+  const isEmpty = attachments.length === 0;
@@
-        <FileTable
+        <FileTable
           baseComponentId={targetBaseComponentId}
           mobileView={isSmall}
-          attachments={attachment}
+          attachments={attachments}
           options={options}
           isSummary={true}
           isFetching={isFetching}
         />

Also applies to: 42-56

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

16-19: Display data should include only uploaded files.

Avoid listing temp/failed items; filter by uploaded state.

Apply:

   useDisplayData(baseComponentId: string): string {
     const attachments = useAttachmentsFor(baseComponentId);
-    return attachments.map((a) => a.data.filename).join(', ');
+    return attachments
+      .filter((a) => a.data.uploaded)
+      .map((a) => a.data.filename)
+      .join(', ');
   }

If isAttachmentUploaded(a) utility exists elsewhere, prefer it.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 99a3dc6 and 0c15edf.

📒 Files selected for processing (6)
  • src/layout/FileUpload/FileUploadTable/FileTable.tsx (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadSummary2.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
  • src/layout/ImageUpload/index.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/layout/ImageUpload/ImageUploadComponent.tsx
  • src/layout/ImageUpload/ImageDropzone.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/ImageUploadSummary2.tsx
  • src/layout/FileUpload/FileUploadTable/FileTable.tsx
  • src/layout/ImageUpload/imageUploadUtils.ts
  • src/layout/ImageUpload/index.tsx
src/layout/*/{config.ts,Component.tsx,index.tsx,config.generated.ts}

📄 CodeRabbit inference engine (CLAUDE.md)

Layout components must follow the standardized structure: config.ts, Component.tsx, index.tsx, and include generated types in config.generated.ts

Files:

  • src/layout/ImageUpload/index.tsx
🧬 Code graph analysis (3)
src/layout/ImageUpload/ImageUploadSummary2.tsx (8)
src/layout/Summary2/SummaryComponent2/types.ts (1)
  • Summary2Props (1-3)
src/layout/FileUpload/Summary/summary.ts (1)
  • useUploaderSummaryData (4-6)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
src/features/options/useOptionsFor.ts (1)
  • useOptionsFor (11-19)
src/hooks/useDeviceWidths.ts (1)
  • useIsMobileOrTablet (33-35)
src/features/pdf/PDFWrapper.tsx (1)
  • usePdfModeActive (11-15)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/FileUpload/FileUploadTable/FileTable.tsx (1)
  • FileTable (25-146)
src/layout/FileUpload/FileUploadTable/FileTable.tsx (1)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
src/layout/ImageUpload/index.tsx (9)
src/features/attachments/hooks.ts (1)
  • useAttachmentsFor (11-14)
src/layout/index.ts (1)
  • PropsFromGenericComponent (28-32)
src/layout/ImageUpload/ImageUploadComponent.tsx (1)
  • ImageUploadComponent (12-36)
src/features/form/layout/makeLayoutLookups.ts (1)
  • LayoutLookups (66-66)
src/layout/layout.ts (1)
  • IDataModelBindings (61-64)
src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts (1)
  • useFileUploaderDataBindingsValidation (10-41)
src/layout/LayoutComponent.tsx (2)
  • SummaryRendererProps (174-179)
  • ExprResolver (41-53)
src/layout/Summary2/SummaryComponent2/types.ts (1)
  • Summary2Props (1-3)
src/layout/ImageUpload/ImageUploadSummary2.tsx (1)
  • ImageUploadSummary2 (16-60)
🔇 Additional comments (1)
src/layout/ImageUpload/index.tsx (1)

34-36: Action required: confirm useFileUploaderDataBindingsValidation accepts 'ImageUpload'

rg returned no matches for the hook declaration; verify whether useFileUploaderDataBindingsValidation's generic includes 'ImageUpload' — if not, update its declaration to:

-export function useFileUploaderDataBindingsValidation<T extends 'FileUpload' | 'FileUploadWithTag'>(
+export function useFileUploaderDataBindingsValidation<T extends 'FileUpload' | 'FileUploadWithTag' | 'ImageUpload'>(
  baseComponentId: string,
  bindings: IDataModelBindings<T>,
): string[]

Call site: src/layout/ImageUpload/index.tsx (lines 34–36)

Run locally to locate the declaration:
rg -n --hidden -S "useFileUploaderDataBindingsValidation" -g '!/node_modules/'

Comment on lines +33 to 36
const { textResourceBindings, type, readOnly } = useItemWhenType<'FileUpload' | 'FileUploadWithTag' | 'ImageUpload'>(
baseComponentId,
(t) => t === 'FileUpload' || t === 'FileUploadWithTag',
(t) => t === 'FileUpload' || t === 'FileUploadWithTag' || t === 'ImageUpload',
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

🧩 Analysis chain

ImageUpload support in FileTable looks correct; verify downstream assumptions.

Union type/predicate update is fine. Confirm that FileTableRow and related render paths don’t assume tags/options for ImageUpload (label() safely returns undefined).

Run to spot any FileTableRow logic keyed only to 'FileUpload'/'FileUploadWithTag':


🏁 Script executed:

#!/bin/bash
rg -n -C2 -e "FileTableRow" -e "tagLabel" -e "FileUploadWithTag" --type=ts --type=tsx

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for FileTableRow / tag-related logic and show the FileTable file
echo "=== src/layout/FileUpload/FileUploadTable/FileTable.tsx (first 400 lines) ==="
sed -n '1,400p' src/layout/FileUpload/FileUploadTable/FileTable.tsx || true

echo
echo "=== Search: FileTableRow occurrences ==="
rg -n -C2 "FileTableRow" -g "**/*.{ts,tsx,js,jsx}" || true

echo
echo "=== Search: useItemWhenType occurrences ==="
rg -n -C2 "useItemWhenType" -g "**/*.{ts,tsx,js,jsx}" || true

echo
echo "=== Search: FileUploadWithTag occurrences ==="
rg -n -C2 "FileUploadWithTag" -g "**/*.{ts,tsx,js,jsx}" || true

echo
echo "=== Search: ImageUpload occurrences ==="
rg -n -C2 "ImageUpload" -g "**/*.{ts,tsx,js,jsx}" || true

echo
echo "=== Search: tagLabel or tag-related label() usage ==="
rg -n -C2 "tagLabel" -g "**/*.{ts,tsx,js,jsx}" || true
rg -n -C2 "label\(" -g "**/*.{ts,tsx,js,jsx}" | rg -n "tag" || true

Length of output: 197965


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/utils/layout/useNodeItem.ts ==="
sed -n '1,240p' src/utils/layout/useNodeItem.ts || true

echo
echo "=== src/layout/FileUpload/FileUploadTable/FileTableButtons.tsx ==="
sed -n '1,260p' src/layout/FileUpload/FileUploadTable/FileTableButtons.tsx || true

echo
echo "=== src/layout/FileUpload/FileUploadTable/FileTableRow.tsx ==="
sed -n '1,260p' src/layout/FileUpload/FileUploadTable/FileTableRow.tsx || true

Length of output: 17156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full src/layout/FileUpload/FileUploadTable/FileTableRow.tsx ==="
sed -n '1,999p' src/layout/FileUpload/FileUploadTable/FileTableRow.tsx || true

echo
echo "=== grep: FileTableButtons usage in this file ==="
rg -n "FileTableButtons" src/layout/FileUpload/FileUploadTable/FileTableRow.tsx || true

echo
echo "=== grep: ButtonCellContent usage in file ==="
rg -n "ButtonCellContent" src/layout/FileUpload/FileUploadTable/FileTableRow.tsx || true

Length of output: 8218


ImageUpload added to FileTable — runtime error possible; fix required

  • FileTable now accepts ImageUpload (src/layout/FileUpload/FileUploadTable/FileTable.tsx:33–36). FileTableRow renders FileTableButtons unconditionally (call in src/layout/FileUpload/FileUploadTable/FileTableRow.tsx), but FileTableButtons calls useItemWhenType<'FileUpload' | 'FileUploadWithTag'> and will throw for ImageUpload (src/layout/FileUpload/FileUploadTable/FileTableButtons.tsx:32–36).
  • label(...) is safe — it returns undefined when no options are present.

Action: either widen FileTableButtons' useItemWhenType to include 'ImageUpload' or avoid rendering FileTableButtons for ImageUpload (change points above).

🤖 Prompt for AI Agents
In src/layout/FileUpload/FileUploadTable/FileTable.tsx around lines 33–36 you
added ImageUpload to the useItemWhenType call but FileTableRow renders
FileTableButtons unconditionally and FileTableButtons uses
useItemWhenType<'FileUpload' | 'FileUploadWithTag'> which will throw for
ImageUpload; fix by either expanding the type union in
src/layout/FileUpload/FileUploadTable/FileTableButtons.tsx to include
'ImageUpload' (so useItemWhenType<'FileUpload' | 'FileUploadWithTag' |
'ImageUpload'>) or update the render in
src/layout/FileUpload/FileUploadTable/FileTableRow.tsx to only render
<FileTableButtons /> when the resolved type is 'FileUpload' or
'FileUploadWithTag'; implement one of these two changes consistently and add a
comment noting why ImageUpload is handled/filtered.

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

🧹 Nitpick comments (6)
src/layout/ImageUpload/ImageDropzone.tsx (2)

28-41: Fix ARIA: move heading to aria-labelledby, keep describedBy for descriptions only.

Pass the drag label id via labelId and remove it from describedBy. This improves SR semantics and avoids double announcement.

-  const ariaDescribedBy = [descriptionId, dragLabelId, formatLabelId].filter(Boolean).join(' ');
+  const ariaDescribedBy = [descriptionId, formatLabelId].filter(Boolean).join(' ') || undefined;
@@
     <Dropzone
       id={baseComponentId}
       readOnly={readOnly}
       onDrop={onDrop}
       onDragActiveChange={setDragActive}
       hasValidationMessages={hasErrors}
       acceptedFiles={acceptedFiles.dropzone}
+      labelId={dragLabelId}
       data-color='neutral'
       className={cn(classes.placeholder, { [classes.dragActive]: dragActive })}
       describedBy={ariaDescribedBy}

49-52: Make “find” text activate the file input.

Use a label tied to the input id so it’s clickable and keyboard accessible.

-              <span className={classes.blueUnderLine}>
-                {' '}
-                <Lang id='form_filler.file_uploader_find' />
-              </span>
+              <label
+                className={classes.blueUnderLine}
+                htmlFor={baseComponentId}
+              >
+                {' '}
+                <Lang id='form_filler.file_uploader_find' />
+              </label>
src/layout/FileUpload/FileUploadComponent.tsx (3)

81-85: ARIA: don’t place the heading in describedBy; reserve for descriptions.

Move dragLabelId to aria-labelledby and keep describedBy for description/format text.

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

134-147: Connect both the main label and the in-zone heading via aria-labelledby.

Also, avoid blocking click-to-upload unless there’s a UX reason.

             <Dropzone
               id={id}
               maxFileSize={{
                 sizeInMB: maxFileSizeInMB,
                 text: langAsString('form_filler.file_uploader_max_size_mb', [maxFileSizeInMB]),
               }}
               readOnly={!!readOnly}
-              onClick={(e) => e.preventDefault()}
               onDrop={handleDrop}
               hasValidationMessages={hasValidationErrors(validations)}
               acceptedFiles={filesToAccept}
-              labelId={textResourceBindings?.title ? getLabelId(id) : undefined}
+              labelId={[textResourceBindings?.title ? getLabelId(id) : undefined, dragLabelId]
+                .filter(Boolean)
+                .join(' ') || undefined}
               describedBy={ariaDescribedBy}

If the dropzone should not open the file picker on click, keep onClick; otherwise, consider removing it as above. Please confirm intended UX.


159-164: Make “find” text activate the file input.

Use a label tied to the input id to improve click/keyboard accessibility.

-                      <span className={cn(classes.blueUnderLine)}>
-                        {' '}
-                        <Lang id='form_filler.file_uploader_find' />
-                      </span>
+                      <label
+                        className={cn(classes.blueUnderLine)}
+                        htmlFor={id}
+                      >
+                        {' '}
+                        <Lang id='form_filler.file_uploader_find' />
+                      </label>
src/layout/ImageUpload/ImageCropper.tsx (1)

161-166: Initial zoom should respect min zoom (don’t force >= 1).

If minZoom < 1 (small images), forcing 1 prevents fitting. Initialize to minZoom or 1, whichever is greater only if UX requires it.

-  const updateImageState = ({ minZoom = minAllowedZoom, img = imageRef.current }: UpdateImageState) => {
-    setZoom(Math.max(1, minZoom));
+  const updateImageState = ({ minZoom = minAllowedZoom, img = imageRef.current }: UpdateImageState) => {
+    setZoom(minZoom);
     setPosition({ x: 0, y: 0 });
     imageRef.current = img;
   };
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c15edf and edf4926.

📒 Files selected for processing (11)
  • src/app-components/Dropzone/Dropzone.tsx (3 hunks)
  • src/language/texts/en.ts (1 hunks)
  • src/language/texts/nb.ts (1 hunks)
  • src/language/texts/nn.ts (1 hunks)
  • src/layout/FileUpload/FileUploadComponent.tsx (3 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/ImageUploadComponent.tsx (1 hunks)
  • src/layout/ImageUpload/config.ts (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/language/texts/nb.ts
  • src/layout/ImageUpload/config.ts
  • src/layout/ImageUpload/ImageUploadComponent.tsx
  • src/language/texts/nn.ts
  • src/language/texts/en.ts
🧰 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/FileUpload/FileUploadComponent.tsx
  • src/layout/ImageUpload/ImageControllers.tsx
  • src/layout/ImageUpload/imageUploadUtils.ts
  • src/layout/ImageUpload/ImageDropzone.tsx
  • src/app-components/Dropzone/Dropzone.tsx
🧬 Code graph analysis (5)
src/layout/ImageUpload/ImageCropper.tsx (5)
src/layout/ImageUpload/imageUploadUtils.ts (10)
  • CropArea (10-10)
  • getAcceptedFiles (159-171)
  • Position (3-3)
  • ValidationErrors (124-124)
  • calculateMinZoom (120-121)
  • constrainToArea (36-47)
  • imagePlacement (56-63)
  • validateFile (125-152)
  • cropAreaPlacement (68-72)
  • drawCropArea (81-89)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (29-194)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-152)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/FileUpload/FileUploadComponent.tsx (1)
src/app-components/Dropzone/mapExtensionToAcceptMime.ts (1)
  • mapExtensionToAcceptMime (11-36)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/layout/ImageUpload/imageUploadUtils.ts (3)
  • AcceptedFiles (154-157)
  • normalToLogZoom (106-109)
  • logToNormalZoom (111-117)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/imageUploadUtils.ts (1)
src/app-components/Dropzone/mapExtensionToAcceptMime.ts (1)
  • mapExtensionToAcceptMime (11-36)
src/layout/ImageUpload/ImageDropzone.tsx (6)
src/layout/ImageUpload/imageUploadUtils.ts (1)
  • AcceptedFiles (154-157)
src/app-components/Dropzone/Dropzone.tsx (2)
  • IDropzoneProps (15-27)
  • Dropzone (31-99)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
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)
⏰ 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 (10)
src/layout/ImageUpload/imageUploadUtils.ts (4)

36-47: Use naturalWidth/Height and guard zero to avoid NaN/∞ and incorrect scaling.

Canvas math should use intrinsic image size and avoid 0-dimension math.

 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 || 0;
+  const ih = image.naturalHeight || image.height || 0;
+  const scaledWidth = Math.max(1, iw) * zoom;
+  const scaledHeight = Math.max(1, ih) * zoom;

56-63: Same intrinsic-size/zero-guard fix for placement calculations.

 export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
-  const scaledWidth = img.width * zoom;
-  const scaledHeight = img.height * zoom;
+  const iw = img.naturalWidth || img.width || 0;
+  const ih = img.naturalHeight || img.height || 0;
+  const scaledWidth = Math.max(1, iw) * zoom;
+  const scaledHeight = Math.max(1, ih) * zoom;

100-117: Harden log zoom math (non‑positive values yield NaN/∞).

Validate inputs, guard logs, and clamp outputs.

-function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
-  const logMin = Math.log(minZoom);
-  const logMax = Math.log(maxZoom);
-  return { logScale: (logMax - logMin) / 100, logMin };
-}
+function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
+  const safeMin = Math.max(Number.EPSILON, minZoom);
+  const safeMax = Math.max(safeMin, maxZoom);
+  const logMin = Math.log(safeMin);
+  const logMax = Math.log(safeMax);
+  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 raw = Math.exp(logMin + logScale * value);
+  return Math.min(maxZoom, Math.max(minZoom, raw));
 }
@@
 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 safe = Math.max(Number.EPSILON, value);
+  const normalized = (Math.log(safe) - logMin) / logScale;
+  return Math.min(100, Math.max(0, normalized));
 }

119-122: calculateMinZoom should use intrinsic dimensions and avoid division by zero.

-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 || 0;
+  const ih = img.naturalHeight || img.height || 0;
+  if (iw <= 0 || ih <= 0) return 1;
+  return Math.max(cropArea.width / iw, cropArea.height / ih);
+};
src/layout/ImageUpload/ImageCropper.tsx (3)

22-30: Thread readOnly through the cropper.

Expose readOnly so initial upload UI respects form state.

 interface ImageCropperProps {
   baseComponentId: string;
   cropArea: CropArea;
   validFileEndings?: string[];
+  readOnly?: boolean;
 }
 
-export function ImageCropper({ baseComponentId, cropArea, validFileEndings }: ImageCropperProps) {
+export function ImageCropper({ baseComponentId, cropArea, validFileEndings, readOnly }: ImageCropperProps) {

171-177: Use the readOnly prop (don’t hardcode false).

         <ImageDropzone
           baseComponentId={baseComponentId}
           onDrop={(files) => handleFileUpload(files[0])}
           acceptedFiles={acceptedFiles}
-          readOnly={false}
+          readOnly={!!readOnly}
           hasErrors={!!validationErrors && validationErrors?.length > 0}
         />

183-218: containerRef won’t attach — AppCard isn’t a forwardRef.

Wrap in a DOM element or convert AppCard to forwardRef.

-  return (
-    <AppCard
-      variant='default'
-      mediaPosition='top'
-      ref={containerRef}
-      media={
+  return (
+    <div ref={containerRef}>
+      <AppCard
+        variant='default'
+        mediaPosition='top'
+        media={
         <ImageCanvas
           canvasRef={canvasRef}
           containerRef={containerRef}
           imageRef={imageRef}
           zoom={zoom}
           position={position}
           cropArea={cropArea}
           baseComponentId={baseComponentId}
           onPositionChange={handlePositionChange}
           onZoomChange={handleZoomChange}
         />
-      }
-    >
+        }
+      >
@@
-      <ValidationMessages validationErrors={validationErrors} />
-    </AppCard>
+        <ValidationMessages validationErrors={validationErrors} />
+      </AppCard>
+    </div>
src/app-components/Dropzone/Dropzone.tsx (1)

50-63: LGTM: accept mapping and drag-active callback.

The new acceptedFiles passthrough and onDragActiveChange effect are correct and align with react-dropzone’s API.

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

114-121: Verify accept string correctness.

accept relies on acceptedFiles.input. Ensure it includes dot prefixes (e.g., “.jpg,.png”) to work across browsers. See suggested refactor in imageUploadUtils.ts.


94-103: Icon-only Reset button needs an accessible name.

Add aria-label on the Button; icon title isn’t a reliable accessible name.

-          <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.reset')}
+          >
+            <ArrowUndoIcon className={classes.resetButton} />
+          </Button>

Comment on lines 159 to 171
export const getAcceptedFiles = (validFileEndings?: string[]): AcceptedFiles => {
if (!validFileEndings?.length) {
return {
dropzone: { 'image/*': [] },
input: 'image/*',
};
}

return {
dropzone: mapExtensionToAcceptMime(validFileEndings),
input: validFileEndings.join(','),
};
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Accept string for file input may be invalid without dot prefixes.

When validFileEndings are bare (e.g., 'jpg'), browsers won’t filter unless prefixed with '.'.

 export const getAcceptedFiles = (validFileEndings?: string[]): AcceptedFiles => {
   if (!validFileEndings?.length) {
     return {
       dropzone: { 'image/*': [] },
       input: 'image/*',
     };
   }
 
   return {
     dropzone: mapExtensionToAcceptMime(validFileEndings),
-    input: validFileEndings.join(','),
+    input: validFileEndings
+      .map((e) => e.trim())
+      .filter(Boolean)
+      .map((e) => (e.startsWith('.') ? e : `.${e}`))
+      .join(','),
   };
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getAcceptedFiles = (validFileEndings?: string[]): AcceptedFiles => {
if (!validFileEndings?.length) {
return {
dropzone: { 'image/*': [] },
input: 'image/*',
};
}
return {
dropzone: mapExtensionToAcceptMime(validFileEndings),
input: validFileEndings.join(','),
};
};
export const getAcceptedFiles = (validFileEndings?: string[]): AcceptedFiles => {
if (!validFileEndings?.length) {
return {
dropzone: { 'image/*': [] },
input: 'image/*',
};
}
return {
dropzone: mapExtensionToAcceptMime(validFileEndings),
input: validFileEndings
.map((e) => e.trim())
.filter(Boolean)
.map((e) => (e.startsWith('.') ? e : `.${e}`))
.join(','),
};
};
🤖 Prompt for AI Agents
In src/layout/ImageUpload/imageUploadUtils.ts around lines 159 to 171, the
accept string produced for the native file input can be invalid when
validFileEndings are bare like "jpg" because browsers require dot-prefixed
extensions (".jpg"); update the function to normalize validFileEndings before
joining by mapping each entry to a proper accept token: if the entry already
contains a '/' (looks like a mime type) leave it as-is, if it starts with '.'
leave it as-is, otherwise prefix with '.'; use the normalized array to build the
input accept string (join with ',') and ensure mapExtensionToAcceptMime is fed
the normalized extensions for the dropzone path so both dropzone and input
accept values are correct.

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: 4

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

115-128: Consider accessibility improvement for keyboard navigation

The keyboard navigation implementation is functional but could benefit from visual feedback. Consider adding a focus indicator or announcing the position changes to screen readers.

Consider adding an announcement for screen readers when the position changes:

 const handleKeyDown = (e: React.KeyboardEvent<HTMLCanvasElement>) => {
   const moveAmount = 10;
   const keyMap: Record<string, () => void> = {
-    ArrowUp: () => onPositionChange({ ...position, y: position.y - moveAmount }),
-    ArrowDown: () => onPositionChange({ ...position, y: position.y + moveAmount }),
-    ArrowLeft: () => onPositionChange({ ...position, x: position.x - moveAmount }),
-    ArrowRight: () => onPositionChange({ ...position, x: position.x + moveAmount }),
+    ArrowUp: () => {
+      onPositionChange({ ...position, y: position.y - moveAmount });
+      // Consider announcing position change for screen readers
+    },
+    ArrowDown: () => {
+      onPositionChange({ ...position, y: position.y + moveAmount });
+    },
+    ArrowLeft: () => {
+      onPositionChange({ ...position, x: position.x - moveAmount });
+    },
+    ArrowRight: () => {
+      onPositionChange({ ...position, x: position.x + moveAmount });
+    },
   };
src/layout/ImageUpload/ImageCropper.tsx (1)

160-165: Type definition placement is unconventional

The UpdateImageState type is defined inside the component function. Move it outside for better code organization.

+type UpdateImageState = { minZoom?: number; img?: HTMLImageElement | null };
+
 export function ImageCropper({ baseComponentId, cropArea, validFileEndings }: ImageCropperProps) {
   // ... component code ...
   
-  type UpdateImageState = { minZoom?: number; img?: HTMLImageElement | null };
   const updateImageState = ({ minZoom = minAllowedZoom, img = imageRef.current }: UpdateImageState) => {
src/layout/ImageUpload/ImageCanvas.module.css (1)

15-15: Use design token for neutral background
Replace the hardcoded #f4f5f6 with the CSS variable var(--ds-color-neutral-3) from @digdir/designsystemet for consistency.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 715a1b4 and 4afbe86.

📒 Files selected for processing (3)
  • src/layout/ImageUpload/ImageCanvas.module.css (1 hunks)
  • src/layout/ImageUpload/ImageCanvas.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.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/ImageUpload/ImageCanvas.tsx
  • src/layout/ImageUpload/ImageCropper.tsx
**/*.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/ImageCanvas.module.css
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageCanvas.tsx (3)
src/layout/ImageUpload/imageUploadUtils.ts (5)
  • Position (3-3)
  • CropArea (10-10)
  • imagePlacement (56-63)
  • cropAreaPlacement (68-72)
  • drawCropArea (81-89)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/ImageCropper.tsx (6)
src/layout/ImageUpload/imageUploadUtils.ts (10)
  • CropArea (10-10)
  • getAcceptedFiles (159-171)
  • Position (3-3)
  • ValidationErrors (124-124)
  • calculateMinZoom (120-121)
  • constrainToArea (36-47)
  • imagePlacement (56-63)
  • validateFile (125-152)
  • cropAreaPlacement (68-72)
  • drawCropArea (81-89)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (21-63)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-152)
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: Analyze (javascript)
  • GitHub Check: Install
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (4)
src/layout/ImageUpload/ImageCanvas.tsx (2)

69-72: Redraw on image load; otherwise canvas may never render the image

The draw() function only runs on mount/prop updates. If the image isn't complete at first render, no redraw occurs when it finishes loading.


82-95: Pointer move lacks pointerId filter and cleanup; also unconstrained panning

  • Multiple pointers can interfere because move events aren't filtered by the initial pointerId
  • Listeners aren't cleaned up if the component unmounts mid-drag
  • Panning isn't constrained to keep the image covering the crop area
src/layout/ImageUpload/ImageCropper.tsx (2)

22-26: Thread readOnly through to the Dropzone

Add readOnly to props so the initial upload UI respects form state.


183-198: AppCard is not a forwardRef component

The AppCard component is a plain function component and won't accept refs. You'll need to either wrap it in a div or update AppCard to use React.forwardRef.

@lassopicasso
Copy link
Contributor

/publish

Copy link
Contributor

github-actions bot commented Sep 26, 2025

PR release:

  • <link rel="stylesheet" type="text/css" href="https://altinncdn.no/toolkits/altinn-app-frontend/4.21.0-pr.2505.image-upload-component.d57d892a/altinn-app-frontend.css">
  • <script src="https://altinncdn.no/toolkits/altinn-app-frontend/4.21.0-pr.2505.image-upload-component.d57d892a/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: 0

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

34-45: Use naturalWidth/naturalHeight to avoid CSS pixel distortion.

image.width/height reflect CSS layout dimensions, not intrinsic pixels. For canvas math, use naturalWidth/naturalHeight and guard against zero to prevent NaN/Infinity in clamp calculations.

Apply this diff:

 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 };
 }

54-61: Use naturalWidth/naturalHeight for accurate image scaling.

Same issue as constrainToArea: CSS dimensions can differ from intrinsic size.

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 };
 };

98-115: Guard zoom math against non-positive values and clamp results.

Math.log with non-positive inputs yields -Infinity or NaN. Also clamp logToNormalZoom output to [0, 100] to keep slider domain stable.

Apply this diff:

 function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
+  if (minZoom <= 0 || maxZoom <= 0 || minZoom > maxZoom) {
+    throw new Error('minZoom and maxZoom must be positive and minZoom <= maxZoom');
+  }
   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 result = Math.exp(logMin + logScale * value);
+  return Math.max(minZoom, Math.min(maxZoom, result));
 }

 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(minZoom, Math.min(maxZoom, value));
+  const normalized = (Math.log(safeValue) - logMin) / logScale;
+  return Math.max(0, Math.min(100, normalized));
 }

118-119: Use naturalWidth/naturalHeight for min-zoom calculation.

Consistent with other image dimension uses.

Apply this diff:

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

18-36: ARIA structure needs correction: move dragLabelId to labelId prop.

The dragLabelId should be passed as labelId (for aria-labelledby), not included in describedBy (aria-describedby). This ensures screen readers correctly announce the dropzone label.

Apply this diff:

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

   return (
     <Dropzone
       id={baseComponentId}
       readOnly={readOnly}
       onDrop={onDrop}
       onDragActiveChange={setDragActive}
       hasValidationMessages={hasErrors}
       acceptedFiles={{ 'image/*': [] }}
+      labelId={dragLabelId}
       data-color='neutral'
       className={cn(classes.placeholder, { [classes.dragActive]: dragActive })}
       describedBy={ariaDescribedBy}
     >
src/layout/ImageUpload/ImageCropper.tsx (4)

21-36: Add readOnly prop to control upload state.

The component should accept a readOnly prop to disable uploads when the form is read-only.

Apply this diff:

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

 const MAX_ZOOM = 5;

-export function ImageCropper({ baseComponentId, cropArea }: ImageCropperProps) {
+export function ImageCropper({ baseComponentId, cropArea, readOnly = false }: ImageCropperProps) {

94-115: Memory leak: Image and FileReader not cleaned up on unmount or re-upload.

If the component unmounts while loading or a new file is uploaded before the previous one finishes, the old Image/FileReader continues to execute stale callbacks.

Apply this diff:

+  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;
     }

+    // Clean up any previous loading operations
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+      loadingImageRef.current = null;
+    }
+    if (fileReaderRef.current) {
+      fileReaderRef.current.onload = null;
+      fileReaderRef.current = null;
+    }
+
     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;
         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
+  React.useEffect(() => {
+    return () => {
+      if (loadingImageRef.current) {
+        loadingImageRef.current.onload = null;
+        loadingImageRef.current.src = '';
+      }
+      if (fileReaderRef.current) {
+        fileReaderRef.current.onload = null;
+      }
+    };
+  }, []);

117-146: Add error handling for toBlob failure.

toBlob can fail silently. Log errors and provide user feedback when blob creation fails.

Apply this diff:

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

165-177: Pass readOnly prop to ImageDropzone.

Once readOnly is added to props, thread it through here.

Apply this diff (after adding readOnly to props):

       <ImageDropzone
         baseComponentId={baseComponentId}
         onDrop={(files) => handleFileUpload(files[0])}
-        readOnly={false}
+        readOnly={readOnly}
         hasErrors={!!validationErrors && validationErrors?.length > 0}
       />
src/layout/ImageUpload/ImageControllers.tsx (3)

41-48: Fix zoom normalization: divide slider value by 100.

The slider range is 0-100, but normalToLogZoom expects 0-1. The incoming value must be divided by 100.

Apply this diff:

   const handleSliderZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
     const logarithmicZoomValue = normalToLogZoom({
-      value: parseFloat(e.target.value),
+      value: parseFloat(e.target.value) / 100,
       minZoom,
       maxZoom,
     });

     updateZoom(logarithmicZoomValue);
   };

81-90: Fix zoom normalization: multiply slider value by 100.

logToNormalZoom returns 0-1, but the slider expects 0-100. Multiply the result by 100.

Apply this diff:

           <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}
           />

91-100: Add aria-label for icon-only Reset button.

The title attribute is a tooltip but not read by screen readers in button context. Add aria-label for accessibility.

Apply this diff:

           <Button
             onClick={onReset}
             variant='tertiary'
             icon={true}
+            aria-label={langAsString('image_upload_component.reset')}
           >
-            <ArrowUndoIcon
-              title={langAsString('image_upload_component.reset')}
-              className={classes.resetButton}
-            />
+            <ArrowUndoIcon className={classes.resetButton} />
           </Button>
🧹 Nitpick comments (1)
src/layout/ImageUpload/ImageDropzone.tsx (1)

45-48: Make "find" text a clickable label for better UX.

Wrapping the "find" text in a <label htmlFor={baseComponentId}> would make it clickable and open the file picker, improving user experience.

Apply this diff:

             <>
               <Lang id='form_filler.file_uploader_drag' />
-              <span className={classes.blueUnderLine}>
+              <label
+                htmlFor={baseComponentId}
+                className={classes.blueUnderLine}
+              >
                 {' '}
                 <Lang id='form_filler.file_uploader_find' />
-              </span>
+              </label>
             </>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b6e0b1e and e683284.

📒 Files selected for processing (9)
  • 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/ImageUploadComponent.tsx (1 hunks)
  • src/layout/ImageUpload/config.ts (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/language/texts/nb.ts
  • src/language/texts/en.ts
  • src/layout/ImageUpload/ImageUploadComponent.tsx
  • src/language/texts/nn.ts
  • src/layout/ImageUpload/config.ts
🧰 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/ImageDropzone.tsx
  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/imageUploadUtils.ts
  • src/layout/ImageUpload/ImageControllers.tsx
🧬 Code graph analysis (3)
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 (6)
src/layout/ImageUpload/imageUploadUtils.ts (8)
  • CropArea (8-8)
  • Position (1-1)
  • calculateMinZoom (118-119)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • validateFile (121-141)
  • 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/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (24-149)
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 (2)
  • normalToLogZoom (104-107)
  • logToNormalZoom (109-115)
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). (2)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install
🔇 Additional comments (15)
src/layout/ImageUpload/imageUploadUtils.ts (5)

1-8: LGTM: Clean type definitions.

The Position, CropForm, CropAreaParams, and CropArea types are well-defined and appropriately scoped.


10-25: LGTM: Sensible default crop area logic.

The function correctly normalizes Circle crops to equal dimensions and provides reasonable defaults.


66-70: LGTM: Correct crop area centering.

The function properly centers the crop area within the canvas.


79-87: LGTM: Proper path drawing for Circle and Square crops.

The drawCropArea function correctly uses arc for Circle and rect for Square.


121-141: LGTM: File validation logic is sound.

The function correctly checks for image MIME type and enforces a 10MB size limit.

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

1-16: LGTM: Clean imports and type definition.

Props are well-typed and appropriate for the component's needs.

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

40-55: LGTM: Position constraint logic is correct.

The handler properly constrains position changes using the constrainToArea utility.


58-92: LGTM: Zoom logic maintains viewport center.

The zoom handler correctly preserves the image point under the viewport center during zoom changes.


148-163: LGTM: Delete and cancel handlers are correct.

The handlers properly clean up state and call the appropriate hooks.


179-211: LGTM: Cropping UI rendering is well-structured.

The card layout with ImageCanvas and ImageControllers is appropriately composed.


214-227: LGTM: ValidationMessages component is simple and correct.

Based on maintainer feedback, Lang handles undefined ids gracefully.

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

1-22: LGTM: Clean imports and well-typed props.

The component interface is clear and appropriately structured.


35-57: LGTM: Unique IDs and file input handling are correct.

The use of useId ensures unique IDs across instances, and the file input is properly cleared after selection.


59-72: LGTM: Delete button correctly reflects storedImage state.

The button is appropriately disabled during upload or deletion.


103-148: LGTM: Action buttons are well-structured and accessible.

The Save, Change, and Cancel buttons have appropriate labels and keyboard handling.

Copy link

sonarqubecloud bot commented Oct 1, 2025

Quality Gate Failed Quality Gate failed

Failed conditions
41.2% Condition Coverage on New Code (required ≥ 45%)
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

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
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants