Naar hoofdinhoud gaan

Een aangepaste React-app bouwen

Doel​

Een aangepaste React-app schrijven — een handgeschreven React-toepassing die in de Parsware-platformshell draait — van een lege map tot het pakket dat een maker uploadt.

Een aangepaste React-app is voor een scherm dat tabellen, formulieren en weergaven niet kunnen uitdrukken: een eigen overzichtsbord, een wizard, een integratieconsole. Alles wat een modelgestuurde app gratis krijgt (aanmelden, de huidige omgeving, gegevenstoegang, logboek), krijgt een aangepaste React-app ook, via de Runtime SDK.

Vereisten​

  • Node.js 18 of nieuwer en npm.
  • Bekendheid met React 18 en TypeScript.
  • Een uitgever en een oplossing in de omgeving waarin de app wordt geïnstalleerd. Het voorvoegsel van de uitgever (bijvoorbeeld par) staat vooraan in de logische naam van de app.

Hoe een aangepaste React-app wordt gehost​

Een aangepaste React-app is geen iframe en geen aparte pagina. Het is een Module Federation-remote: een bundel die de platformshell ophaalt en in haar eigen React-boom hangt.

  1. De app deelt de React van de shell. react, react-dom en @parsware/runtime-sdk zijn gedeelde singletons, zodat de app de kopieën gebruikt die de shell al heeft geladen. Twee React-kopieën op één pagina maken elke hook stuk.
  2. De app stelt precies één component beschikbaar. De shell laadt één module, volgens conventie ./App, en rendert de standaardexport. Die krijgt geen props.
  3. De app bereikt het platform via de Runtime SDK. Geen token, geen omgevings-id en geen adres om te configureren: useParswareRuntime() geeft haar dat allemaal al gekoppeld.

Stappen​

1. Maak het project aan​

De twee Parsware-pakketten zijn downloads op deze helpsite: de Runtime SDK, die de app importeert, en de packer, die de build omzet in het bestand dat een maker uploadt. Installeer ze via hun adres, zoals elke 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 is de eigen versie van de app. Het Beheercentrum toont die naast de geïnstalleerde app, dus verhoog hem vóór elke upload.
  • parsware zegt wat de build niet kan weten: de logische naam waaronder de app wordt geïnstalleerd, de naam die mensen in de shell zien en de scopes die ze vraagt (zie stap 4).
  • pack roept het bestand van de packer aan in plaats van diens opdracht parsware-pack, omdat een opdracht uitvoeren een exec is, en macOS de exec van een gedownload bestand kan weigeren.

Daarnaast een tsconfig.json, een index.html waaruit Vite bouwt, en de typedeclaraties voor geïmporteerde bestanden:

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

De index.html is er alleen omdat Vite een startpunt nodig heeft om van te bouwen. De shell laadt hem nooit.

Daarna npm install.

2. Configureer 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 is niet optioneel. Het staat standaard uit, en zonder stopt de packer met No mf-manifest.json: de naam van de container, de beschikbaar gestelde module en het startbestand worden uit dat bestand gelezen, zodat ze nooit twee keer worden getypt en niet uit de pas kunnen lopen met deze configuratie.

Geef de container de logische naam van de app. Twee apps met dezelfde naam overschrijven elkaar in de browser.

3. Schrijf de app-component​

De standaardexport van de beschikbaar gestelde module is wat de shell inhangt:

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 wat de runtime biedt — query's, opgeslagen query's, aangepaste API's, opslag en het logboek — staat in De Runtime SDK gebruiken.

4. Vraag de scopes die ze nodig heeft​

requestedScopes noemt wat voor aanroepen de app doet: modelapps.read om records te lezen, modelapps.write om ze te wijzigen, storage.read en storage.write voor bestanden. Als iemand de app opent, snijdt de shell die lijst met wat de eigen sessie van die persoon bevat, en geeft het resultaat aan de app als runtime.scopes.

Lees het om weg te laten wat niet gebruikt kan worden:

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

Elke aanroep van de app gebeurt als de persoon die haar gebruikt, dus hun beveiligingsrol bepaalt wat elke aanroep mag lezen en wijzigen, precies zoals in een modelgestuurde app. Een app kan nooit meer dan de persoon die haar opende.

5. Verpak haar​

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

De packer toont wat hij heeft gelezen en waar hij het bestand heeft geschreven:

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.

Het archief bevat de hele build en een parsware.json die uit de twee bronnen hierboven wordt samengesteld. Het is de hele registratie: een maker uploadt het en typt niets (Een aangepaste React-app registreren). Het platform bewaart de code daarna en levert hem vanaf zijn eigen adres, dus de oplossing neemt de app mee: geëxporteerd en in Test geïmporteerd, installeert ze een werkende app zonder dat er buiten iets draaiende hoeft te blijven.

De packer weigert wat het platform later zou weigeren, zoals een ontbrekend parsware-blok of een logische naam zonder voorvoegsel, zodat de fout in je terminal verschijnt in plaats van als geweigerde upload.

6. Test haar​

Render de component onder een stub-runtime met Vitest en Testing Library:

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

Geef de tests een eigen configuratie. Vitest leest vitest.config.ts in plaats van vite.config.ts, en dat houdt Module Federation erbuiten: de virtuele modules daarvan zijn voor de browser, niet voor een unittest.

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

Alleen wat de app aanroept, hoeft in de stub echt te zijn:

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

Voer ze uit met npx vitest run. Dek naast het normale geval ook een lege omgeving en een geweigerde aanroep af.

7. Hot reload tijdens het schrijven​

Voor elke wijziging verpakken is een trage lus. In een ontwikkelomgeving kan een app in plaats daarvan worden geregistreerd tegen je draaiende ontwikkelserver (modus Hosted), die hot reload behoudt. Daar is geen scherm voor: het is één aanroep, POST /environments/{environment}/solutions/{solution}/custom-apps/hosted, die het adres van je remoteEntry.js noemt. Overal elders weigert het platform dat, want een app waarvan de code op jouw machine staat, is een app die haar oplossing niet kan meenemen. De shell moet ook het adres van de ontwikkelserver toestaan (VITE_REMOTE_ALLOWED_ORIGINS).

Wat er achter de schermen gebeurt​

Als iemand de app opent, vraagt de shell Identity om een kortlevend App Context Token dat de app en de omgeving noemt, met de door de app gevraagde scopes ingeperkt tot die van de persoon; die ingeperkte lijst is runtime.scopes. Daarna haalt de shell remoteEntry.js op vanaf het eigen adres van het platform, laat de remote zich koppelen aan de React en de Runtime SDK die al zijn geladen, en rendert de beschikbaar gestelde component in een runtime die aan de omgeving is gekoppeld. De aanroepen van de SDK gaan uit met de eigen aanmelding van de persoon, zodat de server elke aanroep toetst aan hun beveiligingsrol.

Problemen oplossen​

  • No mf-manifest.json bij het verpakken — voeg manifest: true toe aan federation({ … }).
  • Cannot find module … for side-effect import of './x.css' — src/vite-env.d.ts ontbreekt (stap 1).
  • Cannot resolve entry module index.html — Vite heeft de index.html uit stap 1 nodig, ook al laadt de shell hem nooit.
  • "Invalid hook call" zodra de app opent — twee React-kopieën. react en react-dom moeten in shared singleton: true zijn.
  • useParswareRuntime() geeft een fout — de component is buiten een runtime-provider gerenderd. In de shell kan dat niet; in een test omsluit je haar met ParswareRuntimeProvider zoals in stap 6.
  • Een aanroep wordt geweigerd — de persoon die de app gebruikt, mag dat niet; hun beveiligingsrol beslist. Controleer runtime.scopes en laat weg wat zij niet kunnen.
  • De beschikbaar gestelde module wordt niet gevonden — de module moet exact overeenkomen met de sleutel in exposes, inclusief de ./ vooraan.

Gerelateerde scenario's​

  • De Runtime SDK gebruiken.
  • Een aangepast besturingselement bouwen.
  • Een aangepaste React-app registreren.