Eine benutzerdefinierte React-App erstellen
Ziel
Eine benutzerdefinierte React-App schreiben – eine handgeschriebene React-Anwendung, die in der Parsware-Plattform-Shell läuft – vom leeren Ordner bis zum Paket, das ein Ersteller hochlädt.
Eine benutzerdefinierte React-App ist für einen Bildschirm, den Tabellen, Formulare und Ansichten nicht ausdrücken können: eine eigene Übersichtstafel, ein Assistent, eine Integrationskonsole. Alles, was eine modellgesteuerte App umsonst bekommt (Anmeldung, die aktuelle Umgebung, Datenzugriff, Protokollierung), bekommt auch eine benutzerdefinierte React-App – über das Runtime SDK.
Voraussetzungen
- Node.js 18 oder neuer und npm.
- Vertrautheit mit React 18 und TypeScript.
- Ein Herausgeber und eine Lösung in der Umgebung, in der die App installiert wird. Das
Präfix des Herausgebers (zum Beispiel
par) steht am Anfang des logischen Namens der App.
Wie eine benutzerdefinierte React-App gehostet wird
Eine benutzerdefinierte React-App ist kein iframe und keine eigene Seite. Sie ist ein Module-Federation-Remote: ein Bundle, das die Plattform-Shell lädt und in ihren eigenen React-Baum einhängt.
- Die App teilt das React der Shell.
react,react-domund@parsware/runtime-sdksind als gemeinsame Singletons deklariert, sodass die App die Kopien verwendet, die die Shell bereits geladen hat. Zwei React-Kopien auf einer Seite machen jeden Hook kaputt. - Die App stellt genau eine Komponente bereit. Die Shell lädt ein Modul, per Konvention
./App, und rendert dessen Standardexport. Er erhält keine Props. - Die App erreicht die Plattform über das Runtime SDK. Kein Token, keine Umgebungs-ID und
keine Adresse zu konfigurieren:
useParswareRuntime()reicht ihr all das bereits gebunden.
Schritte
1. Das Projekt anlegen
Die beiden Parsware-Pakete sind Downloads auf dieser Hilfeseite: das Runtime SDK, das die App importiert, und der Packer, der den Build in die Datei verwandelt, die ein Ersteller hochlädt. Installieren Sie sie über ihre Adresse, wie jedes npm-Tarball:
{
"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"
}
}
versionist die eigene Version der App. Das Admin Control Center zeigt sie neben der installierten App an; erhöhen Sie sie also vor jedem Hochladen.parswaresagt, was der Build nicht wissen kann: den logischen Namen, unter dem die App installiert wird, den Namen, den die Leute in der Shell sehen, und die Bereiche (Scopes), die sie anfordert (siehe Schritt 4).packruft die Datei des Packers auf statt seines Befehlsparsware-pack, denn einen Befehl auszuführen ist ein exec, und macOS kann den exec einer Datei verweigern, die aus einem Download stammt.
Daneben eine tsconfig.json, eine index.html, aus der Vite baut, und die Typdeklarationen für
importierte Dateien:
{
"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" />
Die index.html gibt es nur, weil Vite einen Einstiegspunkt zum Bauen braucht. Die Shell lädt sie
nie.
Dann npm install.
2. Module Federation konfigurieren
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 ist nicht optional. Es ist standardmäßig ausgeschaltet, und ohne es bricht
der Packer mit No mf-manifest.json ab: Der Name des Containers, das bereitgestellte Modul und die
Einstiegsdatei werden aus dieser Datei gelesen, sodass sie nie zweimal getippt werden und nicht
von dieser Konfiguration abweichen können.
Benennen Sie den Container nach dem logischen Namen der App. Zwei Apps mit demselben Namen überschreiben einander im Browser.
3. Die App-Komponente schreiben
Der Standardexport des bereitgestellten Moduls ist das, was die Shell einhängt:
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>
);
}
Alles, was die Runtime bietet – Abfragen, gespeicherte Abfragen, benutzerdefinierte APIs, Speicher und das Protokoll –, steht in Das Runtime SDK verwenden.
4. Die benötigten Bereiche anfordern
requestedScopes nennt, welche Art von Aufrufen die App macht: modelapps.read, um Datensätze zu
lesen, modelapps.write, um sie zu ändern, storage.read und storage.write für Dateien. Wenn
jemand die App öffnet, schneidet die Shell diese Liste mit dem, was die eigene Sitzung der Person
enthält, und reicht das Ergebnis der App als runtime.scopes.
Lesen Sie es, um wegzulassen, was nicht benutzt werden kann:
const canEdit = runtime.scopes.includes('modelapps.write');
{canEdit && <button onClick={save}>Save</button>}
Jeder Aufruf der App erfolgt als die Person, die sie benutzt, sodass deren Sicherheitsrolle entscheidet, was jeder Aufruf lesen und ändern darf – genau wie in einer modellgesteuerten App. Eine App kann nie mehr tun als die Person, die sie geöffnet hat.
5. Packen
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
Der Packer gibt aus, was er gelesen hat und wohin er die Datei geschrieben hat:
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.
Das Archiv enthält den ganzen Build und eine parsware.json, die aus den beiden Quellen oben
erstellt wird. Es ist die ganze Registrierung: Ein Ersteller lädt es hoch und tippt nichts
(Eine benutzerdefinierte React-App registrieren).
Der Code wird dann von der Plattform gespeichert und von ihrer eigenen Adresse ausgeliefert, sodass
die Lösung die App mitnimmt: Exportiert und in Test importiert, installiert sie eine
funktionierende App, ohne dass außerhalb etwas am Laufen gehalten werden muss.
Der Packer lehnt ab, was die Plattform später ablehnen würde, etwa einen fehlenden
parsware-Block oder einen logischen Namen ohne Präfix, sodass der Fehler in Ihrem Terminal
erscheint statt als abgelehnter Upload.
6. Testen
Rendern Sie die Komponente mit Vitest und Testing Library unter einer Stub-Runtime:
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
Geben Sie den Tests eine eigene Konfiguration. Vitest liest vitest.config.ts statt
vite.config.ts, und das hält Module Federation aus ihnen heraus: Dessen virtuelle Module sind
für den Browser, nicht für einen Unit-Test.
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 } });
Nur was die App aufruft, muss im Stub echt sein:
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();
});
Führen Sie sie mit npx vitest run aus. Decken Sie neben dem Normalfall auch eine leere Umgebung
und einen abgelehnten Aufruf ab.
7. Hot Reload während des Schreibens
Für jede Änderung zu packen ist eine langsame Schleife. In einer Entwicklungsumgebung kann
eine App stattdessen gegen Ihren laufenden Entwicklungsserver registriert werden (Modus
Hosted), der Hot Reload behält. Dafür gibt es keinen Bildschirm: Es ist ein einziger Aufruf,
POST /environments/{environment}/solutions/{solution}/custom-apps/hosted, der die Adresse Ihrer
remoteEntry.js nennt. Überall sonst lehnt die Plattform das ab, denn eine App, deren Code auf
Ihrem Rechner liegt, ist eine App, die ihre Lösung nicht mitnehmen kann. Die Shell muss außerdem
die Adresse des Entwicklungsservers zulassen (VITE_REMOTE_ALLOWED_ORIGINS).
Was im Hintergrund passiert
Wenn jemand die App öffnet, bittet die Shell Identity um ein kurzlebiges App Context Token,
das die App und die Umgebung nennt, mit den angeforderten Bereichen der App, beschnitten auf die
der Person; diese beschnittene Liste ist runtime.scopes. Dann lädt die Shell remoteEntry.js
von der eigenen Adresse der Plattform, lässt das Remote sich an das bereits geladene React und
Runtime SDK binden und rendert die bereitgestellte Komponente in einer an die Umgebung gebundenen
Runtime. Die Aufrufe des SDK gehen mit der eigenen Anmeldung der Person hinaus, sodass der Server
jeden gegen ihre Sicherheitsrolle prüft.
Fehlerbehebung
No mf-manifest.jsonbeim Packen – fügen Siemanifest: truezufederation({ … })hinzu.Cannot find module … for side-effect import of './x.css'–src/vite-env.d.tsfehlt (Schritt 1).Cannot resolve entry module index.html– Vite braucht dieindex.htmlaus Schritt 1, auch wenn die Shell sie nie lädt.- „Invalid hook call“, sobald die App öffnet – zwei React-Kopien.
reactundreact-dommüssen insharedsingleton: truesein. useParswareRuntime()wirft einen Fehler – die Komponente wurde außerhalb eines Runtime-Providers gerendert. In der Shell kann das nicht passieren; in einem Test umschließen Sie sie wie in Schritt 6 mitParswareRuntimeProvider.- Ein Aufruf wird abgelehnt – die Person, die die App benutzt, darf das nicht: Ihre
Sicherheitsrolle entscheidet. Prüfen Sie
runtime.scopesund lassen Sie weg, was sie nicht tun kann. - Das bereitgestellte Modul wird nicht gefunden – das Modul muss genau dem Schlüssel in
exposesentsprechen, einschließlich des führenden./.
Verwandte Szenarien
- Das Runtime SDK verwenden.
- Ein benutzerdefiniertes Steuerelement erstellen.
- Eine benutzerdefinierte React-App registrieren.