25 KiB
PHX Frontend Plugin Demo
Beispielprojekt, das zeigt, wie ein PHX-ERP-Frontend-Plugin als Angular-Web-Component mit @phx-erp/shared und @phx-erp/shared-ui erstellt wird.
Dieselbe Codebasis unterstützt zwei Arbeitsweisen:
| Modus | Zweck | Typischer Befehl |
|---|---|---|
| Plugin host | Ausführung in PHX als Custom Element | yarn run plugin |
| Standalone client | Lokale Entwicklung wie eine normale Angular-App | yarn client |
Inhaltsverzeichnis
- Übersicht
- Voraussetzungen
- Schnellstart
- Live-Demo
- Entwicklungsmodi
- Projektstruktur
- Eigenes Plugin erstellen
- Weitere Themen
- Fehlerbehebung
Übersicht
Ein PHX-Frontend-Plugin ist eine Angular-Anwendung, die als Custom Element (Web Component) verpackt wird. PHX lädt es über ein Manifest, das auf die kompilierte main.js verweist und den Element-Tag-Namen deklariert.
┌─────────────────┐ manifest.json ┌──────────────────┐
│ PHX host │ ─────────────────────► │ main.js (+ deps) │
│ (ERP shell) │ loads & registers │ custom element │
└────────┬────────┘ └────────┬─────────┘
│ │
│ pluginServices (Apollo, notifications…) │
│ hostInjector │
└──────────────────────────────────────────┘
Was diese Demo enthält:
- Custom-Element-Tag:
frontend-plugin-demo - Beispiel-Routen: Hello World, Product View, Address List
- GraphQL-Abfragen über den PHX-Host-Apollo (Production) oder einen lokalen Apollo-Client (Development)
- Login-Flow für die eigenständige Entwicklung ohne vorkonfigurierten API-Key
- PrimeNG- und Tailwind-Styling im PHX-Stil
Voraussetzungen
- Node.js (LTS empfohlen)
- Yarn v4
- Eine laufende PHX-Instanz (für Plugin-Host-Tests und GraphQL-Schema/Codegen)
Für die lokale eigenständige Entwicklung benötigen Sie außerdem eines der folgenden:
- Einen PHX-API-Benutzer-Token in Ihrer Development-Umgebung, oder
- Gültige Anmeldedaten für den integrierten Login-Bildschirm (siehe Apollo, Auth Guard und Login)
Schnellstart
-
Abhängigkeiten installieren:
yarn install -
Lokale Development-Umgebung anlegen (optional, für den Standalone-Modus):
cp src/environments/environment.example.ts src/environments/environment.development.tsPassen Sie
apiUrl,wsUrlund optionalapiKeyin dieser Datei an. Die Datei ist gitignored. -
GraphQL-Typen generieren (erfordert PHX Admin API unter der in
codegen.tskonfigurierten Schema-URL):yarn codegen -
In einem der Entwicklungsmodi unten starten.
Live-Demo
Um diese Demo in Ihrer eigenen Instanz zu nutzen, fügen Sie unter Admin → Custom Elements (https://your.phx.instance/admin/customElements) ein Custom Element mit folgender Manifest-URL hinzu:
https://gitea.phx-erp.de/api/v1/repos/PHXGMBH/phx-frontend-plugin-webcomponent-demo/raw/master/latest/manifest.json
Das Manifest verweist auf die kompilierte main.js im Verzeichnis latest/ dieses Repositorys.
Entwicklungsmodi
Standalone client (üblicher Angular-Workflow)
Startet die App mit der Development-Build-Konfiguration auf Port 4201 — Routing, Login und ein lokaler Apollo-Client funktionieren ohne PHX.
yarn client
# equivalent: ng serve --port 4201 --watch --configuration development
Öffnen Sie http://localhost:4201/.
Plugin-Host-Modus (innerhalb von PHX)
Baut mit der Production-Konfiguration (ohne Output Hashing) und stellt die Ausgabe mit CORS- und No-Cache-Headern bereit, damit PHX während der Entwicklung aktuelle Bundles laden kann.
yarn run plugin
Dies führt yarn build und yarn serve parallel aus:
| Skript | Funktion |
|---|---|
yarn build |
Production-Watch-Build (in diesem Repo zusätzlich Sync nach latest/ für die gehostete Demo) |
yarn serve |
Stellt dist/.../browser/ unter http://localhost:3223/ bereit |
Verweisen Sie Ihre PHX-Instanz auf das lokale Manifest:
http://localhost:3223/manifest.json
(public/manifest.json wird in die bereitgestellte Ausgabe kopiert.)
Hinweis: Verwenden Sie
yarn run plugin, nichtyarn plugin— Yarn behandeltpluginals eingebauten Befehl.
Terminal Keeper ist in .vscode/sessions.json vorkonfiguriert, um sowohl yarn run plugin als auch yarn client zu starten.
Weitere nützliche Befehle
yarn start # ng serve with development configuration (default port 4200)
yarn codegen # Regenerate GraphQL types
Projektstruktur
phx-frontend-plugin-demo/
├── latest/ # Published build output (main.js, manifest.json)
├── public/
│ └── manifest.json # Local manifest (path → localhost:3223)
├── scripts/
│ ├── copy-latest.mjs # (this repo only) Sync dist/*.js → latest/ for hosted demo
│ └── serve-dist.mjs # Static server for plugin-host dev
├── src/
│ ├── app/
│ │ ├── components/ # Demo pages (hello-world, product-view, …)
│ │ ├── login/ # Login form (standalone development)
│ │ ├── services/
│ │ │ ├── apollo.service.ts
│ │ │ └── phoenix-host-bridge.service.ts
│ │ ├── app.config.ts # providePhoenixPluginWithPrimeNG, providers
│ │ ├── app.routes.ts
│ │ ├── apollo.provider.ts # Local Apollo (development only)
│ │ └── auth-guard.ts
│ ├── graphql/ # GraphQL documents for codegen
│ ├── environments/
│ │ ├── environment.ts # Production defaults (used in PHX)
│ │ ├── environment.example.ts # Template for local dev
│ │ └── environment.development.ts # Local overrides (gitignored)
│ └── main.ts # Registers the custom element
├── codegen.ts
├── serve.json # Cache headers for serve-dist.mjs
└── tailwind.config.js
Eigenes Plugin erstellen
Die folgenden Schritte führen durch die Erstellung eines Projekts, das dieser Demo ähnelt, mit Yarn v4, Angular 20, PrimeNG 20 und Tailwind CSS.
Ersetzen Sie Platzhalter wie *PROJECT-NAME*, *YOUR-TAG* und *YOUR-TOKEN* durch Ihre Werte.
1. Grundsetup
mkdir *PROJECT-NAME*
cd *PROJECT-NAME*
npx @angular/cli@20 new *PROJECT-NAME* --directory ./ --package-manager yarn --style scss --ssr false --routing --standalone
yarn add primeng@20 @primeng/themes@20 tailwindcss@3.4.17 postcss tailwindcss-primeui @angular/animations@20 @angular/elements@20
yarn add -D typescript@5.9.2 @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node concurrently
ng add apollo-angular
2. Tailwind CSS
Erstellen Sie tailwind.config.js im Projektroot. Diese Konfiguration ist an PHX angelehnt:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'selector',
content: ['./src/**/*.{html,ts,scss}'],
theme: {
extend: {
animation: {
fadein: 'fadein 0.5s ease-in-out',
fadeout: 'fadeout 0.5s ease-in-out',
fadeinleft: 'fadeinleft 0.5s ease-in-out',
fadeinright: 'fadeinright 0.5s ease-in-out',
fadeintop: 'fadeintop 0.5s ease-in-out',
fadeinbottom: 'fadeinbottom 0.5s ease-in-out',
},
keyframes: {
fadein: { '0%': { opacity: 0 }, '100%': { opacity: 1 } },
fadeout: { '0%': { opacity: 1 }, '100%': { opacity: 0 } },
fadeinleft: { '0%': { opacity: 0, transform: 'translateX(-100%)' }, '100%': { opacity: 1, transform: 'translateX(0)' } },
fadeinright: { '0%': { opacity: 0, transform: 'translateX(100%)' }, '100%': { opacity: 1, transform: 'translateX(0)' } },
fadeintop: { '0%': { opacity: 0, transform: 'translateY(-100%)' }, '100%': { opacity: 1, transform: 'translateY(0)' } },
fadeinbottom: { '0%': { opacity: 0, transform: 'translateY(100%)' }, '100%': { opacity: 1, transform: 'translateY(0)' } },
},
},
},
plugins: [require('tailwindcss-primeui')],
};
Aufgrund eines bekannten Problems müssen diese Direktiven in den styles jeder Komponente (und in src/styles.scss für die lokale Entwicklung) ergänzt werden:
@tailwind base;
@tailwind components;
@tailwind utilities;
3. GraphQL Codegen
Erstellen Sie codegen.ts im Projektroot (passen Sie die Schema-URL an, falls Ihre PHX-Instanz woanders läuft):
import type { CodegenConfig } from '@graphql-codegen/cli';
const sharedConfig = {
scalars: { DateTime: 'Date' },
immutableTypes: false,
} as const;
const config: CodegenConfig = {
overwrite: true,
schema: 'http://localhost:3000/admin-api/schema.gql',
documents: './src/graphql/*.ts',
ignoreNoDocuments: true,
generates: {
'./src/app/schema-types.ts': {
plugins: ['typescript'],
config: sharedConfig,
},
'./src/app/generated.ts': {
plugins: ['typescript-operations', 'typed-document-node'],
config: {
...sharedConfig,
importSchemaTypesFrom: './src/app/schema-types.ts',
},
},
},
};
export default config;
In package.json ergänzen:
"codegen": "graphql-codegen"
Definieren Sie Queries und Mutations in src/graphql/ und führen Sie anschließend aus:
yarn codegen
Dies erzeugt src/app/schema-types.ts und src/app/generated.ts.
4. Environments
Verwenden Sie getrennte Environments, damit derselbe Build als PHX-Plugin (Production) oder als eigenständige Dev-App (Development) läuft.
src/environments/environment.interface.ts
export abstract class Environment {
production: boolean = false;
apiUrl: string | undefined;
wsUrl: string | undefined;
apiKey: string | undefined;
serverUrl: string = '';
}
src/environments/environment.ts (Production — verwendet bei Einbettung in PHX)
import { Environment } from './environment.interface';
export const environment: Environment = {
production: true,
apiUrl: undefined,
wsUrl: undefined,
apiKey: undefined,
serverUrl: '',
};
src/environments/environment.development.ts (lokale Entwicklung — in .gitignore aufnehmen)
import { Environment } from './environment.interface';
export const environment: Environment = {
production: false,
apiUrl: 'http://localhost:3000/admin-api',
wsUrl: 'ws://localhost:3000/admin-api',
apiKey: undefined, // or a PHX API user token; otherwise use the login route
serverUrl: 'https://localhost:4200',
};
Fügen Sie fileReplacements zur Development-Build-Konfiguration in angular.json hinzu:
"development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
Tipp: Legen Sie
environment.example.tsim Repository ab und kopieren Sie es lokal nachenvironment.development.ts, wie diese Demo es tut.
5. PHX-Bibliotheken
@phx-erp/shared und @phx-erp/shared-ui werden in der öffentlichen npm-Registry veröffentlicht.
Installieren Sie die Bibliotheken:
yarn add @phx-erp/shared @phx-erp/shared-ui
6. Plugin-Setup
Application config
Fügen Sie providePhoenixPluginWithPrimeNG in src/app/app.config.ts hinzu:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { providePhoenixPluginWithPrimeNG } from '@phx-erp/shared-ui';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
// stripTrailingSegments: child route segments appended by PHX deep links
// so APP_BASE_HREF still matches the host mount path.
...providePhoenixPluginWithPrimeNG({
stripTrailingSegments: ['*YOUR*', '*ROUTES*', '*HERE*'],
}),
provideRouter(routes),
],
};
Routing: Übergeben Sie Ihre Routen-Pfadsegmente in
stripTrailingSegments, oder leiten Sie sie automatisch ab:stripTrailingSegments: routes.map((r) => r.path!).filter((p) => (p?.length ?? 0) > 0)
Host bridge service
Erstellen Sie src/app/services/phoenix-host-bridge.service.ts:
import { Injectable, Injector, signal } from '@angular/core';
import type { IPluginServices } from '@phx-erp/shared-ui';
@Injectable({ providedIn: 'root' })
export class PhoenixHostBridgeService {
private readonly _hostInjector = signal<Injector | null>(null);
private readonly _pluginServices = signal<IPluginServices | null>(null);
hostInjector(): Injector | null {
return this._hostInjector();
}
setHostInjector(injector: Injector): void {
this._hostInjector.set(injector);
}
pluginServices(): IPluginServices | null {
return this._pluginServices();
}
setPluginServices(services: IPluginServices): void {
this._pluginServices.set(services);
}
}
Die Host Bridge speichert von PHX bereitgestellte Services (Apollo Client, Notification Service usw.), damit Ihre Komponenten sie im Production-Modus nutzen können.
Root component
Ihre Root-Komponente empfängt pluginServices und hostInjector von PHX und leitet sie an die Bridge weiter:
import { Component, effect, inject, Injector, input } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { PhoenixHostBridgeService } from './services/phoenix-host-bridge.service';
import { IPluginServices, syncPhoenixHostInjector } from '@phx-erp/shared-ui';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<router-outlet />`,
styles: [`
@tailwind base;
@tailwind components;
@tailwind utilities;
`],
})
export class App {
private readonly hostBridge = inject(PhoenixHostBridgeService);
readonly pluginServices = input<IPluginServices>({});
readonly hostInjector = input<Injector | undefined>(undefined);
private readonly _syncHostInjector = syncPhoenixHostInjector(this.hostBridge, this.hostInjector);
private readonly _syncPluginServices = effect(() => {
this.hostBridge.setPluginServices(this.pluginServices());
});
}
Bootstrap / custom element registration
Registrieren Sie in src/main.ts Ihr Plugin als Custom Element (ersetzen Sie *YOUR-TAG*):
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { bootstrapPhoenixPluginCustomElement } from '@phx-erp/shared-ui';
import { environment } from './environments/environment';
bootstrapPhoenixPluginCustomElement(App, '*YOUR-TAG*', appConfig).then((app) => {
if (!environment.production) {
return app!.bootstrap(App);
}
return app;
});
bootstrapPhoenixPluginCustomElement— erstellt die Angular-Anwendung und registriert das Custom Element für PHX.app.bootstrap(App)in Development — mountet zusätzlich die Root-Komponente, damitng serve/ der Standalone-Modus mit Routing funktioniert.
Der Custom-Element-Tag muss kleingeschrieben mit Bindestrichen sein (z. B. my-company-orders). Verwenden Sie denselben Tag in Ihrem Manifest.
7. In PHX registrieren
Manifest-Format
PHX lädt ein JSON-Manifest, das auf Ihr Entry-Skript verweist und den Custom-Element-Tag deklariert:
{
"path": "https://example.com/path/to/main.js",
"items": [
{
"tagName": "*YOUR-TAG*"
}
]
}
| Feld | Beschreibung |
|---|---|
path |
Absolute URL zu main.js (und Basis für Chunk-Auflösung) |
items[].tagName |
In main.ts registrierter Custom-Element-Tag |
Beispiel lokale Entwicklung (public/manifest.json in diesem Repo):
{
"path": "http://localhost:3223/main.js",
"items": [{ "tagName": "frontend-plugin-demo" }]
}
Gehostetes Beispiel (latest/manifest.json in diesem Repo):
{
"path": "https://gitea.phx-erp.de/api/v1/repos/PHXGMBH/phx-frontend-plugin-webcomponent-demo/raw/master/latest/main.js",
"items": [{ "tagName": "frontend-plugin-demo" }]
}
Registrierungsschritte
- Hosten Sie
main.js(und ggf. weitere Chunks) unter einer URL, die aus den Browsern Ihrer Nutzer erreichbar ist — dieselben Netzwerkregeln wie für Ihre PHX-Instanz. - Veröffentlichen Sie ein Manifest-JSON unter einer stabilen URL.
- In PHX: Admin → Custom Elements → Manifest-URL hinzufügen und Mount-Pfad oder Tag wählen.
- Ab- und wieder anmelden. Das Plugin ist verfügbar unter
https://your.phx.instance/customElements/*PATH*.
Für die lokale Plugin-Host-Entwicklung siehe Plugin lokal bereitstellen.
Weitere Themen
Plugin lokal bereitstellen
Statischen Server installieren:
yarn add -D serve
scripts/serve-dist.mjs
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const port = process.env.PORT ?? '3223';
const serveBin = join(root, 'node_modules', '.bin', 'serve');
const child = spawn(serveBin, ['-l', port, '--cors', '--no-etag'], {
stdio: 'inherit',
shell: true,
cwd: root,
});
child.on('exit', (code) => process.exit(code ?? 0));
serve.json (ersetzen Sie *PROJECT-NAME* durch Ihren Angular-Projektnamen aus angular.json)
{
"public": "dist/*PROJECT-NAME*/browser",
"headers": [
{
"source": "**/*.{js,mjs}",
"headers": [
{ "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate" }
]
}
]
}
Die Bereitstellung aus dem Projektroot (nicht direkt aus dist/) stellt sicher, dass serve.json angewendet wird und verhindert veraltete gecachte main.js nach Rebuilds.
In package.json ergänzen:
"serve": "node ./scripts/serve-dist.mjs"
Eigener Port: PORT=8080 yarn serve.
Nur in diesem Repository:
scripts/copy-latest.mjskopiert gebaute JS-Dateien nachlatest/, damit das Live-Demo-Manifest auf einen stabilen Pfad in Git verweisen kann. Für die lokale Plugin-Host-Entwicklung ist das nicht erforderlich.
Apollo, Auth Guard und Login
Im Production-Modus (eingebettet in PHX) übernimmt der Host die Authentifizierung. Nutzen Sie den Apollo Client aus IPluginServices über PhoenixHostBridgeService — ein separater Login-Flow ist nicht nötig.
Im Development-Modus (Standalone) stellen Sie einen eigenen Apollo Client und optional einen Login bereit.
Apollo provider
Erstellen Sie src/app/apollo.provider.ts (vollständige Implementierung in diesem Repository). Er:
- verbindet HTTP- und WebSocket-Links mit
environment.apiUrl/environment.wsUrl - sendet
Authorization: Bearer …ausenvironment.apiKeyoderlocalStorage - speichert Tokens aus dem Response-Header
phoenix-auth-token
Nur in Development registrieren, z. B. in app.config.ts:
...(environment.production ? [] : [...apolloProvider()])
Apollo service abstraction
Ein kleiner Service wählt im Production-Modus den Host-Apollo-Client und im Development-Modus den lokalen Client:
@Injectable({ providedIn: 'root' })
export class ApolloService {
private readonly _apollo = signal<Apollo>(undefined!);
constructor(private readonly injector: Injector) {
if (environment.production) {
this._apollo.set(injector.get(PhoenixHostBridgeService)?.pluginServices()?.apollo!);
} else {
this._apollo.set(
injector.get(PhoenixHostBridgeService)?.pluginServices()?.apollo ?? injector.get(Apollo)
);
}
}
apollo = () => this._apollo();
}
Wenden Sie dasselbe Muster auf andere Host-Services an (z. B. Notifications), wenn Sie Standalone-Fallbacks benötigen.
Login component
Für die Entwicklung ohne voreingestellten API-Key fügen Sie eine /login-Route hinzu, die die PHX-login-Mutation aufruft:
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
... on CurrentUser {
id
identifier
channels {
id
token
}
}
... on InvalidCredentialsError {
errorCode
message
}
... on NativeAuthStrategyError {
errorCode
message
}
... on EmailCodeAuthStrategyError {
errorCode
message
}
}
}
Eine vollständige Formular-Implementierung finden Sie unter src/app/login/ in diesem Repo.
Auth guard
Nicht authentifizierte Nutzer nur im Development-Modus zum Login weiterleiten:
@Injectable()
export class AuthGuard implements CanActivate {
private readonly router = inject(Router);
canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean {
const token = environment.apiKey ?? localStorage.getItem('api-key');
if (!environment.production && !token) {
this.router.navigate(['login'], {
queryParams: { redirectTo: btoa(window.location.pathname + window.location.search) },
});
}
return true;
}
}
In app.routes.ts einbinden:
const canActivate = [environment.production ? () => true : AuthGuard];
export const routes: Routes = [
{ path: '', canActivate, component: HelloWorld },
{ path: 'product-view', canActivate, component: ProductView },
{ path: 'address-list', canActivate, component: AddressList },
{ path: 'login', component: Login },
];
package.json scripts
Empfohlene Skripte nach dieser Anleitung:
{
"scripts": {
"build": "ng build --watch --output-hashing none --configuration production",
"serve": "node ./scripts/serve-dist.mjs",
"plugin": "concurrently \"yarn build\" \"yarn serve\"",
"client": "ng serve --port 4201 --watch --configuration development",
"codegen": "graphql-codegen"
}
}
| Skript | Beschreibung |
|---|---|
build |
Production-Watch-Build für PHX |
serve |
Stellt kompilierte Assets für PHX bereit (Standard-Port 3223) |
plugin |
Führt build + serve zusammen aus — verwenden Sie yarn run plugin |
client |
Eigenständiger Angular-Dev-Server auf Port 4201 |
codegen |
Generiert GraphQL-TypeScript-Typen neu |
Dieses Repository führt zusätzlich copy-latest.mjs parallel zu build aus, um Artefakte nach latest/ für die gehostete Demo zu veröffentlichen — das ist für Ihr eigenes Plugin nicht erforderlich.
Fehlerbehebung
| Symptom | Zu prüfen |
|---|---|
| PHX zeigt eine alte Plugin-Version | Hard-Refresh; prüfen, ob serve.json Cache-Control: no-store für JS setzt; yarn run plugin neu starten |
401 / GraphQL-Auth-Fehler im Standalone-Modus |
apiKey in environment.development.ts setzen oder über /login anmelden |
yarn add @phx-erp/shared schlägt fehl |
Netzwerkzugriff auf die öffentliche npm-Registry prüfen; yarn install erneut ausführen |
| Routing in PHX funktioniert nicht | Routen-Segmente zu stripTrailingSegments in providePhoenixPluginWithPrimeNG hinzufügen |
| Tailwind-Klassen fehlen in einer Komponente | @tailwind-Direktiven in den styles der Komponente ergänzen |
yarn codegen schlägt fehl |
PHX muss laufen und die Schema-URL in codegen.ts erreichbar sein |
| Custom Element nicht gefunden | Tag im Manifest muss exakt mit customElements.define / bootstrapPhoenixPluginCustomElement übereinstimmen |
Support
Bei Integrationsfragen oder PHX-spezifischen APIs wenden Sie sich an Ihren PHX-Partner.