Créer un contrôle personnalisé
Objectif
Écrire un contrôle personnalisé — un composant React réutilisable qui remplace l’éditeur intégré d’un champ sur un formulaire Model-Driven — en partant d’un dossier vide jusqu’à un contrôle qu’un créateur peut lier à un champ.
Un contrôle personnalisé est le bon outil lorsqu’un champ a besoin d’un éditeur que la plateforme ne fournit pas : un bloc de signature, un sélecteur de couleur, une notation par étoiles, une carte, un lecteur de codes-barres. Ce n’est pas l’outil adapté pour un écran entier — pour cela, il y a l’application React personnalisée.
Prérequis
- Node.js 18 ou version ultérieure et npm.
- Une bonne connaissance de React 18 et de TypeScript.
- Un éditeur et une solution dans l’environnement où le contrôle sera installé. Le
préfixe de l’éditeur (par exemple
par) fait partie du nom logique du contrôle. - Avoir lu Créer une application React personnalisée aide : un contrôle est hébergé de la même manière qu’une application, seul le contrat diffère.
Le contrat du contrôle
Un contrôle personnalisé est un unique composant React dont les props sont fixées par la plateforme. Le Player d’application pilotée par modèle l’affiche à la place de l’éditeur de champ natif et fournit les quatre 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;
}
Quatre règles découlent de cette forme, et un contrôle qui enfreint l’une d’elles est incomplet :
- Le contrôle est contrôlé. Il affiche
valueet signale les modifications viaonChange. Il ne conserve pas la valeur validée dans son propre état comme source de vérité, et il n’enregistre pas l’enregistrement — c’est le formulaire qui se charge de l’enregistrement. - Le contrôle lit les métadonnées de son champ au lieu de les supposer. Le libellé,
l’indicateur d’obligation, la longueur maximale, la table cible d’une recherche et le nom
d’un groupe d’options arrivent tous dans
fieldMetadata. Un contrôle qui code en dur un libellé est un contrôle utilisable une seule fois. - Le contrôle n’accède à la plateforme que par
context. C’est la même surfaceParswareRuntimeque celle d’une application —context.datapour les lectures,context.logpour la journalisation. Aucun appel HTTP direct. - Le contrôle se dégrade au lieu de lever une exception. Il est monté dans le formulaire de quelqu’un d’autre ; une exception emporte tout l’enregistrement avec elle. Prévoyez l’absence d’une API du navigateur, un type de valeur inattendu et une valeur vide.
Étapes
1. Créer le projet
Le projet se prépare exactement comme celui d’une application React personnalisée — package.json,
tsconfig.json, index.html, src/vite-env.d.ts, vite.config.ts — voir l’étape 1 de
Créer une application React personnalisée. Le Runtime
SDK et l’empaqueteur sont des téléchargements de ce site d’aide, installés par leur adresse :
{
"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"
}
}
Le script pack est celui qu’exécute l’étape 8. Le déclarer ici garantit que l’outil de
packaging est invoqué de la même façon dans chaque projet et sur chaque machine —
npm run pack — au lieu de dépendre de l’endroit où l’outil se trouve installé. Il appelle
le fichier de l’outil plutôt que son shim binaire parsware-pack, car exécuter un shim est
un exec, et un exec peut être refusé pour des raisons sans rapport avec votre projet —
le plus souvent macOS Gatekeeper, qui met en quarantaine les fichiers extraits d’une archive
téléchargée puis signale bad interpreter: Operation not permitted.
2. Configurer 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' },
});
Par convention, un contrôle expose ./Control là où une application expose ./App. Personne
ne le saisit nulle part : l’empaqueteur le lit dans mf-manifest.json, et c’est pourquoi
manifest: true n’est pas facultatif.
3. Écrire le contrôle
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. Décider quels types de champ le contrôle remplace
Lors de l’enregistrement, un contrôle déclare Remplace — les familles de types de
données de champ qu’il peut remplacer (Text, MultilineText, Number, Boolean,
DateTime, Lookup, File, Image, Custom). Le concepteur de formulaires ne propose le
contrôle que pour les champs dont le type de contrôle figure dans cette liste : un
sélecteur de date n’est donc jamais proposé pour un booléen.
Un placement de formulaire choisit un contrôle intégré — TextArea, Toggle, Dropdown,
Date, Time. substitutesFor utilise un vocabulaire différent et plus restreint : la
famille à laquelle appartient le type de données d’un champ. Les deux partagent quatre noms
(Text, DateTime, Lookup, Custom), ce qui les rend faciles à confondre. Déclarer
TextArea est rejeté à l’enregistrement avec /substitutesFor/1 enum.
Choisissez la liste en fonction de ce que le contrôle lit et écrit réellement :
| Le contrôle écrit | Remplace | À lier à |
|---|---|---|
| Une chaîne courte | Text | un champ texte |
| Une chaîne longue (data URL, JSON, texte mis en forme) | MultilineText | un champ texte multiligne |
| Un nombre | Number | un champ entier ou décimal |
| Un booléen | Boolean | un champ booléen |
| Une chaîne de date/heure ISO | DateTime | un champ date/heure |
| Un id d’enregistrement | Lookup | un champ de recherche |
| Une clé de stockage d’objets | File, Image | un champ fichier ou image |
Un champ de groupe d’options n’a pas de famille propre : il est dérivé comme Text, et un
contrôle qui lui est destiné déclare donc Text (ou Custom).
Déclarer un type que le contrôle ne sait pas réellement gérer est la cause la plus fréquente
d’un contrôle qui « fonctionne en développement et casse sur un vrai formulaire » — le
concepteur le proposera pour un champ dont il n’attendait pas la forme de value.
5. Gérer chaque état de valeur
value arrive sous quatre états, et chacun appelle une réponse délibérée :
- Vide (
undefined,nullou'') — un nouvel enregistrement, ou un champ effacé. Affichez l’éditeur vide, pas un éditeur cassé. - Une valeur stockée — un enregistrement existant qu’on rouvre. Affichez-la ; un contrôle qui ne montre que ce que l’utilisateur a saisi pendant cette session masque en silence les données enregistrées.
- Une valeur d’un type inattendu — le contrôle a été lié à un type de champ qu’il ne gère pas. Affichez une solution de repli neutre plutôt que de lever une exception.
- Une valeur modifiée de l’extérieur — le formulaire peut se réinitialiser ou se
recharger. Réagissez à la prop
valueplutôt que de mettre en cache la valeur validée dans l’état.
6. Tester le contrôle
Affichez directement le composant avec un runtime simulé et vérifiez le contrat : ce qu’il
affiche pour une value donnée, et ce qu’il transmet à onChange.
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('');
});
Testez aussi le cas obligatoire mais vide : l’indicateur qu’affiche un champ obligatoire fait
partie de ce que le contrôle promet au formulaire. La configuration des tests — vitest, jsdom,
Testing Library et un vitest.config.ts qui laisse Module Federation de côté — est celle de
l’application, à l’étape 6 de Créer une application React personnalisée ;
ajoutez @testing-library/user-event pour le clic.
7. Déclarer ce que le bundler ne peut pas savoir
Ajoutez un bloc parsware au package.json du contrôle. Il contient exactement les trois
éléments que le build n’a aucun moyen de déterminer — le nom logique préfixé par l’éditeur
sous lequel le contrôle s’installe, le nom affiché dans la palette de champs, et les
familles de types de données de champ qu’il peut remplacer :
{
"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"]
}
}
Le nom du conteneur, le module exposé et le fichier d’entrée ne figurent volontairement pas
ici. Ils sont lus dans le propre mf-manifest.json du build (le manifest: true de l’étape 2) et ne
peuvent donc pas diverger de vite.config.ts.
version est la version npm propre au contrôle. C’est ce que voit un créateur dans la
grille des contrôles personnalisés : exécuter npm version patch avant le packaging est
donc ce qui rend un build identifiable.
8. Le packager
npm run pack # builds, then writes package/<logicalName>-<version>.zip
L’archive contient la sortie du build, plus un parsware.json composé à partir des deux
sources ci-dessus. Ce fichier unique constitue tout l’enregistrement : un créateur le
téléverse dans le Centre de contrôle d’administration et ne renseigne rien d’autre.
npm run pack -- --no-build # package an existing build
npm run pack -- --module ./Grid # when the remote exposes more than one module
Le -- appartient à npm, pas à l’outil de packaging : c’est ce qui empêche npm d’avaler
l’option et la transmet au script.
L’outil de packaging refuse tout ce que la plateforme refuserait plus tard — un bloc
parsware manquant, un nom logique qui ne pourrait jamais passer la vérification de
préfixe, un nom de contrôle intégré là où une famille de types de données est attendue — de
sorte que l’erreur est nommée dans votre terminal plutôt que découverte sous la forme d’un
téléversement rejeté.
Le package stocké est servi depuis l’origine même de la plateforme : la solution transporte donc le code du contrôle. L’exporter puis l’importer en Test installe un contrôle fonctionnel, et non un pointeur vers votre machine. Il n’y a aucune URL à autoriser ni aucune qui puisse devenir obsolète.
Rechargement à chaud pendant l’écriture
Packager à chaque modification est une boucle lente. Dans un environnement de développement,
vous pouvez plutôt enregistrer le contrôle sur son serveur de développement en cours
d’exécution : dans la solution, Contrôles personnalisés, Nouveau, Utiliser un serveur de
développement en cours d’exécution, puis saisissez l’adresse qu’affiche npm run dev. La
plateforme lit le nom du contrôle dans votre package.json, et le scope, le module et le fichier
d’entrée dans le mf-manifest.json que le plugin de federation sert avec manifest: true : il
n’y a rien d’autre à remplir. Si le contrôle est déjà installé, retirez-le d’abord ; quand vous
avez fini, chargez le package, qui remplace l’enregistrement du serveur de développement.
Dès lors, un formulaire charge le contrôle directement depuis votre machine : enregistrez une modification et rechargez le formulaire pour la voir, sans build, sans package et sans chargement. La modification ne s’applique pas formulaire ouvert : le fast refresh de votre serveur de développement n’atteint pas le React du Shell, donc le rechargement à chaud dont vous profitez sur la page de développement du contrôle s’arrête au Shell.
Deux choses doivent l’autoriser. Le Shell ne charge du code que depuis les origines de sa liste
d’autorisation (REMOTE_ALLOWED_ORIGINS pour un déploiement, VITE_REMOTE_ALLOWED_ORIGINS pour
un Shell lancé avec npm run dev) : l’origine de votre serveur de développement doit y figurer.
Et votre serveur de développement doit accepter les requêtes venant de l’adresse de la
plateforme, ce que Vite fait par défaut pour localhost et qui demande sinon server.cors. La
plateforme refuse un serveur de développement en Test et en Production : un contrôle enregistré
ainsi n’a pas de package, son code ne voyagerait donc pas avec la solution, et c’est précisément
le problème que le packaging résout.
Un créateur enregistre ensuite le contrôle (Enregistrer un contrôle personnalisé) et le lie à un champ dans le concepteur de formulaires (Lier un contrôle personnalisé à un champ). Le manifeste que produit cet enregistrement est spécifié dans Manifeste de contrôle personnalisé.
Ce qui se passe en coulisses
Lorsque le Player d’application pilotée par modèle affiche un formulaire, il lit la liaison
de chaque champ. Un champ lié à un contrôle personnalisé se résout en l’enregistrement de ce
contrôle dans l’environnement courant, et le Player charge le remote Module Federation du
contrôle — en partageant les instances React et Runtime SDK du Shell, exactement comme pour
une application React personnalisée — puis monte le composant exposé à la place de l’éditeur
intégré, en lui transmettant la valeur actuelle du champ, un onChange relié au tampon de
modification du formulaire, les métadonnées du champ et le même runtime qu’une application, dont les appels partent au nom de la personne qui utilise le
formulaire. Les valeurs que valide le contrôle rejoignent les
modifications en attente du formulaire et sont écrites à l’enregistrement de celui-ci : le
contrôle participe ainsi à la validation et à l’enregistrement comme n’importe quel éditeur
natif.
Dépannage
- Le contrôle n’apparaît pas dans la liste des contrôles du concepteur de formulaires — le type de contrôle du champ ne figure pas dans la liste Remplace du contrôle, ou le contrôle est enregistré dans un autre environnement que celui dans lequel vous concevez.
- « Invalid hook call » au montage du contrôle — deux copies de React. Vérifiez
singleton: truesurreact/react-dometresolve.dedupedansvite.config.ts. - Les modifications disparaissent à l’enregistrement — le contrôle n’a jamais appelé
onChange, ou l’a appelé avec une valeur que le type de données du champ ne peut pas stocker. Le formulaire enregistre ce qu’on lui a transmis, pas ce qu’affiche le contrôle. - Le contrôle affiche un éditeur vide sur un enregistrement qui a une valeur — le
contrôle affiche son propre état au lieu de la prop
value. Le contrôle doit être contrôlé. - Tout le formulaire échoue à s’afficher — le contrôle a levé une exception pendant le rendu. Un contrôle est monté dans la page de quelqu’un d’autre ; prévoyez l’absence d’une API du navigateur et un type de valeur inattendu au lieu de les supposer.
- Le module exposé est introuvable — le module enregistré doit correspondre
exactement à la clé de
exposes, y compris le./initial (./Control).
Scénarios associés
- Créer une application React personnalisée.
- Utiliser le Runtime SDK.
- Enregistrer un contrôle personnalisé.
- Lier un contrôle personnalisé à un champ.