Skip to content

[WC-3297] Signature web#2138

Open
gjulivan wants to merge 3 commits into
mainfrom
signature-web
Open

[WC-3297] Signature web#2138
gjulivan wants to merge 3 commits into
mainfrom
signature-web

Conversation

@gjulivan

@gjulivan gjulivan commented Mar 16, 2026

Copy link
Copy Markdown
Collaborator

Signature Widget v2.0 - Custom Widget to Pluggable Widget Migration

Summary

Complete rewrite of the Signature widget, migrating from the legacy Custom Widget architecture (v1.0.8) to the modern
Pluggable Widget API (v2.0.0). This is a major architectural change that brings the widget in line with Mendix's current
standards and provides better Studio Pro integration, improved maintainability, and enhanced functionality.

🚨 Breaking Changes

  • Minimum Mendix version: Now requires Mendix 11.8.0+ (previously 7.13.1)
  • Widget ID changed: com.mendix.widget.custom.signature.Signaturecom.mendix.widget.web.signature.Signature
  • No backward compatibility: Existing implementations using v1.x must be reconfigured in Studio Pro
  • Architecture: Complete rewrite - not an in-place upgrade

✨ What's New

Added Features

  • Custom filename support - New fileName property (textTemplate) for customizing saved file names
  • OnSignEnd action - Event triggers after each stroke with signature image URI as parameter
  • Enhanced dimensions - Min/max height controls, viewport units, overflow options
  • Improved editor preview - Interactive preview in Studio Pro showing grid and dimensions
  • Better validation - Uses @mendix/widget-plugin-component-kit ValidationAlert

Technical Improvements

  • Functional components with hooks - Modern React patterns replacing class-based components
  • Declarative data flow - No manual subscriptions, uses Mendix Pluggable Widget API
  • Better build system - Rollup + @mendix/pluggable-widgets-tools replacing Webpack
  • Enhanced type safety - Full TypeScript support with Mendix types
  • Improved testing - Comprehensive unit test suite

🔄 Migration Notes

For Developers

  • Widget location: packages/pluggableWidgets/signature-web/ (new) vs packages/customWidgets/signature-web/ (legacy)
  • Build commands unchanged: pnpm run build, pnpm run dev, pnpm run test
  • Legacy v1.x remains available in packages/customWidgets/ for reference

For App Builders

  • After upgrade, signature widgets must be reconfigured in Studio Pro
  • All functionality from v1.x is preserved and enhanced
  • No data migration needed - existing signature images remain accessible

📋 Preserved Functionality

All v1.x features are maintained:

  • ✅ Canvas-based signature drawing with signature_pad v5.1.3
  • ✅ Three pen types (fountain, ballpoint, marker)
  • ✅ Customizable pen color
  • ✅ Optional background grid
  • ✅ Flexible sizing options
  • ✅ Read-only mode
  • ✅ Clear signature on attribute change
  • ✅ Resize preserves signature data
  • ✅ Offline capability
  • ✅ Touch and mouse input support

🧪 Testing Completed

  • Unit tests passing (pnpm run test)
  • Linting passing (pnpm run lint)
  • Build successful (pnpm run build)
  • Canvas initialization and drawing
  • Signature save to image attribute
  • Custom filename generation
  • OnSignEnd action execution
  • Resize behavior with signature preservation
  • Read-only mode toggle
  • Clear signature on attribute change
  • Grid rendering with various configurations
  • Studio Pro preview rendering
  • Touch and mouse input

@gjulivan gjulivan requested a review from a team as a code owner March 16, 2026 13:14
@gjulivan gjulivan changed the title Signature web [WC-3297] Signature web Mar 16, 2026
Comment thread packages/pluggableWidgets/signature-web/src/__tests__/AppEvents.spec.tsx Outdated
Comment thread packages/pluggableWidgets/signature-web/src/components/Alert.tsx Outdated
Comment thread packages/pluggableWidgets/signature-web/src/ui/SignaturePreview.css Outdated
Comment thread packages/pluggableWidgets/signature-web/src/assets/Signature.icon.dark.png Outdated
Comment thread packages/pluggableWidgets/signature-web/src/package.xml Outdated
Comment thread packages/pluggableWidgets/signature-web/src/Signature.editorConfig.ts Outdated
Comment thread packages/pluggableWidgets/signature-web/src/Signature.tsx Outdated
Comment thread packages/pluggableWidgets/signature-web/CHANGELOG.md
Comment thread packages/pluggableWidgets/signature-web/package.json Outdated
Comment thread packages/pluggableWidgets/signature-web/package.json
Comment thread packages/pluggableWidgets/signature-web/package.json Outdated
@gjulivan gjulivan force-pushed the signature-web branch 8 times, most recently from 1edd154 to c439717 Compare March 26, 2026 08:45
Comment thread packages/pluggableWidgets/signature-web/src/components/Signature.tsx Outdated
Comment thread packages/pluggableWidgets/signature-web/README.md Outdated
Comment thread packages/pluggableWidgets/signature-web/package.json Outdated
Comment thread packages/pluggableWidgets/signature-web/package.json Outdated
Comment thread packages/pluggableWidgets/signature-web/mendix-pluggable-widgets-tools.tgz Outdated
Comment thread packages/pluggableWidgets/signature-web/src/Signature.xml Outdated
Comment thread packages/pluggableWidgets/signature-web/CHANGELOG.md
Comment thread packages/pluggableWidgets/signature-web/README.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need it here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

don't we have the same structure as in combobox?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can keep this one, but let's remove:

  1. Architecture seciton
  2. Key Files section
  3. Properties - they will be later added as props.md

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

done

@gjulivan gjulivan closed this May 19, 2026
@gjulivan gjulivan reopened this May 19, 2026
@gjulivan gjulivan force-pushed the signature-web branch 2 times, most recently from c154fa9 to e9fee3d Compare May 28, 2026 09:54

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

placeholder

@@ -0,0 +1,44 @@
import classNames from "classnames";
import { ReactElement } from "react";
import { useSignaturePad } from "src/utils/useSignaturePad";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔶 Medium — Non-absolute import path will fail in build

import { useSignaturePad } from "src/utils/useSignaturePad" uses a bare src/ prefix. Rollup and TypeScript moduleResolution: "node" resolve this against node_modules, not the package root. The build will error unless src is aliased — but there is no such alias in tsconfig.json or rollup.config.mjs.

Fix: Change to a relative import:

Suggested change
import { useSignaturePad } from "src/utils/useSignaturePad";
import { useSignaturePad } from "../utils/useSignaturePad";

Comment on lines +17 to +19
if (imageDataUrl) {
const customFileName = fileName?.value || Utils.generateFileName("signature");
imageSource.setValue(Utils.convertUrlToBlob(imageDataUrl, customFileName));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔶 Medium — imageSource.setValue() called even when attribute is read-only

imageSource.setValue(...) is called unconditionally when imageDataUrl is truthy. When imageSource.readOnly === true the runtime silently discards the value.

Fix: Add !imageSource.readOnly guard:

Suggested change
if (imageDataUrl) {
const customFileName = fileName?.value || Utils.generateFileName("signature");
imageSource.setValue(Utils.convertUrlToBlob(imageDataUrl, customFileName));
if (imageDataUrl && !imageSource.readOnly) {
imageSource.setValue(convertUrlToBlob(imageDataUrl, getFileName(imageSource)));
}

Comment on lines +40 to +49
const handleSignEnd = useCallback(() => {
const imageDataUrl = signaturePadRef.current?.toDataURL();

if (hasSignatureAttribute) {
hasSignatureAttribute.setValue(!signaturePadRef.current?.isEmpty());
}
if (imageDataUrl && onSignEnd) {
onSignEnd(imageDataUrl);
}
}, [hasSignatureAttribute, onSignEnd]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔶 Medium — onSignEnd missing from useCallback dependency array

onSignEnd is used inside handleSignEnd but is absent from useCallback's deps. When the parent re-renders with a new onSignEnd reference, the memoised callback retains the old one — stale closure bug.

Fix: Add onSignEnd to the dependency array on the closing line of this useCallback.

Comment on lines +84 to +102
useEffect(() => {
if (canvasRef.current) {
// only instantiate when all data is loaded properly to avoid unnecessary re-instantiations
const canInstantiateSignaturePad =
signaturePadRef.current === null &&
(imageSource?.status === "available" ? imageSource.value?.uri : imageSource.status === "unavailable");
if (canInstantiateSignaturePad && !isSignatureInitialized.current) {
signaturePadRef.current = new SignaturePad(canvasRef.current, {
penColor,
...signaturePadOptions
});
signaturePadRef.current.addEventListener("endStroke", handleSignEnd);
if (readOnly) {
signaturePadRef.current?.off();
}
isSignatureInitialized.current = true;
}
}
}, [handleSignEnd, penColor, readOnly, signaturePadOptions, imageSource, hasSignatureAttribute]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔶 Medium — SignaturePad event listener is never removed on unmount

The initialization useEffect attaches pad.addEventListener("endStroke", handleSignEnd) but returns no cleanup function. On unmount the listener leaks and handleSignEnd fires against stale/deallocated props.

Fix: Return a cleanup function from this effect:

return () => {
    pad.removeEventListener("endStroke", handleSignEnd);
    pad.off();
};

@@ -0,0 +1,63 @@
import "@testing-library/jest-dom";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔶 Medium — Insufficient unit test coverage

The test suite mocks out SignatureComponent entirely and only verifies the thin container renders and forwards props. The actual widget logic — sign-end callback, canExecute guard, read-only prevention, clear-on-attribute-change, resize — has zero coverage, despite the PR description claiming "comprehensive unit tests".

Fix: Add tests directly against SignatureComponent using @mendix/widget-plugin-test-utils builders (EditableValueBuilder, actionValue()). Key cases to cover:

  • canExecute: false must not call execute
  • hasSignatureAttribute transitioning true → false clears the canvas
  • Read-only imageSource prevents setValue

@@ -0,0 +1,44 @@
// eslint-disable-next-line @typescript-eslint/no-extraneous-class

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Low — All-static class with eslint suppression

The @typescript-eslint/no-extraneous-class suppression on this file exists only to allow an all-static class. Plain exported functions are idiomatic TypeScript, tree-shakable, and eliminate the need for this suppression comment entirely.

>
<Grid
gridBorderColor={gridBorderColor || "#000000"}
gridBorderWidth={gridBorderWidth || 50}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Low — gridBorderWidth fallback is 50px instead of 1px

gridBorderWidth || 50 uses ||, so an intentional value of 0 falls back to 50 (50px border). The XML default is 1. Use nullish coalescing to only fall back on null/undefined:

Suggested change
gridBorderWidth={gridBorderWidth || 50}
gridBorderWidth={gridBorderWidth ?? 1}

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

AI Code Review

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


What was reviewed

File Change
packages/pluggableWidgets/signature-web/src/Signature.tsx New root widget component
packages/pluggableWidgets/signature-web/src/Signature.xml New widget manifest
packages/pluggableWidgets/signature-web/typings/SignatureProps.d.ts Generated typings
packages/pluggableWidgets/signature-web/src/components/Signature.tsx Main widget rendering component
packages/pluggableWidgets/signature-web/src/components/SizeContainer.tsx Resize wrapper component
packages/pluggableWidgets/signature-web/src/components/Grid.tsx SVG grid component
packages/pluggableWidgets/signature-web/src/utils/useSignaturePad.ts Core signature pad hook
packages/pluggableWidgets/signature-web/src/utils/Utils.ts Blob/filename helpers
packages/pluggableWidgets/signature-web/src/utils/dimensions.ts CSS dimension calculator
packages/pluggableWidgets/signature-web/src/Signature.editorConfig.ts Studio Pro design-time config
packages/pluggableWidgets/signature-web/src/Signature.editorPreview.tsx Studio Pro preview component
packages/pluggableWidgets/signature-web/src/ui/Signature.scss Widget styles
packages/pluggableWidgets/signature-web/src/ui/SignaturePreview.scss Preview styles
packages/pluggableWidgets/signature-web/src/assets/icons.tsx Icon components
packages/pluggableWidgets/signature-web/src/__tests__/Signature.spec.tsx Unit tests
packages/pluggableWidgets/signature-web/CHANGELOG.md Changelog
packages/pluggableWidgets/signature-web/package.json Package manifest
packages/customWidgets/signature-web/* Deleted legacy custom widget (all files)

Skipped (out of scope): pnpm-lock.yaml, dist/, PNG/SVG asset files


Findings

🔶 Medium — penColor change does not update an already-initialised pad

File: packages/pluggableWidgets/signature-web/src/utils/useSignaturePad.ts line 84–109

Problem: penColor and signaturePadOptions are in the dependency array of the init useEffect, but because of the isSignatureInitialized.current guard the pad is only constructed once. After first render, changing pen color or type in Studio Pro at runtime has no effect on the live pad. The old v1 widget explicitly called signaturePad.penColor = penColor on updates.

Fix: Separate construction from configuration. Keep the init useEffect with the guard as-is, but add a second useEffect that updates the existing pad when pen properties change:

useEffect(() => {
    if (signaturePadRef.current) {
        signaturePadRef.current.penColor = penColor;
        Object.assign(signaturePadRef.current, signaturePadOptions);
    }
}, [penColor, signaturePadOptions]);

🔶 Medium — Existing saved image is never drawn onto the canvas

File: packages/pluggableWidgets/signature-web/src/utils/useSignaturePad.ts line 88–100

Problem: The init logic correctly waits until imageSource.value?.uri is available, but never renders it onto the canvas (SignaturePad.fromDataURL or drawing the image to the 2D context is absent). Loading a record that already has a stored signature will show a blank canvas.

Fix: After constructing the pad, load the image if a URI is present:

const uri = imageSource.value?.uri;
if (uri) {
    signaturePadRef.current.fromDataURL(uri);
}

🔶 Medium — Stale closure: handleSignEnd recreated every render cycle

File: packages/pluggableWidgets/signature-web/src/utils/useSignaturePad.ts line 40–49, 103–106

Problem: The cleanup in the init useEffect removes the current handleSignEnd listener, but because the effect has handleSignEnd in its dep array, every time hasSignatureAttribute or onSignEnd changes the cleanup runs and a new listener is registered — but isSignatureInitialized.current is true so the pad is not re-created. The removeEventListener call in cleanup uses the stale previous handleSignEnd reference, which is a different function instance than the one that was actually added, so the old listener leaks.

Fix: Use a stable ref for the callback:

const onSignEndRef = useRef(onSignEnd);
useEffect(() => { onSignEndRef.current = onSignEnd; }, [onSignEnd]);

const handleSignEnd = useCallback(() => {
    // use onSignEndRef.current instead of onSignEnd
}, [hasSignatureAttribute]); // remove onSignEnd from deps

Or register the endStroke listener only once in the init effect and read props via refs inside it.


🔶 Medium — Unit tests use manual mock object instead of builder utilities

File: packages/pluggableWidgets/signature-web/src/__tests__/Signature.spec.tsx line 14–16

Problem: imageSource is constructed as a raw cast (as unknown as SignatureContainerProps["imageSource"]). The test-utils package (@mendix/widget-plugin-test-utils) is in devDependencies and provides EditableImageValueBuilder for this purpose. Manual casts miss status edge cases (loading, unavailable, readOnly) that are important for this widget's behaviour.

Fix:

import { EditableImageValueBuilder } from "@mendix/widget-plugin-test-utils";
const imageSource = new EditableImageValueBuilder().withValue({ uri: undefined }).build();

🔶 Medium — No tests for SignatureComponent or useSignaturePad

File: packages/pluggableWidgets/signature-web/src/__tests__/Signature.spec.tsx

Problem: The only unit tests mock out SignatureComponent entirely and only verify the wrapper passes props through. None of the widget's behaviour — validation display, grid toggle, handleSignEnd callback, readonly toggling, hasSignatureAttribute clearing — is covered. This is a new widget with non-trivial logic; the test suite provides almost no regression safety.

Fix: Add tests for SignatureComponent covering at minimum:

  • Renders ValidationAlert when imageSource.validation is set
  • Does not render grid when showGrid=false or readOnly=true
  • Canvas has correct aria-label and aria-required attributes
  • onSignEndAction.execute is called only when canExecute=true and not readOnly

⚠️ Low — !important in Signature.scss

File: packages/pluggableWidgets/signature-web/src/ui/Signature.scss line 4

Note: padding: 0 !important overrides Atlas UI form control padding. Per the frontend guidelines, !important should be avoided. Applying padding: 0 at a more specific selector (e.g. .widget-signature .widget-signature-wrapper) would avoid the escalation.


⚠️ Low — EventsIcon component is unused dead code

File: packages/pluggableWidgets/signature-web/src/assets/icons.tsx

Note: EventsIcon is exported but not imported anywhere in the widget. It appears to be a leftover from another widget or a scaffolding artifact. It should be removed to keep the bundle clean.


⚠️ Low — ariaRequired reads .value without checking .status

File: packages/pluggableWidgets/signature-web/src/components/Signature.tsx line 48

Note: ariaRequired is a DynamicValue<boolean>. Accessing .value when .status !== "available" returns undefined, but undefined === true is false so it silently falls back to omitting the attribute. This is not harmful here, but it is inconsistent with the Mendix pattern of checking .status first and should be guarded for completeness.


Positives

  • Clean migration structure: legacy customWidgets/signature-web is fully deleted, not left as a zombie alongside the new package.
  • useId() in Grid.tsx correctly generates a unique SVG <pattern id> — avoids the duplicate-id bug that commonly breaks grids when multiple instances are on a page.
  • onSignEndAction.canExecute is checked before execute() — correct Mendix action guard pattern.
  • isSignatureInitialized ref prevents the pad from being re-created on every re-render, which was a common pitfall in older implementations.
  • CHANGELOG entry is present and correctly documents breaking changes for v2.0.0.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants