Skip to content

Shell & routing

The shell is what wraps the render surfaces: a router that turns a URL into the right component, a layout with a module rail + sidebar + header, the navbar fed from the engine, the auth screens, and the API client every part shares. createGenieApp wires these together — see Frontend configuration.

GenieRouter reads the current route (via useGenieRoute) and renders the matching component. An object (a table/view definition) lives in one /object/{name} space, with the record mode encoded in the path; wizards live at /form/{name}:

Route Mode Renders Hook
/object/{name}?params grid (listing) GenieTable useGenieTable
/object/{name}?id={pk} view a record GenieView useGenieForm (read-only)
/object/{name}/edit?id={pk} edit GenieForm useGenieForm
/object/{name}/create create GenieForm useGenieForm
/form/{key}?id=&flow= wizard GenieWizardRunner —

The record’s primary key travels as the reserved lowercase id query param (a bare /object/{name} with id is a single-record view; without it, the grid). Internally the client canonicalizes id → Id for the engine’s form/view contract — so a model field literally named lowercase id collides with the pk (the pk wins); avoid it.

Beyond these, the router handles the report page (/reports/{slug}, plural — see Report pages), the visual designers (/workflow-designer, /wizard-designer, /navbar-designer — System-role only — and /report-designer, which takes the definition to edit as ?Id={slug} and opens a blank new document without one) and the admin tools (/object-explorer, /jobs, /hosted-services) — all lazy-loaded, so their (heavy) chunks download only when the route is opened, and only when the owning module is enabled (a disabled module shows a “not enabled” pane and never triggers the download). Any other non-root path becomes a path route — the extension point for host-registered custom pages — and / is home.

The view/edit/create branches are keyed by route so navigating between records remounts the component with fresh state rather than leaking the previous record’s values (the grid is keyed by object name). The router also owns the create/edit flow: a top-level grid navigates to an /object/{name}/create|edit page by default, while an object whose FormStyle is Modal opens the form in a dialog and refreshes in place on save. After a directed form submits, it returns to the grid it was opened from (carried in the URL), which keeps that object’s navbar item highlighted throughout the view/edit/create flow.

Legacy routes. The former /table/{name}, /view/{name}?Id=, and /wizard/{name} URLs still resolve — they are normalized to the new scheme (and rewritten in the address bar). The one exception is the old /form/{name} entity-form URL: /form/ now means wizard, so those old links no longer resolve.

useGenieRoute (in shell/useGenieRoute.ts) parses window.location into a GenieRoute ({ kind, name, params, mode?, raw } — mode is set for kind: "object") and re-renders on popstate and on programmatic navigation.

  • navigate(href, { replace? }) updates history (pushState / replaceState) and dispatches an internal event so the hook re-reads the route — no full-page load.
  • buildObjectHref(name, { mode, id?, params? }) builds a canonical object href, e.g. buildObjectHref("Orders", { mode: "grid", params: { Status: "Active" } }) → /object/Orders?Status=Active, buildObjectHref("Orders", { mode: "edit", id: "5" }) → /object/Orders/edit?id=5.
  • buildGenieHref("wizard", name, params) builds a wizard href → /form/{name}?….
  • normalizeGenieHref rewrites legacy routes from the navbar/DB (hash #/table/Name/c/k=v, or browser /table,/view,/wizard) into the canonical /object / /form scheme, so older stored routes keep working with no data migration.

Routing mode is set by createGenieApp({ routing }): "browser" (default) uses History-API paths and requires the host to serve index.html for unknown paths (SPA fallback); "hash" avoids that server requirement. Auth/account pages always use the hash, independent of this setting.

GenieLayout is the three-column shell: a module rail, a contextual sidebar, and a header, on a has-rail grid. Its behaviour:

  • Modules are derived from the navbar’s top-level items. Clicking a rail module swaps the sidebar (or navigates, for a direct-link module); the active module is inferred from the current route, with a manual selection winning until navigation moves elsewhere.
  • Collapse — the sidebar collapses to a rail-only view (persisted to localStorage); on mobile the rail + sidebar become an off-canvas drawer that auto-closes on navigation. The full-canvas designers auto-collapse the sidebar for room and restore the preference on exit.
  • Header — the hamburger, an optional global search (nav + configured datasets, focused with Ctrl/Cmd-Shift-F when headerSearch is enabled), the accent picker and light/dark toggle (both from useGenieTheme), and a host-supplied right slot (notifications, user menu).
  • Breadcrumb — rendered from the current route + nav items; a per-route error boundary isolates a render failure to its page.

Theming (data-theme / data-accent, persistence, the picker) is covered in Theming.

GenieNavbar renders the contextual sidebar: a module-context header, a menu search, and the active module’s navigation tree (up to three levels, collapsible groups). Typing in the menu search switches to a flat cross-module result list so nothing hides behind module selection. It consumes the navbar JSON from the engine — nodes of kind Section (heading), Divider, or a regular item/group — and renders right-aligned badges: a numeric count pill or a themed status dot. Where the tree comes from (the permission-filtered /navbar endpoint or a host-supplied static tree) is set by navbar.source. See Navigation for the navbar model and Frontend configuration for the config.

The auth experience — login, MFA/TOTP, forgot/reset password, change password, profile — lives under auth/. GenieAuthProvider holds the session; useAuth exposes the current user, roles, and sign-in/out. The auth screens run on hash routes (independent of the data-page routing mode) and their affordances adapt to the server’s enabled features, read from GET /auth/config (which login methods, MFA channels, reCAPTCHA, password rules are on) — so nothing is hardcoded on the client. The login/forgot/reset screens render beside a gradient brand panel customizable via the auth block (see Theming). While the session resolves and the authenticated app-shell chunk downloads, a branded loading splash shows (replaceable via preloader).

GenieApiClient (api/client.ts) is the single fetch layer every part shares. It:

  • Carries the JWT — login returns an access token (not a cookie); the client stores it in localStorage, sends it as Authorization: Bearer …, and (for the WebSocket transport, which can’t set a header) exposes it for the SignalR access_token query.
  • Unwraps the envelope — the engine wraps every response in { Success, Error, Data }; the client returns Data (or throws a GenieApiError carrying the server’s message) so callers work with plain payloads. Error bodies are reduced to a human-readable message; raw JSON is never shown to the user.
  • Sends context headers — the antiforgery token (X-XSRF-TOKEN) on unsafe requests and the browser’s IANA time zone (X-TimeZone) on every request, plus credentials: "include" for the cookie/company/timezone session.
  • Handles 401 — a 401 on any non-auth endpoint fires the registered unauthorized handler (the auth provider signs out and bounces to login); auth-endpoint 401s are left to their callers so a bad login doesn’t loop.
  • Caches structure & de-dupes requests — object metadata is cached in-memory (schemaCacheTtlSeconds), and concurrent identical reads (every GET plus the read-style POSTs) share one in-flight fetch. Mutations are never coalesced. See Request de-duplication.

Because the auth, notification, wizard, workflow, and Hangfire controllers sit as siblings of the /genie base, the client derives those bases from apiBase (e.g. .../auth, .../notification) — so you only ever configure the one apiBase.