Saltar al contenido principal

Crear una aplicación React personalizada

Objetivo​

Escribir una aplicación React personalizada —una aplicación React escrita a mano que se ejecuta dentro del Shell de la plataforma Parsware— desde una carpeta vacía hasta el paquete que sube un creador.

Una aplicación React personalizada es para una pantalla que las tablas, los formularios y las vistas no pueden expresar: un tablero a medida, un asistente, una consola de integración. Todo lo que una aplicación basada en modelo obtiene gratis (inicio de sesión, el entorno actual, acceso a datos, registro) también lo obtiene una aplicación React personalizada, a través del Runtime SDK.

Requisitos previos​

  • Node.js 18 o posterior y npm.
  • Conocer React 18 y TypeScript.
  • Un editor y una solución en el entorno donde se instalará la aplicación. El prefijo del editor (por ejemplo par) encabeza el nombre lógico de la aplicación.

Cómo se aloja una aplicación React personalizada​

Una aplicación React personalizada no es un iframe ni una página aparte. Es un remoto de Module Federation: un paquete que el Shell de la plataforma descarga y monta en su propio árbol de React.

  1. La aplicación comparte el React del Shell. react, react-dom y @parsware/runtime-sdk se declaran singletons compartidos, así que la aplicación usa las copias que el Shell ya cargó. Dos copias de React en una página rompen todos los hooks.
  2. La aplicación expone exactamente un componente. El Shell carga un módulo, por convención ./App, y representa su exportación predeterminada. No recibe props.
  3. La aplicación llega a la plataforma a través del Runtime SDK. Ni token, ni id de entorno, ni dirección que configurar: useParswareRuntime() le entrega todo eso ya vinculado.

Pasos​

1. Crear el proyecto​

Los dos paquetes de Parsware son descargas de este sitio de ayuda: el Runtime SDK, que importa la aplicación, y el empaquetador, que convierte la compilación en el archivo que sube un creador. Instálelos por su dirección, como cualquier tarball de npm:

package.json
{
"name": "sales-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"parsware": {
"kind": "CustomApp",
"logicalName": "par_sales_dashboard",
"displayName": "Sales Dashboard",
"description": "Tables and their record counts.",
"requestedScopes": ["modelapps.read"]
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"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": {
"@module-federation/vite": "^1.20.3",
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^7.0.2",
"vite": "^8.2.0"
}
}
  • version es la versión propia de la aplicación. El Centro de control de administración la muestra junto a la aplicación instalada, así que súbala antes de cada carga.
  • parsware dice lo que la compilación no puede saber: el nombre lógico con el que se instala la aplicación, el nombre que la gente ve en el Shell y los ámbitos (scopes) que pide (véase el paso 4).
  • pack llama al archivo del empaquetador en lugar de a su comando parsware-pack, porque ejecutar un comando es un exec, y macOS puede negarse a ejecutar un archivo que vino de una descarga.

Junto a él, un tsconfig.json, un index.html a partir del cual compila Vite y las declaraciones de tipos para los archivos importados:

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
index.html
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Sales Dashboard</title></head>
<body></body>
</html>
src/vite-env.d.ts
/// <reference types="vite/client" />

El index.html solo está porque Vite necesita una entrada desde la que compilar. El Shell nunca lo carga.

Después, npm install.

2. Configurar Module Federation​

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
plugins: [
react(),
federation({
name: 'par_sales_dashboard', // the container's name: unique per environment
filename: 'remoteEntry.js', // what the Shell fetches
exposes: { './App': './src/App.tsx' },
shared: {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
},
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
// Module Federation's shared-scope negotiation needs modern output.
build: { target: 'esnext' },
});

manifest: true no es opcional. Está desactivado de forma predeterminada y, sin él, el empaquetador se detiene con No mf-manifest.json: el nombre del contenedor, el módulo expuesto y el archivo de entrada se leen de ese archivo, así que nunca se escriben dos veces y no pueden desviarse de esta configuración.

Ponga al contenedor el nombre lógico de la aplicación. Dos aplicaciones con el mismo nombre se sobrescriben en el navegador.

3. Escribir el componente de la aplicación​

La exportación predeterminada del módulo expuesto es lo que monta el Shell:

src/App.tsx
import { useEffect, useState } from 'react';
import { useParswareRuntime, type EntityDto } from '@parsware/runtime-sdk';

interface Row { entity: EntityDto; count: number }

export default function App() {
// 1. The runtime for the environment the Shell opened this app in.
const runtime = useParswareRuntime();
const [rows, setRows] = useState<Row[] | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;

// 2. Read through the SDK: no token, no environment id, no address.
(async () => {
try {
const entities = await runtime.data.listEntities();
const counted = await Promise.all(entities.map(async (entity) => {
const page = await runtime.data.queryRecords(entity.logicalName, { pageSize: 1 });
return { entity, count: page.totalCount };
}));
if (!cancelled) setRows(counted);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
runtime.log('error', 'Sales dashboard failed to load', { message });
if (!cancelled) setError(message);
}
})();

return () => { cancelled = true; }; // 3. Never set state after the app is closed.
}, [runtime]);

if (error) return <p role="alert">Could not load data: {error}</p>;
if (rows === null) return <p>Loading…</p>;

return (
<section>
<h2>Sales Dashboard</h2>
<p>{runtime.user.name} in {runtime.environmentName}</p>
<table>
<tbody>
{rows.map(({ entity, count }) => (
<tr key={entity.id}><td>{entity.displayCollectionName}</td><td>{count}</td></tr>
))}
</tbody>
</table>
</section>
);
}

Todo lo que ofrece el runtime —consultas, consultas guardadas, API personalizadas, almacenamiento y el registro— está en Usar el Runtime SDK.

4. Pedir los ámbitos que necesita​

requestedScopes nombra qué tipo de llamadas hace la aplicación: modelapps.read para leer registros, modelapps.write para cambiarlos, storage.read y storage.write para archivos. Cuando alguien abre la aplicación, el Shell cruza esa lista con lo que contiene la sesión de esa persona y entrega el resultado a la aplicación como runtime.scopes.

Léalo para dejar fuera lo que no se puede usar:

const canEdit = runtime.scopes.includes('modelapps.write');
{canEdit && <button onClick={save}>Save</button>}

Cada llamada de la aplicación se hace como la persona que la usa, así que su rol de seguridad decide qué puede leer y cambiar cada llamada, exactamente como en una aplicación basada en modelo. Una aplicación nunca puede hacer más que la persona que la abrió.

5. Empaquetarla​

npm run pack # builds, then writes package/<logicalName>-<version>.zip
npm run pack -- --no-build # package an existing build
npm run pack -- --module ./App # when the remote exposes more than one module

El empaquetador muestra lo que leyó y dónde escribió el archivo:

Packaged par_sales_dashboard 1.0.0
scope par_sales_dashboard
module ./App
entry remoteEntry.js
files 18

Upload it in the Admin Control Center: open the solution, then Custom Apps → New.

El archivo contiene toda la compilación y un parsware.json hecho a partir de las dos fuentes de arriba. Es todo el registro: un creador lo sube y no escribe nada (Registrar una aplicación React personalizada). Después la plataforma guarda el código y lo sirve desde su propia dirección, así que la solución lleva la aplicación consigo: exportada e importada en Test, instala una aplicación que funciona sin nada externo que mantener en marcha.

El empaquetador rechaza lo que la plataforma rechazaría después, como un bloque parsware que falta o un nombre lógico sin prefijo, así que el error aparece en su terminal y no como una carga rechazada.

6. Probarla​

Represente el componente bajo un runtime simulado con Vitest y Testing Library:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom

Dé a las pruebas su propia configuración. Vitest lee vitest.config.ts en lugar de vite.config.ts, lo que deja Module Federation fuera de ellas: sus módulos virtuales son para el navegador, no para una prueba unitaria.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

// globals: Testing Library clears the page between tests only when Vitest's globals are on.
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true } });

En el simulado solo tiene que ser real lo que llama la aplicación:

src/App.test.tsx
import { expect, it } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { ParswareRuntimeProvider, type ParswareRuntime } from '@parsware/runtime-sdk';
import App from './App';

const runtime = {
environmentName: 'Development',
user: { id: 'u1', name: 'Dev User', email: 'dev@example.com', tenantId: 't1', roles: [] },
scopes: ['modelapps.read'],
log: () => {},
data: {
listEntities: async () => [{ id: 'e1', logicalName: 'par_order', displayName: 'Order',
displayCollectionName: 'Orders', primaryFieldLogicalName: 'par_name' }],
queryRecords: async () => ({ items: [], totalCount: 42, pageNumber: 1, pageSize: 1,
totalPages: 42, hasNextPage: true, hasPreviousPage: false }),
},
} as unknown as ParswareRuntime;

it('shows each table with its record count', async () => {
render(<ParswareRuntimeProvider runtime={runtime}><App /></ParswareRuntimeProvider>);
expect(await screen.findByText('Orders')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});

Ejecútelas con npx vitest run. Cubra un entorno vacío y una llamada rechazada además del caso normal.

7. Recarga en caliente mientras la escribe​

Empaquetar en cada cambio es un ciclo lento. En un entorno de desarrollo, una aplicación puede registrarse contra su servidor de desarrollo en marcha (modo Hosted), que conserva la recarga en caliente. No hay pantalla para ello: es una sola llamada, POST /environments/{environment}/solutions/{solution}/custom-apps/hosted, que indica la dirección de su remoteEntry.js. En cualquier otro lugar la plataforma lo rechaza, porque una aplicación cuyo código está en su equipo es una aplicación que su solución no puede llevar consigo. El Shell también debe permitir la dirección del servidor de desarrollo (VITE_REMOTE_ALLOWED_ORIGINS).

Qué ocurre entre bastidores​

Cuando alguien abre la aplicación, el Shell pide a Identity un App Context Token de corta duración que nombra la aplicación y el entorno, con los ámbitos pedidos por la aplicación reducidos a los de la persona; esa lista reducida es runtime.scopes. Después el Shell descarga remoteEntry.js desde la propia dirección de la plataforma, deja que el remoto se vincule al React y al Runtime SDK que ya cargó y representa el componente expuesto dentro de un runtime vinculado al entorno. Las llamadas del SDK salen con el propio inicio de sesión de la persona, así que el servidor comprueba cada una contra su rol de seguridad.

Solución de problemas​

  • No mf-manifest.json al empaquetar: añada manifest: true a federation({ … }).
  • Cannot find module … for side-effect import of './x.css': falta src/vite-env.d.ts (paso 1).
  • Cannot resolve entry module index.html: Vite necesita el index.html del paso 1, aunque el Shell nunca lo cargue.
  • «Invalid hook call» en cuanto se abre la aplicación: dos copias de React. react y react-dom deben ser singleton: true en shared.
  • useParswareRuntime() lanza un error: el componente se representó fuera de un proveedor de runtime. En el Shell no puede ocurrir; en una prueba, envuélvalo en ParswareRuntimeProvider como en el paso 6.
  • Se rechaza una llamada: la persona que usa la aplicación no puede hacer eso; decide su rol de seguridad. Compruebe runtime.scopes y deje fuera lo que no puede hacer.
  • No se encuentra el módulo expuesto: el módulo debe coincidir exactamente con la clave de exposes, incluido el ./ inicial.

Escenarios relacionados​

  • Usar el Runtime SDK.
  • Crear un control personalizado.
  • Registrar una aplicación React personalizada.