Naar hoofdinhoud gaan

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:

  1. Het besturingselement is gecontroleerd. Het rendert value en meldt wijzigingen via onChange. 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.
  2. 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.
  3. Het besturingselement bereikt het platform alleen via context. Dat is hetzelfde ParswareRuntime-oppervlak dat een app krijgt — context.data om te lezen, context.log om te loggen. Geen rechtstreekse HTTP-aanroepen.
  4. 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:

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"
}
}

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​

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' },
});

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​

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. 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.

Dit zijn niet de namen van de ingebouwde besturingselementen

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 schrijftVervangtKoppel het aan
Een korte tekenreeksTexteen tekstveld
Een lange tekenreeks (data-URL, JSON, opgemaakte tekst)MultilineTexteen tekstveld met meerdere regels
Een getalNumbereen veld met een geheel of decimaal getal
Een booleaanse waardeBooleaneen booleaans veld
Een ISO-tekenreeks voor datum/tijdDateTimeeen datum/tijd-veld
Een record-idLookupeen zoekveld
Een Object Storage-sleutelFile, Imageeen 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, null of '') — 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 value in 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.

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('');
});

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: true op react/react-dom en resolve.dedupe in vite.config.ts.
  • Wijzigingen verdwijnen wanneer de record wordt opgeslagen — het besturingselement heeft onChange nooit 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.