Créer une application React personnalisée
Objectif
Écrire une application React personnalisée — une application React écrite à la main qui s’exécute dans le Shell de la plateforme Parsware — d’un dossier vide jusqu’au paquet qu’un créateur téléverse.
Une application React personnalisée sert à un écran que les tables, les formulaires et les vues ne peuvent pas exprimer : un tableau sur mesure, un assistant, une console d’intégration. Tout ce qu’une application pilotée par modèle obtient gratuitement (connexion, environnement courant, accès aux données, journalisation), une application React personnalisée l’obtient aussi, par le Runtime SDK.
Prérequis
- Node.js 18 ou ultérieur et npm.
- Connaître React 18 et TypeScript.
- Un éditeur et une solution dans l’environnement où l’application sera installée. Le
préfixe de l’éditeur (par exemple
par) commence le nom logique de l’application.
Comment une application React personnalisée est hébergée
Une application React personnalisée n’est pas un iframe et pas une page à part. C’est un remote Module Federation : un bundle que le Shell de la plateforme charge et monte dans son propre arbre React.
- L’application partage le React du Shell.
react,react-domet@parsware/runtime-sdksont déclarés singletons partagés, donc l’application utilise les copies que le Shell a déjà chargées. Deux copies de React dans une page cassent tous les hooks. - L’application expose exactement un composant. Le Shell charge un module, par convention
./App, et affiche son export par défaut. Il ne reçoit aucune prop. - L’application atteint la plateforme par le Runtime SDK. Ni jeton, ni identifiant
d’environnement, ni adresse à configurer :
useParswareRuntime()lui fournit tout cela déjà lié.
Étapes
1. Créer le projet
Les deux paquets Parsware sont des téléchargements de ce site d’aide : le Runtime SDK, que l’application importe, et l’empaqueteur, qui transforme le build en fichier qu’un créateur téléverse. Installez-les par leur adresse, comme n’importe quelle archive npm :
{
"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"
}
}
versionest la version propre de l’application. Le Centre de contrôle d’administration l’affiche à côté de l’application installée : augmentez-la avant chaque téléversement.parswaredit ce que le build ne peut pas savoir : le nom logique sous lequel l’application s’installe, le nom que les gens voient dans le Shell et les étendues (scopes) qu’elle demande (voir l’étape 4).packappelle le fichier de l’empaqueteur plutôt que sa commandeparsware-pack, car lancer une commande est un exec, et macOS peut refuser l’exec d’un fichier venu d’un téléchargement.
À côté, un tsconfig.json, un index.html à partir duquel Vite construit, et les déclarations de
types des fichiers importés :
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Sales Dashboard</title></head>
<body></body>
</html>
/// <reference types="vite/client" />
Le index.html n’est là que parce que Vite a besoin d’une entrée à construire. Le Shell ne le
charge jamais.
Puis npm install.
2. Configurer Module Federation
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 n’est pas facultatif. Il est désactivé par défaut et, sans lui,
l’empaqueteur s’arrête sur No mf-manifest.json : le nom du conteneur, le module exposé et le
fichier d’entrée sont lus dans ce fichier, si bien qu’ils ne sont jamais saisis deux fois et ne
peuvent pas diverger de cette configuration.
Donnez au conteneur le nom logique de l’application. Deux applications de même nom s’écrasent l’une l’autre dans le navigateur.
3. Écrire le composant de l’application
L’export par défaut du module exposé est ce que le Shell monte :
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>
);
}
Tout ce que le runtime offre — requêtes, requêtes enregistrées, API personnalisées, stockage et journal — se trouve dans Utiliser le Runtime SDK.
4. Demander les étendues dont elle a besoin
requestedScopes indique quel genre d’appels fait l’application : modelapps.read pour lire des
enregistrements, modelapps.write pour les modifier, storage.read et storage.write pour les
fichiers. Quand quelqu’un ouvre l’application, le Shell croise cette liste avec ce que contient la
session de la personne et transmet le résultat à l’application sous le nom runtime.scopes.
Lisez-le pour laisser de côté ce qui ne pourra pas servir :
const canEdit = runtime.scopes.includes('modelapps.write');
{canEdit && <button onClick={save}>Save</button>}
Chaque appel de l’application est fait au nom de la personne qui l’utilise : son rôle de sécurité décide ce que chaque appel peut lire et modifier, exactement comme dans une application pilotée par modèle. Une application ne peut jamais faire plus que la personne qui l’a ouverte.
5. L’empaqueter
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
L’empaqueteur affiche ce qu’il a lu et où il a écrit le fichier :
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.
L’archive contient tout le build et un parsware.json composé à partir des deux sources
ci-dessus. C’est toute l’inscription : un créateur la téléverse et ne saisit rien
(Inscrire une application React personnalisée).
La plateforme stocke ensuite le code et le sert depuis sa propre adresse, donc la solution
emporte l’application : exportée puis importée en Test, elle installe une application qui
fonctionne, sans rien d’extérieur à maintenir en marche.
L’empaqueteur refuse ce que la plateforme refuserait plus tard, comme un bloc parsware manquant
ou un nom logique sans préfixe : l’erreur apparaît dans votre terminal plutôt que sous forme de
téléversement refusé.
6. La tester
Affichez le composant sous un runtime factice avec Vitest et Testing Library :
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
Donnez aux tests leur propre configuration. Vitest lit vitest.config.ts au lieu de
vite.config.ts, ce qui tient Module Federation à l’écart : ses modules virtuels sont faits pour le
navigateur, pas pour un test unitaire.
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 } });
Seul ce que l’application appelle doit être réel dans le factice :
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();
});
Lancez-les avec npx vitest run. Couvrez un environnement vide et un appel refusé en plus du cas
normal.
7. Rechargement à chaud pendant l’écriture
Empaqueter à chaque modification est une boucle lente. Dans un environnement de
développement, une application peut être inscrite sur votre serveur de développement en cours
d’exécution (mode Hosted), qui garde le rechargement à chaud. Il n’y a pas d’écran pour cela :
c’est un seul appel, POST /environments/{environment}/solutions/{solution}/custom-apps/hosted, qui
donne l’adresse de votre remoteEntry.js. Partout ailleurs la plateforme le refuse, car une
application dont le code se trouve sur votre machine est une application que sa solution ne peut
pas emporter. Le Shell doit aussi autoriser l’adresse du serveur de développement
(VITE_REMOTE_ALLOWED_ORIGINS).
Ce qui se passe en coulisses
Quand quelqu’un ouvre l’application, le Shell demande à Identity un App Context Token de courte
durée qui nomme l’application et l’environnement, avec les étendues demandées par l’application
réduites à celles de la personne ; cette liste réduite est runtime.scopes. Le Shell charge
ensuite remoteEntry.js depuis la propre adresse de la plateforme, laisse le remote se lier au
React et au Runtime SDK déjà chargés, et affiche le composant exposé dans un runtime lié à
l’environnement. Les appels du SDK partent avec la propre connexion de la personne : le serveur
vérifie chacun d’eux par rapport à son rôle de sécurité.
Dépannage
No mf-manifest.jsonà l’empaquetage : ajoutezmanifest: trueàfederation({ … }).Cannot find module … for side-effect import of './x.css': il manquesrc/vite-env.d.ts(étape 1).Cannot resolve entry module index.html: Vite a besoin duindex.htmlde l’étape 1, même si le Shell ne le charge jamais.- « Invalid hook call » dès l’ouverture de l’application : deux copies de React.
reactetreact-domdoivent êtresingleton: truedansshared. useParswareRuntime()lève une erreur : le composant a été affiché hors d’un fournisseur de runtime. Dans le Shell cela ne peut pas arriver ; dans un test, enveloppez-le dansParswareRuntimeProvidercomme à l’étape 6.- Un appel est refusé : la personne qui utilise l’application n’a pas le droit de le faire ;
c’est son rôle de sécurité qui décide. Vérifiez
runtime.scopeset laissez de côté ce qu’elle ne peut pas faire. - Le module exposé est introuvable : le module doit correspondre exactement à la clé de
exposes,./initial compris.
Scénarios associés
- Utiliser le Runtime SDK.
- Créer un contrôle personnalisé.
- Inscrire une application React personnalisée.