Files
inter-hub/docs/ihp-controllers-views-forms.md
tegwick 8b6ce5bbc8 docs: add specification, reference docs, workplan, and agent guidance
Adds all Phase 0 content that was created but never committed:
- CLAUDE.md and SCOPE.md — agent and developer orientation
- specs/TailwindForInteractionHubs_v0.2.md — IHF Tailwind coding guide
- docs/ — five IHP v1.5 reference guides (overview, data, controllers, realtime, ihf-mapping)
- workplans/IHUB-WP-0001 — Phase 1 implementation plan (12 tasks, state-hub synced)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 02:07:13 +01:00

8.5 KiB

IHP: Controllers, Views, Forms, and Authentication


Controllers and Actions

Controllers live in Web/Controller/. Each action is a constructor in Web/Types.hs and a case in the controller's action handler.

-- Web/Controller/Widgets.hs
instance Controller WidgetsController where
    action WidgetsAction = do
        widgets <- query @Widget |> fetch
        render IndexView { .. }

    action ShowWidgetAction { widgetId } = do
        widget  <- fetch widgetId
        events  <- query @InteractionEvent
                    |> filterWhere (#widgetId, widgetId)
                    |> orderByDesc #occurredAt
                    |> limit 20
                    |> fetch
        render ShowView { .. }

    action CreateWidgetAction = do
        let widget = newRecord @Widget
        widget
            |> fill @'["name", "widgetType", "hubId"]
            |> validateField #name nonEmpty
            |> ifValid \case
                Left widget -> render NewView { widget }
                Right widget -> do
                    widget <- createRecord widget
                    setSuccessMessage "Widget created"
                    redirectTo WidgetsAction

Key Patterns

Pattern Description
fill @'["fieldA", "fieldB"] Type-safe HTTP param binding; compile error if field doesn't exist
validateField #name nonEmpty Chains a validator onto the record
ifValid \case Left r -> ... Right r -> ... Branches on validation success/failure
createRecord r INSERT; returns the persisted record
updateRecord r UPDATE
deleteRecord r DELETE
redirectTo SomeAction { ... } Type-safe redirect
render SomeView { .. } Passes in-scope bindings to the view (record wildcard)
respondJson value JSON response (no view)
respondHtml someHtml Partial HTML response (useful with htmx)

Before Filters

instance Controller WidgetsController where
    beforeAction = do
        ensureIsUser    -- redirects to login if not authenticated
        -- custom per-controller guards here
    action ...

ControllerContext

An implicit key-value map threaded through all actions and views. Stores current user, request, flash messages, layout. Access via fromContext @SomeType or framework helpers like currentUser.


Views and HSX

The View Typeclass

Views are data types with a View instance:

data ShowView = ShowView
    { widget :: !Widget
    , events :: ![InteractionEvent]
    }

instance View ShowView where
    html ShowView { .. } = [hsx|
        <div class="widget-detail">
            <h1>{widget.name}</h1>
            <div class="events">
                {forEach events renderEvent}
            </div>
        </div>
    |]

Views can also implement a json method for content-negotiated API responses.

HSX Syntax

HSX is a quasi-quoter ([hsx| ... |]) that compiles to BlazeHtml. Checked at compile time.

[hsx|
    -- Interpolation (auto-escaped, XSS-safe)
    <span>{widget.name}</span>

    -- Raw HTML (opt-out of escaping)
    <div>{preEscapedToHtml trustedHtml}</div>

    -- Conditionals
    {if widget.status == "active"
        then [hsx|<span class="badge active">Active</span>|]
        else [hsx|<span class="badge inactive">Inactive</span>|]}

    -- Loops
    <ul>{forEach events renderEvent}</ul>

    -- Boolean attributes: True → present, False → omitted
    <input disabled={isReadOnly} />

    -- Maybe attributes: Just → present, Nothing → omitted
    <a target={maybeTarget}>Link</a>

    -- Multiple root elements are OK (unlike JSX)
    <dt>Name</dt>
    <dd>{widget.name}</dd>
|]

<script> and <style> blocks: curly braces inside are literal (not interpolated). Pass values via data- attributes instead.

Tag variants:

  • hsx — strict whitelist of known HTML tags/attributes
  • uncheckedHsx — skips tag/attribute validation (for web components)
  • customHsx — extends the whitelist

Layouts

Layouts have type Layout = Html -> Html. They wrap the view's html output:

-- Web/View/Layout.hs
defaultLayout :: Layout
defaultLayout inner = [hsx|
    <!DOCTYPE html>
    <html>
        <head>
            <title>IHF</title>
            {autoRefreshMeta}
            <script src="/vendor/morphdom.js"></script>
            <script src="/vendor/ihp-auto-refresh.js"></script>
        </head>
        <body>
            <nav>...</nav>
            <main>{inner}</main>
        </body>
    </html>
|]

Set per-controller in beforeAction or globally in FrontController.initContext:

setLayout defaultLayout

Data shared between layout and views (e.g., current user for the nav) is passed via putContext/fromFrozenContext.


Forms and Validation

Building Forms

formFor binds a record to a form, auto-generating method and action:

renderForm :: Widget -> Html
renderForm widget = formFor widget [hsx|
    {textField #name}
    {selectField #widgetType widgetTypeOptions}
    {selectField #hubId hubOptions}
    {textareaField #description}
    {submitButton}
|]

IHP detects whether the record is new (POST to /CreateWidget) or persisted (POST to /UpdateWidget) automatically.

Available field helpers: textField, emailField, passwordField, urlField, numberField, colorField, dateField, dateTimeField, textareaField, selectField, radioField, checkboxField, fileField, hiddenField.

Field customisation options: helpText, fieldLabel, placeholder, required, disabled, autofocus, fieldClass, labelClass, additionalAttributes, disableLabel, disableGroup, disableValidationResult.

Custom forms: formForWithOptions — override formAction, formClass, form ID, HTTP method, disable AJAX submission.

AJAX submission: forms submit via AJAX + TurboLinks by default (no full page reload). Opt out with disableJavascriptSubmission = True.

Validation

annotation
    |> validateField #body nonEmpty
    |> validateField #category (`elem` validCategories)
    |> ifValid \case
        Left invalid -> render NewView { annotation = invalid }
        Right valid  -> do
            createRecord valid
            redirectTo WidgetAnnotationsAction { widgetId = valid.widgetId }

Built-in validators: nonEmpty, isEmail, isPhoneNumber, isInRange (min, max).

Custom validators: return Success or Failure "message". For DB-aware validators, use validateFieldIO.

How errors attach: validateField stores failures in record.meta.annotations (a [(fieldName, errorMessage)] list). When formFor renders a field with a failure, it auto-adds is-invalid CSS class and renders the error message.

Manual attachment: |> attachFailure #field "Custom message".


Authentication

IHP provides built-in session-based authentication using salted password hashing (pwstore-fast). Accounts are locked for 1 hour after 10 failed attempts.

Database Requirements

The users table must include: id, email, password_hash, locked_at, failed_login_attempts.

Setup

-- Web/Types.hs
import IHP.LoginSupport.Types
type CurrentUserRecord = User
instance HasNewSessionUrl User where
    newSessionUrl _ = "/NewSession"

-- Web/FrontController.hs
instance InitControllerContext WebApplication where
    initContext = do
        initAuthentication @User

Mount parseRoute @SessionsController in routes. The framework handles NewSessionAction, CreateSessionAction, DeleteSessionAction.

Accessing the Current User

currentUser           -- User; redirects to login if absent
currentUserOrNothing  -- Maybe User
currentUserId         -- convenience shortcut

Hooks

SessionsControllerConfig instance supports beforeLogin callback for login history, account status checks, and external identity validation (relevant for IHF's multi-tenant actor attribution).

Password Handling

-- On registration:
user <- newRecord @User
    |> fill @'["email", "passwordHash"]
    |> set #passwordHash (hashPassword plaintext)
    |> createRecord

-- On update (preserve hash when blank):
updatedUser <- user
    |> fill @'["email"]
    |> ifPasswordChanged (set #passwordHash . hashPassword)
    |> updateRecord

Actor Attribution in IHF

For IHF's interaction capture, the actor_id and actor_type on InteractionEvent and Annotation should be populated from currentUserOrNothing in controllers. For anonymous/low-trust actors, actor_type = "anonymous" with a session token as actor_id.