moved login component; improved documentation; other minor improvements
This commit is contained in:
2
.vscode/sessions.json
vendored
2
.vscode/sessions.json
vendored
@@ -9,6 +9,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "plugin",
|
"name": "plugin",
|
||||||
|
"icon": "globe",
|
||||||
"commands": [
|
"commands": [
|
||||||
"clear && yarn plugin"
|
"clear && yarn plugin"
|
||||||
],
|
],
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
|
"icon": "device-desktop",
|
||||||
"commands": [
|
"commands": [
|
||||||
"clear && yarn client"
|
"clear && yarn client"
|
||||||
],
|
],
|
||||||
|
|||||||
781
README.DE.md
Normal file
781
README.DE.md
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
# PHX Frontend Plugin Demo
|
||||||
|
|
||||||
|
> [English version](README.md)
|
||||||
|
|
||||||
|
Beispielprojekt, das zeigt, wie ein **PHX-ERP-Frontend-Plugin** als Angular-Web-Component mit `@phx/shared` und `@phx/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](#übersicht)
|
||||||
|
- [Voraussetzungen](#voraussetzungen)
|
||||||
|
- [Schnellstart](#schnellstart)
|
||||||
|
- [Live-Demo](#live-demo)
|
||||||
|
- [Entwicklungsmodi](#entwicklungsmodi)
|
||||||
|
- [Projektstruktur](#projektstruktur)
|
||||||
|
- [Eigenes Plugin erstellen](#eigenes-plugin-erstellen)
|
||||||
|
- [1. Grundsetup](#1-grundsetup)
|
||||||
|
- [2. Tailwind CSS](#2-tailwind-css)
|
||||||
|
- [3. GraphQL Codegen](#3-graphql-codegen)
|
||||||
|
- [4. Environments](#4-environments)
|
||||||
|
- [5. PHX-Bibliotheken](#5-phx-bibliotheken)
|
||||||
|
- [6. Plugin-Setup](#6-plugin-setup)
|
||||||
|
- [7. In PHX registrieren](#7-in-phx-registrieren)
|
||||||
|
- [Weitere Themen](#weitere-themen)
|
||||||
|
- [Plugin lokal bereitstellen](#plugin-lokal-bereitstellen)
|
||||||
|
- [Apollo, Auth Guard und Login](#apollo-auth-guard-und-login)
|
||||||
|
- [package.json-Skripte](#packagejson-skripte)
|
||||||
|
- [Fehlerbehebung](#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](https://nodejs.org/) (LTS empfohlen)
|
||||||
|
- [Yarn v4](https://yarnpkg.com/getting-started/install)
|
||||||
|
- Eine laufende PHX-Instanz (für Plugin-Host-Tests und GraphQL-Schema/Codegen)
|
||||||
|
- Ein **npm-Zugangstoken** mit Lesezugriff auf PHXGMBH-Pakete — anfordern unter [support@phx-erp.de](mailto:support@phx-erp.de)
|
||||||
|
|
||||||
|
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](#apollo-auth-guard-und-login))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
1. **Yarn konfigurieren**, um auf die PHX-npm-Registry zuzugreifen (siehe [PHX-Bibliotheken](#5-phx-bibliotheken)). `.yarnrc.yml` nicht committen.
|
||||||
|
|
||||||
|
2. **Abhängigkeiten installieren:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lokale Development-Umgebung anlegen** (optional, für den Standalone-Modus):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp src/environments/environment.example.ts src/environments/environment.development.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Passen Sie `apiUrl`, `wsUrl` und optional `apiKey` in dieser Datei an. Die Datei ist gitignored.
|
||||||
|
|
||||||
|
4. **GraphQL-Typen generieren** (erfordert PHX Admin API unter der in `codegen.ts` konfigurierten Schema-URL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn codegen
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **In einem der Entwicklungsmodi** unten starten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live-Demo
|
||||||
|
|
||||||
|
Eine gehostete Demo ist auf der PHX-Alpha-Instanz verfügbar:
|
||||||
|
|
||||||
|
**https://alpha.phx-erp.de/customElements/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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`, nicht `yarn plugin` — Yarn behandelt `plugin` als eingebauten Befehl.
|
||||||
|
|
||||||
|
[Terminal Keeper](https://open-vsx.org/extension/nguyenngoclong/terminal-keeper) ist in `.vscode/sessions.json` vorkonfiguriert, um sowohl `yarn run plugin` als auch `yarn client` zu starten.
|
||||||
|
|
||||||
|
### Weitere nützliche Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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](https://yarnpkg.com/getting-started/install), [Angular 20](https://angular.dev/), [PrimeNG 20](https://primeng.org/) und [Tailwind CSS](https://tailwindcss.com/).
|
||||||
|
|
||||||
|
Ersetzen Sie Platzhalter wie `*PROJECT-NAME*`, `*YOUR-TAG*` und `*YOUR-TOKEN*` durch Ihre Werte.
|
||||||
|
|
||||||
|
### 1. Grundsetup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** @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:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@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):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"codegen": "graphql-codegen"
|
||||||
|
```
|
||||||
|
|
||||||
|
Definieren Sie Queries und Mutations in `src/graphql/` und führen Sie anschließend aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`**
|
||||||
|
|
||||||
|
```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)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"development": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tipp:** Legen Sie `environment.example.ts` im Repository ab und kopieren Sie es lokal nach `environment.development.ts`, wie diese Demo es tut.
|
||||||
|
|
||||||
|
### 5. PHX-Bibliotheken
|
||||||
|
|
||||||
|
`@phx/shared` und `@phx/shared-ui` werden in der PHXGMBH-npm-Registry veröffentlicht.
|
||||||
|
|
||||||
|
Erstellen Sie `.yarnrc.yml` im Projektroot (oder bearbeiten Sie `~/.yarnrc.yml` für eine globale Konfiguration):
|
||||||
|
|
||||||
|
```yml
|
||||||
|
nodeLinker: node-modules
|
||||||
|
npmScopes:
|
||||||
|
phx:
|
||||||
|
npmRegistryServer: "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/"
|
||||||
|
npmAuthToken: "*YOUR-TOKEN*"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Wichtig:** Nehmen Sie `.yarnrc.yml` in `.gitignore` auf, wenn sie Ihren Token enthält.
|
||||||
|
|
||||||
|
Installieren Sie anschließend die Bibliotheken:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add @phx/shared @phx/shared-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Plugin-Setup
|
||||||
|
|
||||||
|
#### Application config
|
||||||
|
|
||||||
|
Fügen Sie `providePhoenixPluginWithPrimeNG` in `src/app/app.config.ts` hinzu:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { providePhoenixPluginWithPrimeNG } from '@phx/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:
|
||||||
|
>
|
||||||
|
> ```ts
|
||||||
|
> 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`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable, Injector, signal } from '@angular/core';
|
||||||
|
import type { IPluginServices } from '@phx/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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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/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*`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { bootstrapPhoenixPluginCustomElement } from '@phx/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, damit `ng 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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "http://localhost:3223/main.js",
|
||||||
|
"items": [{ "tagName": "frontend-plugin-demo" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gehostetes Beispiel** (`latest/manifest.json` in diesem Repo):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
|
||||||
|
1. 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.
|
||||||
|
2. Veröffentlichen Sie ein Manifest-JSON unter einer stabilen URL.
|
||||||
|
3. In PHX: **Admin → Custom Elements** → Manifest-URL hinzufügen und Mount-Pfad oder Tag wählen.
|
||||||
|
4. 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](#plugin-lokal-bereitstellen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weitere Themen
|
||||||
|
|
||||||
|
### Plugin lokal bereitstellen
|
||||||
|
|
||||||
|
Statischen Server installieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add -D serve
|
||||||
|
```
|
||||||
|
|
||||||
|
**`scripts/serve-dist.mjs`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
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`)
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"serve": "node ./scripts/serve-dist.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Eigener Port: `PORT=8080 yarn serve`.
|
||||||
|
|
||||||
|
> **Nur in diesem Repository:** `scripts/copy-latest.mjs` kopiert gebaute JS-Dateien nach `latest/`, damit das [Live-Demo](#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 …` aus `environment.apiKey` oder `localStorage`
|
||||||
|
- speichert Tokens aus dem Response-Header `phoenix-auth-token`
|
||||||
|
|
||||||
|
Nur in Development registrieren, z. B. in `app.config.ts`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@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:
|
||||||
|
|
||||||
|
```gql
|
||||||
|
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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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/shared` schlägt fehl | Token in `.yarnrc.yml` prüfen; [support@phx-erp.de](mailto:support@phx-erp.de) kontaktieren |
|
||||||
|
| 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 Registry-Zugang, Integrationsfragen oder PHX-spezifischen APIs wenden Sie sich an [support@phx-erp.de](mailto:support@phx-erp.de).
|
||||||
778
README.md
778
README.md
@@ -1,3 +1,777 @@
|
|||||||
# PhxFrontendPluginDemo
|
# PHX Frontend Plugin Demo
|
||||||
|
|
||||||
TODO
|
> [Deutsche Version](README.DE.md)
|
||||||
|
|
||||||
|
Example project showing how to build a **PHX ERP frontend plugin** as an Angular web component, using `@phx/shared` and `@phx/shared-ui`.
|
||||||
|
|
||||||
|
The same codebase supports two workflows:
|
||||||
|
|
||||||
|
| Mode | Purpose | Typical command |
|
||||||
|
|------|---------|-----------------|
|
||||||
|
| **Plugin host** | Run inside PHX as a custom element | `yarn run plugin` |
|
||||||
|
| **Standalone client** | Develop locally like a normal Angular app | `yarn client` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Live demo](#live-demo)
|
||||||
|
- [Development modes](#development-modes)
|
||||||
|
- [Project structure](#project-structure)
|
||||||
|
- [Create your own plugin](#create-your-own-plugin)
|
||||||
|
- [1. Basic setup](#1-basic-setup)
|
||||||
|
- [2. Tailwind CSS](#2-tailwind-css)
|
||||||
|
- [3. GraphQL Codegen](#3-graphql-codegen)
|
||||||
|
- [4. Environments](#4-environments)
|
||||||
|
- [5. PHX libraries](#5-phx-libraries)
|
||||||
|
- [6. Plugin setup](#6-plugin-setup)
|
||||||
|
- [7. Register in PHX](#7-register-in-phx)
|
||||||
|
- [Additional topics](#additional-topics)
|
||||||
|
- [Serving the plugin locally](#serving-the-plugin-locally)
|
||||||
|
- [Apollo, auth guard, and login](#apollo-auth-guard-and-login)
|
||||||
|
- [package.json scripts](#packagejson-scripts)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A PHX frontend plugin is an Angular application packaged as a **custom element** (web component). PHX loads it via a **manifest** that points to your compiled `main.js` and declares the element tag name.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ manifest.json ┌───────────────────┐
|
||||||
|
│ PHX host │ ─────────────────────> │ main.js (+ deps) │
|
||||||
|
│ (ERP shell) │ loads & registers │ custom element │
|
||||||
|
└────────┬────────┘ └────────┬──────────┘
|
||||||
|
│ │
|
||||||
|
│ pluginServices (Apollo, notifications…) │
|
||||||
|
│ hostInjector │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this demo includes:**
|
||||||
|
|
||||||
|
- Custom element tag: `frontend-plugin-demo`
|
||||||
|
- Sample routes: hello world, product view, address list
|
||||||
|
- GraphQL queries via PHX host Apollo (production) or a local Apollo client (development)
|
||||||
|
- Login flow for standalone development without a pre-configured API key
|
||||||
|
- PrimeNG + Tailwind styling aligned with PHX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) (LTS recommended)
|
||||||
|
- [Yarn v4](https://yarnpkg.com/getting-started/install)
|
||||||
|
- A running PHX instance (for plugin-host testing and GraphQL schema/codegen)
|
||||||
|
- An **npm access token** with read access to PHXGMBH packages — request one at your PHX partner
|
||||||
|
|
||||||
|
For local standalone development you also need either:
|
||||||
|
|
||||||
|
- A PHX API user token in your development environment, or
|
||||||
|
- Valid credentials for the built-in login screen (see [Apollo, auth guard, and login](#apollo-auth-guard-and-login))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. **Configure Yarn** to access the PHX npm registry (see [PHX libraries](#5-phx-libraries)). Do not commit `.yarnrc.yml`.
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create a local development environment** (optional, for standalone mode):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp src/environments/environment.example.ts src/environments/environment.development.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust `apiUrl`, `wsUrl`, and optionally `apiKey` in that file. The file is gitignored.
|
||||||
|
|
||||||
|
4. **Generate GraphQL types** (requires PHX admin API at the schema URL configured in `codegen.ts`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn codegen
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run in one of the development modes** below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live demo
|
||||||
|
|
||||||
|
To use this demo in your own instance, add a custom element in **Admin → Custom Elements** (`https://your.phx.instance/admin/customElements`) with this manifest URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitea.phx-erp.de/api/v1/repos/PHXGMBH/phx-frontend-plugin-webcomponent-demo/raw/master/latest/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest resolves to the compiled `main.js` in the `latest/` directory of this repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development modes
|
||||||
|
|
||||||
|
### Standalone client (usual Angular workflow)
|
||||||
|
|
||||||
|
Runs the app with the **development** build configuration on port **4201** — routing, login, and a local Apollo client work without PHX.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn client
|
||||||
|
# equivalent: ng serve --port 4201 --watch --configuration development
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:4201/**.
|
||||||
|
|
||||||
|
### Plugin host mode (inside PHX)
|
||||||
|
|
||||||
|
Builds with the **production** configuration (no output hashing) and serves the output with CORS and no-cache headers so PHX can load fresh bundles during development.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn run plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `yarn build` and `yarn serve` concurrently:
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|--------|----------------|
|
||||||
|
| `yarn build` | Watches production build (in this repo, also syncs output to `latest/` for the hosted demo) |
|
||||||
|
| `yarn serve` | Serves `dist/.../browser/` at **http://localhost:3223/** |
|
||||||
|
|
||||||
|
Point your PHX instance at the local manifest:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3223/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(`public/manifest.json` is copied into the served output.)
|
||||||
|
|
||||||
|
> **Note:** Use `yarn run plugin`, not `yarn plugin` — Yarn treats `plugin` as a built-in command.
|
||||||
|
|
||||||
|
[Terminal Keeper](https://open-vsx.org/extension/nguyenngoclong/terminal-keeper) is preconfigured in `.vscode/sessions.json` to start both `yarn run plugin` and `yarn client`.
|
||||||
|
|
||||||
|
### Other useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn start # ng serve with development configuration (default port 4200)
|
||||||
|
yarn codegen # Regenerate GraphQL types
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create your own plugin
|
||||||
|
|
||||||
|
The steps below walk through creating a project similar to this demo, using [Yarn v4](https://yarnpkg.com/getting-started/install), [Angular 20](https://angular.dev/), [PrimeNG 20](https://primeng.org/), and [Tailwind CSS](https://tailwindcss.com/).
|
||||||
|
|
||||||
|
Replace placeholders such as `*PROJECT-NAME*`, `*YOUR-TAG*`, and `*YOUR-TOKEN*` with your values.
|
||||||
|
|
||||||
|
### 1. Basic setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
Create `tailwind.config.js` in the project root. This configuration is aligned with PHX:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** @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')],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Due to a known issue, add these directives to **each component's** `styles` (and to `src/styles.scss` for local development):
|
||||||
|
|
||||||
|
```css
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. GraphQL Codegen
|
||||||
|
|
||||||
|
Create `codegen.ts` in the project root (adjust the schema URL if your PHX instance runs elsewhere):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"codegen": "graphql-codegen"
|
||||||
|
```
|
||||||
|
|
||||||
|
Define queries and mutations in `src/graphql/`, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn codegen
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates `src/app/schema-types.ts` and `src/app/generated.ts`.
|
||||||
|
|
||||||
|
### 4. Environments
|
||||||
|
|
||||||
|
Use separate environments so the same build runs as a PHX plugin (production) or as a standalone dev app (development).
|
||||||
|
|
||||||
|
**`src/environments/environment.interface.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export abstract class Environment {
|
||||||
|
production: boolean = false;
|
||||||
|
apiUrl: string | undefined;
|
||||||
|
wsUrl: string | undefined;
|
||||||
|
apiKey: string | undefined;
|
||||||
|
serverUrl: string = '';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/environments/environment.ts`** (production — used when embedded in PHX)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Environment } from './environment.interface';
|
||||||
|
|
||||||
|
export const environment: Environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: undefined,
|
||||||
|
wsUrl: undefined,
|
||||||
|
apiKey: undefined,
|
||||||
|
serverUrl: '',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/environments/environment.development.ts`** (local development — add to `.gitignore`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `fileReplacements` to the **development** build configuration in `angular.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"development": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** Ship `environment.example.ts` in the repo and copy it to `environment.development.ts` locally, as this demo does.
|
||||||
|
|
||||||
|
### 5. PHX libraries
|
||||||
|
|
||||||
|
`@phx/shared` and `@phx/shared-ui` are published on the PHXGMBH npm registry.
|
||||||
|
|
||||||
|
Create `.yarnrc.yml` in the project root (or edit `~/.yarnrc.yml` for a global setup):
|
||||||
|
|
||||||
|
```yml
|
||||||
|
nodeLinker: node-modules
|
||||||
|
npmScopes:
|
||||||
|
phx:
|
||||||
|
npmRegistryServer: "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/"
|
||||||
|
npmAuthToken: "*YOUR-TOKEN*"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** Add `.yarnrc.yml` to `.gitignore` if it contains your token.
|
||||||
|
|
||||||
|
Then install the libraries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add @phx/shared @phx/shared-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Plugin setup
|
||||||
|
|
||||||
|
#### Application config
|
||||||
|
|
||||||
|
Add `providePhoenixPluginWithPrimeNG` in `src/app/app.config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { providePhoenixPluginWithPrimeNG } from '@phx/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:** Pass your route path segments in `stripTrailingSegments`, or derive them automatically:
|
||||||
|
>
|
||||||
|
> ```ts
|
||||||
|
> stripTrailingSegments: routes.map((r) => r.path!).filter((p) => (p?.length ?? 0) > 0)
|
||||||
|
> ```
|
||||||
|
|
||||||
|
#### Host bridge service
|
||||||
|
|
||||||
|
Create `src/app/services/phoenix-host-bridge.service.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable, Injector, signal } from '@angular/core';
|
||||||
|
import type { IPluginServices } from '@phx/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The host bridge stores services injected by PHX (Apollo client, notification service, etc.) so your components can use them in production mode.
|
||||||
|
|
||||||
|
#### Root component
|
||||||
|
|
||||||
|
Your root component receives `pluginServices` and `hostInjector` from PHX and forwards them to the bridge:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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/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
|
||||||
|
|
||||||
|
In `src/main.ts`, register your plugin as a custom element (replace `*YOUR-TAG*`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { bootstrapPhoenixPluginCustomElement } from '@phx/shared-ui';
|
||||||
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
|
bootstrapPhoenixPluginCustomElement(App, '*YOUR-TAG*', appConfig).then((app) => {
|
||||||
|
if (!environment.production) {
|
||||||
|
return app!.bootstrap(App);
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`bootstrapPhoenixPluginCustomElement`** — creates the Angular application and registers the custom element for PHX.
|
||||||
|
- **`app.bootstrap(App)` in development** — additionally mounts the root component so `ng serve` / standalone mode works with routing.
|
||||||
|
|
||||||
|
The custom element tag must be **lowercase with hyphens** (e.g. `my-company-orders`). Use the same tag in your manifest.
|
||||||
|
|
||||||
|
### 7. Register in PHX
|
||||||
|
|
||||||
|
#### Manifest format
|
||||||
|
|
||||||
|
PHX loads a JSON manifest that points to your entry script and declares the custom element tag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "https://example.com/path/to/main.js",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"tagName": "*YOUR-TAG*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `path` | Absolute URL to `main.js` (and base for chunk resolution) |
|
||||||
|
| `items[].tagName` | Custom element tag registered in `main.ts` |
|
||||||
|
|
||||||
|
**Local development example** (`public/manifest.json` in this repo):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "http://localhost:3223/main.js",
|
||||||
|
"items": [{ "tagName": "frontend-plugin-demo" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hosted example** (`latest/manifest.json` in this repo):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Registration steps
|
||||||
|
|
||||||
|
1. Host `main.js` (and any sibling chunks) at a URL reachable from your users' browsers — same network rules as your PHX instance.
|
||||||
|
2. Publish a manifest JSON at a stable URL.
|
||||||
|
3. In PHX: **Admin → Custom Elements** → add the manifest URL and choose a mount path or tag.
|
||||||
|
4. Log out and back in. The plugin is available at `https://your.phx.instance/customElements/*PATH*`.
|
||||||
|
|
||||||
|
For local plugin-host development, see [Serving the plugin locally](#serving-the-plugin-locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional topics
|
||||||
|
|
||||||
|
### Serving the plugin locally
|
||||||
|
|
||||||
|
Install the static server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add -D serve
|
||||||
|
```
|
||||||
|
|
||||||
|
**`scripts/serve-dist.mjs`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
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`** (replace `*PROJECT-NAME*` with your Angular project name from `angular.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"public": "dist/*PROJECT-NAME*/browser",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "**/*.{js,mjs}",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Serving from the project root (not directly from `dist/`) ensures `serve.json` is applied and avoids stale cached `main.js` after rebuilds.
|
||||||
|
|
||||||
|
Add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"serve": "node ./scripts/serve-dist.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a custom port: `PORT=8080 yarn serve`.
|
||||||
|
|
||||||
|
> **This repository only:** `scripts/copy-latest.mjs` copies built JS files into `latest/` so the [live demo](#live-demo) manifest can point at a stable path in git. You do not need this for local plugin-host development.
|
||||||
|
|
||||||
|
### Apollo, auth guard, and login
|
||||||
|
|
||||||
|
In **production** (embedded in PHX), authentication is handled by the host. Use the Apollo client from `IPluginServices` via `PhoenixHostBridgeService` — you do not need a separate login flow.
|
||||||
|
|
||||||
|
In **development** (standalone), provide your own Apollo client and optional login.
|
||||||
|
|
||||||
|
#### Apollo provider
|
||||||
|
|
||||||
|
Create `src/app/apollo.provider.ts` (see this repository for the full implementation). It:
|
||||||
|
|
||||||
|
- Connects HTTP and WebSocket links to `environment.apiUrl` / `environment.wsUrl`
|
||||||
|
- Sends `Authorization: Bearer …` from `environment.apiKey` or `localStorage`
|
||||||
|
- Persists tokens from the `phoenix-auth-token` response header
|
||||||
|
|
||||||
|
Register it only in development, e.g. in `app.config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
...(environment.production ? [] : [...apolloProvider()])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Apollo service abstraction
|
||||||
|
|
||||||
|
Use a small service to pick the host Apollo client in production and the local client in development:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same pattern for other host services (e.g. notifications) when you need standalone fallbacks.
|
||||||
|
|
||||||
|
#### Login component
|
||||||
|
|
||||||
|
For development without a preset API key, add a `/login` route that calls the PHX `login` mutation:
|
||||||
|
|
||||||
|
```gql
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See `src/app/login/` in this repo for a complete form implementation.
|
||||||
|
|
||||||
|
#### Auth guard
|
||||||
|
|
||||||
|
Redirect unauthenticated users to login in development only:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it in `app.routes.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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
|
||||||
|
|
||||||
|
Recommended scripts after following this guide:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `build` | Production watch build for PHX |
|
||||||
|
| `serve` | Serves compiled assets for PHX (default port 3223) |
|
||||||
|
| `plugin` | Runs `build` + `serve` together — use `yarn run plugin` |
|
||||||
|
| `client` | Standalone Angular dev server on port 4201 |
|
||||||
|
| `codegen` | Regenerates GraphQL TypeScript types |
|
||||||
|
|
||||||
|
This repository additionally runs `copy-latest.mjs` alongside `build` to publish artifacts into `latest/` for the hosted demo — that is not required for your own plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Things to check |
|
||||||
|
|---------|-----------------|
|
||||||
|
| PHX shows an old version of the plugin | Hard-refresh; confirm `serve.json` sets `Cache-Control: no-store` for JS; restart `yarn run plugin` |
|
||||||
|
| `401` / GraphQL auth errors in standalone mode | Set `apiKey` in `environment.development.ts` or log in via `/login` |
|
||||||
|
| `yarn add @phx/shared` fails | Verify `.yarnrc.yml` token; contact [support@phx-erp.de](mailto:support@phx-erp.de) |
|
||||||
|
| Routing broken inside PHX | Add route segments to `stripTrailingSegments` in `providePhoenixPluginWithPrimeNG` |
|
||||||
|
| Tailwind classes missing in a component | Add `@tailwind` directives to that component's `styles` |
|
||||||
|
| `yarn codegen` fails | Ensure PHX is running and the schema URL in `codegen.ts` is reachable |
|
||||||
|
| Custom element not found | Tag in manifest must exactly match `customElements.define` / `bootstrapPhoenixPluginCustomElement` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For registry access, integration questions, or PHX-specific APIs, contact [support@phx-erp.de](mailto:support@phx-erp.de).
|
||||||
|
|||||||
140
latest/main.js
140
latest/main.js
File diff suppressed because one or more lines are too long
10
package.json
10
package.json
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve --configuration development",
|
||||||
"build": "concurrently \"ng build --watch --output-hashing none --configuration production\" \"node scripts/copy-latest.mjs --watch\"",
|
"build": "concurrently \"ng build --watch --output-hashing none --configuration production\" \"node scripts/copy-latest.mjs --watch\"",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"build:dev": "ng build --output-hashing none --watch --configuration development",
|
"build:dev": "ng build --output-hashing none --watch --configuration development",
|
||||||
@@ -36,15 +36,15 @@
|
|||||||
"@angular/platform-browser": "^20.3.0",
|
"@angular/platform-browser": "^20.3.0",
|
||||||
"@angular/router": "^20.3.0",
|
"@angular/router": "^20.3.0",
|
||||||
"@apollo/client": "^4.0.1",
|
"@apollo/client": "^4.0.1",
|
||||||
"@phx/shared": "^0.1.3",
|
"@phx/shared": "^0.1.4",
|
||||||
"@phx/shared-ui": "^0.1.3-b",
|
"@phx/shared-ui": "^0.1.4",
|
||||||
"@primeng/themes": "20",
|
"@primeng/themes": "20",
|
||||||
"apollo-angular": "14.0.0",
|
"apollo-angular": "14.0.0",
|
||||||
"graphql": "^16",
|
"graphql": "^16",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"primeng": "20",
|
"primeng": "20",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tailwindcss": "3",
|
"tailwindcss": "3.4.17",
|
||||||
"tailwindcss-primeui": "^0.6.1",
|
"tailwindcss-primeui": "^0.6.1",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
@@ -66,6 +66,6 @@
|
|||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"serve": "^14.2.6",
|
"serve": "^14.2.6",
|
||||||
"typescript": "5.9"
|
"typescript": "5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { createClient } from "graphql-ws";
|
|||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
const authTokenHeaderName = 'phoenix-auth-token';
|
|
||||||
|
|
||||||
export function apolloOptionsFactory(httpLink: HttpLink): ApolloClient.Options | undefined {
|
export function apolloOptionsFactory(httpLink: HttpLink): ApolloClient.Options | undefined {
|
||||||
if (!environment.apiUrl || !environment.wsUrl) {
|
if (!environment.apiUrl || !environment.wsUrl) {
|
||||||
console.error('API URL or WS URL is not set');
|
console.error('API URL or WS URL is not set');
|
||||||
@@ -58,7 +56,7 @@ export function apolloOptionsFactory(httpLink: HttpLink): ApolloClient.Options |
|
|||||||
const afterwareLink = new ApolloLink((operation, forward) => {
|
const afterwareLink = new ApolloLink((operation, forward) => {
|
||||||
return forward(operation).pipe(map((response: any) => {
|
return forward(operation).pipe(map((response: any) => {
|
||||||
const context = operation.getContext();
|
const context = operation.getContext();
|
||||||
const authHeader = context['response']?.headers.get(authTokenHeaderName);
|
const authHeader = context['response']?.headers.get('phoenix-auth-token');
|
||||||
|
|
||||||
if (authHeader?.length) {
|
if (authHeader?.length) {
|
||||||
environment.apiKey = authHeader;
|
environment.apiKey = authHeader;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
...providePhoenixPluginWithPrimeNG({ stripTrailingSegments: routes.map(route => route.path).filter(Boolean) as string[] }),
|
...providePhoenixPluginWithPrimeNG({ stripTrailingSegments: routes.map(route => route.path!).filter(path => (path?.length??0) > 0) }),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
...(environment.production ? [] : [
|
...(environment.production ? [] : [
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { HelloWorld } from './components/hello-world/hello-world';
|
import { HelloWorld } from './components/hello-world/hello-world';
|
||||||
import { ProductView } from './components/product-view/product-view';
|
import { ProductView } from './components/product-view/product-view';
|
||||||
import { Login } from './components/login/login';
|
import { Login } from './login/login';
|
||||||
import { AuthGuard } from './auth-guard';
|
import { AuthGuard } from './auth-guard';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { AddressList } from './components/address-list/address-list';
|
import { AddressList } from './components/address-list/address-list';
|
||||||
|
|
||||||
|
const canActivate = [environment.production ? () => true : AuthGuard];
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', canActivate: [environment.production ? () => true : AuthGuard], component: HelloWorld },
|
{ path: '', canActivate, component: HelloWorld },
|
||||||
{ path: 'product-view', canActivate: [environment.production ? () => true : AuthGuard], component: ProductView, },
|
{ path: 'product-view', canActivate, component: ProductView, },
|
||||||
{ path: 'address-list', canActivate: [environment.production ? () => true : AuthGuard], component: AddressList, },
|
{ path: 'address-list', canActivate, component: AddressList, },
|
||||||
{ path: 'login', component: Login, },
|
{ path: 'login', component: Login, },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -28,5 +28,4 @@ export class App {
|
|||||||
this.hostBridge.setPluginServices(this.pluginServices());
|
this.hostBridge.setPluginServices(this.pluginServices());
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { FormBuilder, FormsModule, Validators } from '@angular/forms';
|
|||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { InputText } from 'primeng/inputtext';
|
import { InputText } from 'primeng/inputtext';
|
||||||
import { Button } from 'primeng/button';
|
import { Button } from 'primeng/button';
|
||||||
import { ApolloService } from '../../services/apollo.service';
|
import { ApolloService } from '../services/apollo.service';
|
||||||
import { LOGIN } from '../../../graphql/base-definitions';
|
import { LOGIN } from '../../graphql/base-definitions';
|
||||||
import { LoginMutation } from '../../generated';
|
import { LoginMutation } from '../generated';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { AuthenticationResult } from '../../schema-types';
|
import { AuthenticationResult } from '../schema-types';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -33,13 +33,16 @@ export class Login {
|
|||||||
readonly apollo = inject(ApolloService);
|
readonly apollo = inject(ApolloService);
|
||||||
readonly fb = inject(FormBuilder);
|
readonly fb = inject(FormBuilder);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
|
|
||||||
readonly loginForm = this.fb.group({
|
readonly loginForm = this.fb.group({
|
||||||
username: ['', Validators.required],
|
username: ['', Validators.required],
|
||||||
password: ['', Validators.required],
|
password: ['', Validators.required],
|
||||||
});
|
});
|
||||||
|
|
||||||
redirectTo = '/';
|
redirectTo = '/';
|
||||||
loading = signal(false);
|
|
||||||
|
readonly loading = signal(false);
|
||||||
|
|
||||||
constructor(private readonly route: ActivatedRoute) {
|
constructor(private readonly route: ActivatedRoute) {
|
||||||
this.route.queryParams.subscribe(params => {
|
this.route.queryParams.subscribe(params => {
|
||||||
if(params['redirectTo']) {
|
if(params['redirectTo']) {
|
||||||
@@ -61,10 +64,10 @@ export class Login {
|
|||||||
password: this.loginForm.value.password,
|
password: this.loginForm.value.password,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const authResult = result.data?.login as AuthenticationResult;
|
const authResult = result.data?.login as AuthenticationResult;
|
||||||
if(authResult.__typename === 'CurrentUser') {
|
if(authResult.__typename === 'CurrentUser')
|
||||||
this.router.navigate([this.redirectTo]);
|
this.router.navigate([this.redirectTo]);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
10
src/main.ts
10
src/main.ts
@@ -5,16 +5,10 @@ import { createApplication } from '@angular/platform-browser';
|
|||||||
import { createCustomElement } from '@angular/elements';
|
import { createCustomElement } from '@angular/elements';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
createApplication(appConfig).then((app) => {
|
bootstrapPhoenixPluginCustomElement(App, 'frontend-plugin-demo', appConfig)
|
||||||
const el = createCustomElement(App, { injector: app.injector });
|
|
||||||
customElements.define('frontend-plugin-demo', el);
|
|
||||||
return app;
|
|
||||||
})
|
|
||||||
// bootstrapPhoenixPluginCustomElement(App, 'frontend-plugin-demo', appConfig)
|
|
||||||
.then((app) => {
|
.then((app) => {
|
||||||
//if development:
|
|
||||||
if(!environment.production)
|
if(!environment.production)
|
||||||
return app.bootstrap(App);
|
return app!.bootstrap(App);
|
||||||
else
|
else
|
||||||
return app;
|
return app;
|
||||||
});
|
});
|
||||||
55
yarn.lock
55
yarn.lock
@@ -2000,17 +2000,17 @@
|
|||||||
"@parcel/watcher-win32-ia32" "2.5.6"
|
"@parcel/watcher-win32-ia32" "2.5.6"
|
||||||
"@parcel/watcher-win32-x64" "2.5.6"
|
"@parcel/watcher-win32-x64" "2.5.6"
|
||||||
|
|
||||||
"@phx/shared-ui@^0.1.3-b":
|
"@phx/shared-ui@^0.1.4":
|
||||||
version "0.1.3-b"
|
version "0.1.4"
|
||||||
resolved "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/%40phx%2Fshared-ui/-/0.1.3-b/shared-ui-0.1.3-b.tgz#d3ac96c01d6067eb7c5403161ee4b971ee3ed5f8"
|
resolved "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/%40phx%2Fshared-ui/-/0.1.4/shared-ui-0.1.4.tgz#e9ed9ede01366aaca92086c2ac50d114a4e2c5b9"
|
||||||
integrity sha512-mUUSqSvLYRX9z6/Tp09ZqgYlILxhuSskPaWDz4TZkETDvqBEqbCih0aKum732XXl/Fy+nEluUP9grRI9m1zWdw==
|
integrity sha512-ZJrb68JosqjN1p3l0K+VEF2SQbOCxe7HK0U1Uj/6ZeMTHtCWuEAo/XxGVLJvjUpGE5pF5gw+jcvIBJsvafNxYg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@phx/shared" "^0.1.3"
|
"@phx/shared" "^0.1.4"
|
||||||
|
|
||||||
"@phx/shared@^0.1.3":
|
"@phx/shared@^0.1.4":
|
||||||
version "0.1.3"
|
version "0.1.4"
|
||||||
resolved "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/%40phx%2Fshared/-/0.1.3/shared-0.1.3.tgz#68427cf1ce068b1dcbd927c75f9da4636190162f"
|
resolved "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/%40phx%2Fshared/-/0.1.4/shared-0.1.4.tgz#082ebe561ce1614df6ab664252fa6e525ae96e72"
|
||||||
integrity sha512-EpjorixJS0vrT19utFitUcr2W9A2ItF9iCN2nIylTdJnkVwIMIlDg8a/Y1Qjll6iWQ6ODf02fG4yXKMsCQ3goQ==
|
integrity sha512-4UKLpDJxBRRgX5sNBtdTAeYYb0mftPcBNyuxbpdI/YIqQcTPmTJRE+QkhmEq/GDRGHYnFjn+DBDr95UwzKZSjQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@apollo/client" "^3.9.6"
|
"@apollo/client" "^3.9.6"
|
||||||
graphql "16.8.2"
|
graphql "16.8.2"
|
||||||
@@ -4296,7 +4296,7 @@ jasmine-core@~5.9.0:
|
|||||||
resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-5.9.0.tgz#140199862ee83f906bf0ff88287e7c440a1776e7"
|
resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-5.9.0.tgz#140199862ee83f906bf0ff88287e7c440a1776e7"
|
||||||
integrity sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==
|
integrity sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==
|
||||||
|
|
||||||
jiti@^1.21.7:
|
jiti@^1.21.6:
|
||||||
version "1.21.7"
|
version "1.21.7"
|
||||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
|
||||||
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
||||||
@@ -4439,7 +4439,7 @@ karma@~6.4.0:
|
|||||||
ua-parser-js "^0.7.30"
|
ua-parser-js "^0.7.30"
|
||||||
yargs "^16.1.1"
|
yargs "^16.1.1"
|
||||||
|
|
||||||
lilconfig@^3.1.1, lilconfig@^3.1.3:
|
lilconfig@^3.0.0, lilconfig@^3.1.3:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
|
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
|
||||||
integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
|
integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
|
||||||
@@ -5286,12 +5286,13 @@ postcss-js@^4.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
camelcase-css "^2.0.1"
|
camelcase-css "^2.0.1"
|
||||||
|
|
||||||
"postcss-load-config@^4.0.2 || ^5.0 || ^6.0":
|
postcss-load-config@^4.0.2:
|
||||||
version "6.0.1"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096"
|
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3"
|
||||||
integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==
|
integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
lilconfig "^3.1.1"
|
lilconfig "^3.0.0"
|
||||||
|
yaml "^2.3.4"
|
||||||
|
|
||||||
postcss-media-query-parser@^0.2.3:
|
postcss-media-query-parser@^0.2.3:
|
||||||
version "0.2.3"
|
version "0.2.3"
|
||||||
@@ -6126,10 +6127,10 @@ tailwindcss-primeui@^0.6.1:
|
|||||||
resolved "https://registry.yarnpkg.com/tailwindcss-primeui/-/tailwindcss-primeui-0.6.1.tgz#fcb74738e80d4253c14521114773f5ce638c6ee8"
|
resolved "https://registry.yarnpkg.com/tailwindcss-primeui/-/tailwindcss-primeui-0.6.1.tgz#fcb74738e80d4253c14521114773f5ce638c6ee8"
|
||||||
integrity sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==
|
integrity sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==
|
||||||
|
|
||||||
tailwindcss@3:
|
tailwindcss@3.4.17:
|
||||||
version "3.4.19"
|
version "3.4.17"
|
||||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2"
|
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"
|
||||||
integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==
|
integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@alloc/quick-lru" "^5.2.0"
|
"@alloc/quick-lru" "^5.2.0"
|
||||||
arg "^5.0.2"
|
arg "^5.0.2"
|
||||||
@@ -6139,7 +6140,7 @@ tailwindcss@3:
|
|||||||
fast-glob "^3.3.2"
|
fast-glob "^3.3.2"
|
||||||
glob-parent "^6.0.2"
|
glob-parent "^6.0.2"
|
||||||
is-glob "^4.0.3"
|
is-glob "^4.0.3"
|
||||||
jiti "^1.21.7"
|
jiti "^1.21.6"
|
||||||
lilconfig "^3.1.3"
|
lilconfig "^3.1.3"
|
||||||
micromatch "^4.0.8"
|
micromatch "^4.0.8"
|
||||||
normalize-path "^3.0.0"
|
normalize-path "^3.0.0"
|
||||||
@@ -6148,7 +6149,7 @@ tailwindcss@3:
|
|||||||
postcss "^8.4.47"
|
postcss "^8.4.47"
|
||||||
postcss-import "^15.1.0"
|
postcss-import "^15.1.0"
|
||||||
postcss-js "^4.0.1"
|
postcss-js "^4.0.1"
|
||||||
postcss-load-config "^4.0.2 || ^5.0 || ^6.0"
|
postcss-load-config "^4.0.2"
|
||||||
postcss-nested "^6.2.0"
|
postcss-nested "^6.2.0"
|
||||||
postcss-selector-parser "^6.1.2"
|
postcss-selector-parser "^6.1.2"
|
||||||
resolve "^1.22.8"
|
resolve "^1.22.8"
|
||||||
@@ -6282,10 +6283,10 @@ type-is@~1.6.18:
|
|||||||
media-typer "0.3.0"
|
media-typer "0.3.0"
|
||||||
mime-types "~2.1.24"
|
mime-types "~2.1.24"
|
||||||
|
|
||||||
typescript@5.9:
|
typescript@5.9.2:
|
||||||
version "5.9.3"
|
version "5.9.2"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
|
||||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
|
||||||
|
|
||||||
ua-parser-js@^0.7.30:
|
ua-parser-js@^0.7.30:
|
||||||
version "0.7.41"
|
version "0.7.41"
|
||||||
@@ -6520,7 +6521,7 @@ yallist@^5.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
|
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
|
||||||
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
|
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
|
||||||
|
|
||||||
yaml@^2.3.1:
|
yaml@^2.3.1, yaml@^2.3.4:
|
||||||
version "2.9.0"
|
version "2.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4"
|
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4"
|
||||||
integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==
|
integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==
|
||||||
|
|||||||
Reference in New Issue
Block a user