Saltar al contenido principal

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:

  1. El control es controlado. Representa value y comunica las ediciones mediante onChange. No guarda el valor confirmado en su propio estado como fuente de verdad, ni guarda el registro: el guardado le corresponde al formulario.
  2. 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.
  3. El control solo accede a la plataforma a través de context. Es la misma superficie ParswareRuntime que recibe una aplicación: context.data para las lecturas y context.log para el registro de eventos. Nada de llamadas HTTP directas.
  4. 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:

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

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​

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

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​

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

No son los nombres de los controles integrados

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 escribeSustituye aEnlácelo a
Una cadena cortaTextun campo de texto
Una cadena larga (URL de datos, JSON, texto con formato)MultilineTextun campo de texto de varias líneas
Un númeroNumberun campo entero o decimal
Un booleanoBooleanun campo booleano
Una cadena de fecha/hora ISODateTimeun campo de fecha/hora
Un id de registroLookupun campo de búsqueda
Una clave de almacenamiento de objetosFile, Imageun 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, null o ''): 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 value en 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.

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

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: true en react/react-dom y resolve.dedupe en vite.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.