2026-06-09 17:36:46 +02:00
2026-06-08 15:45:41 +02:00
2026-06-09 17:36:46 +02:00
2026-06-09 17:36:46 +02:00
2026-06-09 17:36:46 +02:00
2026-06-09 17:36:46 +02:00
2026-06-08 15:45:41 +02:00
2026-06-08 15:45:41 +02:00
2026-06-08 15:45:41 +02:00

PHX Frontend Plugin Demo

Deutsche Version

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.

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

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 (LTS recommended)
  • Yarn v4
  • A running PHX instance (for plugin-host testing and GraphQL schema/codegen)

For local standalone development you also need either:


Quick start

  1. Install dependencies:

    yarn install
    
  2. Create a local development environment (optional, for standalone mode):

    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):

    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://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, PHX login redirect, and a local Apollo client work without embedding the plugin in PHX.

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.

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 is preconfigured in .vscode/sessions.json to start both yarn run plugin and yarn client.

Other useful commands

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.ts           # Auth callback route (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, Angular 20, PrimeNG 20, and Tailwind CSS.

Replace placeholders such as *PROJECT-NAME*, *YOUR-TAG*, and *YOUR-TOKEN* with your values.

1. Basic setup

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:

/** @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):

@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):

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:

"codegen": "graphql-codegen"

Define queries and mutations in src/graphql/, then run:

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

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)

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)

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:

"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:

yarn add @phx-erp/shared @phx-erp/shared-ui

6. Plugin setup

Application config

Add providePhoenixPluginWithPrimeNG in src/app/app.config.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:

stripTrailingSegments: routes.map((r) => r.path!).filter((p) => (p?.length ?? 0) > 0)

Host bridge service

Create src/app/services/phoenix-host-bridge.service.ts:

import { Injectable, Injector, signal } from '@angular/core';
import type { IPluginServices } from '@phx-erp/shared-ui';

@Injectable({ providedIn: 'root' })
export class PhoenixHostBridgeService {
  private readonly _hostInjector = signal<Injector | null>(null);
  private readonly _pluginServices = signal<IPluginServices | null>(null);

  hostInjector(): Injector | null {
    return this._hostInjector();
  }

  setHostInjector(injector: Injector): void {
    this._hostInjector.set(injector);
  }

  pluginServices(): IPluginServices | null {
    return this._pluginServices();
  }

  setPluginServices(services: IPluginServices): void {
    this._pluginServices.set(services);
  }
}

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:

import { Component, effect, inject, Injector, input } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { PhoenixHostBridgeService } from './services/phoenix-host-bridge.service';
import { IPluginServices, syncPhoenixHostInjector } from '@phx-erp/shared-ui';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet />`,
  styles: [`
@tailwind base;
@tailwind components;
@tailwind utilities;
`],
})
export class App {
  private readonly hostBridge = inject(PhoenixHostBridgeService);

  readonly pluginServices = input<IPluginServices>({});
  readonly hostInjector = input<Injector | undefined>(undefined);

  private readonly _syncHostInjector = syncPhoenixHostInjector(this.hostBridge, this.hostInjector);

  private readonly _syncPluginServices = effect(() => {
    this.hostBridge.setPluginServices(this.pluginServices());
  });
}

Bootstrap / custom element registration

In src/main.ts, register your plugin as a custom element (replace *YOUR-TAG*):

import { appConfig } from './app/app.config';
import { App } from './app/app';
import { bootstrapPhoenixPluginCustomElement } from '@phx-erp/shared-ui';
import { environment } from './environments/environment';

bootstrapPhoenixPluginCustomElement(App, '*YOUR-TAG*', appConfig).then((app) => {
  if (!environment.production) {
    return app!.bootstrap(App);
  }
  return app;
});
  • bootstrapPhoenixPluginCustomElement — 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:

{
  "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):

{
  "path": "http://localhost:3223/main.js",
  "items": [{ "tagName": "frontend-plugin-demo" }]
}

Hosted example (latest/manifest.json in this repo):

{
  "path": "https://gitea.phx-erp.de/api/v1/repos/PHXGMBH/phx-frontend-plugin-webcomponent-demo/raw/master/latest/main.js",
  "items": [{ "tagName": "frontend-plugin-demo" }]
}

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.


Additional topics

Serving the plugin locally

Install the static server:

yarn add -D serve

scripts/serve-dist.mjs

import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const port = process.env.PORT ?? '3223';

const serveBin = join(root, 'node_modules', '.bin', 'serve');
const child = spawn(serveBin, ['-l', port, '--cors', '--no-etag'], {
  stdio: 'inherit',
  shell: true,
  cwd: root,
});

child.on('exit', (code) => process.exit(code ?? 0));

serve.json (replace *PROJECT-NAME* with your Angular project name from angular.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:

"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 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 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:

...(environment.production ? [] : [...apolloProvider()])

Apollo service abstraction

Use a small service to pick the host Apollo client in production and the local client in development:

@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 callback route

For development without a preset API key, add a /login route that acts as an auth callback — not a login form. 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:

@Component({
  selector: 'app-login',
  template: `
    <div class="flex flex-col items-center justify-center h-screen w-screen opacity-20">
      <div class="animate-fadein duration-1000 animate-infinite animate-alternate text-4xl">Signing in...</div>
    </div>
  `,
})
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:

@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:

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:

{
  "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 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.

Description
No description provided
Readme 2.7 MiB
Languages
TypeScript 99%
HTML 0.8%
JavaScript 0.2%