Crear un control personalizado
Objetivo
Escribir un control personalizado —un componente de React reutilizable que sustituye al editor integrado de un campo en un formulario basado en modelo— desde una carpeta vacía hasta un control que un creador puede enlazar a un campo.
Un control personalizado es la herramienta adecuada cuando un campo necesita un editor que la plataforma no incluye: un panel de firma, un selector de color, una valoración con estrellas, un mapa, un lector de códigos de barras. No es la herramienta para una pantalla completa; para eso está la aplicación React personalizada.
Requisitos previos
- Node.js 18 o posterior y npm.
- Conocimientos de React 18 y TypeScript.
- Un editor y una solución en el entorno en el que se instalará el control. El prefijo del
editor (por ejemplo,
par) forma parte del nombre lógico del control. - Es útil haber leído Crear una aplicación React personalizada: un control se hospeda igual que una aplicación, y solo cambia el contrato.
El contrato del control
Un control personalizado es un único componente de React cuyas props fija la plataforma. El reproductor de aplicaciones basadas en modelo lo representa en lugar del editor de campo nativo y proporciona las cuatro 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;
}
De esa forma se derivan cuatro reglas, y un control que incumple cualquiera de ellas está incompleto:
- El control es controlado. Representa
valuey comunica las ediciones medianteonChange. No guarda el valor confirmado en su propio estado como fuente de verdad, ni guarda el registro: el guardado le corresponde al formulario. - El control lee los metadatos de su campo en lugar de suponerlos. La etiqueta, el indicador
de obligatoriedad, la longitud máxima, la entidad de destino de una búsqueda y el nombre de un
conjunto de opciones llegan todos en
fieldMetadata. Un control que codifica de forma fija una etiqueta es un control que solo puede usarse una vez. - El control solo accede a la plataforma a través de
context. Es la misma superficieParswareRuntimeque recibe una aplicación:context.datapara las lecturas ycontext.logpara el registro de eventos. Nada de llamadas HTTP directas. - El control se degrada en lugar de lanzar excepciones. Está montado dentro del formulario de otra persona; una excepción arrastra consigo todo el registro. Protéjase frente a una API del navegador que falte, un tipo de valor inesperado y un valor vacío.
Pasos
1. Crear el proyecto
El proyecto se prepara exactamente como el de una aplicación React personalizada —package.json,
tsconfig.json, index.html, src/vite-env.d.ts, vite.config.ts—; consulte el paso 1 de
Crear una aplicaci ón React personalizada. El Runtime
SDK y el empaquetador son descargas de este sitio de ayuda y se instalan por su dirección:
{
"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"
}
}
El script pack es el que ejecuta el paso 8. Declararlo aquí hace que el empaquetador se invoque
de la misma forma en todos los proyectos y en todas las máquinas —npm run pack— en lugar de
depender de dónde esté instalada la herramienta. Llama al archivo del empaquetador y no a su
lanzador parsware-pack, porque ejecutar un lanzador es un exec, y un exec puede rechazarse por
motivos que no tienen nada que ver con su proyecto; lo más habitual es Gatekeeper de macOS, que pone
en cuarentena los archivos extraídos de un archivo comprimido descargado y luego informa
bad interpreter: Operation not permitted.
2. Configurar 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' },
});
Por convención, un control expone ./Control donde una aplicación expone ./App. Nadie lo
escribe en ninguna parte: el empaquetador lo lee de mf-manifest.json, y por eso
manifest: true no es opcional.
3. Escribir el control
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. Decidir qué tipos de campo sustituye el control
En el registro, un control declara Sustituye a: las familias de tipos de datos de campo que
puede reemplazar (Text, MultilineText, Number, Boolean, DateTime, Lookup, File,
Image, Custom). El diseñador de formularios ofrece el control solo para los campos cuyo tipo de
control está en esa lista, de modo que nunca se ofrece un selector de fecha para un booleano.
Una ubicación en un formulario elige un control integrado: TextArea, Toggle, Dropdown,
Date, Time. substitutesFor usa otro vocabulario, más corto: la familia a la que pertenece el
tipo de datos de un campo. Los dos comparten cuatro nombres (Text, DateTime, Lookup,
Custom), y eso es lo que hace que sea fácil confundirlos. Declarar TextArea se rechaza en el
registro con /substitutesFor/1 enum.
Elija la lista en función de lo que el control lee y escribe realmente:
| El control escribe | Sustituye a | Enlácelo a |
|---|---|---|
| Una cadena corta | Text | un campo de texto |
| Una cadena larga (URL de datos, JSON, texto con formato) | MultilineText | un campo de texto de varias líneas |
| Un número | Number | un campo entero o decimal |
| Un booleano | Boolean | un campo booleano |
| Una cadena de fecha/hora ISO | DateTime | un campo de fecha/hora |
| Un id de registro | Lookup | un campo de búsqueda |
| Una clave de almacenamiento de objetos | File, Image | un campo de archivo o de imagen |
Un campo de conjunto de opciones no tiene familia propia: se deriva como Text, así que un control
para uno de ellos declara Text (o Custom).
Declarar un tipo que el control no puede gestionar realmente es la causa más habitual de un control
que «funciona en desarrollo y falla en un formulario real»: el diseñador lo ofrecerá para un campo
cuya forma de value nunca esperó.
5. Gestionar todos los estados del valor
value llega en cuatro estados, y cada uno necesita una respuesta deliberada:
- Vacío (
undefined,nullo''): un registro nuevo, o un campo borrado. Represente el editor vacío, no uno roto. - Un valor almacenado: un registro existente que se vuelve a abrir. Represéntelo; un control que solo muestra lo que el usuario ha escrito en esta sesión oculta en silencio los datos guardados.
- Un valor de un tipo inesperado: el control se enlazó a un tipo de campo que no gestiona. Represente una alternativa neutra en lugar de lanzar una excepción.
- Un valor cambiado desde fuera: el formulario puede restablecerse o recargarse. Reaccione a la
prop
valueen lugar de guardar en caché el valor confirmado en el estado.
6. Probar el control
Represente el componente directamente con un runtime simulado y compruebe el contrato: qué
representa para un value dado y qué pasa a 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('');
});
Pruebe también el caso de obligatorio pero vacío: el indicador que muestra un campo obligatorio
forma parte de lo que el control promete al formulario. La configuración de pruebas —vitest,
jsdom, Testing Library y un vitest.config.ts que deja fuera Module Federation— es la de la
aplicación, en el paso 6 de Crear una aplicación React personalizada;
añada @testing-library/user-event para el clic.
7. Declarar lo que el empaquetador no puede saber
Añada un bloque parsware al package.json del control. Contiene exactamente las tres cosas que la
compilación no tiene forma de averiguar: el nombre lógico con el prefijo del editor con el que se
instala el control, el nombre que se muestra en la paleta de campos y las familias de tipos de
datos de campo que puede sustituir:
{
"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"]
}
}
El nombre del contenedor, el módulo expuesto y el archivo de entrada no están aquí a propósito.
Se leen del propio mf-manifest.json de la compilación (el manifest: true del paso 2), así que no
pueden desviarse de vite.config.ts.
version es la propia versión npm del control. Es lo que ve un creador en la cuadrícula de
controles personalizados, así que ejecutar npm version patch antes de empaquetar es la forma de
que una compilación sea identificable.
8. Empaquetarlo
npm run pack # builds, then writes package/<logicalName>-<version>.zip
El archivo comprimido contiene la salida de la compilación más un parsware.json compuesto a
partir de las dos fuentes anteriores. Ese único archivo es todo el registro: un creador lo carga en
el Centro de control de administración y no rellena nada más.
npm run pack -- --no-build # package an existing build
npm run pack -- --module ./Grid # when the remote exposes more than one module
El -- es de npm, no del empaquetador: es lo que impide que npm se trague la opción y la pasa al
script.
El empaquetador rechaza todo lo que la plataforma rechazaría más tarde —un bloque parsware que
falta, un nombre lógico que nunca superaría una comprobación de prefijo, un nombre de control
integrado donde corresponde una familia de tipos de datos—, de modo que el error se indica en su
terminal en lugar de descubrirse como una carga rechazada.
El paquete almacenado se sirve desde el propio origen de la plataforma, así que la solución incluye el código del control: exportarla e importarla en Pruebas instala un control que funciona, no un puntero a su máquina. No hay ninguna URL que añadir a la lista de permitidos ni ninguna que pueda quedar obsoleta.
Recarga en caliente mientras lo escribe
Empaquetar por cada cambio es un ciclo lento. En un entorno de desarrollo puede registrar el
control contra su servidor de desarrollo en ejecución: en la solución, Controles
personalizados, Nuevo, Usar un servidor de desarrollo en ejecución, y escriba la
dirección que muestra npm run dev. La plataforma lee el nombre del control de su
package.json y el scope, el módulo y el archivo de entrada del mf-manifest.json que el plugin
de federation sirve con manifest: true, así que no hay nada más que rellenar. Si el control ya
está instalado, quítelo primero; cuando termine, cargue el paquete, que reemplaza el registro del
servidor de desarrollo.
Desde entonces un formulario carga el control directamente desde su máquina: guarde un cambio y recargue el formulario para verlo, sin compilar, sin empaquetar y sin cargar nada. El cambio no se sustituye con el formulario abierto: el fast refresh de su servidor de desarrollo no alcanza el React del Shell, así que la recarga en caliente que tiene en la página de desarrollo del propio control se detiene en el Shell.
Dos cosas tienen que permitirlo. El Shell solo carga código de orígenes de su lista de permitidos
(REMOTE_ALLOWED_ORIGINS para un despliegue, VITE_REMOTE_ALLOWED_ORIGINS para un Shell
ejecutado con npm run dev), así que el origen de su servidor de desarrollo debe estar en ella. Y
su servidor de desarrollo debe aceptar solicitudes desde la dirección de la plataforma, cosa que
Vite hace para localhost por defecto y que en otro caso necesita server.cors. La plataforma
rechaza un servidor de desarrollo en Prueba y Producción: un control registrado así no tiene
paquete, de modo que su código no viajaría con la solución, que es justo el problema que resuelve
el empaquetado.
Después, un creador registra el control (Registrar un control personalizado) y lo enlaza a un campo en el diseñador de formularios (Enlazar un control personalizado a un campo). El manifiesto que genera el registro se especifica en Manifiesto de control personalizado.
Qué ocurre entre bastidores
Cuando el reproductor de aplicaciones basadas en modelo representa un formulario, lee el enlace de
cada campo. Un campo enlazado a un control personalizado se resuelve en el registro de ese control
en el entorno actual, y el reproductor carga el remoto de Module Federation del control
—compartiendo las instancias de React y del Runtime SDK del Shell, exactamente igual que con una
aplicación React personalizada— y monta el componente expuesto en lugar del editor integrado,
pasándole el valor actual del campo, un onChange conectado al búfer de edición del formulario,
los metadatos del campo y el mismo runtime que recibe una aplicación, cuyas llamadas salen como la persona que usa el
formulario.
Los valores que confirma el control pasan a formar parte de las ediciones pendientes del formulario
y se escriben cuando se guarda el registro, de modo que el control participa en la validación y el
guardado como cualquier editor nativo.
Solución de problemas
- El control no aparece en la lista de controles del diseñador de formularios: el tipo de control del campo no está en la lista Sustituye a del control, o el control está registrado en un entorno distinto de aquel en el que se está diseñando.
- «Invalid hook call» al montarse el control: hay dos copias de React. Compruebe
singleton: trueenreact/react-domyresolve.dedupeenvite.config.ts. - Las ediciones desaparecen al guardar el registro: el control nunca llamó a
onChange, o lo llamó con un valor que el tipo de datos del campo no puede almacenar. El formulario guarda lo que se le indicó, no lo que muestra el control. - El control muestra un editor vacío en un registro que tiene valor: el control está
representando su propio estado en lugar de la prop
value. El control debe ser controlado. - No se representa el formulario entero: el control lanzó una excepción durante el renderizado. Un control se monta dentro de la página de otra persona; protéjase frente a una API del navegador que falte y un tipo de valor inesperado, en lugar de dar cosas por supuestas.
- No se encuentra el módulo expuesto: el módulo registrado debe coincidir exactamente con
la clave de
exposes, incluido el./inicial (./Control).
Escenarios relacionados
- Crear una aplicación React personalizada.
- Usar el Runtime SDK.
- Registrar un control personalizado.
- Enlazar un control personalizado a un campo.