# PHX Frontend Plugin Demo Example project showing how to build a **PHX ERP frontend plugin** as an Angular web component, using `@phx-erp/shared` and `@phx-erp/shared-ui`. **Starting a new plugin?** Use [@phx-erp/create-phx-frontend-plugin](https://www.npmjs.com/package/@phx-erp/create-phx-frontend-plugin) to scaffold a project from the official template. This repository is a full demo you can explore, clone, or use as a reference. 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) - [Scaffold CLI (recommended)](#scaffold-cli-recommended) - [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) - PHX login redirect and callback route 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) For local standalone development you also need either: - A PHX API user token in your development environment, or - Access to the PHX login page (see [Apollo, auth guard, and login](#apollo-auth-guard-and-login)) --- ## Quick start These steps apply to **this demo repository**. To create your own plugin from scratch, use the scaffold CLI — see [Create your own plugin](#create-your-own-plugin). 1. **Install dependencies:** ```bash yarn install ``` 2. **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. 3. **Generate GraphQL types** (requires PHX admin API at the schema URL configured in `codegen.ts`): ```bash yarn codegen ``` 4. **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://unpkg.com/@phx-erp/phx-frontend-plugin-demo/manifest.json ``` The manifest's `path` field points at the package root; unpkg serves `main.js` via the `"."` export. For local development with `yarn run plugin`, register `http://localhost:3223/local.manifest.json` in Admin → Custom Elements instead. --- ## Development modes ### Standalone client (usual Angular workflow) Runs the app with the **development** build configuration on port **4201** — routing, PHX login redirect, and a local Apollo client work without embedding the plugin in PHX. ```bash yarn client # equivalent: ng serve --port 4201 --watch --configuration development ``` Open **[http://localhost:4201/](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 | | `yarn serve` | Serves `dist/.../browser/` at **[http://localhost:3223/](http://localhost:3223/)** | Point your PHX instance at the local manifest: ``` http://localhost:3223/local.manifest.json ``` (`public/local.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/ ├── public/ │ └── local.manifest.json # Local dev manifest (copied into dist) ├── manifest.json # Published manifest (npm/unpkg) ├── scripts/ │ └── serve-dist.mjs # Static server for plugin-host dev ├── src/ │ ├── app/ │ │ ├── components/ # Demo pages (hello-world, product-view, …) │ │ ├── login.ts # Auth callback route — redirect flow (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 ### Scaffold CLI (recommended) The fastest way to start is the official scaffold CLI, published as [@phx-erp/create-phx-frontend-plugin](https://www.npmjs.com/package/@phx-erp/create-phx-frontend-plugin): ```bash # Yarn Berry — one-shot, no global install yarn dlx @phx-erp/create-phx-frontend-plugin my-orders-plugin # npm npx @phx-erp/create-phx-frontend-plugin@latest my-orders-plugin ``` This copies the official plugin template, replaces placeholders (project name, custom element tag, API URLs), creates `environment.development.ts`, and can run `yarn install`. See the [package page](https://www.npmjs.com/package/@phx-erp/create-phx-frontend-plugin) for non-interactive flags (`--yes`, `--install`, `--api-url`, `--ui-url`, `--api-key`, etc.). ### Manual setup (step by step) If you prefer to understand each piece or customize from scratch, 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 redirects to PHX login serverUrl: 'https://localhost:4200', // PHX instance used for standalone login redirect }; ``` 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-erp/shared` and `@phx-erp/shared-ui` are published on the public npm registry. Install the libraries: ```bash yarn add @phx-erp/shared @phx-erp/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-erp/shared-ui'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), // stripTrailingSegments: child route segments appended by PHX deep links // so APP_BASE_HREF still matches the host mount path. ...providePhoenixPluginWithPrimeNG({ stripTrailingSegments: ['*YOUR*', '*ROUTES*', '*HERE*'], }), provideRouter(routes), ], }; ``` > **Routing:** 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-erp/shared-ui'; @Injectable({ providedIn: 'root' }) export class PhoenixHostBridgeService { private readonly _hostInjector = signal(null); private readonly _pluginServices = signal(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-erp/shared-ui'; @Component({ selector: 'app-root', imports: [RouterOutlet], template: ``, styles: [` @tailwind base; @tailwind components; @tailwind utilities; `], }) export class App { private readonly hostBridge = inject(PhoenixHostBridgeService); readonly pluginServices = input({}); readonly hostInjector = input(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-erp/shared-ui'; import { environment } from './environments/environment'; bootstrapPhoenixPluginCustomElement(App, '*YOUR-TAG*', appConfig).then((app) => { if (!environment.production) { return app!.bootstrap(App); } return app; }); ``` - `bootstrapPhoenixPluginCustomElement` — 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/local.manifest.json` in this repo): ```json { "path": "http://localhost:3223/main.js", "items": [{ "tagName": "frontend-plugin-demo" }] } ``` **Published example** (root `manifest.json`, served from npm via unpkg): ```json { "path": "https://unpkg.com/@phx-erp/phx-frontend-plugin-demo", "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`. ### 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 redirect unauthenticated users to the PHX login page. PHX redirects back to a local `/login` callback route that stores the token and returns the user to the original page. #### 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(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 callback route For development without a preset API key, you need a way to obtain and store an API token. This demo uses a **redirect-based auth callback** on `/login` rather than an embedded login form, but either approach is valid — you can also build a login form in your plugin, call the GraphQL `login` mutation, and handle the token yourself. With the redirect flow, when the user is unauthenticated, the auth guard sends them to the PHX login page (`environment.serverUrl`). After successful login, PHX redirects back to your plugin's `/login` route with an `authToken` query parameter and a base64-encoded `redirectTo` target. The callback component stores the token in `localStorage` and navigates back to the original page: ```ts @Component({ selector: 'app-login', template: `
Signing in...
`, }) export class Login { constructor(private readonly route: ActivatedRoute) { this.route.queryParams.subscribe((params) => { const token = params['authToken']; if (token) { const redirectTo = decodeURIComponent(atob(params['redirectTo'])); localStorage.setItem('api-key', token); window.history.replaceState({}, '', redirectTo); window.location.href = redirectTo; } }); } } ``` See `src/app/login.ts` in this repo for the full implementation. #### Auth guard Redirect unauthenticated users to the PHX login page in development only. The `redirectTo` query parameter chains back through your plugin's `/login` callback: ```ts @Injectable() export class AuthGuard implements CanActivate { canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean { const token = environment.apiKey ?? localStorage.getItem('api-key'); if (!environment.production && !token) { const redirectTo = encodeURIComponent( btoa(`${window.location.protocol}//${window.location.host}/login?redirectTo=${encodeURIComponent(btoa(window.location.href))}`) ); window.location.href = `${environment.serverUrl}/login?redirectTo=${redirectTo}`; return false; } 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 | --- ## 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 ensure `serverUrl` points to your PHX instance so the login redirect works | | `yarn add @phx-erp/shared` fails | Check network access to the public npm registry; retry `yarn install` | | 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 integration questions or PHX-specific APIs, contact your PHX partner.