Direkt zum Hauptinhalt

Ein benutzerdefiniertes Steuerelement erstellen

Ziel​

Schreiben Sie ein benutzerdefiniertes Steuerelement – eine wiederverwendbare React-Komponente, die den eingebauten Editor eines Felds auf einem modellgesteuerten Formular ersetzt – vom leeren Ordner bis zu einem Steuerelement, das ein Ersteller an ein Feld binden kann.

Ein benutzerdefiniertes Steuerelement ist das richtige Werkzeug, wenn ein Feld einen Editor braucht, den die Plattform nicht mitliefert: ein Unterschriftenfeld, eine Farbauswahl, eine Sternebewertung, eine Karte, einen Barcode-Scanner. Für einen ganzen Bildschirm ist es nicht das richtige Werkzeug – dafür gibt es die benutzerdefinierte React-App.

Voraussetzungen​

  • Node.js 18 oder höher und npm.
  • Vertrautheit mit React 18 und TypeScript.
  • Ein Herausgeber und eine Lösung in der Umgebung, in der das Steuerelement installiert werden soll. Das Präfix des Herausgebers (zum Beispiel par) ist Teil des logischen Namens des Steuerelements.
  • Es hilft, Eine benutzerdefinierte React-App erstellen gelesen zu haben: Ein Steuerelement wird genauso gehostet wie eine App, nur der Vertrag unterscheidet sich.

Der Vertrag des Steuerelements​

Ein benutzerdefiniertes Steuerelement ist eine einzelne React-Komponente, deren Props von der Plattform festgelegt sind. Der Player für modellgesteuerte Apps rendert sie anstelle des nativen Feldeditors und liefert alle vier Props:

interface ParswareControlProps {
/** The bound field's current value. Its type follows the field's data type. */
value: unknown;
/** Commit a new value back to the form. */
onChange: (next: unknown) => void;
/** The bound field's metadata — type, label, required flag, constraints. */
fieldMetadata: FieldMetadata;
/** The same auth/data/logging context a Custom React App receives. */
context: RuntimeContext;
}

Aus dieser Form ergeben sich vier Regeln, und ein Steuerelement, das auch nur eine davon verletzt, ist unvollständig:

  1. Das Steuerelement ist kontrolliert. Es rendert value und meldet Änderungen über onChange. Es hält den festgeschriebenen Wert nicht als maßgebliche Quelle in seinem eigenen State, und es speichert den Datensatz nicht – das Speichern gehört dem Formular.
  2. Das Steuerelement liest die Metadaten seines Felds, statt sie anzunehmen. Die Beschriftung, das Pflichtfeld-Kennzeichen, die maximale Länge, die Zieltabelle eines Nachschlagefelds und der Name eines Optionssatzes kommen alle in fieldMetadata an. Ein Steuerelement mit fest codierter Beschriftung lässt sich genau ein einziges Mal verwenden.
  3. Das Steuerelement erreicht die Plattform ausschließlich über context. Das ist dieselbe ParswareRuntime-Schnittstelle, die auch eine App erhält – context.data zum Lesen, context.log zum Protokollieren. Keine direkten HTTP-Aufrufe.
  4. Das Steuerelement schränkt sich ein, statt einen Fehler auszulösen. Es ist in das Formular eines anderen eingehängt; eine Ausnahme reißt den ganzen Datensatz mit. Sichern Sie sich gegen eine fehlende Browser-API, einen unerwarteten Werttyp und einen leeren Wert ab.

Schritte​

1. Das Projekt anlegen​

Das Projekt wird genau wie das einer benutzerdefinierten React-App angelegt – package.json, tsconfig.json, index.html, src/vite-env.d.ts, vite.config.ts –, siehe Schritt 1 von Benutzerdefinierte React-App erstellen. Das Runtime SDK und der Packer sind Downloads auf dieser Hilfeseite und werden über ihre Adresse installiert:

package.json (excerpt)
{
"name": "signature-pad",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run",
"pack": "node node_modules/@parsware/pack/src/cli.mjs"
},
"dependencies": {
"@parsware/runtime-sdk": "https://docs.parsware.com/sdk/parsware-runtime-sdk-1.0.0.tgz",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz"
}
}

Das Skript pack ist das, was Schritt 8 ausführt. Wenn Sie es hier deklarieren, wird der Packer in jedem Projekt und auf jedem Rechner auf dieselbe Weise aufgerufen – npm run pack –, statt davon abzuhängen, wo das Werkzeug zufällig installiert ist. Es ruft die Datei des Packers auf, nicht dessen Bin-Shim parsware-pack, denn das Ausführen eines Shims ist ein exec, und ein exec kann aus Gründen verweigert werden, die mit Ihrem Projekt nichts zu tun haben – am häufigsten von macOS Gatekeeper, das aus einem heruntergeladenen Archiv entpackte Dateien unter Quarantäne stellt und dann bad interpreter: Operation not permitted meldet.

2. Module Federation konfigurieren​

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

// The control reuses the Shell's React and Runtime SDK instances; its bundle ships
// no copy of either. Without `singleton: true`, a second React would break hooks.
const sharedSingletons = {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
};

export default defineConfig({
plugins: [
react(),
federation({
name: 'par_signature_pad', // the container's name: unique per environment
filename: 'remoteEntry.js',
exposes: { './Control': './src/Control.tsx' },
shared: sharedSingletons,
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
build: { target: 'esnext' },
});

Per Konvention stellt ein Steuerelement ./Control bereit, wo eine App ./App bereitstellt. Niemand tippt das irgendwo ein: Der Packer liest es aus mf-manifest.json, und deshalb ist manifest: true nicht optional.

3. Das Steuerelement schreiben​

src/Control.tsx
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import type { ParswareControlProps } from '@parsware/runtime-sdk';
import './index.css';

/**
* A signature pad bound to a text field. The committed value is a PNG data URL, so
* the field it binds to is an ordinary text field — the control decides what the
* text means, the platform stores it unchanged.
*/
export default function Control({ value, onChange, fieldMetadata, context }: ParswareControlProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const drawing = useRef(false);
const [hasInk, setHasInk] = useState(false);

// 1. Announce the mount through the platform's log — a control reaches platform
// services only through `context`.
useEffect(() => {
context.log('debug', 'Signature pad mounted', { field: fieldMetadata.logicalName });
}, [context, fieldMetadata.logicalName]);

// 2. Begin a stroke. A missing 2D context is a no-op rather than a throw, so the
// control degrades on a canvas-less environment instead of breaking the form.
const startStroke = (e: PointerEvent<HTMLCanvasElement>) => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
drawing.current = true;
const rect = e.currentTarget.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
};

// 3. Commit the drawing to the form when the stroke ends. `onChange` is the only
// way a value reaches the record — the control never saves anything itself.
const endStroke = () => {
if (!drawing.current) return;
drawing.current = false;
const dataUrl = canvasRef.current?.toDataURL('image/png');
if (dataUrl) onChange(dataUrl);
};

// 4. Clearing is an edit like any other: wipe the canvas, commit an empty value.
const clear = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasInk(false);
onChange('');
};

const signed = typeof value === 'string' && value.length > 0;

return (
<div className="signature-pad">
<canvas
ref={canvasRef}
width={320}
height={120}
role="img"
aria-label={`${fieldMetadata.displayName} signature canvas`}
onPointerDown={startStroke}
onPointerUp={endStroke}
onPointerLeave={endStroke}
/>
<div className="signature-pad__row">
<span>
{signed || hasInk ? 'Signed' : 'Not signed'}
{/* The required marker comes from metadata, not from a hard-coded assumption. */}
{fieldMetadata.isRequired && !signed && !hasInk ? ' — required' : ''}
</span>
<button type="button" onClick={clear}>Clear</button>
</div>
{/* Re-opening a saved record must show the stored signature. */}
{signed && <img src={value as string} alt={`${fieldMetadata.displayName} signature`} />}
</div>
);
}

4. Festlegen, welche Feldtypen das Steuerelement ersetzt​

Bei der Registrierung deklariert ein Steuerelement Ersatz für – die Datentypfamilien von Feldern, die es ersetzen kann (Text, MultilineText, Number, Boolean, DateTime, Lookup, File, Image, Custom). Der Formulardesigner bietet das Steuerelement nur für Felder an, deren Steuerelementtyp auf dieser Liste steht; eine Datumsauswahl wird also nie für ein boolesches Feld angeboten.

Das sind nicht die Namen der eingebauten Steuerelemente

Eine Platzierung auf einem Formular wählt ein eingebautes Steuerelement – TextArea, Toggle, Dropdown, Date, Time. substitutesFor verwendet ein anderes, kürzeres Vokabular: die Familie, zu der der Datentyp eines Felds gehört. Beide teilen sich vier Namen (Text, DateTime, Lookup, Custom), was sie leicht verwechselbar macht. Wer TextArea deklariert, wird bei der Registrierung mit /substitutesFor/1 enum abgewiesen.

Wählen Sie die Liste danach, was das Steuerelement tatsächlich liest und schreibt:

Das Steuerelement schreibtErsatz fürBinden an
Eine kurze ZeichenfolgeTextein Textfeld
Eine lange Zeichenfolge (Daten-URL, JSON, formatierter Text)MultilineTextein mehrzeiliges Textfeld
Eine ZahlNumberein Ganzzahl- oder Dezimalfeld
Einen booleschen WertBooleanein boolesches Feld
Eine ISO-Datums-/Uhrzeit-ZeichenfolgeDateTimeein Datums-/Uhrzeitfeld
Eine Datensatz-IDLookupein Nachschlagefeld
Einen Object-Storage-SchlüsselFile, Imageein Datei- oder Bildfeld

Ein Optionssatzfeld hat keine eigene Familie: Es wird als Text abgeleitet, daher deklariert ein Steuerelement dafür Text (oder Custom).

Einen Typ zu deklarieren, den das Steuerelement gar nicht verarbeiten kann, ist die häufigste Ursache für ein Steuerelement, das „in der Entwicklung funktioniert und auf einem echten Formular versagt“ – der Designer bietet es dann für ein Feld an, dessen value-Form es nie erwartet hat.

5. Jeden Wertzustand behandeln​

value kommt in vier Zuständen an, und jeder braucht eine bewusste Antwort:

  • Leer (undefined, null oder '') – ein neuer Datensatz oder ein geleertes Feld. Rendern Sie den leeren Editor, keinen defekten.
  • Ein gespeicherter Wert – ein vorhandener Datensatz wird erneut geöffnet. Rendern Sie ihn; ein Steuerelement, das nur zeigt, was der Benutzer in dieser Sitzung eingegeben hat, verbirgt stillschweigend gespeicherte Daten.
  • Ein Wert unerwarteten Typs – das Steuerelement wurde an einen Feldtyp gebunden, den es nicht verarbeitet. Rendern Sie eine neutrale Ersatzdarstellung, statt einen Fehler auszulösen.
  • Ein von außen geänderter Wert – das Formular kann zurückgesetzt oder neu geladen werden. Reagieren Sie auf die Prop value, statt den festgeschriebenen Wert im State zwischenzuspeichern.

6. Das Steuerelement testen​

Rendern Sie die Komponente direkt mit einer Mock-Runtime und prüfen Sie den Vertrag: was sie für einen bestimmten value rendert und was sie an onChange übergibt.

src/Control.test.tsx
import { expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ParswareRuntime } from '@parsware/runtime-sdk';
import Control from './Control';

// Only what the control calls needs to be real.
const mockRuntime = () => ({ log: () => {} }) as unknown as ParswareRuntime;

const fieldMetadata = {
logicalName: 'par_signature',
displayName: 'Signature',
dataType: 0, // Text
isRequired: true,
} as never;

it('renders a stored signature when the record already has one', () => {
render(
<Control
value="data:image/png;base64,AAA"
onChange={() => {}}
fieldMetadata={fieldMetadata}
context={mockRuntime()}
/>,
);

expect(screen.getByAltText('Signature signature')).toBeInTheDocument();
});

it('commits an empty value when cleared', async () => {
const onChange = vi.fn();
render(<Control value="data:image/png;base64,AAA" onChange={onChange} fieldMetadata={fieldMetadata} context={mockRuntime()} />);

await userEvent.click(screen.getByRole('button', { name: 'Clear' }));

expect(onChange).toHaveBeenCalledWith('');
});

Testen Sie auch den Fall „Pflichtfeld, aber leer“: Die Markierung, die ein Pflichtfeld anzeigt, gehört zu dem, was das Steuerelement dem Formular zusagt. Die Testumgebung – vitest, jsdom, Testing Library und eine vitest.config.ts, die Module Federation weglässt – ist die der App, in Schritt 6 von Benutzerdefinierte React-App erstellen; für den Klick kommt @testing-library/user-event hinzu.

7. Deklarieren, was der Bundler nicht wissen kann​

Fügen Sie der package.json des Steuerelements einen parsware-Block hinzu. Er enthält genau die drei Dinge, die der Build auf keinem Weg ermitteln kann – den mit dem Herausgeberpräfix versehenen logischen Namen, unter dem das Steuerelement installiert wird, den in der Feldpalette angezeigten Namen und die Datentypfamilien von Feldern, die es vertreten kann:

{
"version": "1.0.0",
"parsware": {
"logicalName": "par_signature_pad",
"displayName": "Signature Pad",
"description": "Capture a hand-drawn signature on a text field.",
"substitutesFor": ["Text", "Custom"]
}
}

Der Name des Containers, das bereitgestellte Modul und die Einstiegsdatei stehen bewusst nicht hier. Sie werden aus der eigenen mf-manifest.json des Builds gelesen (dem manifest: true aus Schritt 2) und können daher nicht von vite.config.ts abweichen.

version ist die eigene npm-Version des Steuerelements. Sie ist das, was ein Ersteller im Raster der benutzerdefinierten Steuerelemente sieht; mit npm version patch vor dem Packen wird ein Build also identifizierbar.

8. Packen​

npm run pack # builds, then writes package/<logicalName>-<version>.zip

Das Archiv enthält die Build-Ausgabe plus eine parsware.json, die aus den beiden obigen Quellen zusammengesetzt ist. Diese eine Datei ist die gesamte Registrierung: Ein Ersteller lädt sie im Admin Control Center hoch und füllt sonst nichts aus.

npm run pack -- --no-build # package an existing build
npm run pack -- --module ./Grid # when the remote exposes more than one module

Das -- gehört zu npm, nicht zum Packer: Es verhindert, dass npm das Flag selbst verarbeitet, und reicht es an das Skript durch.

Der Packer lehnt alles ab, was die Plattform später ablehnen würde – einen fehlenden parsware-Block, einen logischen Namen, der niemals eine Präfixprüfung bestehen könnte, den Namen eines eingebauten Steuerelements dort, wo eine Datentypfamilie hingehört –, sodass der Fehler in Ihrem Terminal benannt wird, statt erst als abgewiesener Upload aufzufallen.

Das gespeicherte Paket wird vom eigenen Ursprung der Plattform ausgeliefert, daher enthält die Lösung den Code des Steuerelements: Wer sie exportiert und in die Testumgebung importiert, installiert ein funktionierendes Steuerelement, keinen Verweis auf Ihren Rechner. Es gibt keine URL, die auf eine Positivliste gesetzt werden muss, und keine, die veralten kann.

Hot Reload während des Schreibens​

Für jede Änderung zu packen ist eine langsame Schleife. In einer Entwicklungsumgebung können Sie das Steuerelement stattdessen gegen seinen laufenden Entwicklungsserver registrieren: in der Lösung Benutzerdefinierte Steuerelemente, Neu, Laufenden Entwicklungsserver verwenden, und die Adresse eingeben, die npm run dev ausgibt. Die Plattform liest den Namen des Steuerelements aus Ihrer package.json und Scope, Modul und Einstiegsdatei aus der mf-manifest.json, die das Federation-Plugin mit manifest: true ausliefert. Mehr ist nicht auszufüllen. Ist das Steuerelement bereits installiert, entfernen Sie es zuerst; wenn Sie fertig sind, laden Sie das Paket hoch, das die Registrierung des Entwicklungsservers ersetzt.

Ab dann lädt ein Formular das Steuerelement direkt von Ihrem Rechner: Speichern Sie eine Änderung und laden Sie das Formular neu, um sie zu sehen, ohne Build, ohne Packen und ohne Upload. Bei geöffnetem Formular wird die Änderung nicht eingetauscht: Fast Refresh Ihres Entwicklungsservers erreicht das React der Shell nicht, daher endet das Hot Reload, das Sie auf der eigenen Entwicklungsseite des Steuerelements haben, an der Shell.

Zwei Dinge müssen es erlauben. Die Shell lädt Code nur von Origins auf ihrer Zulassungsliste (REMOTE_ALLOWED_ORIGINS für eine Bereitstellung, VITE_REMOTE_ALLOWED_ORIGINS für eine mit npm run dev gestartete Shell), der Origin Ihres Entwicklungsservers muss also darauf stehen. Und Ihr Entwicklungsserver muss Anfragen von der Adresse der Plattform annehmen; Vite tut das für localhost standardmäßig, sonst braucht es server.cors. In Test und Produktion lehnt die Plattform einen Entwicklungsserver ab: Ein so registriertes Steuerelement hat kein Paket, sein Code würde also nicht mit der Lösung reisen, und genau dieses Problem löst das Packen.

Anschließend registriert ein Ersteller das Steuerelement (Ein benutzerdefiniertes Steuerelement registrieren) und bindet es im Formulardesigner an ein Feld (Ein benutzerdefiniertes Steuerelement an ein Feld binden). Das Manifest, das bei der Registrierung entsteht, ist in Manifest eines benutzerdefinierten Steuerelements spezifiziert.

Was im Hintergrund passiert​

Wenn der Player für modellgesteuerte Apps ein Formular rendert, liest er die Bindung jedes Felds. Ein an ein benutzerdefiniertes Steuerelement gebundenes Feld wird zur Registrierung dieses Steuerelements in der aktuellen Umgebung aufgelöst, und der Player lädt das Module-Federation-Remote des Steuerelements – unter gemeinsamer Nutzung der React- und Runtime-SDK-Instanzen der Shell, genau wie bei einer benutzerdefinierten React-App – und hängt die bereitgestellte Komponente anstelle des eingebauten Editors ein. Dabei übergibt er den aktuellen Wert des Felds, ein onChange, das mit dem Bearbeitungspuffer des Formulars verbunden ist, die Metadaten des Felds und dieselbe Runtime, die eine App bekommt und deren Aufrufe als die Person ausgeführt werden, die das Formular benutzt. Werte, die das Steuerelement festschreibt, werden Teil der ausstehenden Änderungen des Formulars und beim Speichern des Datensatzes geschrieben; das Steuerelement nimmt also wie jeder native Editor an Validierung und Speichern teil.

Fehlerbehebung​

  • Das Steuerelement erscheint nicht in der Steuerelementliste des Formulardesigners – der Steuerelementtyp des Felds steht nicht auf der Liste Ersatz für des Steuerelements, oder das Steuerelement ist in einer anderen Umgebung registriert als der, in der Sie gerade entwerfen.
  • „Invalid hook call“ beim Einhängen des Steuerelements – zwei React-Kopien. Prüfen Sie singleton: true bei react/react-dom und resolve.dedupe in vite.config.ts.
  • Änderungen verschwinden beim Speichern des Datensatzes – das Steuerelement hat onChange nie aufgerufen oder mit einem Wert aufgerufen, den der Datentyp des Felds nicht speichern kann. Das Formular speichert, was ihm mitgeteilt wurde, nicht was das Steuerelement anzeigt.
  • Das Steuerelement zeigt bei einem Datensatz mit Wert einen leeren Editor – das Steuerelement rendert seinen eigenen State statt der Prop value. Das Steuerelement muss kontrolliert sein.
  • Das gesamte Formular lässt sich nicht rendern – das Steuerelement hat beim Rendern einen Fehler ausgelöst. Ein Steuerelement ist in die Seite eines anderen eingehängt; sichern Sie sich gegen eine fehlende Browser-API und einen unerwarteten Werttyp ab, statt Annahmen zu treffen.
  • Das bereitgestellte Modul wird nicht gefunden – das registrierte Modul muss exakt dem Schlüssel in exposes entsprechen, einschließlich des führenden ./ (./Control).

Verwandte Szenarien​

  • Eine benutzerdefinierte React-App erstellen.
  • Das Runtime SDK verwenden.
  • Ein benutzerdefiniertes Steuerelement registrieren.
  • Ein benutzerdefiniertes Steuerelement an ein Feld binden.