diff --git a/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/.openspec.yaml b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/.openspec.yaml new file mode 100644 index 0000000000..e0c0898ffd --- /dev/null +++ b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-11 diff --git a/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/design.md b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/design.md new file mode 100644 index 0000000000..59251490e0 --- /dev/null +++ b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/design.md @@ -0,0 +1,44 @@ +## Context + +Document Viewer v1.2.0 uses `react-pdf` to render PDFs via PDF.js. The `options` object passed to the `` component includes `cMapUrl` and `standardFontDataUrl` as relative paths. PDF.js passes these to the worker thread for font/cmap fetching. When the worker is loaded from a cross-origin URL (the default is unpkg CDN: `//unpkg.com/pdfjs-dist@.../pdf.worker.min.mjs`), the worker's `fetch()` cannot resolve relative URLs — it has no document origin to resolve against — causing a `TypeError: Failed to parse URL` and silent font load failure. + +The customer's W9 PDF draws a checkmark using the ZapfDingbats standard font (glyph `0x34`) via a Form XObject. Without the font, PDF.js renders a blank rectangle. + +## Goals / Non-Goals + +**Goals:** + +- Fix standard font and cmap fetching when the PDF.js worker is cross-origin +- No new XML properties, no API surface changes, no dependency updates + +**Non-Goals:** + +- Changing how the worker URL is configured +- Supporting self-hosted worker deployments (already supported via `pdfjsWorkerUrl` prop) + +## Decisions + +### Use an absolute `origin` to make resource URLs absolute + +Compute a module-scope `origin` and prepend it to both `cMapUrl` and `standardFontDataUrl` at module evaluation time: + +```ts +const origin: string = (window.mx?.appUrl ?? window.location.origin).replace(/\/$/, ""); +``` + +**Rationale:** The worker needs absolute URLs. `window.mx.appUrl` is the canonical Mendix application URL and is correct even when the app is served from a non-origin base path or behind a reverse proxy; `window.location.origin` is the fallback when `mx.appUrl` is unset (e.g. tests). The trailing-slash strip prevents `//` in the joined resource URLs. Evaluated at module load (not per-render), so no React re-render cost. + +**Alternative considered:** Use `window.location.origin` alone. Rejected — does not account for apps served under a base path that `mx.appUrl` encodes. + +**Alternative considered:** Move `options` inside the component and use `useMemo`. Rejected — no reactive dependencies, module-scope evaluation is simpler and equivalent. + +**Alternative considered:** Set `useWorkerFetch: false` to force main-thread font loading. Rejected — works around the symptom, not the cause; disabling worker fetch has broader performance implications. + +### Supporting typings + +`window.mx` is not in the widget's ambient types, and CSS imports (`react-pdf/dist/Page/*.css`) need a module declaration. Added `typings/global.d.ts` (declares `Window.mx?: { appUrl?: string }`) and `typings/modules.d.ts` (`declare module "*.css"`). + +## Risks / Trade-offs + +- Neither `window.mx?.appUrl` nor `window.location.origin` is guaranteed in SSR. Tests don't exercise PDF rendering and `mx.appUrl` falls back to `location.origin` — no impact. If server-side rendering is ever added, this will need to be guarded. +- If the Mendix app is served from a subpath (e.g. `/app/`), `mx.appUrl` already encodes it correctly; fonts resolve under the app's `/widgets/` path. diff --git a/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/proposal.md b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/proposal.md new file mode 100644 index 0000000000..0eb7d1377b --- /dev/null +++ b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/proposal.md @@ -0,0 +1,31 @@ +## Why + +PDFs containing glyphs from ZapfDingbats (a PDF standard font) — such as checkmarks generated by .NET PDF libraries — render blank in Document Viewer. The checkmark is drawn via a Form XObject using the ZapfDingbats font, which PDF.js must fetch from `standardFontDataUrl`. The URL was relative, causing the PDF.js worker (loaded from unpkg CDN, a different origin) to fail parsing it as an absolute URL. + +## What Changes + +- `standardFontDataUrl` and `cMapUrl` in `PDFViewer.tsx` are prefixed with an absolute `origin` so the worker can fetch font resources regardless of where it was loaded from. +- `origin` resolves to `window.mx.appUrl` (the Mendix app URL) when available, falling back to `window.location.origin`. A trailing slash is stripped to avoid double slashes in the resulting resource URLs. +- A trailing slash is added to `standard_fonts/` so the font directory URL is well-formed. +- Supporting typings added: `window.mx` declaration (`typings/global.d.ts`) and a CSS module declaration (`typings/modules.d.ts`). + +## Capabilities + +### New Capabilities + +- `pdf-form-rendering`: Correct visual rendering of standard-font glyphs (ZapfDingbats checkmarks, Symbol characters) in PDFs rendered by the Document Viewer widget + +### Modified Capabilities + + + +## Impact + +- **Files**: + - `packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx` + - `packages/pluggableWidgets/document-viewer-web/typings/global.d.ts` (new — `window.mx` type) + - `packages/pluggableWidgets/document-viewer-web/typings/modules.d.ts` (new — `*.css` module type) +- **Behavior**: ZapfDingbats and other standard font glyphs now render correctly when PDF.js worker is loaded from a cross-origin URL (e.g. unpkg CDN) +- **No API or XML changes** +- **No dependency version changes** +- **Affected widget**: `@mendix/document-viewer-web` v1.2.0+ diff --git a/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/specs/pdf-form-rendering/spec.md b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/specs/pdf-form-rendering/spec.md new file mode 100644 index 0000000000..13c77430d3 --- /dev/null +++ b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/specs/pdf-form-rendering/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Standard font glyphs render correctly in PDFs + +The Document Viewer SHALL correctly render PDF glyphs that depend on PDF standard fonts (ZapfDingbats, Symbol, etc.) by fetching font resources using absolute URLs resolvable by the PDF.js worker. + +#### Scenario: ZapfDingbats checkmark renders in cross-origin worker context + +- **WHEN** a PDF contains a glyph drawn from the ZapfDingbats standard font (e.g. a checkmark drawn via a Form XObject) +- **AND** the PDF.js worker is loaded from a cross-origin URL (e.g. unpkg CDN) +- **THEN** the glyph SHALL render visibly on the canvas + +#### Scenario: Font fetch uses absolute URL + +- **WHEN** PDF.js requests a standard font file from the worker thread +- **THEN** the request URL SHALL be an absolute URL rooted at the Mendix application URL (`window.mx.appUrl`, falling back to `window.location.origin`, with any trailing slash stripped) — e.g. `https://example.com/widgets/.../FoxitDingbats.pfb` + +#### Scenario: PDFs without standard fonts are unaffected + +- **WHEN** a PDF contains no glyphs requiring standard font substitution +- **THEN** rendering SHALL be identical to previous behavior diff --git a/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/tasks.md b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/tasks.md new file mode 100644 index 0000000000..383f0392a8 --- /dev/null +++ b/openspec/changes/archive/2026-06-12-document-viewer-render-forms-fix/tasks.md @@ -0,0 +1,11 @@ +## 1. Implementation + +- [x] 1.1 Prepend an absolute `origin` (`window.mx?.appUrl ?? window.location.origin`, trailing slash stripped) to `cMapUrl` and `standardFontDataUrl` in `packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx` +- [x] 1.2 Add `typings/global.d.ts` (`window.mx`) and `typings/modules.d.ts` (`*.css`) + +## 2. Verification + +- [x] 2.1 Run unit tests: `cd packages/pluggableWidgets/document-viewer-web && pnpm run test` +- [x] 2.2 Build widget: `pnpm --filter @mendix/document-viewer-web run build` +- [x] 2.3 Verify customer W9 PDF shows Section 3.a checkbox as checked in Document Viewer +- [x] 2.4 Verify a PDF without AcroForms renders correctly (no regression) diff --git a/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md b/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md index b2800d5a62..712e4c27b2 100644 --- a/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md +++ b/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We changed the internal structure of the widget +### Fixed + +- We fixed an issue where checkmarks and other special characters in PDFs were not displayed correctly. + ## [1.2.0] - 2025-10-29 ### Added diff --git a/packages/pluggableWidgets/document-viewer-web/package.json b/packages/pluggableWidgets/document-viewer-web/package.json index 4d914e44cf..cee7a9e7c0 100644 --- a/packages/pluggableWidgets/document-viewer-web/package.json +++ b/packages/pluggableWidgets/document-viewer-web/package.json @@ -39,7 +39,7 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "echo 'FIXME: Add unit tests'", + "test": "pluggable-widgets-tools test:unit:web", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, diff --git a/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx b/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx index 1f3b7da0a6..e7b93313a2 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx +++ b/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx @@ -1,15 +1,17 @@ import { ChangeEvent, FormEvent, Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useState } from "react"; import { Document, Page, pdfjs } from "react-pdf"; +import { If } from "@mendix/widget-plugin-component-kit/If"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; -import { downloadFile } from "../utils/helpers"; -import { useZoomScale } from "../utils/useZoomScale"; import BaseViewer from "./BaseViewer"; import { DocRendererElement, DocumentRendererProps, DocumentStatus } from "./documentRenderer"; -import { If } from "@mendix/widget-plugin-component-kit/If"; +import { downloadFile } from "../utils/helpers"; +import { useZoomScale } from "../utils/useZoomScale"; + +const origin: string = (window.mx?.appUrl ?? window.location.origin).replace(/\/$/, ""); const options = { - cMapUrl: "/widgets/com/mendix/shared/pdfjs/cmaps/", - standardFontDataUrl: "/widgets/com/mendix/shared/pdfjs/standard_fonts" + cMapUrl: `${origin}/widgets/com/mendix/shared/pdfjs/cmaps/`, + standardFontDataUrl: `${origin}/widgets/com/mendix/shared/pdfjs/standard_fonts/` }; const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { diff --git a/packages/pluggableWidgets/document-viewer-web/src/components/__tests__/BaseViewer.spec.tsx b/packages/pluggableWidgets/document-viewer-web/src/components/__tests__/BaseViewer.spec.tsx new file mode 100644 index 0000000000..4b47656e45 --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/components/__tests__/BaseViewer.spec.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import BaseViewer, { BaseControlViewer } from "../BaseViewer"; + +const file = { + status: "available" as const, + value: { uri: "https://apps.example.com/file/42.pdf", name: "report.pdf" } +}; + +describe("BaseViewer", () => { + it("renders the file name and children", () => { + render( + + content + + ); + + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + expect(screen.getByText("content")).toBeInTheDocument(); + }); +}); + +describe("BaseControlViewer", () => { + it("renders the file name, custom controls and children", () => { + render( + custom}> + page content + + ); + + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + expect(screen.getByText("custom")).toBeInTheDocument(); + expect(screen.getByText("page content")).toBeInTheDocument(); + }); + + it("downloads the file in a new window when the download button is clicked", () => { + const openSpy = jest.spyOn(window, "open").mockImplementation(() => null); + + render( + + page content + + ); + + fireEvent.click(screen.getByLabelText("Download")); + + expect(openSpy).toHaveBeenCalledTimes(1); + const [url, windowName] = openSpy.mock.calls[0]; + expect(windowName).toBe("mendix_file"); + expect(url!.toString()).toBe("https://apps.example.com/file/42.pdf?target=window"); + openSpy.mockRestore(); + }); + + it("exposes zoom controls", () => { + render( + + page content + + ); + + expect(screen.getByLabelText("Zoom in")).toBeInTheDocument(); + expect(screen.getByLabelText("Zoom out")).toBeInTheDocument(); + expect(screen.getByLabelText("Fit to width")).toBeInTheDocument(); + }); +}); diff --git a/packages/pluggableWidgets/document-viewer-web/src/components/__tests__/PDFViewer.spec.tsx b/packages/pluggableWidgets/document-viewer-web/src/components/__tests__/PDFViewer.spec.tsx new file mode 100644 index 0000000000..c9d864b295 --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/components/__tests__/PDFViewer.spec.tsx @@ -0,0 +1,302 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { Document, pdfjs } from "react-pdf"; +import PDFViewer from "../PDFViewer"; +import type { DocumentRendererProps } from "../documentRenderer"; + +jest.mock("react-pdf", () => ({ + Document: jest.fn((props: { children?: unknown }) => props.children ?? null), + Page: jest.fn(() => null), + pdfjs: { + GlobalWorkerOptions: {} as { workerSrc: string }, + version: "4.8.69" + } +})); + +function buildProps(overrides: Partial = {}): DocumentRendererProps { + return { + file: { + status: "available", + value: { uri: "https://apps.example.com/file/123.pdf", name: "file.pdf" } + }, + pdfjsWorkerUrl: { status: "available", value: "https://apps.example.com/worker.js" }, + setDocumentStatus: jest.fn(), + documentStatus: { status: "available" as never }, + ...overrides + } as unknown as DocumentRendererProps; +} + +function lastDocumentProps(): { + options: { cMapUrl: string; standardFontDataUrl: string }; + onLoadSuccess: (args: { numPages: number }) => void; + onLoadError: () => void; +} { + const calls = (Document as unknown as jest.Mock).mock.calls; + return calls[calls.length - 1][0]; +} + +describe("PDFViewer resource URL resolution", () => { + const originalMx = window.mx; + + afterEach(() => { + window.mx = originalMx; + }); + + /** + * Re-evaluates the PDFViewer module against the current `window` state and + * returns the resource `options` passed down to react-pdf's `Document`. + * The `origin` constant is computed at module load, so the module must be + * re-imported between cases for the different `window.mx` states to take effect. + */ + function loadOptions(): { cMapUrl: string; standardFontDataUrl: string } { + jest.resetModules(); + // Re-require React, RTL, react-pdf and PDFViewer together so they all share + // the same freshly-evaluated React instance (resetModules resets React too). + /* eslint-disable @typescript-eslint/no-require-imports */ + const { createElement } = require("react"); + const { render: renderFresh } = require("@testing-library/react"); + const reactPdf = require("react-pdf"); + const FreshPDFViewer = require("../PDFViewer").default; + /* eslint-enable @typescript-eslint/no-require-imports */ + + renderFresh(createElement(FreshPDFViewer, buildProps())); + + const calls = (reactPdf.Document as jest.Mock).mock.calls; + return calls[calls.length - 1][0].options; + } + + it("uses mx.appUrl to build absolute cMap and standard font URLs", () => { + window.mx = { appUrl: "https://apps.example.com/my-app" }; + + expect(loadOptions()).toEqual({ + cMapUrl: "https://apps.example.com/my-app/widgets/com/mendix/shared/pdfjs/cmaps/", + standardFontDataUrl: "https://apps.example.com/my-app/widgets/com/mendix/shared/pdfjs/standard_fonts/" + }); + }); + + it("strips a trailing slash from mx.appUrl to avoid double slashes", () => { + window.mx = { appUrl: "https://apps.example.com/my-app/" }; + + expect(loadOptions()).toEqual({ + cMapUrl: "https://apps.example.com/my-app/widgets/com/mendix/shared/pdfjs/cmaps/", + standardFontDataUrl: "https://apps.example.com/my-app/widgets/com/mendix/shared/pdfjs/standard_fonts/" + }); + }); + + it("falls back to window.location.origin when mx is absent", () => { + window.mx = undefined; + + expect(loadOptions()).toEqual({ + cMapUrl: `${window.location.origin}/widgets/com/mendix/shared/pdfjs/cmaps/`, + standardFontDataUrl: `${window.location.origin}/widgets/com/mendix/shared/pdfjs/standard_fonts/` + }); + }); + + it("falls back to window.location.origin when mx.appUrl is undefined", () => { + window.mx = {}; + + expect(loadOptions()).toEqual({ + cMapUrl: `${window.location.origin}/widgets/com/mendix/shared/pdfjs/cmaps/`, + standardFontDataUrl: `${window.location.origin}/widgets/com/mendix/shared/pdfjs/standard_fonts/` + }); + }); +}); + +describe("PDFViewer worker resolution", () => { + it("uses the provided worker url when available", () => { + render(); + + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe("https://apps.example.com/worker.js"); + }); + + it("falls back to the unpkg cdn worker when no url is provided", () => { + render(); + + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe( + `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs` + ); + }); + + it("reports an error and uses no worker when the worker is unavailable", () => { + const setDocumentStatus = jest.fn(); + + render( + + ); + + expect(setDocumentStatus).toHaveBeenCalledWith({ + status: "error", + message: "Failed to load PDF document : pdfjsWorker unavailable" + }); + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(""); + }); + + it("uses no worker when the worker status is loading", () => { + render(); + + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(""); + }); +}); + +describe("PDFViewer rendering", () => { + it("shows a placeholder when no document is selected", () => { + render(); + + expect(screen.getByText("No document selected")).toBeInTheDocument(); + }); + + it("renders the file name and pagination controls", () => { + render(); + + expect(screen.getByText("file.pdf")).toBeInTheDocument(); + expect(screen.getByLabelText("Page number")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + }); + + it("updates the total page count when the document loads", () => { + render(); + + act(() => lastDocumentProps().onLoadSuccess({ numPages: 7 })); + + expect(screen.getByText("/ 7")).toBeInTheDocument(); + }); + + it("reports an error when the document fails to load", () => { + const setDocumentStatus = jest.fn(); + render(); + + act(() => lastDocumentProps().onLoadError()); + + expect(setDocumentStatus).toHaveBeenCalledWith({ + status: "error", + message: "Failed to load PDF document" + }); + }); +}); + +describe("PDFViewer pagination", () => { + function renderWithPages(numPages: number): void { + render(); + act(() => lastDocumentProps().onLoadSuccess({ numPages })); + } + + it("disables the previous button on the first page and enables next", () => { + renderWithPages(3); + + expect(screen.getByLabelText("Go to previous page")).toBeDisabled(); + expect(screen.getByLabelText("Go to next page")).toBeEnabled(); + }); + + it("navigates to the next and previous page", () => { + renderWithPages(3); + + fireEvent.click(screen.getByLabelText("Go to next page")); + expect(screen.getByLabelText("Page number").value).toBe("2"); + + fireEvent.click(screen.getByLabelText("Go to previous page")); + expect(screen.getByLabelText("Page number").value).toBe("1"); + }); + + it("disables the next button on the last page", () => { + renderWithPages(2); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + expect(screen.getByLabelText("Go to next page")).toBeDisabled(); + }); + + it("accepts only numeric input in the page field", () => { + renderWithPages(5); + const input = screen.getByLabelText("Page number"); + + fireEvent.change(input, { target: { value: "3" } }); + expect(input.value).toBe("3"); + + fireEvent.change(input, { target: { value: "abc" } }); + expect(input.value).toBe("3"); + }); + + it("jumps to a valid page on submit", () => { + renderWithPages(5); + const input = screen.getByLabelText("Page number"); + + fireEvent.change(input, { target: { value: "4" } }); + fireEvent.submit(input.closest("form")!); + + expect(input.value).toBe("4"); + }); + + it("resets to the current page when an out-of-range page is submitted", () => { + renderWithPages(5); + const input = screen.getByLabelText("Page number"); + + fireEvent.change(input, { target: { value: "99" } }); + fireEvent.blur(input); + + expect(input.value).toBe("1"); + }); + + it("validates the page on Enter key press", () => { + renderWithPages(5); + const input = screen.getByLabelText("Page number"); + + fireEvent.change(input, { target: { value: "2" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input.value).toBe("2"); + }); + + it("prevents non-numeric key presses", () => { + renderWithPages(5); + const input = screen.getByLabelText("Page number"); + + const notPrevented = fireEvent.keyDown(input, { key: "a" }); + + expect(notPrevented).toBe(false); + }); + + it("allows control keys such as Backspace", () => { + renderWithPages(5); + const input = screen.getByLabelText("Page number"); + + const notPrevented = fireEvent.keyDown(input, { key: "Backspace" }); + + expect(notPrevented).toBe(true); + }); +}); + +describe("PDFViewer toolbar actions", () => { + it("downloads the file in a new window", () => { + const openSpy = jest.spyOn(window, "open").mockImplementation(() => null); + render(); + + fireEvent.click(screen.getByLabelText("Download")); + + expect(openSpy).toHaveBeenCalledTimes(1); + const [url, windowName] = openSpy.mock.calls[0]; + expect(windowName).toBe("mendix_file"); + expect(url!.toString()).toBe("https://apps.example.com/file/123.pdf?target=window"); + openSpy.mockRestore(); + }); + + it("disables zoom out at the minimum and zoom in at the maximum", () => { + render(); + + const zoomOut = screen.getByLabelText("Zoom out"); + const zoomIn = screen.getByLabelText("Zoom in"); + + // Many zoom-outs hit the lower bound and disable the button. + for (let i = 0; i < 10; i++) { + fireEvent.click(zoomOut); + } + expect(zoomOut).toBeDisabled(); + + fireEvent.click(screen.getByLabelText("Fit to width")); + + // Many zoom-ins hit the upper bound and disable the button. + for (let i = 0; i < 20; i++) { + fireEvent.click(zoomIn); + } + expect(zoomIn).toBeDisabled(); + }); +}); diff --git a/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/dimension.spec.ts b/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/dimension.spec.ts new file mode 100644 index 0000000000..11f163da0f --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/dimension.spec.ts @@ -0,0 +1,71 @@ +import { constructWrapperStyle, DimensionContainerProps } from "../dimension"; + +const baseProps: DimensionContainerProps = { + widthUnit: "pixels", + width: 300, + heightUnit: "pixels", + height: 400, + minHeightUnit: "none", + minHeight: 0, + maxHeightUnit: "none", + maxHeight: 0, + overflowY: "auto" +}; + +describe("constructWrapperStyle", () => { + it("builds pixel width and height", () => { + expect(constructWrapperStyle(baseProps)).toEqual({ + width: "300px", + height: "400px" + }); + }); + + it("builds percentage width", () => { + expect(constructWrapperStyle({ ...baseProps, widthUnit: "percentage", width: 80 })).toMatchObject({ + width: "80%" + }); + }); + + it("uses fit-content for contentFit width", () => { + expect(constructWrapperStyle({ ...baseProps, widthUnit: "contentFit" })).toMatchObject({ + width: "fit-content" + }); + }); + + it("sets height to auto for percentageOfWidth without min/max constraints", () => { + expect(constructWrapperStyle({ ...baseProps, heightUnit: "percentageOfWidth" })).toEqual({ + width: "300px", + height: "auto" + }); + }); + + it("applies min height when minHeightUnit is set", () => { + const style = constructWrapperStyle({ + ...baseProps, + heightUnit: "percentageOfWidth", + minHeightUnit: "pixels", + minHeight: 120 + }); + + expect(style.minHeight).toBe("120px"); + }); + + it("applies max height and overflow when maxHeightUnit is set", () => { + const style = constructWrapperStyle({ + ...baseProps, + heightUnit: "percentageOfWidth", + maxHeightUnit: "percentageOfView", + maxHeight: 50, + overflowY: "scroll" + }); + + expect(style.maxHeight).toBe("50vh"); + expect(style.overflowY).toBe("scroll"); + }); + + it("maps percentageOfParent height unit to a percentage value", () => { + expect(constructWrapperStyle({ ...baseProps, heightUnit: "percentageOfParent", height: 75 })).toMatchObject({ + height: "75%" + }); + }); +}); diff --git a/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/helpers.spec.ts b/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..bcdb0c7f73 --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/helpers.spec.ts @@ -0,0 +1,36 @@ +import { downloadFile } from "../helpers"; + +describe("downloadFile", () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(window, "open").mockImplementation(() => null); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("does nothing when the file url is undefined", () => { + downloadFile(undefined); + + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("opens the file in a dedicated window with a target query param", () => { + downloadFile("https://apps.example.com/file/123"); + + expect(openSpy).toHaveBeenCalledTimes(1); + const [url, windowName] = openSpy.mock.calls[0]; + expect(windowName).toBe("mendix_file"); + expect(url.toString()).toBe("https://apps.example.com/file/123?target=window"); + }); + + it("preserves existing query params when appending target", () => { + downloadFile("https://apps.example.com/file/123?foo=bar"); + + const [url] = openSpy.mock.calls[0]; + expect(url.searchParams.get("foo")).toBe("bar"); + expect(url.searchParams.get("target")).toBe("window"); + }); +}); diff --git a/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/useZoomScale.spec.ts b/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/useZoomScale.spec.ts new file mode 100644 index 0000000000..458f7fbc0d --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/utils/__tests__/useZoomScale.spec.ts @@ -0,0 +1,59 @@ +import { renderHook, act } from "@testing-library/react"; +import { useZoomScale } from "../useZoomScale"; + +describe("useZoomScale", () => { + it("starts at a zoom level of 1", () => { + const { result } = renderHook(() => useZoomScale()); + + expect(result.current.zoomLevel).toBe(1); + }); + + it("zooms in by a factor of 1.2", () => { + const { result } = renderHook(() => useZoomScale()); + + act(() => result.current.zoomIn()); + + expect(result.current.zoomLevel).toBeCloseTo(1.2); + }); + + it("zooms out by a factor of 0.8", () => { + const { result } = renderHook(() => useZoomScale()); + + act(() => result.current.zoomOut()); + + expect(result.current.zoomLevel).toBeCloseTo(0.8); + }); + + it("does not zoom in beyond the maximum of 10", () => { + const { result } = renderHook(() => useZoomScale()); + + act(() => { + for (let i = 0; i < 50; i++) { + result.current.zoomIn(); + } + }); + + expect(result.current.zoomLevel).toBe(10); + }); + + it("does not zoom out below the minimum of 0.3", () => { + const { result } = renderHook(() => useZoomScale()); + + act(() => { + for (let i = 0; i < 50; i++) { + result.current.zoomOut(); + } + }); + + expect(result.current.zoomLevel).toBe(0.3); + }); + + it("resets the zoom level back to 1", () => { + const { result } = renderHook(() => useZoomScale()); + + act(() => result.current.zoomIn()); + act(() => result.current.reset()); + + expect(result.current.zoomLevel).toBe(1); + }); +}); diff --git a/packages/pluggableWidgets/document-viewer-web/typings/global.d.ts b/packages/pluggableWidgets/document-viewer-web/typings/global.d.ts new file mode 100644 index 0000000000..3c83dddd5d --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/typings/global.d.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + mx?: { appUrl?: string }; + } +} + +export {}; diff --git a/packages/pluggableWidgets/document-viewer-web/typings/modules.d.ts b/packages/pluggableWidgets/document-viewer-web/typings/modules.d.ts new file mode 100644 index 0000000000..cbe652dbe0 --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/typings/modules.d.ts @@ -0,0 +1 @@ +declare module "*.css";