REF-801-01: Admin UI — Inertia Spec
ADR: ADR-801 — Admin UI & PortalEscopo: InertiaAdapter API, hybrid page system, page controllers, Vite build, CSS isolation, WordPress integration, portal NextJS modules
1. InertiaAdapter — WordPress-to-Inertia Bridge
Custom implementation in src/WordPress/Admin/InertiaAdapter.php. Not an open-source package — built internally inspired by the official Laravel and Rails adapters.
API (all methods static, final class)
| Method | Signature | Description |
|---|---|---|
render | render(string $component, array $props = []): void | Render Inertia component. XHR (X-Inertia: true): returns JSON. Initial GET: renders <div id="middag-app" data-page="...">. |
share | share(string $key, mixed $value): void | Register globally shared prop. Closures resolved lazily on each render(). |
location | location(string $url): never | Redirect. XHR Inertia: header X-Inertia-Location + HTTP 409. Normal GET: wp_redirect(). |
isInertiaRequest | isInertiaRequest(): bool | Checks for header HTTP_X_INERTIA === 'true'. |
Page Object JSON
{
"component": "Contract:Organizations/Index",
"props": {
"contract": {
"..."
},
"auth": {
"..."
},
"flash": null
},
"url": "/wp-admin/admin.php?page=middag-organizations",
"version": "5.0.0"
}Supports partial reloads via X-Inertia-Partial-Component and X-Inertia-Partial-Data headers.
Shared Props (registered during boot)
| Prop | Type | Content | Resolution |
|---|---|---|---|
auth | SharedPropsAuth | id, name, email, isAdmin of logged-in user | Closure (lazy) |
navigation | NavigationItem[] | Menu items visible to user (filtered by scope) | Closure (lazy) |
version | string | Plugin version constant | Direct value |
flash | SharedPropsFlash | null | Flash messages (success, error, info, warning) | Closure (consume-once) |
2. Hybrid Page System
The React app has two rendering modes, selected by page-resolver.tsx based on the component name prefix.
Contract Pages (majority of pages)
Component name starts with Contract:. PHP sends a PageContract JSON; React resolves shell, layout, and blocks via registries.
interface PageContract {
version: '1';
shell: 'admin';
page: PageMeta;
layout: LayoutDescriptor;
}
interface LayoutDescriptor {
template: 'stack' | 'split' | 'dashboard';
regions: Record<string, BlockDescriptor[]>;
}Direct Pages (Dashboard, special pages)
Component name without Contract: prefix. Resolved from ui/src/pages/ and receives props directly.
Block Registry
| Block | Purpose | Used in |
|---|---|---|
DenseTableBlock | Table with columns, sort, search, badges, pagination, row actions | Index pages |
FormPanelBlock | CRUD form with submit via router.post() | Show/Create/Edit |
EmptyStateBlock | Empty state with title, description, and action | Not found, empty |
Registries are extensible via registerShell(), registerLayout(), registerBlock().
3. Page Controllers
Located in src/UI/Controllers/. Each controller corresponds to a domain.
| Page | Controller | Menu Slug | Scope Required | React Component |
|---|---|---|---|---|
| Dashboard | DashboardController | middag-dashboard | — | Dashboard (direct) |
| Organizations | OrganizationPageController | middag-organizations | organization | Contract:Organizations/* |
| Collaborators | CollaboratorPageController | middag-collaborators | organization | Contract:Collaborators/* |
| Invoices | InvoicePageController | middag-invoices | finances | Contract:Invoices/* |
| Tax Invoices | TaxInvoicePageController | middag-taxinvoices | finances | Contract:TaxInvoices/* |
| Contracts | ContractPageController | middag-contracts | contracts | Contract:Contracts/* |
| Documents | DocumentPageController | middag-documents | documents | Contract:Documents/* |
| Entitlements | EntitlementPageController | middag-entitlements | organization | Contract:Entitlements/* |
| Environments | EnvironmentPageController | middag-environments | contracts | Contract:Environments/* |
| Licenses | LicensePageController | middag-licenses | licenses | Contract:Licenses/* |
| Orders | OrderPageController | middag-orders | orders | Contract:Orders/* |
| Services | ServicePageController | middag-services | contracts | Contract:Services/* |
| Service Requests | ServiceRequestPageController | middag-service-requests | contracts | Contract:ServiceRequests/* |
| Quotes | QuotePageController | middag-quotes | quotes | Contract:Quotes/* |
Controller Method Convention
| Method | HTTP | Route Pattern | Description |
|---|---|---|---|
index() | GET | /{domain} | Paginated listing (DenseTable) |
show($id) | GET | /{domain}/{id} | Read-only detail (FormPanel) |
create() | GET | /{domain}/new | Empty creation form |
edit($id) | GET | /{domain}/{id}/edit | Pre-filled edit form |
store() | POST | /{domain} | Persist new record |
update($id) | POST | /{domain}/{id} | Update existing record |
destroy($id) | POST | /{domain}/{id}/delete | Remove record |
ScopeGuardTrait
All controllers (except DashboardController) use ScopeGuardTrait for access control. WP admins without a collaborator record (superadmins) have unrestricted access. Owner bypass is delegated to PermissionsMiddleware.
Dependency Injection
Controllers are resolved via Symfony DI Container. Each controller receives its domain service in the constructor and never calls WordPress functions directly.
4. Vite Build Pipeline
Configuration
{
plugins: [react(), tailwindcss()],
build
:
{
outDir: '../assets/dist',
emptyOutDir
:
true,
cssCodeSplit
:
false,
manifest
:
true,
rollupOptions
:
{
input: {
app: './src/main.tsx'
}
,
output: {
format: 'iife',
entryFileNames
:
'app.js',
assetFileNames
:
'[name].[ext]',
inlineDynamicImports
:
true,
}
,
}
,
}
,
}Why IIFE
WordPress loads app.js via wp_enqueue_script (standard <script> tag, not type="module"). ESM format would cause relative import errors resolved against the page URL (/wp-admin/admin.php), not the script URL. IIFE solves this with a single self-executing bundle.
Build Artifacts
| File | Description |
|---|---|
assets/dist/app.js | Single IIFE bundle (React + Inertia + components) |
assets/dist/style.css | Single CSS (Tailwind v4 + design tokens + reset) |
assets/dist/.vite/manifest.json | Asset map for cache busting |
5. CSS Isolation Strategy
wp-admin loads unlayered CSS (a { color: #2271b1 }) that overrides Tailwind v4 utilities (@layer utilities). Three-layer solution:
- Unlayered reset in
tailwind.css:#middag-app a { color: inherit; text-decoration: inherit; }— neutralizes wp-admin by specificity all: initialon#middag-appto fully isolate the React container!suffix on color/bg utilities applied to<a>and<button>:bg-primary!,text-primary-foreground!— layered!importantbeats unlayered normal
Rule: Every color/background utility on <a> or <button> inside React MUST use the ! suffix.
Portal Container
Modals, popovers, and dropdowns (Radix UI) render inside #middag-portals — a <div> created on <body> during bootstrap. Also isolated from wp-admin styles.
6. WordPress Integration
Menu Registration
AdminRegistrar registers 1 main menu + 14 sub-menus. All sub-menus point to the same renderApp() callback, which resolves the route via Router and dispatches to the correct controller.
Internal Routing
1. WordPress loads admin.php?page=middag-organizations
2. AdminRegistrar::renderApp() identifies the menu slug
3. Maps slug to base route (/organizations)
4. If &route= parameter present, uses explicit route
5. Router::resolve(method, path) does regex matching
6. Controller resolved via DI Container
7. Controller calls InertiaAdapter::render()Inertia Interception (admin_init)
AdminRegistrar::handleInertiaRequest() runs on admin_init hook. When X-Inertia header is present for a middag page, it calls renderApp() and exits before WordPress emits admin HTML.
Asset Loading
AdminRegistrar::enqueueAssets() runs on admin_enqueue_scripts. Verifies the hook suffix matches a MIDDAG page, checks assets/dist/app.js exists, and enqueues app.js (strategy: defer, in_footer) and style.css. No jQuery dependency.
7. Portal NextJS — Modules and API Mapping
The NextJS portal (app.middag.io) has 15 client-facing modules. Each consumes specific REST API v1 endpoints from the plugin:
| Portal Module | API Prefix | Key Operations | Scope Required |
|---|---|---|---|
| Dashboard | Multiple (aggregation) | Read widgets data | Per-widget |
| Quotes | /quotes/* | List, detail, accept, reject | quotes |
| Orders | /orders/* | List, detail, cancel, refund | orders |
| Invoices | /invoices/* + /tax-invoices/* | List, detail, PDF download | finances |
| Licenses | /licenses/* | List, detail, activate, deactivate | licenses |
| Downloads | /downloads/* | List, signed URL download | downloads |
| Contracts | /contracts/* | List, detail, PDF download | contracts |
| Documents | /documents/* | List, detail, file download | documents |
| Entitlements | /entitlements/* | List, detail, linked entities | entitlements |
| Services | /services/* | List, detail, linked SRs | entitlements |
| Service Requests | /service-requests/* | List, detail, create | entitlements |
| Support | (Jira API integration) | Ticket list, create | tickets |
| Teams | /organizations/* + /collaborators/* | CRUD, invites | organization |
| Account | /auth/user, /auth/update-profile | Profile management | — |
| Checkout | /orders/*, /quotes/* | Payment flow | orders+quotes |
Scope note: Services, Service Requests, and Environments use entitlements scope in the REST API (section 7 above and REF-701-01 §9.13-9.15), but contracts scope in admin page controllers (section 3). This is intentional — the admin UI groups these under the contracts menu section for operational convenience, while the API enforces the canonical entitlements scope.
All modules support dual-entity filtering via X-Middag-Company header (middag_br / middag_global).