Eigenes Frontend einbinden
Reichen generierte Formulare und Seiten mit BpPage nicht aus, bringt ein Modul eine eigene Weboberfläche mit. Das kann eine React-, Vue- oder Svelte-Anwendung sein oder schlichtes HTML mit JavaScript. Studio und App-Portal zeigen das Frontend in einem eigenen Reiter an, die Daten liefert das Modul über eigene HTTP-Endpunkte.
Note
Das Frontend lädt seine Daten über HTTP-Endpunkte. Module mit
[HttpRoute] benötigen das Addon Integrationen (actions-integration).
Funktionsweise
- Das gebaute Frontend liegt im Modulpaket unter
assets/ui/. Enthält der Ordner eineindex.html, erkennt Studio das Frontend automatisch. - Das Frontend läuft in einem isolierten iframe. Es sieht weder die Anmeldung noch die Cookies von Studio.
- Aufrufe an
ui-api/...leitet die Plattform an die HTTP-Endpunkte des Moduls weiter. Anmeldung, Berechtigung und die microtech-ERP-Verbindung setzt dabei die Plattform, nicht der Browser. - Jeder Aufruf erscheint als Ausführung in der Historie, zugeordnet zum angemeldeten Benutzer.
Projekt aufbauen
Das Frontend liegt als eigener Ordner im Modulprojekt. Der Build schreibt das Ergebnis nach
assets/ui/:
MeinModul/
├─ MeinModul.csproj
├─ ArtikelBrowserModule.cs
├─ manifest.json
├─ assets/
│ └─ ui/ ← Build-Ausgabe, wird mit dem Modul ausgeliefert
└─ ui/ ← Quellcode des Frontends
├─ package.json
├─ vite.config.ts
├─ index.html
├─ public/
│ └─ ui.json
└─ src/
├─ main.tsx
└─ App.tsx
Nehmen Sie assets/** in die Projektausgabe auf und schließen Sie node_modules aus dem
C#-Projekt aus:
<PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);ui\node_modules\**;ui\**\*.tsbuildinfo</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<None Include="assets\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Titel und Zielgruppe festlegen
Die Datei ui.json im Ordner assets/ui/ legt Titel und Zielgruppen fest. Mit Vite legen Sie
sie unter ui/public/ ab, dann kopiert der Build sie mit:
{ "title": "Artikel-Browser", "audiences": ["admin", "app"] }
| Zielgruppe | Anzeige | microtech-ERP-Verbindung |
|---|---|---|
admin |
Reiter auf der Modulseite in Studio | Verbindung des Moduls |
app |
App im App-Portal | Verbindung des angemeldeten Benutzers |
Fehlt ui.json, gilt der Modulname als Titel und das Frontend ist für beide Zielgruppen
sichtbar.
Datenendpunkt im Modul anlegen
Das Frontend ruft ausschließlich Actions mit [HttpRoute] auf. Query-Parameter füllen die
Eingabeklasse, der Rückgabewert wird als JSON ausgeliefert. Form = false blendet das
generierte Formular aus, da die Action nur als Datenquelle dient:
public sealed class ArtikelBrowserModule(IBpSession session) : BpModule
{
public override string ModuleName => "Artikel-Browser";
public override string ModuleVersion => "1.0.0";
public override string ModuleDescription => "Durchsucht Artikel seitenweise.";
[Action("list-artikel", Display = "Artikel auflisten", Form = false)]
[HttpRoute("GET", "artikel")]
public ArtikelSeite ListArtikel(ArtikelAbfrage abfrage, CancellationToken cancellationToken)
{
var pageSize = Math.Clamp(abfrage.PageSize, 1, 100);
var page = Math.Max(abfrage.Page, 1);
var artikel = session.QueryDataSet<Artikel>(
includes: ["ArtNr", "SuchBeg", "KuBez1", "Einh", "Vk0Preis"]);
if (!string.IsNullOrWhiteSpace(abfrage.Search))
artikel = artikel.Where(a => a.SuchBeg!.Contains(abfrage.Search));
// Eine Zeile mehr lesen, um zu erkennen, ob eine weitere Seite existiert.
var rows = artikel.Skip((page - 1) * pageSize).Take(pageSize + 1).ToList();
return new ArtikelSeite(
page,
rows.Count > pageSize,
rows.Take(pageSize)
.Select(a => new ArtikelZeile(a.ArtNr!, a.KuBez1 ?? a.SuchBeg!, a.Einh, a.Vk0Preis))
.ToList());
}
}
public sealed class ArtikelAbfrage
{
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
public string? Search { get; init; }
}
public sealed record ArtikelSeite(int Page, bool HasMore, List<ArtikelZeile> Items);
public sealed record ArtikelZeile(string Nummer, string Name, string? Einheit, double? Preis);
Im JSON sind die Property-Namen camelCase: HasMore wird zu hasMore, Nummer zu nummer.
React-Frontend mit Vite anlegen
Legen Sie im Modulprojekt ein Vite-Projekt mit React und TypeScript an:
npm create vite@latest ui -- --template react-ts
cd ui
npm install
Passen Sie vite.config.ts an. base: './' erzeugt relative Pfade, sodass das Frontend unter
jeder Adresse funktioniert, die Studio ihm zuweist. outDir schreibt den Build in den
Asset-Ordner des Moduls:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: '../assets/ui',
emptyOutDir: true,
},
})
Bauen Sie das Frontend vor dem Modul:
npm run build
Daten abrufen
Rufen Sie Endpunkte immer relativ über ui-api/ auf, ohne führenden Schrägstrich. Der Pfad
nach ui-api/ entspricht der Route aus [HttpRoute]:
import { useEffect, useState } from 'react'
type ArtikelZeile = { nummer: string; name: string; einheit: string | null; preis: number | null }
type ArtikelSeite = { page: number; hasMore: boolean; items: ArtikelZeile[] }
export default function App() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [data, setData] = useState<ArtikelSeite | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (search) params.set('search', search)
fetch(`ui-api/artikel?${params}`, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<ArtikelSeite>
})
.then(setData)
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : String(err))
})
return () => controller.abort()
}, [page, search])
return (
<main>
<input
type="search"
placeholder="Suchen"
value={search}
onChange={(event) => { setPage(1); setSearch(event.target.value) }}
/>
{error && <p>Fehler beim Laden: {error}</p>}
<table>
<tbody>
{data?.items.map((artikel) => (
<tr key={artikel.nummer}>
<td>{artikel.nummer}</td>
<td>{artikel.name}</td>
<td>{artikel.preis?.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>Zurück</button>
<button disabled={!data?.hasMore} onClick={() => setPage(page + 1)}>Weiter</button>
</main>
)
}
Der AbortController verwirft veraltete Antworten, wenn sich Suche oder Seite ändern, bevor die
vorherige Anfrage beendet ist. Bei Eingaben in ein Suchfeld empfiehlt sich zusätzlich eine
kurze Verzögerung, damit nicht jeder Tastendruck eine Ausführung erzeugt.
Daten senden
Schreibende Endpunkte erhalten den Body als JSON. Body, Routen- und Query-Parameter werden zur Eingabeklasse zusammengeführt:
[Action("set-preis", Display = "Preis ändern", Form = false)]
[HttpRoute("POST", "artikel/{artNr}/preis")]
public void SetPreis(PreisInput input) { /* ... */ }
public sealed class PreisInput
{
public required string ArtNr { get; init; }
public double Preis { get; init; }
}
await fetch(`ui-api/artikel/${encodeURIComponent(artNr)}/preis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preis: 12.5 }),
})
Fehler anzeigen
Wirft das Modul ein HttpProblem, erhält das Frontend eine Antwort nach RFC 9457 mit
title und detail. Zeigen Sie diese Texte statt des reinen Statuscodes an:
async function readError(response: Response) {
if (response.headers.get('Content-Type')?.includes('json')) {
const problem = await response.json()
return problem.detail ?? problem.title ?? `HTTP ${response.status}`
}
return `HTTP ${response.status}`
}
Details zu HttpProblem stehen unter
Parameter binden und Fehler zurückgeben.
Zwischen Ansichten wechseln
Verwenden Sie für die Navigation innerhalb des Frontends Anwendungszustand statt der
Browser-History. Router, die pushState verwenden, kollidieren mit der Zurück-Navigation von
Studio und App-Portal:
export default function App() {
const [selected, setSelected] = useState<string | null>(null)
return selected
? <DetailView artNr={selected} onBack={() => setSelected(null)} />
: <ListView onSelect={setSelected} />
}
Für größere Anwendungen eignet sich ein Router mit Speicher-History, etwa createMemoryRouter
aus React Router.
Einschränkungen der Sandbox
Das iframe ist isoliert und erhält strenge Sicherheitsrichtlinien. Daraus ergeben sich folgende Regeln:
| Einschränkung | Vorgehen |
|---|---|
| Skripte, Styles, Bilder und Schriften nur aus dem eigenen Bundle | Bibliotheken und Schriften mitbauen statt über ein CDN laden |
Anfragen nur an ui-api/ |
Externe Dienste über einen Endpunkt im Modul ansprechen |
| Keine Inline-Skripte | Code in Moduldateien bündeln, wie es Vite standardmäßig tut |
Kein Zugriff auf Cookies, localStorage und sessionStorage |
Zustand im Speicher halten oder über das Modul speichern |
| Kein klassisches Absenden von HTML-Formularen | Eingaben per fetch senden |
Keine Pop-ups, neuen Fenster und Dialoge wie alert() |
Hinweise im Frontend selbst darstellen |
Inline-Styles und Bilder als data:-URL sind erlaubt.
Das Frontend benötigt keine eigene Anmeldung. Der Zugang ist an eine Sitzung gebunden, die nach zwölf Stunden abläuft; danach genügt es, die Seite neu zu öffnen.
Lokal entwickeln
Während der Entwicklung starten Sie das Frontend mit dem Vite-Entwicklungsserver und leiten
ui-api/ auf die öffentliche API-Adresse der Runtime um. Dafür benötigen Sie einen
API-Schlüssel. Hinterlegen Sie ihn in
ui/.env.local, damit er nicht eingecheckt wird:
BPNEXT_API_KEY=<schlüssel>
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()],
base: './',
build: { outDir: '../assets/ui', emptyOutDir: true },
server: {
proxy: {
'/ui-api': {
target: 'http://localhost:5220',
rewrite: (path) => path.replace(/^\/ui-api/, '/m/meinefirma.artikel-browser'),
headers: { Authorization: `Bearer ${env.BPNEXT_API_KEY}` },
},
},
},
}
})
npm run dev
Änderungen am Frontend erscheinen so sofort im Browser, ohne das Modul neu zu bauen. Die Aufrufe laufen dabei über den API-Schlüssel und die Verbindung des Moduls. Prüfen Sie das fertige Frontend abschließend im iframe von Studio, da dort die Einschränkungen der Sandbox gelten.
Andere Frameworks
Die Plattform stellt keine Anforderungen an das Framework. Entscheidend ist ein statischer Build
mit index.html, relativen Pfaden und Aufrufen über ui-api/. Für Vue und Svelte gelten dieselben
Vite-Einstellungen. Ohne Build-Werkzeug genügt eine index.html mit einer eingebundenen
JavaScript-Datei direkt unter assets/ui/.
Checkliste
assets/ui/index.htmlist im Modulpaket enthalten.ui.jsonlegt Titel und Zielgruppen fest.- Asset-Pfade sind relativ (
base: './'). - Datenaufrufe verwenden
ui-api/...ohne führenden Schrägstrich. - Alle Bibliotheken, Styles und Schriften sind im Bundle enthalten.
- Die Navigation verwendet keine Browser-History.