Skip to content

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)

MethodSignatureDescription
renderrender(string $component, array $props = []): voidRender Inertia component. XHR (X-Inertia: true): returns JSON. Initial GET: renders <div id="middag-app" data-page="...">.
shareshare(string $key, mixed $value): voidRegister globally shared prop. Closures resolved lazily on each render().
locationlocation(string $url): neverRedirect. XHR Inertia: header X-Inertia-Location + HTTP 409. Normal GET: wp_redirect().
isInertiaRequestisInertiaRequest(): boolChecks for header HTTP_X_INERTIA === 'true'.

Page Object JSON

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)

PropTypeContentResolution
authSharedPropsAuthid, name, email, isAdmin of logged-in userClosure (lazy)
navigationNavigationItem[]Menu items visible to user (filtered by scope)Closure (lazy)
versionstringPlugin version constantDirect value
flashSharedPropsFlash | nullFlash 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.

typescript
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

BlockPurposeUsed in
DenseTableBlockTable with columns, sort, search, badges, pagination, row actionsIndex pages
FormPanelBlockCRUD form with submit via router.post()Show/Create/Edit
EmptyStateBlockEmpty state with title, description, and actionNot found, empty

Registries are extensible via registerShell(), registerLayout(), registerBlock().


3. Page Controllers

Located in src/UI/Controllers/. Each controller corresponds to a domain.

PageControllerMenu SlugScope RequiredReact Component
DashboardDashboardControllermiddag-dashboardDashboard (direct)
OrganizationsOrganizationPageControllermiddag-organizationsorganizationContract:Organizations/*
CollaboratorsCollaboratorPageControllermiddag-collaboratorsorganizationContract:Collaborators/*
InvoicesInvoicePageControllermiddag-invoicesfinancesContract:Invoices/*
Tax InvoicesTaxInvoicePageControllermiddag-taxinvoicesfinancesContract:TaxInvoices/*
ContractsContractPageControllermiddag-contractscontractsContract:Contracts/*
DocumentsDocumentPageControllermiddag-documentsdocumentsContract:Documents/*
EntitlementsEntitlementPageControllermiddag-entitlementsorganizationContract:Entitlements/*
EnvironmentsEnvironmentPageControllermiddag-environmentscontractsContract:Environments/*
LicensesLicensePageControllermiddag-licenseslicensesContract:Licenses/*
OrdersOrderPageControllermiddag-ordersordersContract:Orders/*
ServicesServicePageControllermiddag-servicescontractsContract:Services/*
Service RequestsServiceRequestPageControllermiddag-service-requestscontractsContract:ServiceRequests/*
QuotesQuotePageControllermiddag-quotesquotesContract:Quotes/*

Controller Method Convention

MethodHTTPRoute PatternDescription
index()GET/{domain}Paginated listing (DenseTable)
show($id)GET/{domain}/{id}Read-only detail (FormPanel)
create()GET/{domain}/newEmpty creation form
edit($id)GET/{domain}/{id}/editPre-filled edit form
store()POST/{domain}Persist new record
update($id)POST/{domain}/{id}Update existing record
destroy($id)POST/{domain}/{id}/deleteRemove 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

typescript
{
  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

FileDescription
assets/dist/app.jsSingle IIFE bundle (React + Inertia + components)
assets/dist/style.cssSingle CSS (Tailwind v4 + design tokens + reset)
assets/dist/.vite/manifest.jsonAsset 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:

  1. Unlayered reset in tailwind.css: #middag-app a { color: inherit; text-decoration: inherit; } — neutralizes wp-admin by specificity
  2. all: initial on #middag-app to fully isolate the React container
  3. ! suffix on color/bg utilities applied to <a> and <button>: bg-primary!, text-primary-foreground! — layered !important beats 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

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 ModuleAPI PrefixKey OperationsScope Required
DashboardMultiple (aggregation)Read widgets dataPer-widget
Quotes/quotes/*List, detail, accept, rejectquotes
Orders/orders/*List, detail, cancel, refundorders
Invoices/invoices/* + /tax-invoices/*List, detail, PDF downloadfinances
Licenses/licenses/*List, detail, activate, deactivatelicenses
Downloads/downloads/*List, signed URL downloaddownloads
Contracts/contracts/*List, detail, PDF downloadcontracts
Documents/documents/*List, detail, file downloaddocuments
Entitlements/entitlements/*List, detail, linked entitiesentitlements
Services/services/*List, detail, linked SRsentitlements
Service Requests/service-requests/*List, detail, createentitlements
Support(Jira API integration)Ticket list, createtickets
Teams/organizations/* + /collaborators/*CRUD, invitesorganization
Account/auth/user, /auth/update-profileProfile management
Checkout/orders/*, /quotes/*Payment floworders+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).