diff --git a/packages/pluggableWidgets/barcode-generator-web/CHANGELOG.md b/packages/pluggableWidgets/barcode-generator-web/CHANGELOG.md index 2fc2506a98..70ce88ccea 100644 --- a/packages/pluggableWidgets/barcode-generator-web/CHANGELOG.md +++ b/packages/pluggableWidgets/barcode-generator-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added Data Matrix and GS1 Data Matrix generation support, including square and rectangular symbol shapes. + ## [1.0.0] - 2026-04-17 ### Added diff --git a/packages/pluggableWidgets/barcode-generator-web/e2e/BarcodeGenerator.spec.js b/packages/pluggableWidgets/barcode-generator-web/e2e/BarcodeGenerator.spec.js index 433adf2aea..8557847ba0 100644 --- a/packages/pluggableWidgets/barcode-generator-web/e2e/BarcodeGenerator.spec.js +++ b/packages/pluggableWidgets/barcode-generator-web/e2e/BarcodeGenerator.spec.js @@ -1,20 +1,70 @@ import { test, expect } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; +/** + * These tests are dormant until the test project exists: `package.json` still has + * `"e2e": "echo ..."` because https://github.com/mendix/testProjects has no + * `barcode-generator-web` branch yet (only `barcode-scanner-web`). + * + * To enable, create that branch with a `/p/datamatrix` page containing: + * - dataMatrixPlain Data Matrix, GS1 off, square, value "ABC-12345" + * - dataMatrixGs1 Data Matrix, GS1 on, value "(01)09501101020917(17)261231(10)ABC123" + * - dataMatrixRectangle Data Matrix, shape Rectangle + * - dataMatrixDownload Data Matrix with "Allow download" on, file name "datamatrix" + * - textBoxCodeValue text box bound to the attribute dataMatrixBound reads from + * - dataMatrixBound Data Matrix bound to that attribute + * then swap the `e2e` script to `run-e2e ci`. + */ test.describe("BarcodeGenerator", () => { test.beforeEach(async ({ page }) => { - await page.goto("/"); - await waitForMendixApp(page); + await page.goto("/p/datamatrix"); }); - test("renders barcode generator widget", async ({ page }) => { - // TODO: Replace with actual barcode generator test when implementation is complete - // Example test structure for barcode generator: - // await expect(page.locator(".mx-name-barcodeGenerator").first()).toBeVisible(); - // await page.locator(".mx-name-textInput").fill("Test QR Code"); - // await expect(page.locator(".mx-name-barcodeGenerator canvas")).toBeVisible(); + test("renders a Data Matrix symbol as inline SVG @smoke", async ({ page }) => { + const symbol = page.locator(".mx-name-dataMatrixPlain .datamatrix-svg svg"); - // Placeholder test for now - await expect(page.locator("body")).toBeVisible(); + await expect(symbol).toBeVisible(); + await expect(symbol).toHaveAttribute("viewBox", /^0 0 \d+(\.\d+)? \d+(\.\d+)?$/); + }); + + test("renders a GS1 Data Matrix without falling back to the error state", async ({ page }) => { + const widget = page.locator(".mx-name-dataMatrixGs1"); + + await expect(widget.locator(".datamatrix-svg svg")).toBeVisible(); + await expect(widget.locator(".alert-danger")).toHaveCount(0); + }); + + test("renders the rectangular shape wider than it is tall", async ({ page }) => { + const symbol = page.locator(".mx-name-dataMatrixRectangle .datamatrix-svg svg"); + await expect(symbol).toBeVisible(); + + await expect + .poll(async () => { + const box = await symbol.boundingBox(); + return box ? box.width > box.height : false; + }) + .toBe(true); + }); + + test("re-renders when the bound value changes", async ({ page }) => { + const symbol = page.locator(".mx-name-dataMatrixBound .datamatrix-svg svg"); + await expect(symbol).toBeVisible(); + const before = await symbol.getAttribute("viewBox"); + + // A longer value needs more modules, so the symbol grows + await page.locator(".mx-name-textBoxCodeValue input").fill("ABC-12345-67890-LONGER-VALUE"); + await page.locator(".mx-name-textBoxCodeValue input").blur(); + + await expect(symbol).not.toHaveAttribute("viewBox", before); + }); + + test("downloads the Data Matrix as a PNG", async ({ page }) => { + await expect(page.locator(".mx-name-dataMatrixDownload .datamatrix-svg svg")).toBeVisible(); + + // Start waiting for the download before clicking + const downloadPromise = page.waitForEvent("download"); + await page.locator(".mx-name-dataMatrixDownload .barcode-generator-download-button").click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/\.png$/); }); }); diff --git a/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/.openspec.yaml b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/.openspec.yaml new file mode 100644 index 0000000000..074342d550 --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-09 diff --git a/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/design.md b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/design.md new file mode 100644 index 0000000000..ab3a5e1a48 --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/design.md @@ -0,0 +1,51 @@ +## Context + +`@mendix/barcode-generator-web` renders three-way today: 1D barcodes via `jsbarcode` (isolated in `src/utils/barcodeRenderer-utils.ts`) and QR via `qrcode.react` (isolated in `src/components/QRCode.tsx`). `src/config/Barcode.config.ts` maps Mendix props into a lib-agnostic discriminated union `BarcodeConfig = BarcodeTypeConfig | QRCodeTypeConfig`, and `src/BarcodeGenerator.tsx:25` dispatches on `config.type`. Rendering is SVG-first; download converts SVG→PNG via `src/utils/download-code.ts` + `download-utils.ts`. + +Neither current lib can produce DataMatrix. Pharma requires **GS1 DataMatrix** (FNC1 + GS1 Application Identifiers), not just plain DataMatrix. + +## Goals / Non-Goals + +**Goals:** + +- Add DataMatrix and GS1 DataMatrix generation as a third render path, reusing the existing config-union + dispatch + SVG→PNG download architecture. +- Keep the new library isolated behind a single seam module, mirroring the QR split. +- No regression to existing barcode/QR behavior. + +**Non-Goals:** + +- Replacing jsbarcode or qrcode.react. +- Building a GS1 AI parser/validator beyond loose syntax checks (bwip-js validates the encoding). +- Adding non-DataMatrix 2D symbologies (Aztec, PDF417) — out of scope. + +## Decisions + +**Library: `@bwip-js/browser`.** Only maintained (v4.11.x, ~676k dl/wk, MIT) library with native GS1 DataMatrix (`bcid: "gs1datamatrix"`, accepts human-readable AI syntax) and rectangular support. Import the named `datamatrix` + `drawingSVG` exports so bundlers tree-shake — do NOT use `toSVG()`, which links all ~100 BWIPP encoders. + +- _Alternatives:_ `datamatrix-svg` (no GS1, unmaintained since 2020) rejected; `@zxing/library` (used by scanner) is decode-only, cannot encode. + +**Third render path, not a `customCodeFormat` sub-option.** DataMatrix is 2D with its own options (GS1 toggle, shape), like QR. Add `DataMatrix` to the top-level `codeFormat` enum and a `DataMatrixTypeConfig` (`type: "datamatrix"`) to the union. `barcodeConfig()` branches on `format === "DataMatrix"` before the QR check. + +- _Alternative:_ nesting under `customCodeFormat` (the 1D list) rejected — that list is jsbarcode-specific and 1D-shaped. + +**GS1 as a boolean toggle under Data Matrix, not a separate top-level format.** Matches the pharma mental model ("same symbology, GS1-encoded") and keeps the top-level enum small. `dmGs1Mode` selects `gs1datamatrix` vs `datamatrix`. + +**New seam `src/components/DataMatrix.tsx`.** Mirrors `QRCode.tsx`: builds bwip-js options, renders SVG, exposes an `SVGSVGElement` ref so the existing `DownloadButton` + `downloadCode(ref, config, ...)` pipeline works unchanged. Validation + try/catch error state live in this component (the `useRenderBarcode` hook is 1D/jsbarcode-specific). + +**Validation.** Extend `validateBarcodeValue` in `src/config/validation.ts` with a `DataMatrix` case (the `format` param already unions `CodeFormatEnum`): plain mode = charset/length sanity; GS1 mode = loose balanced-`(nn)` AI-syntax check. Encoder errors caught at render. + +## Risks / Trade-offs + +- **Bundle size** → bwip-js is large if fully linked. Mitigation: import only `datamatrix`/`drawingSVG` named exports; verify bundle delta at build time. +- **Download ref shape** → bwip-js `drawingSVG()` returns SVG markup, not a React-managed ``. Mitigation: inject markup into a container and target the real `SVGSVGElement` for the ref so SVG→PNG works. +- **GS1 AI validation drift** → hand-rolled loose check may diverge from bwip-js. Mitigation: keep it minimal (structure only), let bwip-js be the source of truth and catch its errors. +- **Two enums to keep in sync** → `codeFormat` value must round-trip through config + validation + preview. Mitigation: covered by unit tests. + +## Migration Plan + +Additive, no data migration. New dependency added to `package.json`; `typings/BarcodeGeneratorProps.d.ts` regenerated by build. Rollback = revert the change and drop the dependency; existing barcode/QR configs unaffected. + +## Open Questions + +- Exact bwip-js option for rectangular DataMatrix (shape flag vs explicit `rows`/`columns`) — confirm at implementation. +- ~~Whether to reuse `codeMargin`/`qrSize`-style sizing or add dedicated `dmSize`/`dmMargin` props — lean toward dedicated to avoid overloading 1D "bar width" semantics.~~ Resolved: dedicated `dmSize` and `dmMargin`. bwip-js `paddingwidth`/`paddingheight` are multiplied by `scale`, so the Data Matrix margin is in module units like `qrMargin`, not pixels like `codeMargin` — reusing `codeMargin` would have mislabelled the unit and let a 1D margin of 0 strip the required quiet zone. diff --git a/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/proposal.md b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/proposal.md new file mode 100644 index 0000000000..29088bdf48 --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/proposal.md @@ -0,0 +1,34 @@ +## Why + +The Barcode Generator widget (`@mendix/barcode-generator-web`) can only produce 1D linear barcodes (jsbarcode) and QR codes (qrcode.react); it cannot generate DataMatrix. DataMatrix — specifically **GS1 DataMatrix** (FNC1 + GS1 Application Identifiers) — is mandatory for pharma serialization (EU FMD, US DSCSA). The Barcode Scanner widget already decodes DataMatrix, so generation closes the round-trip gap for pharma and logistics apps. + +## What Changes + +- Add **Data Matrix** as a top-level `codeFormat` option in the Barcode Generator. +- Support two encodings: plain DataMatrix and **GS1 DataMatrix** (pharma), selectable via a boolean toggle. +- Support square and rectangular symbol shapes. +- Add `@bwip-js/browser` as a dependency (tree-shakeable DataMatrix encoder) — the only maintained library with native GS1 DataMatrix support. jsbarcode and qrcode.react remain for their existing formats. +- Render DataMatrix as inline SVG (consistent with existing render + SVG→PNG download pipeline). +- Add runtime/design-time validation for DataMatrix values (plain charset/length; GS1 AI syntax). +- Add Studio Pro editor preview for the DataMatrix format. + +No breaking changes — existing barcode/QR behavior is untouched; DataMatrix is additive. + +## Capabilities + +### New Capabilities + +- `barcode-generation`: Generation of barcodes, QR codes, and (new) DataMatrix / GS1 DataMatrix from a string input in the Barcode Generator widget, including format selection, encoding options, validation, rendering, and download. + +### Modified Capabilities + + + +## Impact + +- **Package**: `packages/pluggableWidgets/barcode-generator-web` +- **New dependency**: `@bwip-js/browser` (MIT, tree-shakeable) +- **Widget config**: `src/BarcodeGenerator.xml` (new enum value + Data Matrix property group); regenerated `typings/BarcodeGeneratorProps.d.ts` +- **Source**: new `src/components/DataMatrix.tsx` (bwip-js seam), extended `src/config/Barcode.config.ts` (discriminated union), `src/BarcodeGenerator.tsx` (dispatch), `src/config/validation.ts`, editor-preview files +- **Reuses** existing `DownloadButton` + `src/utils/download-code.ts` SVG→PNG pipeline unchanged +- **Tests**: Jest unit tests + Playwright E2E; `CHANGELOG.md` entry diff --git a/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/specs/barcode-generation/spec.md b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/specs/barcode-generation/spec.md new file mode 100644 index 0000000000..d615f9a0dd --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/specs/barcode-generation/spec.md @@ -0,0 +1,127 @@ +## ADDED Requirements + +### Requirement: DataMatrix format selection + +The Barcode Generator widget SHALL offer "Data Matrix" as a top-level barcode format alongside the existing Barcode (1D) and QR Code options. + +#### Scenario: Data Matrix appears in format list + +- **WHEN** a developer configures the Barcode Generator in Studio Pro and opens the Barcode Format property +- **THEN** "Data Matrix" is available as a selectable format value + +#### Scenario: Selecting Data Matrix routes to the DataMatrix renderer + +- **WHEN** the Barcode Format is set to "Data Matrix" and a non-empty value is provided +- **THEN** the widget renders a DataMatrix symbol as inline SVG, and does not invoke the 1D barcode or QR code renderers + +### Requirement: Plain DataMatrix generation + +The widget SHALL encode the provided string value as a standard (non-GS1) DataMatrix symbol when GS1 mode is off. + +#### Scenario: Encode a plain string + +- **WHEN** the format is "Data Matrix", GS1 mode is off, and the value is `ABC-12345` +- **THEN** the widget renders a scannable DataMatrix symbol encoding exactly that string + +#### Scenario: Round-trip with the scanner + +- **WHEN** a plain DataMatrix generated by the widget is scanned by the Barcode Scanner widget +- **THEN** the decoded value equals the original input string + +### Requirement: GS1 DataMatrix generation + +The widget SHALL support GS1 DataMatrix encoding (FNC1 leading character, GS1 Application Identifier data) when GS1 mode is enabled, so pharma serialization data (GTIN, expiry, batch, serial) can be encoded. + +#### Scenario: Encode GS1 Application Identifier data + +- **WHEN** GS1 mode is enabled and the value is `(01)09501101020917(17)261231(10)ABC123` +- **THEN** the widget renders a GS1 DataMatrix symbol with the FNC1 indicator and the AI-structured data intact + +#### Scenario: GS1 mode toggles the encoder + +- **WHEN** the same value is rendered first with GS1 mode off and then on +- **THEN** the off result is a plain DataMatrix and the on result is a GS1 DataMatrix (distinct symbols) + +### Requirement: DataMatrix symbol shape + +The widget SHALL allow choosing between square and rectangular DataMatrix symbol shapes. + +#### Scenario: Rectangular shape + +- **WHEN** the DataMatrix shape option is set to "rectangle" +- **THEN** the rendered symbol uses a rectangular DataMatrix layout + +#### Scenario: Square shape is the default + +- **WHEN** no shape is explicitly chosen +- **THEN** the widget renders a square DataMatrix symbol + +### Requirement: DataMatrix value validation + +The widget SHALL validate the DataMatrix value and surface errors consistently with existing formats, honoring the configured log level. + +#### Scenario: Empty value at design time + +- **WHEN** no value is present (design time, before dynamic binding resolves) +- **THEN** validation passes and no error is shown, matching existing barcode behavior + +#### Scenario: Malformed GS1 AI syntax + +- **WHEN** GS1 mode is on and the value has unbalanced or malformed Application Identifier syntax +- **THEN** the widget reports a validation error and, when the log level permits, displays the generic "unable to generate" message and logs detail to the console + +#### Scenario: Encoding failure + +- **WHEN** the DataMatrix encoder throws for an invalid input +- **THEN** the error is caught, the error UI is shown per log level, and the widget does not crash + +### Requirement: DataMatrix download + +The widget SHALL allow downloading a generated DataMatrix as a PNG using the existing download control, when downloads are enabled. + +#### Scenario: Download a DataMatrix as PNG + +- **WHEN** downloads are enabled and the user activates the download button on a DataMatrix +- **THEN** a PNG file of the rendered DataMatrix is downloaded, using the configured or an auto-generated filename + +### Requirement: DataMatrix sizing and quiet zone + +The widget SHALL expose dedicated size and margin properties for Data Matrix, with the margin expressed in module units so the required quiet zone can be controlled independently of the 1D barcode margin. + +#### Scenario: Margin is independent of the 1D and QR margins + +- **WHEN** the format is "Data Matrix" and the Data Matrix margin is set to 6 while the 1D margin is 4 and the QR margin is 8 +- **THEN** the rendered symbol uses a quiet zone of 6 module units, and changing the 1D or QR margin has no effect on it + +#### Scenario: Missing quiet zone is flagged + +- **WHEN** the format is "Data Matrix" and the Data Matrix margin is set to 0 +- **THEN** Studio Pro shows a warning that at least 1 module unit is needed for the symbol to stay scannable + +### Requirement: Format-scoped property visibility + +The widget SHALL only show the property groups that apply to the selected barcode format, so Data Matrix settings are hidden for other formats and 1D/QR settings are hidden for Data Matrix. + +#### Scenario: Data Matrix settings are scoped to the Data Matrix format + +- **WHEN** the Barcode Format is "Barcode", "QR Code" or "Custom" +- **THEN** the "Advanced Data Matrix Settings" properties (GS1 mode, symbol shape, size) are hidden in Studio Pro + +#### Scenario: 1D and QR settings are hidden for Data Matrix + +- **WHEN** the Barcode Format is "Data Matrix" +- **THEN** bar width, code height, display value, the 1D pixel margin, the advanced barcode settings (EAN-128, flat, last character, Mod43), the EAN addon properties and the QR properties are all hidden, and the Data Matrix margin is shown in their place + +#### Scenario: Design-time validation follows the visible properties + +- **WHEN** the Barcode Format is "Data Matrix" and the Data Matrix size is below the supported minimum +- **THEN** Studio Pro reports the problem on the Data Matrix size property and does not report problems on hidden 1D or QR sizing properties + +### Requirement: DataMatrix editor preview + +The widget SHALL show a representative DataMatrix preview in the Studio Pro editor when the Data Matrix format is selected. + +#### Scenario: Preview in Studio Pro + +- **WHEN** the Data Matrix format is selected in the Studio Pro page editor +- **THEN** the widget preview displays a DataMatrix-style glyph rather than a 1D barcode or QR preview diff --git a/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/tasks.md b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/tasks.md new file mode 100644 index 0000000000..2aa12c82fe --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/openspec/changes/add-datamatrix-generation/tasks.md @@ -0,0 +1,42 @@ +## 1. Dependency & Widget Config + +- [x] 1.1 Add `@bwip-js/browser` to `packages/pluggableWidgets/barcode-generator-web/package.json` dependencies +- [x] 1.2 Add `DataMatrix` enum value to top-level `codeFormat` in `src/BarcodeGenerator.xml` +- [x] 1.3 Add "Advanced Data Matrix Settings" property group in XML: `dmGs1Mode` (boolean, default false), `dmShape` (enum square/rectangle, default square), `dmSize` (integer, default 128) and `dmMargin` (integer, default 2, module units — bwip-js scales `paddingwidth`, so this is not the pixel-based `codeMargin`) +- [x] 1.4 Build to regenerate `typings/BarcodeGeneratorProps.d.ts` and confirm `CodeFormatEnum` includes `DataMatrix` +- [x] 1.5 Scope property visibility per format in `src/BarcodeGenerator.editorConfig.ts`: hide `dm*` unless `codeFormat === "DataMatrix"`, and hide 1D/QR-only properties (bar sizing, display value, EAN-128, flat, last char, Mod43, EAN addons) when Data Matrix is selected; gate `check()` sizing validation to the visible properties + +## 2. Config Model + +- [x] 2.1 Add `DataMatrixTypeConfig` (`type: "datamatrix"`, `gs1Mode`, `shape`, `size`, `margin`) to the `BarcodeConfig` union in `src/config/Barcode.config.ts` +- [x] 2.2 In `barcodeConfig()`, branch `format === "DataMatrix"` before the QR check, returning the new config +- [x] 2.3 Extend `src/utils/download-code.ts` filename-prefix logic to handle `config.type === "datamatrix"` + +## 3. Rendering Seam + +- [x] 3.1 Create `src/components/DataMatrix.tsx` with `DataMatrixRenderer({ config })`, importing `{ datamatrix, drawingSVG }` from `@bwip-js/browser` (tree-shakeable; do NOT use `toSVG()`) +- [x] 3.2 Build bwip-js options: `bcid` = `gs1datamatrix` when `gs1Mode` else `datamatrix`; apply rectangular option when `shape === "rectangle"`; set `text`, size, margin +- [x] 3.3 Render SVG and expose a real `SVGSVGElement` ref so `DownloadButton` + `downloadCode(ref, config, ...)` work unchanged +- [x] 3.4 Wrap encoding in try/catch → error state; render the existing error-alert markup honoring `logLevel` (mirror `BarcodeRenderer`) +- [x] 3.5 Extend dispatch in `src/BarcodeGenerator.tsx:25` to route `config.type === "datamatrix"` to `DataMatrixRenderer` + +## 4. Validation + +- [x] 4.1 Add `DataMatrix` case to `validateBarcodeValue` in `src/config/validation.ts` (plain: charset/length sanity; GS1: loose balanced-`(nn)` AI syntax) +- [x] 4.2 Wire GS1-mode-aware validation into the DataMatrix renderer before encoding + +## 5. Editor Preview + +- [x] 5.1 Add a DataMatrix preview asset/branch alongside `src/hooks/useBarcodePreviewSvg.ts`, `src/assets/barcodes/*.svg`, `src/components/preview/*` so Studio Pro shows a DataMatrix glyph for the format + +## 6. Tests & Changelog + +- [x] 6.1 Unit tests: config mapping for DataMatrix, GS1 vs plain `bcid` selection, shape option, validation (valid GS1 AI, malformed AI, plain string, empty value) +- [~] 6.2 Playwright E2E: `e2e/BarcodeGenerator.spec.js` covers plain render, GS1 render, rectangular shape, value re-render and PNG download _(dormant: `mendix/testProjects` has no `barcode-generator-web` branch — only `barcode-scanner-web` — so `package.json` keeps `"e2e": "echo ..."`. Create the branch with a `/p/datamatrix` page using the mx-names listed in the spec header, then swap the script to `run-e2e ci`.)_ +- [x] 6.3 Add user-facing `CHANGELOG.md` entry ("Added Data Matrix and GS1 Data Matrix generation support") + +## 7. Verification + +- [x] 7.1 `cd packages/pluggableWidgets/barcode-generator-web && pnpm run test` passes +- [x] 7.2 `pnpm run build` succeeds; bwip-js is tree-shaken to the Data Matrix encoders (no aztec/pdf417/royalmail/codablockf in the bundle), but they still add ~228 KB minified / ~73 KB gzipped because the bwipp runtime core comes along — larger than "small", acceptable for the feature but worth noting in the PR +- [ ] 7.3 Live Studio Pro test: plain DataMatrix scans via Barcode Scanner (round-trip); GS1 value `(01)09501101020917(17)261231(10)ABC123` renders + decodes; PNG download works; rectangular shape renders _(requires human: live Studio Pro session with `MX_PROJECT_PATH` set)_ diff --git a/packages/pluggableWidgets/barcode-generator-web/package.json b/packages/pluggableWidgets/barcode-generator-web/package.json index 9c2d22b6df..21c3b45e61 100644 --- a/packages/pluggableWidgets/barcode-generator-web/package.json +++ b/packages/pluggableWidgets/barcode-generator-web/package.json @@ -43,7 +43,9 @@ "verify": "rui-verify-package-format" }, "dependencies": { + "@bwip-js/browser": "^4.11.2", "classnames": "^2.5.1", + "dompurify": "^3.4.11", "jsbarcode": "^3.12.1", "qrcode.react": "^4.2.0" }, diff --git a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorConfig.ts b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorConfig.ts index 3cdb497b87..d31856912c 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorConfig.ts +++ b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorConfig.ts @@ -1,7 +1,7 @@ import { hidePropertiesIn, hidePropertyIn, Properties } from "@mendix/pluggable-widgets-tools"; import { StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { BarcodeGeneratorPreviewProps, CodeFormatEnum, CustomCodeFormatEnum } from "../typings/BarcodeGeneratorProps"; -import { validateAddonValue, validateBarcodeValue } from "./config/validation"; +import { validateAddonValue, validateBarcodeValue, validateGs1DataMatrixValue } from "./config/validation"; export type Problem = { property?: string; // key of the property, at which the problem exists @@ -13,7 +13,12 @@ export type Problem = { }; export function getProperties(values: BarcodeGeneratorPreviewProps, defaultProperties: Properties): Properties { - if (values.codeFormat === "QRCode") { + const isQrCode = values.codeFormat === "QRCode"; + const isDataMatrix = values.codeFormat === "DataMatrix"; + // Both "Barcode" (CODE128) and "Custom" render as 1D barcodes through JsBarcode + const isBarcode = !isQrCode && !isDataMatrix; + + if (isQrCode) { hidePropertiesIn(defaultProperties, values, ["codeWidth", "codeHeight", "displayValue", "codeMargin"]); } else { hidePropertiesIn(defaultProperties, values, [ @@ -26,7 +31,15 @@ export function getProperties(values: BarcodeGeneratorPreviewProps, defaultPrope ]); } - if (values.codeFormat !== "QRCode" || !values.qrOverlay) { + if (isDataMatrix) { + // Data Matrix is a 2D symbol: bar width/height, the human-readable value and the + // pixel-based 1D margin don't apply — it uses dmMargin (module units) instead + hidePropertiesIn(defaultProperties, values, ["codeWidth", "codeHeight", "displayValue", "codeMargin"]); + } else { + hidePropertiesIn(defaultProperties, values, ["dmGs1Mode", "dmShape", "dmSize", "dmMargin"]); + } + + if (!isQrCode || !values.qrOverlay) { hidePropertiesIn(defaultProperties, values, [ "qrOverlaySrc", "qrOverlayCenter", @@ -39,7 +52,8 @@ export function getProperties(values: BarcodeGeneratorPreviewProps, defaultPrope ]); } - if (values.codeFormat === "QRCode" || (values.codeFormat !== "CODE128" && values.customCodeFormat !== "CODE128")) { + // EAN-128 only applies to CODE128, either as the top-level format or the custom one + if (!isBarcode || (values.codeFormat === "Custom" && values.customCodeFormat !== "CODE128")) { hidePropertyIn(defaultProperties, values, "enableEan128"); } @@ -67,29 +81,20 @@ export function getProperties(values: BarcodeGeneratorPreviewProps, defaultPrope } // EAN addons are only supported for EAN-13, EAN-8, and UPC - if ( - values.codeFormat === "QRCode" || - values.codeFormat === "CODE128" || - (values.codeFormat === "Custom" && - values.customCodeFormat !== "EAN13" && - values.customCodeFormat !== "EAN8" && - values.customCodeFormat !== "UPC") - ) { + const supportsAddons = + values.codeFormat === "Custom" && + (values.customCodeFormat === "EAN13" || + values.customCodeFormat === "EAN8" || + values.customCodeFormat === "UPC"); + if (!supportsAddons) { hidePropertiesIn(defaultProperties, values, ["addonFormat", "addonValue", "addonSpacing"]); } - if ( - values.codeFormat === "QRCode" || - values.codeFormat === "CODE128" || - (values.codeFormat === "Custom" && values.addonFormat !== "EAN5" && values.addonFormat !== "EAN2") - ) { + if (!supportsAddons || (values.addonFormat !== "EAN5" && values.addonFormat !== "EAN2")) { hidePropertiesIn(defaultProperties, values, ["addonValue", "addonSpacing"]); } - if ( - values.codeFormat === "QRCode" || - values.codeFormat === "CODE128" || - (values.codeFormat === "Custom" && values.customCodeFormat !== "CODE39") - ) { + // Mod43 is a CODE39 check digit + if (!(values.codeFormat === "Custom" && values.customCodeFormat === "CODE39")) { hidePropertyIn(defaultProperties, values, "enableMod43"); } @@ -122,30 +127,52 @@ export function getPreview(_: StructurePreviewProps, _isDarkMode: boolean): Stru export function check(_values: BarcodeGeneratorPreviewProps): Problem[] { const errors: Problem[] = []; - if (!_values.codeWidth || _values.codeWidth < 1) { - errors.push({ - property: `codeWidth`, - severity: "error", - message: `The value of 'Bar width' must be at least 1.` - }); - } + // Only validate the sizing properties that are visible for the selected format + if (_values.codeFormat !== "QRCode" && _values.codeFormat !== "DataMatrix") { + if (!_values.codeWidth || _values.codeWidth < 1) { + errors.push({ + property: `codeWidth`, + severity: "error", + message: `The value of 'Bar width' must be at least 1.` + }); + } - if (!_values.codeHeight || _values.codeHeight < 20) { - errors.push({ - property: `codeHeight`, - severity: "error", - message: `The value of 'Code height' must be at least 20.` - }); + if (!_values.codeHeight || _values.codeHeight < 20) { + errors.push({ + property: `codeHeight`, + severity: "error", + message: `The value of 'Code height' must be at least 20.` + }); + } } - if (!_values.qrSize || _values.qrSize < 50) { + if (_values.codeFormat === "QRCode" && (!_values.qrSize || _values.qrSize < 50)) { errors.push({ - property: `codeHeight`, + property: `qrSize`, severity: "error", message: `The value of 'QR size' must be at least 50.` }); } + if (_values.codeFormat === "DataMatrix") { + if (!_values.dmSize || _values.dmSize < 32) { + errors.push({ + property: `dmSize`, + severity: "error", + message: `The value of 'Data Matrix size' must be at least 32.` + }); + } + + // The Data Matrix spec requires a quiet zone of at least one module on every side + if (!_values.dmMargin || _values.dmMargin < 1) { + errors.push({ + property: `dmMargin`, + severity: "warning", + message: `A Data Matrix needs a quiet zone of at least 1 module unit to stay scannable.` + }); + } + } + // Design-time validation for static barcode value(s) const valueProblems = validateCodeValues(_values); return errors.concat(valueProblems); @@ -187,7 +214,8 @@ function getFormatHint(format: string): string { MSI: "MSI: numeric only (max 30 digits)", pharmacode: "Pharmacode: numeric only (max 7 digits)", codabar: "Codabar: digits, A-D start/stop, and - $ : / . + (max 20 chars)", - QRCode: "QR Code: any text (max 1200 chars recommended)" + QRCode: "QR Code: any text (max 1200 chars recommended)", + DataMatrix: "Data Matrix: any text; GS1 mode expects Application Identifier syntax, e.g. (01)09501101020917" }; return hints[format] || ""; } @@ -218,6 +246,11 @@ function validateCodeValues(values: BarcodeGeneratorPreviewProps): Problem[] { if (!result.valid) { const msg = result.message || "Invalid barcode value for selected format."; problems.push({ property: "codeValue", severity: "error", message: msg }); + } else if (format === "DataMatrix" && values.dmGs1Mode) { + const gs1Result = validateGs1DataMatrixValue(val); + if (!gs1Result.valid) { + problems.push({ property: "codeValue", severity: "error", message: gs1Result.message }); + } } } } diff --git a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorPreview.tsx b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorPreview.tsx index 44f78544d7..4a0f8cc9b0 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorPreview.tsx +++ b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.editorPreview.tsx @@ -4,6 +4,7 @@ import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import { BarcodeGeneratorPreviewProps } from "../typings/BarcodeGeneratorProps"; import { DownloadIcon } from "./components/icons/DownloadIcon"; import { BarcodePreview } from "./components/preview/BarcodePreview"; +import { DataMatrixPreview } from "./components/preview/DataMatrixPreview"; import { QRCodePreview } from "./components/preview/QRCodePreview"; const defaultDownloadCaption = "Download"; @@ -23,6 +24,7 @@ function PreviewDownloadButton(props: BarcodeGeneratorPreviewProps): ReactElemen export function preview(props: BarcodeGeneratorPreviewProps): ReactElement { const styles = parseStyle(props.style); const isQrCode = props.codeFormat === "QRCode"; + const isDataMatrix = props.codeFormat === "DataMatrix"; const downloadButton = ; return ( @@ -34,6 +36,8 @@ export function preview(props: BarcodeGeneratorPreviewProps): ReactElement { > {isQrCode ? ( + ) : isDataMatrix ? ( + ) : ( )} diff --git a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.tsx b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.tsx index 9304ef8979..ff9b195822 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.tsx +++ b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.tsx @@ -2,6 +2,7 @@ import classNames from "classnames"; import { ReactElement } from "react"; import { BarcodeGeneratorContainerProps } from "../typings/BarcodeGeneratorProps"; import { BarcodeRenderer } from "./components/Barcode"; +import { DataMatrixRenderer } from "./components/DataMatrix"; import { QRCodeRenderer } from "./components/QRCode"; import { barcodeConfig } from "./config/Barcode.config"; @@ -22,7 +23,13 @@ export default function BarcodeGenerator(props: BarcodeGeneratorContainerProps): tabIndex={props.tabIndex} style={props.style} > - {config.type === "qrcode" ? : } + {config.type === "qrcode" ? ( + + ) : config.type === "datamatrix" ? ( + + ) : ( + + )} ); } diff --git a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.xml b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.xml index 7aaec31075..d59aaf8c9c 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.xml +++ b/packages/pluggableWidgets/barcode-generator-web/src/BarcodeGenerator.xml @@ -19,6 +19,7 @@ Barcode QR Code + Data Matrix Custom @@ -135,6 +136,24 @@ The size of the QR box. Note: In preview, the max height is 200px. The QR code will render at full size in your application. + + + GS1 Data Matrix + Encode as GS1 Data Matrix (FNC1 + Application Identifiers), e.g. (01)09501101020917(17)261231(10)ABC123. Used for pharma serialization. + + + Symbol shape + Choose square or rectangular Data Matrix symbol shape + + Square + Rectangle + + + + Data Matrix size + The size of the Data Matrix symbol in pixels. Note: In preview, the max height is 200px. The symbol will render at full size in your application. + + Log Level @@ -173,6 +192,10 @@ Margin size Number of module units (QR grid cells) to use for margin. Increasing compresses the QR pattern within the fixed size. Note: not visible in preview. + + Margin size + Number of module units (Data Matrix cells) to use for the quiet zone. Increasing compresses the symbol within the fixed size. Keep at least 1 to stay scannable. Note: not visible in preview. + Title Used for accessibility diff --git a/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.editorConfig.spec.ts b/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.editorConfig.spec.ts new file mode 100644 index 0000000000..7ac785b21c --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.editorConfig.spec.ts @@ -0,0 +1,221 @@ +import { Properties } from "@mendix/pluggable-widgets-tools"; +import { BarcodeGeneratorPreviewProps } from "../../typings/BarcodeGeneratorProps"; +import { check, getProperties } from "../BarcodeGenerator.editorConfig"; + +const ALL_KEYS = [ + "codeValue", + "codeFormat", + "emptyMessage", + "allowDownload", + "downloadButtonCaption", + "downloadButtonAriaLabel", + "downloadFileName", + "buttonPosition", + "customCodeFormat", + "enableEan128", + "enableFlat", + "lastChar", + "enableMod43", + "addonFormat", + "addonValue", + "addonSpacing", + "qrLevel", + "qrSize", + "dmGs1Mode", + "dmShape", + "dmSize", + "logLevel", + "displayValue", + "showAsCard", + "codeWidth", + "codeHeight", + "codeMargin", + "qrMargin", + "dmMargin", + "qrTitle", + "showTitle", + "qrOverlay", + "qrOverlaySrc", + "qrOverlayCenter", + "qrOverlayX", + "qrOverlayY", + "qrOverlayHeight", + "qrOverlayWidth", + "qrOverlayOpacity", + "qrOverlayExcavate" +] as const; + +function defaultValues(overrides: Partial = {}): BarcodeGeneratorPreviewProps { + return { + className: "", + class: "", + style: "", + readOnly: false, + renderMode: "design", + translate: (text: string) => text, + codeValue: "", + codeFormat: "CODE128", + emptyMessage: "", + allowDownload: false, + downloadButtonCaption: "", + downloadButtonAriaLabel: "", + downloadFileName: "", + buttonPosition: "bottom", + customCodeFormat: "CODE128", + enableEan128: false, + enableFlat: false, + lastChar: "", + enableMod43: false, + addonFormat: "None", + addonValue: "", + addonSpacing: 20, + qrLevel: "L", + qrSize: 128, + dmGs1Mode: false, + dmShape: "square", + dmSize: 128, + logLevel: "None", + displayValue: false, + showAsCard: false, + codeWidth: 2, + codeHeight: 200, + codeMargin: 2, + qrMargin: 2, + dmMargin: 2, + qrTitle: "QR Code", + showTitle: false, + qrOverlay: false, + qrOverlaySrc: null, + qrOverlayCenter: true, + qrOverlayX: 0, + qrOverlayY: 0, + qrOverlayHeight: 24, + qrOverlayWidth: 24, + qrOverlayOpacity: 1, + qrOverlayExcavate: true, + ...overrides + }; +} + +/** Mirrors the XML property tree as a flat group so `hidePropertiesIn` can splice from it. */ +function allProperties(): Properties { + return [ + { + caption: "All", + propertyGroups: [ + { + caption: "Flat", + properties: ALL_KEYS.map(key => ({ key, caption: key, description: "", type: "string" })) + } + ] + } + ]; +} + +function visibleKeys(overrides: Partial = {}): string[] { + const values = defaultValues(overrides); + const properties = getProperties(values, allProperties()); + return properties[0].propertyGroups![0].properties!.map(prop => prop.key); +} + +describe("BarcodeGenerator editor config", () => { + describe("Data Matrix settings", () => { + const dataMatrixKeys = ["dmGs1Mode", "dmShape", "dmSize", "dmMargin"]; + + it("shows Data Matrix settings for the Data Matrix format", () => { + expect(visibleKeys({ codeFormat: "DataMatrix" })).toEqual(expect.arrayContaining(dataMatrixKeys)); + }); + + it.each(["CODE128", "QRCode", "Custom"] as const)("hides Data Matrix settings for %s", codeFormat => { + const visible = visibleKeys({ codeFormat }); + dataMatrixKeys.forEach(key => expect(visible).not.toContain(key)); + }); + }); + + describe("barcode-only settings", () => { + it("hides bar sizing, human-readable value and the pixel margin for Data Matrix", () => { + const visible = visibleKeys({ codeFormat: "DataMatrix" }); + expect(visible).not.toContain("codeWidth"); + expect(visible).not.toContain("codeHeight"); + expect(visible).not.toContain("displayValue"); + // Data Matrix has its own margin in module units + expect(visible).not.toContain("codeMargin"); + expect(visible).not.toContain("qrMargin"); + expect(visible).toContain("dmMargin"); + }); + + it("hides advanced barcode settings for Data Matrix", () => { + const visible = visibleKeys({ codeFormat: "DataMatrix" }); + [ + "enableEan128", + "enableFlat", + "lastChar", + "enableMod43", + "addonFormat", + "addonValue", + "addonSpacing" + ].forEach(key => expect(visible).not.toContain(key)); + }); + + it("hides QR settings for Data Matrix", () => { + const visible = visibleKeys({ codeFormat: "DataMatrix" }); + ["qrOverlay", "qrSize", "qrMargin", "qrLevel", "qrTitle", "showTitle"].forEach(key => + expect(visible).not.toContain(key) + ); + }); + + it("keeps EAN-128 for CODE128 and custom CODE128 only", () => { + expect(visibleKeys({ codeFormat: "CODE128" })).toContain("enableEan128"); + expect(visibleKeys({ codeFormat: "Custom", customCodeFormat: "CODE128" })).toContain("enableEan128"); + expect(visibleKeys({ codeFormat: "Custom", customCodeFormat: "EAN13" })).not.toContain("enableEan128"); + }); + + it("keeps Mod43 for custom CODE39 only", () => { + expect(visibleKeys({ codeFormat: "Custom", customCodeFormat: "CODE39" })).toContain("enableMod43"); + expect(visibleKeys({ codeFormat: "CODE128" })).not.toContain("enableMod43"); + }); + + it("keeps EAN addons for EAN-13, EAN-8 and UPC only", () => { + expect(visibleKeys({ codeFormat: "Custom", customCodeFormat: "EAN13" })).toContain("addonFormat"); + expect(visibleKeys({ codeFormat: "Custom", customCodeFormat: "CODE93" })).not.toContain("addonFormat"); + }); + }); + + describe("check", () => { + it("validates Data Matrix size instead of bar sizing", () => { + const problems = check( + defaultValues({ codeFormat: "DataMatrix", dmSize: 10, codeHeight: 0, codeWidth: 0 }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].property).toBe("dmSize"); + }); + + it("warns when the Data Matrix quiet zone is dropped", () => { + const problems = check(defaultValues({ codeFormat: "DataMatrix", dmMargin: 0 })); + expect(problems).toEqual([expect.objectContaining({ property: "dmMargin", severity: "warning" })]); + }); + + it("reports QR size problems on the qrSize property", () => { + const problems = check(defaultValues({ codeFormat: "QRCode", qrSize: 10 })); + expect(problems.map(problem => problem.property)).toContain("qrSize"); + }); + + it("flags malformed GS1 values at design time", () => { + const problems = check( + defaultValues({ codeFormat: "DataMatrix", dmGs1Mode: true, codeValue: '"not-gs1"' }) + ); + expect(problems.some(problem => problem.property === "codeValue")).toBe(true); + }); + + it("accepts well-formed GS1 values", () => { + const problems = check( + defaultValues({ + codeFormat: "DataMatrix", + dmGs1Mode: true, + codeValue: '"(01)09501101020917(17)261231(10)ABC123"' + }) + ); + expect(problems).toHaveLength(0); + }); + }); +}); diff --git a/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.spec.tsx b/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.spec.tsx index 97fda1610d..0f1eb952c9 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.spec.tsx +++ b/packages/pluggableWidgets/barcode-generator-web/src/__tests__/BarcodeGenerator.spec.tsx @@ -1,4 +1,5 @@ import "@testing-library/jest-dom"; +import * as bwip from "@bwip-js/browser"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Big } from "big.js"; @@ -33,6 +34,19 @@ jest.mock("../utils/download-code", () => ({ downloadCode: jest.fn() })); +// Mock bwip-js (ESM-only, not resolvable in jest without transform) +jest.mock( + "@bwip-js/browser", + () => ({ + drawingSVG: jest.fn(() => ({})), + datamatrix: jest.fn(() => ""), + datamatrixrectangular: jest.fn(() => ""), + gs1datamatrix: jest.fn(() => ""), + gs1datamatrixrectangular: jest.fn(() => "") + }), + { virtual: true } +); + import { BarcodeGeneratorContainerProps, CodeFormatEnum, @@ -69,6 +83,10 @@ const createBarcodeProps = ( codeMargin: 4, qrSize: 128, qrMargin: 2, + dmGs1Mode: false, + dmShape: "square", + dmSize: 128, + dmMargin: 2, qrTitle: dynamic.available(""), qrLevel: "L", qrOverlay: false, @@ -1036,4 +1054,67 @@ describe("BarcodeGenerator", () => { expect(screen.getByText("Export")).toBeInTheDocument(); }); }); + + describe("Data Matrix", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders a plain Data Matrix using the datamatrix encoder", () => { + const props = createBarcodeProps({ + codeFormat: "DataMatrix" as CodeFormatEnum, + codeValue: dynamic.available("ABC-12345") + }); + + render(); + + expect(bwip.datamatrix).toHaveBeenCalledWith( + expect.objectContaining({ bcid: "datamatrix", text: "ABC-12345" }), + expect.anything() + ); + expect(bwip.gs1datamatrix).not.toHaveBeenCalled(); + }); + + it("uses the gs1datamatrix encoder in GS1 mode", () => { + const props = createBarcodeProps({ + codeFormat: "DataMatrix" as CodeFormatEnum, + dmGs1Mode: true, + codeValue: dynamic.available("(01)09501101020917(17)261231(10)ABC123") + }); + + render(); + + expect(bwip.gs1datamatrix).toHaveBeenCalledWith( + expect.objectContaining({ bcid: "gs1datamatrix", parse: true }), + expect.anything() + ); + expect(bwip.datamatrix).not.toHaveBeenCalled(); + }); + + it("uses the rectangular encoder for the rectangle shape", () => { + const props = createBarcodeProps({ + codeFormat: "DataMatrix" as CodeFormatEnum, + dmShape: "rectangle", + codeValue: dynamic.available("RECT") + }); + + render(); + + expect(bwip.datamatrixrectangular).toHaveBeenCalled(); + }); + + it("shows the error alert when GS1 syntax is malformed and log level is not None", () => { + const props = createBarcodeProps({ + codeFormat: "DataMatrix" as CodeFormatEnum, + dmGs1Mode: true, + logLevel: "Debug", + codeValue: dynamic.available("not-a-valid-ai") + }); + + render(); + + expect(screen.getByText(/Unable to generate Data Matrix/)).toBeInTheDocument(); + expect(bwip.gs1datamatrix).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/pluggableWidgets/barcode-generator-web/src/components/DataMatrix.tsx b/packages/pluggableWidgets/barcode-generator-web/src/components/DataMatrix.tsx new file mode 100644 index 0000000000..fef917a014 --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/src/components/DataMatrix.tsx @@ -0,0 +1,129 @@ +import { + datamatrix, + datamatrixrectangular, + drawingSVG, + gs1datamatrix, + gs1datamatrixrectangular +} from "@bwip-js/browser"; +import DOMPurify from "dompurify"; +import { ReactElement, useMemo, useRef } from "react"; +import { DownloadButton } from "./DownloadButton"; +import { DataMatrixTypeConfig } from "../config/Barcode.config"; +import { validateBarcodeValue, validateGs1DataMatrixValue } from "../config/validation"; +import { downloadCode } from "../utils/download-code"; +import { printError } from "../utils/helpers"; + +interface DataMatrixRendererProps { + config: DataMatrixTypeConfig; +} + +type DataMatrixEncodeParams = Pick; + +/** Selects the bwip-js encoder for the requested GS1 mode and symbol shape. */ +function encodeDataMatrix({ codeValue, size, margin, gs1Mode, shape }: DataMatrixEncodeParams): string { + const opts = { + text: codeValue, + // bwip-js scale is in module units; map the pixel size onto a reasonable scale. + scale: Math.max(1, Math.round(size / 32)), + paddingwidth: margin, + paddingheight: margin, + // GS1 AI syntax uses parentheses; parse must be on for the human-readable form. + parse: gs1Mode + } as const; + + if (gs1Mode) { + return shape === "rectangle" + ? gs1datamatrixrectangular({ ...opts, bcid: "gs1datamatrixrectangular" }, drawingSVG()) + : gs1datamatrix({ ...opts, bcid: "gs1datamatrix" }, drawingSVG()); + } + + return shape === "rectangle" + ? datamatrixrectangular({ ...opts, bcid: "datamatrixrectangular" }, drawingSVG()) + : datamatrix({ ...opts, bcid: "datamatrix" }, drawingSVG()); +} + +/** bwip-js SVGs only carry a viewBox, no width/height attributes; derive pixel dimensions from it. */ +function getSvgPixelSize(svg: string, size: number): { width: number; height: number } { + const match = svg.match(/viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/); + if (!match) { + return { width: size, height: size }; + } + const [viewBoxWidth, viewBoxHeight] = [parseFloat(match[1]), parseFloat(match[2])]; + return viewBoxWidth >= viewBoxHeight + ? { width: size, height: (size * viewBoxHeight) / viewBoxWidth } + : { width: (size * viewBoxWidth) / viewBoxHeight, height: size }; +} + +export function DataMatrixRenderer({ config }: DataMatrixRendererProps): ReactElement { + const containerRef = useRef(null); + const { codeValue, downloadButton, size, gs1Mode, shape, margin, logLevel } = config; + const buttonPosition = downloadButton?.buttonPosition ?? "bottom"; + + const { svg, error } = useMemo<{ svg: string | null; error: boolean }>(() => { + if (!codeValue) { + return { svg: null, error: false }; + } + + const baseValidation = validateBarcodeValue("DataMatrix", codeValue); + if (!baseValidation.valid) { + printError(`Validation failed for Data Matrix: ${baseValidation.message}`, logLevel); + return { svg: null, error: true }; + } + + if (gs1Mode) { + const gs1Validation = validateGs1DataMatrixValue(codeValue); + if (!gs1Validation.valid) { + printError(`GS1 Data Matrix validation failed: ${gs1Validation.message}`, logLevel); + return { svg: null, error: true }; + } + } + + try { + return { svg: encodeDataMatrix({ codeValue, size, margin, gs1Mode, shape }), error: false }; + } catch (e) { + const message = e instanceof Error ? e.message : "Error generating Data Matrix"; + printError(`Rendering failed: ${message} \nValue: "${codeValue}"`, logLevel); + return { svg: null, error: true }; + } + }, [codeValue, size, gs1Mode, shape, margin, logLevel]); + + if (error || !svg) { + return ( +
+ {error && config.logLevel !== "None" && ( +
+ Unable to generate Data Matrix. Please check the value and format + configuration. +
+ )} +
+ ); + } + + const getSvgElement = (): SVGSVGElement | null => containerRef.current?.querySelector("svg") ?? null; + + const button = downloadButton && ( + downloadCode({ current: getSvgElement() }, config, downloadButton.fileName)} + ariaLabel={downloadButton.label} + caption={downloadButton.caption} + /> + ); + + const { width, height } = getSvgPixelSize(svg, size); + + return ( +
+ {buttonPosition === "top" && button} +
+ {buttonPosition === "bottom" && button} +
+ ); +} diff --git a/packages/pluggableWidgets/barcode-generator-web/src/components/preview/DataMatrixPreview.tsx b/packages/pluggableWidgets/barcode-generator-web/src/components/preview/DataMatrixPreview.tsx new file mode 100644 index 0000000000..4bac0830d8 --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/src/components/preview/DataMatrixPreview.tsx @@ -0,0 +1,48 @@ +import { datamatrix, drawingSVG, gs1datamatrix } from "@bwip-js/browser"; +import DOMPurify from "dompurify"; +import { ReactElement, useMemo } from "react"; +import { BarcodeGeneratorPreviewProps } from "../../../typings/BarcodeGeneratorProps"; + +interface DataMatrixPreviewProps extends BarcodeGeneratorPreviewProps { + downloadButton: ReactElement | null; +} + +const SAMPLE_PLAIN = "DATA MATRIX"; +const SAMPLE_GS1 = "(01)09501101020917(17)261231(10)ABC123"; + +export function DataMatrixPreview(props: DataMatrixPreviewProps): ReactElement { + const { downloadButton, ...restProps } = props; + const size = restProps.dmSize ?? 128; + const displaySize = Math.min(size, 200); // Clamped to 200px for preview + const gs1Mode = restProps.dmGs1Mode === true; + + const svg = useMemo(() => { + try { + return gs1Mode + ? gs1datamatrix({ bcid: "gs1datamatrix", text: SAMPLE_GS1, scale: 3, parse: true }, drawingSVG()) + : datamatrix({ bcid: "datamatrix", text: SAMPLE_PLAIN, scale: 3 }, drawingSVG()); + } catch { + return null; + } + }, [gs1Mode]); + + return ( +
+ {restProps.buttonPosition === "top" && downloadButton} + {svg ? ( +
+ ) : ( +
+ Data Matrix preview unavailable +
+ )} + {restProps.buttonPosition === "bottom" && downloadButton} +
+ ); +} diff --git a/packages/pluggableWidgets/barcode-generator-web/src/config/Barcode.config.ts b/packages/pluggableWidgets/barcode-generator-web/src/config/Barcode.config.ts index 7d772a7df1..b46e41cf0f 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/config/Barcode.config.ts +++ b/packages/pluggableWidgets/barcode-generator-web/src/config/Barcode.config.ts @@ -55,7 +55,15 @@ export interface QRCodeTypeConfig extends CodeBaseTypeConfig { }; } -export type BarcodeConfig = BarcodeTypeConfig | QRCodeTypeConfig; +/** Configuration for Data Matrix rendering */ +export interface DataMatrixTypeConfig extends CodeBaseTypeConfig { + type: "datamatrix"; + size: number; + gs1Mode: boolean; + shape: "square" | "rectangle"; +} + +export type BarcodeConfig = BarcodeTypeConfig | QRCodeTypeConfig | DataMatrixTypeConfig; export function barcodeConfig(props: BarcodeGeneratorContainerProps): BarcodeConfig { const codeValue = props.codeValue?.value ?? ""; @@ -72,11 +80,22 @@ export function barcodeConfig(props: BarcodeGeneratorContainerProps): BarcodeCon const baseConfig: CodeBaseTypeConfig = { codeValue, - margin: (format === "QRCode" ? props.qrMargin : props.codeMargin) ?? 2, + // 1D barcodes measure the margin in pixels; QR and Data Matrix in module units + margin: getMargin(props, format) ?? 2, logLevel: props.logLevel, downloadButton: downloadButtonConfig }; + if (format === "DataMatrix") { + return { + type: "datamatrix", + ...baseConfig, + size: props.dmSize ?? 128, + gs1Mode: props.dmGs1Mode ?? false, + shape: props.dmShape ?? "square" + }; + } + if (format === "QRCode") { return { type: "qrcode", @@ -119,6 +138,19 @@ export function barcodeConfig(props: BarcodeGeneratorContainerProps): BarcodeCon }; } +function getMargin( + props: BarcodeGeneratorContainerProps, + format: CodeFormatEnum | CustomCodeFormatEnum +): number | undefined { + if (format === "QRCode") { + return props.qrMargin; + } + if (format === "DataMatrix") { + return props.dmMargin; + } + return props.codeMargin; +} + function getFileName(customFileName: string | undefined): string | undefined { // Use custom filename if provided if (customFileName && customFileName.trim()) { diff --git a/packages/pluggableWidgets/barcode-generator-web/src/config/__tests__/Barcode.config.spec.ts b/packages/pluggableWidgets/barcode-generator-web/src/config/__tests__/Barcode.config.spec.ts new file mode 100644 index 0000000000..63daacc6e8 --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/src/config/__tests__/Barcode.config.spec.ts @@ -0,0 +1,85 @@ +import { Big } from "big.js"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { BarcodeGeneratorContainerProps } from "../../../typings/BarcodeGeneratorProps"; +import { barcodeConfig, DataMatrixTypeConfig } from "../Barcode.config"; + +function props(overrides: Partial = {}): BarcodeGeneratorContainerProps { + return { + name: "bg1", + class: "", + tabIndex: -1, + codeValue: dynamic.available("ABC123"), + codeFormat: "DataMatrix", + customCodeFormat: "CODE128", + enableEan128: false, + enableFlat: false, + lastChar: "", + enableMod43: false, + allowDownload: false, + buttonPosition: "bottom", + addonFormat: "None", + addonValue: dynamic.available(""), + addonSpacing: 20, + displayValue: false, + showAsCard: false, + codeWidth: 2, + codeHeight: 200, + codeMargin: 4, + qrSize: 128, + qrMargin: 2, + dmGs1Mode: false, + dmShape: "square", + dmSize: 128, + dmMargin: 2, + qrTitle: dynamic.available("QR"), + qrLevel: "L", + qrOverlay: false, + qrOverlaySrc: dynamic.available({ uri: "" } as any), + qrOverlayCenter: true, + qrOverlayX: 0, + qrOverlayY: 0, + qrOverlayHeight: 24, + qrOverlayWidth: 24, + qrOverlayOpacity: new Big(1), + qrOverlayExcavate: true, + logLevel: "None", + ...overrides + } as BarcodeGeneratorContainerProps; +} + +describe("barcodeConfig - DataMatrix", () => { + it("maps DataMatrix format to a datamatrix config", () => { + const config = barcodeConfig(props()) as DataMatrixTypeConfig; + expect(config.type).toBe("datamatrix"); + expect(config.codeValue).toBe("ABC123"); + expect(config.size).toBe(128); + expect(config.shape).toBe("square"); + expect(config.gs1Mode).toBe(false); + }); + + it("carries GS1 mode and rectangular shape", () => { + const config = barcodeConfig(props({ dmGs1Mode: true, dmShape: "rectangle" })) as DataMatrixTypeConfig; + expect(config.gs1Mode).toBe(true); + expect(config.shape).toBe("rectangle"); + }); + + it("uses dmMargin for the DataMatrix margin, not the 1D or QR margin", () => { + const config = barcodeConfig(props({ dmMargin: 6, codeMargin: 4, qrMargin: 8 })) as DataMatrixTypeConfig; + expect(config.margin).toBe(6); + }); + + it("falls back to a default margin when dmMargin is unset", () => { + const config = barcodeConfig(props({ dmMargin: null as any })) as DataMatrixTypeConfig; + expect(config.margin).toBe(2); + }); + + it("keeps the pixel margin for 1D barcodes and the module margin for QR", () => { + expect(barcodeConfig(props({ codeFormat: "CODE128", codeMargin: 4 })).margin).toBe(4); + expect(barcodeConfig(props({ codeFormat: "QRCode", qrMargin: 8 })).margin).toBe(8); + }); + + it("does not route non-DataMatrix formats to datamatrix", () => { + expect(barcodeConfig(props({ codeFormat: "QRCode" })).type).toBe("qrcode"); + expect(barcodeConfig(props({ codeFormat: "CODE128" })).type).toBe("barcode"); + }); +}); diff --git a/packages/pluggableWidgets/barcode-generator-web/src/config/__tests__/validation.spec.ts b/packages/pluggableWidgets/barcode-generator-web/src/config/__tests__/validation.spec.ts new file mode 100644 index 0000000000..96269a614a --- /dev/null +++ b/packages/pluggableWidgets/barcode-generator-web/src/config/__tests__/validation.spec.ts @@ -0,0 +1,38 @@ +import { validateBarcodeValue, validateGs1DataMatrixValue } from "../validation"; + +describe("validateBarcodeValue - DataMatrix", () => { + it("passes for an empty value (dynamic binding at runtime)", () => { + expect(validateBarcodeValue("DataMatrix", "")).toEqual({ valid: true }); + }); + + it("passes for a plain string", () => { + expect(validateBarcodeValue("DataMatrix", "ABC-12345")).toEqual({ valid: true }); + }); + + it("rejects an excessively long value", () => { + const result = validateBarcodeValue("DataMatrix", "x".repeat(2001)); + expect(result.valid).toBe(false); + }); +}); + +describe("validateGs1DataMatrixValue", () => { + it("passes for an empty value", () => { + expect(validateGs1DataMatrixValue("")).toEqual({ valid: true }); + }); + + it("passes for valid GS1 AI syntax", () => { + expect(validateGs1DataMatrixValue("(01)09501101020917(17)261231(10)ABC123")).toEqual({ valid: true }); + }); + + it("rejects a value that does not start with an Application Identifier", () => { + expect(validateGs1DataMatrixValue("09501101020917").valid).toBe(false); + }); + + it("rejects an Application Identifier with no data", () => { + expect(validateGs1DataMatrixValue("(01)(17)261231").valid).toBe(false); + }); + + it("rejects malformed / unbalanced parentheses", () => { + expect(validateGs1DataMatrixValue("(01)09501101020917(17").valid).toBe(false); + }); +}); diff --git a/packages/pluggableWidgets/barcode-generator-web/src/config/validation.ts b/packages/pluggableWidgets/barcode-generator-web/src/config/validation.ts index bcca55073d..58b2ea60fb 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/config/validation.ts +++ b/packages/pluggableWidgets/barcode-generator-web/src/config/validation.ts @@ -1,5 +1,7 @@ import { AddonFormatEnum, CodeFormatEnum, CustomCodeFormatEnum } from "../../typings/BarcodeGeneratorProps"; +const MAX_2D_BARCODE_STATIC_VALUE_LENGTH = 2000; + export type ValidationResult = | { valid: true; @@ -114,9 +116,18 @@ export function validateBarcodeValue(format: CustomCodeFormatEnum | CodeFormatEn return { valid: false, message: "CODE93 should not contain control characters." }; } return { valid: true }; + case "DataMatrix": + // DataMatrix: encoder handles the heavy lifting; guard extremely long static values. + if (value.length > MAX_2D_BARCODE_STATIC_VALUE_LENGTH) { + return { + valid: false, + message: "The Data Matrix value is very long; consider a shorter value or a dynamic attribute." + }; + } + return { valid: true }; case "QRCode": - // QRCode: accepts most characters, but warn for extremely long static values - if (value.length > 1200) { + // QRCode: accepts most characters, but warn for extremely long static values. + if (value.length > MAX_2D_BARCODE_STATIC_VALUE_LENGTH) { return { valid: false, message: @@ -140,6 +151,33 @@ export function validateBarcodeValue(format: CustomCodeFormatEnum | CodeFormatEn } } +/** + * Loosely validate GS1 Data Matrix Application Identifier syntax. + * Expects human-readable AI form, e.g. `(01)09501101020917(17)261231(10)ABC123`. + * bwip-js is the source of truth for encoding; this catches obvious structural errors early. + */ +export function validateGs1DataMatrixValue(value: string): ValidationResult { + if (!value) { + return { valid: true }; + } + + // Must start with an application identifier and contain only balanced (nn) groups with data. + if (!/^(\(\d{2,4}\)[^(]*)+$/.test(value)) { + return { + valid: false, + message: + "GS1 Data Matrix expects Application Identifier syntax, e.g. (01)09501101020917(17)261231(10)ABC123." + }; + } + + // Every AI group must carry at least one data character. + if (/\(\d{2,4}\)(?=\(|$)/.test(value)) { + return { valid: false, message: "Each GS1 Application Identifier must be followed by data." }; + } + + return { valid: true }; +} + /** Validate addon (EAN-5 / EAN-2) values. */ export function validateAddonValue(addonFormat: AddonFormatEnum | null | undefined, value: string): ValidationResult { if (!addonFormat || addonFormat === "None") { diff --git a/packages/pluggableWidgets/barcode-generator-web/src/ui/BarcodeGenerator.scss b/packages/pluggableWidgets/barcode-generator-web/src/ui/BarcodeGenerator.scss index 431a27f89e..8a66daa53e 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/ui/BarcodeGenerator.scss +++ b/packages/pluggableWidgets/barcode-generator-web/src/ui/BarcodeGenerator.scss @@ -28,6 +28,16 @@ $widget-prefix: "barcode-generator"; height: auto; } + .datamatrix-svg { + max-width: 100%; + + svg { + display: block; + width: 100%; + height: 100%; + } + } + .qrcode-renderer-title { font-weight: var(--font-weight-normal); font-size: var(--font-size-small); @@ -98,3 +108,13 @@ $widget-prefix: "barcode-generator"; position: absolute; object-fit: contain; } + +.barcode-generator-datamatrix-preview-image { + max-width: 100%; + + svg { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/packages/pluggableWidgets/barcode-generator-web/src/utils/download-code.ts b/packages/pluggableWidgets/barcode-generator-web/src/utils/download-code.ts index f5685b031d..895100cd7f 100644 --- a/packages/pluggableWidgets/barcode-generator-web/src/utils/download-code.ts +++ b/packages/pluggableWidgets/barcode-generator-web/src/utils/download-code.ts @@ -25,6 +25,8 @@ export async function downloadCode( // Process overlay images for QR codes if (config.type === "qrcode") { await processQRImages(clonedSvg); + } else if (config.type === "datamatrix") { + fileNamePrefix = config.gs1Mode ? "datamatrix_gs1" : "datamatrix"; } else { fileNamePrefix = `${config.type}_${config.format}`; } diff --git a/packages/pluggableWidgets/barcode-generator-web/typings/BarcodeGeneratorProps.d.ts b/packages/pluggableWidgets/barcode-generator-web/typings/BarcodeGeneratorProps.d.ts index 222cd8bf98..ea4140c288 100644 --- a/packages/pluggableWidgets/barcode-generator-web/typings/BarcodeGeneratorProps.d.ts +++ b/packages/pluggableWidgets/barcode-generator-web/typings/BarcodeGeneratorProps.d.ts @@ -7,7 +7,7 @@ import { DynamicValue, WebImage } from "mendix"; import { Big } from "big.js"; import { CSSProperties } from "react"; -export type CodeFormatEnum = "CODE128" | "QRCode" | "Custom"; +export type CodeFormatEnum = "CODE128" | "QRCode" | "DataMatrix" | "Custom"; export type ButtonPositionEnum = "top" | "bottom"; @@ -17,6 +17,8 @@ export type AddonFormatEnum = "None" | "EAN5" | "EAN2"; export type QrLevelEnum = "L" | "M" | "Q" | "H"; +export type DmShapeEnum = "square" | "rectangle"; + export type LogLevelEnum = "None" | "Info" | "Debug"; export interface BarcodeGeneratorContainerProps { @@ -42,6 +44,9 @@ export interface BarcodeGeneratorContainerProps { addonSpacing: number; qrLevel: QrLevelEnum; qrSize: number; + dmGs1Mode: boolean; + dmShape: DmShapeEnum; + dmSize: number; logLevel: LogLevelEnum; displayValue: boolean; showAsCard: boolean; @@ -49,6 +54,7 @@ export interface BarcodeGeneratorContainerProps { codeHeight: number; codeMargin: number; qrMargin: number; + dmMargin: number; qrTitle: DynamicValue; showTitle: boolean; qrOverlay: boolean; @@ -91,6 +97,9 @@ export interface BarcodeGeneratorPreviewProps { addonSpacing: number | null; qrLevel: QrLevelEnum; qrSize: number | null; + dmGs1Mode: boolean; + dmShape: DmShapeEnum; + dmSize: number | null; logLevel: LogLevelEnum; displayValue: boolean; showAsCard: boolean; @@ -98,6 +107,7 @@ export interface BarcodeGeneratorPreviewProps { codeHeight: number | null; codeMargin: number | null; qrMargin: number | null; + dmMargin: number | null; qrTitle: string; showTitle: boolean; qrOverlay: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 331dfd1a66..48cf37194a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,9 +554,15 @@ importers: packages/pluggableWidgets/barcode-generator-web: dependencies: + '@bwip-js/browser': + specifier: ^4.11.2 + version: 4.11.2 classnames: specifier: ^2.5.1 version: 2.5.1 + dompurify: + specifier: ^3.4.11 + version: 3.4.13 jsbarcode: specifier: ^3.12.1 version: 3.12.3 @@ -3877,6 +3883,9 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bwip-js/browser@4.11.2': + resolution: {integrity: sha512-+6wZZY218c0Q6e9xjpESAs851ezWp8pZp2vS7WSjwX0GBOeGoT04NbvgHWPvtg4NIWXEIxoOgSBPnhmkFXhyNg==} + '@choojs/findup@0.2.1': resolution: {integrity: sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==} hasBin: true @@ -4525,6 +4534,7 @@ packages: '@plotly/mapbox-gl@1.13.4': resolution: {integrity: sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==} engines: {node: '>=6.4.0'} + deprecated: This package is deprecated as of August 2026. plotly.js v4 uses MapLibre for map traces — see https://github.com/maplibre/maplibre-gl-js. '@plotly/point-cluster@3.1.9': resolution: {integrity: sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==} @@ -11331,6 +11341,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bwip-js/browser@4.11.2': {} + '@choojs/findup@0.2.1': dependencies: commander: 2.20.3