Een aangepast besturingselement bouwen
Doel
Schrijf een aangepast besturingselement — een herbruikbare React-component die de ingebouwde editor voor een veld op een modelgestuurd formulier vervangt — van een lege map tot een besturingselement dat een maker aan een veld kan koppelen.
Een aangepast besturingselement is het juiste gereedschap wanneer een veld een editor nodig heeft die het platform niet meelevert: een handtekeningvlak, een kleurkiezer, een sterrenbeoordeling, een kaart, een barcodescanner. Het is niet het gereedschap voor een heel scherm — daarvoor is er de aangepaste React-app.
Vereisten
- Node.js 18 of hoger en npm.
- Bekendheid met React 18 en TypeScript.
- Een uitgever en een oplossing in de omgeving waarin het besturingselement wordt
geïnstalleerd. Het voorvoegsel van de uitgever (bijvoorbeeld
par) maakt deel uit van de logische naam van het besturingselement. - Het helpt als je Een aangepaste React-app bouwen hebt gelezen: een besturingselement wordt op dezelfde manier gehost als een app, alleen het contract verschilt.
Het contract van een besturingselement
Een aangepast besturingselement is één React-component waarvan de props door het platform vastliggen. De Player van de modelgestuurde app rendert het op de plaats van de standaard veldeditor en levert alle vier de 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;
}
Uit die vorm volgen vier regels, en een besturingselement dat er één van breekt, is niet af:
- Het besturingselement is gecontroleerd. Het rendert
valueen meldt wijzigingen viaonChange. Het bewaart de vastgelegde waarde niet in zijn eigen state als bron van waarheid, en het slaat de record niet op — het formulier is eigenaar van het opslaan. - Het besturingselement leest de metadata van zijn veld in plaats van die aan te
nemen. Het label, de verplicht-vlag, de maximale lengte, de doeltabel van een
zoekveld en de naam van een keuzeset komen allemaal binnen via
fieldMetadata. Een besturingselement met een hardgecodeerd label kun je precies één keer gebruiken. - Het besturingselement bereikt het platform alleen via
context. Dat is hetzelfdeParswareRuntime-oppervlak dat een app krijgt —context.dataom te lezen,context.logom te loggen. Geen rechtstreekse HTTP-aanroepen. - Het besturingselement valt terug in plaats van een fout te gooien. Het is gekoppeld in het formulier van iemand anders; een exceptie neemt de hele record mee in zijn val. Houd rekening met een ontbrekende browser-API, een onverwacht waardetype en een lege waarde.
Stappen
1. Maak het project aan
Het project wordt precies zo opgezet als dat van een aangepaste React-app — package.json,
tsconfig.json, index.html, src/vite-env.d.ts, vite.config.ts — zie stap 1 van
Een aangepaste React-app bouwen. De Runtime SDK en de
packer zijn downloads op deze helpsite en worden via hun adres geïnstalleerd:
{
"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"
}
}
Het pack-script is wat stap 8 uitvoert. Door het hier te declareren wordt de packer in
elk project en op elke machine op dezelfde manier aangeroepen — npm run pack — in plaats
van af te hangen van waar de tool toevallig is geïnstalleerd. Het roept het bestand van de
packer aan in plaats van de bin-shim parsware-pack, omdat het draaien van een shim een
exec is, en een exec kan worden geweigerd om redenen die niets met je project te maken
hebben — meestal macOS Gatekeeper, dat bestanden uit een gedownload archief in quarantaine
plaatst en dan bad interpreter: Operation not permitted meldt.
2. Configureer Module Federation
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' },
});
Volgens conventie stelt een besturingselement ./Control beschikbaar waar een app ./App
beschikbaar stelt. Niemand typt dat ergens in: de packer leest het uit mf-manifest.json, en
daarom is manifest: true niet optioneel.
3. Schrijf het besturingselement
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. Bepaal voor welke veldtypen het besturingselement in de plaats komt
Bij registratie declareert een besturingselement Vervangt (Substitutes for) — de
families van gegevenstypen die het kan vervangen (Text, MultilineText, Number,
Boolean, DateTime, Lookup, File, Image, Custom). De formulierontwerper biedt het
besturingselement alleen aan voor velden waarvan het besturingstype op die lijst staat,
dus een datumkiezer wordt nooit aangeboden voor een booleaans veld.
Een plaatsing op een formulier kiest een ingebouwd besturingselement — TextArea,
Toggle, Dropdown, Date, Time. substitutesFor gebruikt een ander, korter
vocabulaire: de familie waartoe het gegevenstype van een veld behoort. De twee delen vier
namen (Text, DateTime, Lookup, Custom), en juist daardoor haal je ze makkelijk door
elkaar. Wie TextArea declareert, wordt bij registratie afgewezen met
/substitutesFor/1 enum.
Kies de lijst op basis van wat het besturingselement werkelijk leest en schrijft:
| Het besturingselement schrijft | Vervangt | Koppel het aan |
|---|---|---|
| Een korte tekenreeks | Text | een tekstveld |
| Een lange tekenreeks (data-URL, JSON, opgemaakte tekst) | MultilineText | een tekstveld met meerdere regels |
| Een getal | Number | een veld met een geheel of decimaal getal |
| Een booleaanse waarde | Boolean | een booleaans veld |
| Een ISO-tekenreeks voor datum/tijd | DateTime | een datum/tijd-veld |
| Een record-id | Lookup | een zoekveld |
| Een Object Storage-sleutel | File, Image | een bestands- of afbeeldingsveld |
Een keuzesetveld heeft geen eigen familie: het wordt afgeleid als Text, dus een
besturingselement daarvoor declareert Text (of Custom).
Een type declareren dat het besturingselement niet echt aankan, is de meest voorkomende
oorzaak van een besturingselement dat "werkt tijdens het ontwikkelen en stukgaat op een
echt formulier" — de ontwerper biedt het dan aan voor een veld waarvan het de vorm van
value nooit had verwacht.
5. Behandel elke toestand van de waarde
value komt binnen in vier toestanden, en elk daarvan vraagt om een bewust antwoord:
- Leeg (
undefined,nullof'') — een nieuwe record, of een leeggemaakt veld. Render de lege editor, geen kapotte. - Een opgeslagen waarde — een bestaande record die opnieuw wordt geopend. Render die; een besturingselement dat alleen toont wat de gebruiker in deze sessie heeft getypt, verbergt ongemerkt opgeslagen gegevens.
- Een waarde van een onverwacht type — het besturingselement is gekoppeld aan een veldtype dat het niet aankan. Render een neutrale terugvaloptie in plaats van een fout te gooien.
- Een van buitenaf gewijzigde waarde — het formulier kan resetten of opnieuw laden.
Reageer op de prop
valuein plaats van de vastgelegde waarde in state te cachen.
6. Test het besturingselement
Render de component rechtstreeks met een mock-runtime en toets het contract: wat het
rendert voor een gegeven value, en wat het aan onChange doorgeeft.
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('');
});
Test ook het geval verplicht-maar-leeg: de markering die een verplicht veld toont, hoort
bij wat het besturingselement het formulier belooft. De testopzet — vitest, jsdom,
Testing Library en een vitest.config.ts die Module Federation weglaat — is die van de app, in
stap 6 van Een aangepaste React-app bouwen; voeg
@testing-library/user-event toe voor de klik.
7. Declareer wat de bundler niet kan weten
Voeg een parsware-blok toe aan de package.json van het besturingselement. Daarin staan
precies de drie dingen die de build op geen enkele manier kan afleiden — de logische naam
met uitgeversvoorvoegsel waaronder het besturingselement wordt geïnstalleerd, de naam die
in het veldpalet verschijnt, en de families van veldgegevenstypen die het kan vervangen:
{
"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"]
}
}
De naam van de container, de beschikbaar gestelde module en het startbestand staan hier bewust
niet. Ze worden uit de eigen mf-manifest.json van de build gelezen (de manifest: true uit
stap 2), zodat ze niet uit de pas kunnen lopen met vite.config.ts.
version is de eigen npm-versie van het besturingselement. Die ziet een maker in het
raster met aangepaste besturingselementen, dus npm version patch vóór het verpakken is
hoe een build herkenbaar wordt.
8. Verpak het
npm run pack # builds, then writes package/<logicalName>-<version>.zip
Het archief bevat de build-uitvoer plus een parsware.json die is samengesteld uit de
twee bronnen hierboven. Dat ene bestand is de hele registratie: een maker uploadt het in
het Beheercentrum en vult verder niets in.
npm run pack -- --no-build # package an existing build
npm run pack -- --module ./Grid # when the remote exposes more than one module
De -- is van npm, niet van de packer: die zorgt ervoor dat npm de vlag niet zelf opslokt
en hem doorgeeft aan het script.
De packer weigert alles wat het platform later ook zou weigeren — een ontbrekend
parsware-blok, een logische naam die nooit door een voorvoegselcontrole kan komen, de
naam van een ingebouwd besturingselement waar een gegevenstypefamilie hoort — zodat de
fout in je terminal wordt benoemd in plaats van te worden ontdekt als geweigerde upload.
Het opgeslagen pakket wordt geserveerd vanaf de eigen origin van het platform, dus de oplossing bevat de code van het besturingselement: exporteer je die en importeer je hem in Test, dan installeer je een werkend besturingselement, geen verwijzing naar je eigen machine. Er is geen URL om op een allowlist te zetten en geen URL die kan verouderen.
Hot reload tijdens het schrijven
Voor elke wijziging inpakken is een trage lus. In een ontwikkelomgeving kun je het
besturingselement in plaats daarvan registreren tegen de draaiende ontwikkelserver: in de
oplossing Aangepaste besturingselementen, Nieuw, Een draaiende ontwikkelserver
gebruiken, en typ het adres dat npm run dev toont. Het platform leest de naam van het
besturingselement uit je package.json, en scope, module en entrybestand uit de
mf-manifest.json die de federation-plugin serveert met manifest: true, dus er valt verder
niets in te vullen. Is het besturingselement al geïnstalleerd, verwijder het dan eerst; ben je
klaar, upload dan het pakket, dat de registratie van de ontwikkelserver vervangt.
Vanaf dan laadt een formulier het besturingselement rechtstreeks van je machine: sla een wijziging op en vernieuw het formulier om haar te zien, zonder build, zonder inpakken en zonder upload. Bij een open formulier wordt de wijziging niet ingewisseld: fast refresh van je ontwikkelserver bereikt de React van de Shell niet, dus de hot reload die je op de eigen ontwikkelpagina van het besturingselement hebt, stopt bij de Shell.
Twee dingen moeten het toestaan. De Shell laadt alleen code van origins op zijn toegestane lijst
(REMOTE_ALLOWED_ORIGINS voor een uitrol, VITE_REMOTE_ALLOWED_ORIGINS voor een Shell die met
npm run dev draait), dus de origin van je ontwikkelserver moet erop staan. En je ontwikkelserver
moet verzoeken van het adres van het platform accepteren; Vite doet dat standaard voor
localhost, anders is server.cors nodig. Het platform weigert een ontwikkelserver in Test en
Productie: een besturingselement dat zo is geregistreerd, heeft geen pakket, dus de code zou niet
met de oplossing meereizen, en dat is precies het probleem dat inpakken oplost.
Daarna registreert een maker het besturingselement (Een aangepast besturingselement registreren) en koppelt het in de formulierontwerper aan een veld (Een aangepast besturingselement aan een veld koppelen). Het manifest dat die registratie oplevert, is gespecificeerd in Manifest van een aangepast besturingselement.
Wat er achter de schermen gebeurt
Wanneer de Player van de modelgestuurde app een formulier rendert, leest hij de koppeling
van elk veld. Een veld dat aan een aangepast besturingselement is gekoppeld, verwijst naar
de registratie van dat besturingselement in de huidige omgeving. De Player laadt dan de
Module Federation-remote van het besturingselement — met de React- en Runtime
SDK-instanties van de shell, precies zoals bij een aangepaste React-app — en koppelt de
beschikbaar gestelde component op de plaats van de ingebouwde editor. Daarbij geeft hij de
huidige waarde van het veld door, een onChange die is aangesloten op de bewerkingsbuffer
van het formulier, de metadata van het veld en dezelfde runtime die een app krijgt, waarvan de aanroepen uitgaan als de persoon die het
formulier gebruikt. Waarden die het besturingselement
vastlegt, worden onderdeel van de openstaande wijzigingen van het formulier en worden
weggeschreven wanneer de record wordt opgeslagen. Zo doet het besturingselement mee aan
validatie en opslaan, net als elke standaard editor.
Problemen oplossen
- Het besturingselement verschijnt niet in de lijst met besturingselementen van de formulierontwerper — het besturingstype van het veld staat niet op de lijst Vervangt van het besturingselement, of het besturingselement is geregistreerd in een andere omgeving dan die waarin je ontwerpt.
- "Invalid hook call" wanneer het besturingselement wordt gekoppeld — twee
React-kopieën. Controleer
singleton: trueopreact/react-domenresolve.dedupeinvite.config.ts. - Wijzigingen verdwijnen wanneer de record wordt opgeslagen — het besturingselement
heeft
onChangenooit aangeroepen, of heeft het aangeroepen met een waarde die het gegevenstype van het veld niet kan opslaan. Het formulier slaat op wat het is verteld, niet wat het besturingselement toont. - Het besturingselement toont een lege editor bij een record die wel een waarde heeft
— het besturingselement rendert zijn eigen state in plaats van de prop
value. Het besturingselement moet gecontroleerd zijn. - Het hele formulier kan niet worden gerenderd — het besturingselement gooide een fout tijdens het renderen. Een besturingselement wordt gekoppeld in de pagina van iemand anders; houd rekening met een ontbrekende browser-API en een onverwacht waardetype in plaats van ervan uit te gaan dat alles er is.
- De beschikbaar gestelde module wordt niet gevonden — de geregistreerde module
moet exact overeenkomen met de sleutel in
exposes, inclusief de voorloop-./(./Control).
Gerelateerde scenario's
- Een aangepaste React-app bouwen.
- De Runtime SDK gebruiken.
- Een aangepast besturingselement registreren.
- Een aangepast besturingselement aan een veld koppelen.