Aller au contenu principal

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.

  1. L’application partage le React du Shell. react, react-dom et @parsware/runtime-sdk sont 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.
  2. 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.
  3. 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 :

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 est 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.
  • parsware dit 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).
  • pack appelle le fichier de l’empaqueteur plutôt que sa commande parsware-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 :

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

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​

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

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

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.

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

Seul ce que l’application appelle doit être réel dans le factice :

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

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 : ajoutez manifest: true à federation({ … }).
  • Cannot find module … for side-effect import of './x.css' : il manque src/vite-env.d.ts (étape 1).
  • Cannot resolve entry module index.html : Vite a besoin du index.html de 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. react et react-dom doivent être singleton: true dans shared.
  • 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 dans ParswareRuntimeProvider comme à 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.scopes et 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.