Direkt zum Hauptinhalt

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.

  1. Die App teilt das React der Shell. react, react-dom und @parsware/runtime-sdk sind 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.
  2. 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.
  3. 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:

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 ist die eigene Version der App. Das Admin Control Center zeigt sie neben der installierten App an; erhöhen Sie sie also vor jedem Hochladen.
  • parsware sagt, 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).
  • pack ruft die Datei des Packers auf statt seines Befehls parsware-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:

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

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​

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

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

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.

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

Nur was die App aufruft, muss im Stub echt sein:

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

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.json beim Packen – fügen Sie manifest: true zu federation({ … }) hinzu.
  • Cannot find module … for side-effect import of './x.css' – src/vite-env.d.ts fehlt (Schritt 1).
  • Cannot resolve entry module index.html – Vite braucht die index.html aus Schritt 1, auch wenn die Shell sie nie lädt.
  • „Invalid hook call“, sobald die App öffnet – zwei React-Kopien. react und react-dom müssen in shared singleton: true sein.
  • 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 mit ParswareRuntimeProvider.
  • Ein Aufruf wird abgelehnt – die Person, die die App benutzt, darf das nicht: Ihre Sicherheitsrolle entscheidet. Prüfen Sie runtime.scopes und lassen Sie weg, was sie nicht tun kann.
  • Das bereitgestellte Modul wird nicht gefunden – das Modul muss genau dem Schlüssel in exposes entsprechen, einschließlich des führenden ./.

Verwandte Szenarien​

  • Das Runtime SDK verwenden.
  • Ein benutzerdefiniertes Steuerelement erstellen.
  • Eine benutzerdefinierte React-App registrieren.