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:
- Das Steuerelement ist kontrolliert. Es rendert
valueund meldet Änderungen überonChange. 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. - 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
fieldMetadataan. Ein Steuerelement mit fest codierter Beschriftung lässt sich genau ein einziges Mal verwenden. - Das Steuerelement erreicht die Plattform ausschließlich über
context. Das ist dieselbeParswareRuntime-Schnittstelle, die auch eine App erhält –context.datazum Lesen,context.logzum Protokollieren. Keine direkten HTTP-Aufrufe. - 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:
{
"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
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
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.
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 schreibt | Ersatz für | Binden an |
|---|---|---|
| Eine kurze Zeichenfolge | Text | ein Textfeld |
| Eine lange Zeichenfolge (Daten-URL, JSON, formatierter Text) | MultilineText | ein mehrzeiliges Textfeld |
| Eine Zahl | Number | ein Ganzzahl- oder Dezimalfeld |
| Einen booleschen Wert | Boolean | ein boolesches Feld |
| Eine ISO-Datums-/Uhrzeit-Zeichenfolge | DateTime | ein Datums-/Uhrzeitfeld |
| Eine Datensatz-ID | Lookup | ein Nachschlagefeld |
| Einen Object-Storage-Schlüssel | File, Image | ein 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,nulloder'') – 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.
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: truebeireact/react-domundresolve.dedupeinvite.config.ts. - Änderungen verschwinden beim Speichern des Datensatzes – das Steuerelement hat
onChangenie 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
exposesentsprechen, 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.