Aller au contenu principal

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 :

  1. Le contrôle est contrôlé. Il affiche value et signale les modifications via onChange. 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.
  2. 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.
  3. Le contrôle n’accède à la plateforme que par context. C’est la même surface ParswareRuntime que celle d’une application — context.data pour les lectures, context.log pour la journalisation. Aucun appel HTTP direct.
  4. 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 :

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

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​

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

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​

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

Ce ne sont pas les noms des contrôles intégrés

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 écritRemplaceÀ lier à
Une chaîne courteTextun champ texte
Une chaîne longue (data URL, JSON, texte mis en forme)MultilineTextun champ texte multiligne
Un nombreNumberun champ entier ou décimal
Un booléenBooleanun champ booléen
Une chaîne de date/heure ISODateTimeun champ date/heure
Un id d’enregistrementLookupun champ de recherche
Une clé de stockage d’objetsFile, Imageun 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, null ou '') — 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 value plutô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.

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

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: true sur react/react-dom et resolve.dedupe dans vite.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.