Table of Contents

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 eine index.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.html ist im Modulpaket enthalten.
  • ui.json legt 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.