Files
phx-frontend-plugin-webcomp…/README.md

778 lines
24 KiB
Markdown

# PHX Frontend Plugin Demo
> [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).