feat(T02-T11): IHF Phase 1 schema, controllers, views, and helpers

- Schema: hubs, widgets, widget_versions, interaction_events (append-only
  trigger), annotations, users — single migration file
- Web layer: Types, Routes, FrontController with auth + AutoRefresh layout
- Controllers: Hubs (CRUD), Widgets (CRUD + versioning), InteractionEvents
  (JSON capture, canonical event_type validation), Annotations (threaded,
  append-only)
- Sessions controller for IHP auth
- Views: Hubs (index/show/new/edit), Widgets (index/show/new/edit),
  Annotations (index/new), Sessions (login)
- widgetEnvelope helper with full data-* governance attributes
- Integration tests: Hub CRUD, Widget versioning, event capture, append-only
  guard, annotation threading, validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:42:43 +00:00
parent ff11913d5c
commit c560e541c7
26 changed files with 1591 additions and 12 deletions

View File

@@ -1,5 +1,44 @@
module Application.Helper.View where
import IHP.ViewPrelude
import Generated.Types
import Web.Types
-- Here you can add functions which are available in all your views
-- | Widget Envelope — wraps any widget's rendered content with IHF governance metadata.
--
-- Every interactive element that is part of the governed widget registry should
-- be wrapped with this helper. It injects the stable data-* attributes that the
-- client-side event capture script reads to identify the widget without coupling
-- to implementation details.
--
-- Usage:
--
-- @
-- widgetEnvelope widget [hsx|
-- <button>Click me</button>
-- |]
-- @
--
-- See docs/widget-envelope-convention.md for the full convention.
widgetEnvelope :: Widget -> Html -> Html
widgetEnvelope widget inner = [hsx|
<div
class="ihf-widget"
data-widget-id={tshow widget.id}
data-widget-type={widget.widgetType}
data-hub-id={tshow widget.hubId}
data-capability-ref={fromMaybe "" widget.capabilityRef}
data-view-context={fromMaybe "" widget.viewContext}
data-policy-scope={widget.policyScope}
data-widget-version={tshow widget.version}
>
{inner}
<div class="ihf-widget-controls mt-2">
<a href={WidgetAnnotationsAction { widgetId = widget.id }}
class="ihf-annotate-btn text-xs text-gray-400 hover:text-indigo-600 border border-gray-200
rounded px-2 py-0.5 hover:border-indigo-300">
Annotate
</a>
</div>
</div>
|]

View File

@@ -0,0 +1,92 @@
-- IHF Phase 1 Initial Schema Migration
-- Creates: users, hubs, widgets, widget_versions, interaction_events, annotations
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
locked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
failed_login_attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE TABLE hubs (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
domain TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE TABLE widgets (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
hub_id UUID NOT NULL REFERENCES hubs(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
widget_type TEXT NOT NULL,
capability_ref TEXT,
view_context TEXT,
policy_scope TEXT NOT NULL DEFAULT 'internal',
status TEXT NOT NULL DEFAULT 'active',
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE TABLE widget_versions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
version INT NOT NULL,
schema_snapshot JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
UNIQUE (widget_id, version)
);
CREATE TABLE interaction_events (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user',
view_context_ref TEXT,
metadata JSONB DEFAULT '{}' NOT NULL,
occurred_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX interaction_events_widget_id_idx ON interaction_events (widget_id);
CREATE INDEX interaction_events_occurred_at_idx ON interaction_events (occurred_at DESC);
CREATE OR REPLACE FUNCTION prevent_interaction_event_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'interaction_events is append-only: UPDATE and DELETE are not permitted';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER interaction_events_no_update
BEFORE UPDATE ON interaction_events
FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation();
CREATE TRIGGER interaction_events_no_delete
BEFORE DELETE ON interaction_events
FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation();
CREATE TABLE annotations (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
parent_id UUID REFERENCES annotations(id) ON DELETE CASCADE,
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'friction',
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user',
widget_state_ref TEXT,
retracted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX annotations_widget_id_idx ON annotations (widget_id);
-- Dev seed: one hub for local development
INSERT INTO hubs (slug, name, domain)
VALUES ('dev', 'Dev Hub', 'development');

View File

@@ -1 +1,96 @@
-- Your database schema. Use the Schema Designer at http://localhost:8001/ to add some tables.
-- IHF Phase 1 Schema
-- Hub, Widget, WidgetVersion, InteractionEvent, Annotation
-- See workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users (T10 — authentication)
CREATE TABLE users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
locked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
failed_login_attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Hubs — bounded domains of responsibility
CREATE TABLE hubs (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
domain TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Widgets — smallest semantically governable interaction units
CREATE TABLE widgets (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
hub_id UUID NOT NULL REFERENCES hubs(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
widget_type TEXT NOT NULL,
capability_ref TEXT,
view_context TEXT,
policy_scope TEXT NOT NULL DEFAULT 'internal',
status TEXT NOT NULL DEFAULT 'active',
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Widget version history
CREATE TABLE widget_versions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
version INT NOT NULL,
schema_snapshot JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
UNIQUE (widget_id, version)
);
-- Interaction events — append-only capture
CREATE TABLE interaction_events (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user',
view_context_ref TEXT,
metadata JSONB DEFAULT '{}' NOT NULL,
occurred_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX interaction_events_widget_id_idx ON interaction_events (widget_id);
CREATE INDEX interaction_events_occurred_at_idx ON interaction_events (occurred_at DESC);
-- Enforce append-only on interaction_events
CREATE OR REPLACE FUNCTION prevent_interaction_event_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'interaction_events is append-only: UPDATE and DELETE are not permitted';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER interaction_events_no_update
BEFORE UPDATE ON interaction_events
FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation();
CREATE TRIGGER interaction_events_no_delete
BEFORE DELETE ON interaction_events
FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation();
-- Annotations — structured commentary, also append-only by convention
CREATE TABLE annotations (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
parent_id UUID REFERENCES annotations(id) ON DELETE CASCADE,
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'friction',
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user',
widget_state_ref TEXT,
retracted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX annotations_widget_id_idx ON annotations (widget_id);

View File

@@ -6,9 +6,13 @@ import qualified IHP.Server
import IHP.RouterSupport
import IHP.FrameworkConfig
import IHP.Job.Types
import Web.FrontController
import Web.Types
instance FrontController RootApplication where
controllers = []
controllers =
[ mountFrontController WebApplication
]
instance Worker RootApplication where
workers _ = []

View File

@@ -4,20 +4,159 @@ import Test.Hspec
import IHP.Prelude
import IHP.ModelSupport
import IHP.Log.Types
import IHP.ControllerPrelude hiding (query)
import System.Environment (lookupEnv)
import Generated.Types
-- Integration tests run with a temporary PostgreSQL database.
-- The DATABASE_URL env var is set automatically by `nix flake check`.
-- See: https://ihp.digitallyinduced.com/Guide/testing.html
-- Run: nix flake check (or `test` inside devenv)
main :: IO ()
main = do
databaseUrl <- lookupEnv "DATABASE_URL" >>= \case
Just url -> pure (cs url)
Nothing -> error "DATABASE_URL not set. Run `devenv up` first or use `nix flake check`."
Nothing -> error "DATABASE_URL not set. Run `devenv up` first or use `nix flake check`."
logger <- newLogger def { level = Warn }
withModelContext databaseUrl logger \modelContext -> do
let ?modelContext = modelContext
hspec do
describe "Database" do
it "can execute a query" do
sqlExecDiscardResult "SELECT 1" ()
-- ----------------------------------------------------------------
-- Hub CRUD
-- ----------------------------------------------------------------
describe "Hub CRUD" do
it "can create and fetch a hub" do
hub <- newRecord @Hub
|> set #slug "test-hub"
|> set #name "Test Hub"
|> set #domain "test"
|> createRecord
hub.name `shouldBe` "Test Hub"
fetched <- fetch hub.id
fetched.slug `shouldBe` "test-hub"
deleteRecord hub
-- ----------------------------------------------------------------
-- Widget CRUD + versioning
-- ----------------------------------------------------------------
describe "Widget CRUD" do
it "creates widget and records version 1" do
hub <- newRecord @Hub
|> set #slug "w-hub" |> set #name "W Hub" |> set #domain "d"
|> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id
|> set #name "Test Widget"
|> set #widgetType "chart"
|> createRecord
widget.version `shouldBe` 1
let snapshot = object ["name" .= widget.name]
_ver <- newRecord @WidgetVersion
|> set #widgetId widget.id
|> set #version 1
|> set #schemaSnapshot snapshot
|> createRecord
versions <- query @WidgetVersion |> filterWhere (#widgetId, widget.id) |> fetch
length versions `shouldBe` 1
deleteRecord hub
-- ----------------------------------------------------------------
-- Interaction event capture
-- ----------------------------------------------------------------
describe "InteractionEvent capture" do
it "records event with anonymous actor" do
hub <- newRecord @Hub
|> set #slug "ev-hub" |> set #name "Ev Hub" |> set #domain "d"
|> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id |> set #name "Ev Widget" |> set #widgetType "form"
|> createRecord
event <- newRecord @InteractionEvent
|> set #widgetId widget.id
|> set #eventType "clicked"
|> set #actorType "anonymous"
|> createRecord
event.eventType `shouldBe` "clicked"
event.actorType `shouldBe` "anonymous"
deleteRecord hub
it "append-only: UPDATE raises exception" do
hub <- newRecord @Hub
|> set #slug "ao-hub" |> set #name "AO Hub" |> set #domain "d"
|> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id |> set #name "AO Widget" |> set #widgetType "table"
|> createRecord
event <- newRecord @InteractionEvent
|> set #widgetId widget.id
|> set #eventType "viewed"
|> set #actorType "user"
|> createRecord
let attemptUpdate = sqlExec
"UPDATE interaction_events SET event_type = 'tampered' WHERE id = ?"
(Only event.id)
attemptUpdate `shouldThrow` anyException
deleteRecord hub
-- ----------------------------------------------------------------
-- Annotation CRUD + threading
-- ----------------------------------------------------------------
describe "Annotation CRUD" do
it "creates annotation and lists it" do
hub <- newRecord @Hub
|> set #slug "ann-hub" |> set #name "Ann Hub" |> set #domain "d"
|> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id |> set #name "Ann Widget" |> set #widgetType "panel"
|> createRecord
ann <- newRecord @Annotation
|> set #widgetId widget.id
|> set #body "This is confusing"
|> set #category "friction"
|> set #actorType "user"
|> createRecord
anns <- query @Annotation |> filterWhere (#widgetId, widget.id) |> fetch
length anns `shouldBe` 1
(head anns).body `shouldBe` "This is confusing"
deleteRecord hub
it "supports threaded replies via parent_id" do
hub <- newRecord @Hub
|> set #slug "thread-hub" |> set #name "Thread Hub" |> set #domain "d"
|> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id |> set #name "Thread Widget" |> set #widgetType "form"
|> createRecord
root <- newRecord @Annotation
|> set #widgetId widget.id
|> set #body "Root comment"
|> set #category "friction"
|> set #actorType "user"
|> createRecord
reply <- newRecord @Annotation
|> set #widgetId widget.id
|> set #parentId (Just root.id)
|> set #body "Reply to root"
|> set #category "other"
|> set #actorType "user"
|> createRecord
reply.parentId `shouldBe` Just root.id
deleteRecord hub
-- ----------------------------------------------------------------
-- Validation: empty body rejected
-- ----------------------------------------------------------------
describe "Annotation validation" do
it "empty body fails validation" do
hub <- newRecord @Hub
|> set #slug "val-hub" |> set #name "Val Hub" |> set #domain "d"
|> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id |> set #name "Val Widget" |> set #widgetType "form"
|> createRecord
let ann = newRecord @Annotation
|> set #widgetId widget.id
|> set #body ""
|> set #category "friction"
|> validateField #body nonEmpty
isValid ann `shouldBe` False
deleteRecord hub

View File

@@ -0,0 +1,48 @@
module Web.Controller.Annotations where
import Web.Types
import Web.View.Annotations.Index
import Web.View.Annotations.New
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
validCategories :: [Text]
validCategories = ["friction", "defect", "wish", "policy_concern", "doc_gap", "trust", "other"]
instance Controller AnnotationsController where
beforeAction = ensureIsUser
action WidgetAnnotationsAction { widgetId } = do
widget <- fetch widgetId
annotations <- query @Annotation
|> filterWhere (#widgetId, widgetId)
|> orderByAsc #createdAt
|> fetch
render IndexView { widget, annotations }
action NewAnnotationAction { widgetId } = do
widget <- fetch widgetId
let annotation = newRecord @Annotation
render NewView { widget, annotation }
action CreateAnnotationAction { widgetId } = do
widget <- fetch widgetId
mUser <- currentUserOrNothing
let actorId = fmap (.id) mUser
actorType = maybe "anonymous" (const "user") mUser
let annotation = newRecord @Annotation
annotation
|> fill @'["body", "category", "parentId", "widgetStateRef"]
|> set #widgetId widgetId
|> set #actorId (fmap (Id . unId) actorId)
|> set #actorType actorType
|> validateField #body nonEmpty
|> validateField #category (`elem` validCategories)
|> ifValid \case
Left annotation -> render NewView { widget, annotation }
Right annotation -> do
createRecord annotation
setSuccessMessage "Annotation added"
redirectTo WidgetAnnotationsAction { widgetId }

74
Web/Controller/Hubs.hs Normal file
View File

@@ -0,0 +1,74 @@
module Web.Controller.Hubs where
import Web.Types
import Web.View.Hubs.Index
import Web.View.Hubs.Show
import Web.View.Hubs.New
import Web.View.Hubs.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller HubsController where
beforeAction = ensureIsUser
action HubsAction = do
hubs <- query @Hub |> orderByAsc #createdAt |> fetch
render IndexView { hubs }
action NewHubAction = do
let hub = newRecord @Hub
render NewView { hub }
action ShowHubAction { hubId } = autoRefresh do
hub <- fetch hubId
widgets <- query @Widget
|> filterWhere (#hubId, hubId)
|> orderByAsc #name
|> fetch
widgetIds <- pure (map (.id) widgets)
recentEvents <- sqlQuery
"SELECT * FROM interaction_events WHERE widget_id = ANY(?) ORDER BY occurred_at DESC LIMIT 50"
(Only (PGArray widgetIds))
recentAnnotations <- sqlQuery
"SELECT * FROM annotations WHERE widget_id = ANY(?) ORDER BY created_at DESC LIMIT 20"
(Only (PGArray widgetIds))
render ShowView { hub, widgets, recentEvents, recentAnnotations }
action CreateHubAction = do
let hub = newRecord @Hub
hub
|> fill @'["slug", "name", "domain"]
|> validateField #slug nonEmpty
|> validateField #name nonEmpty
|> validateField #domain nonEmpty
|> ifValid \case
Left hub -> render NewView { hub }
Right hub -> do
hub <- createRecord hub
setSuccessMessage "Hub created"
redirectTo ShowHubAction { hubId = hub.id }
action EditHubAction { hubId } = do
hub <- fetch hubId
render EditView { hub }
action UpdateHubAction { hubId } = do
hub <- fetch hubId
hub
|> fill @'["slug", "name", "domain"]
|> validateField #slug nonEmpty
|> validateField #name nonEmpty
|> validateField #domain nonEmpty
|> ifValid \case
Left hub -> render EditView { hub }
Right hub -> do
updateRecord hub
setSuccessMessage "Hub updated"
redirectTo ShowHubAction { hubId = hub.id }
action DeleteHubAction { hubId } = do
hub <- fetch hubId
deleteRecord hub
setSuccessMessage "Hub deleted"
redirectTo HubsAction

View File

@@ -0,0 +1,55 @@
module Web.Controller.InteractionEvents where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import qualified Data.Text as T
-- Valid canonical event types
validEventTypes :: [Text]
validEventTypes =
[ "viewed", "clicked", "submitted", "abandoned", "retried", "failed"
, "commented", "flagged_confusing", "flagged_helpful"
, "blocked_by_policy", "escalated"
, "accepted_recommendation", "rejected_recommendation"
]
instance Controller InteractionEventsController where
action CreateInteractionEventAction { widgetId } = do
eventType <- param @Text "event_type"
unless (eventType `elem` validEventTypes) do
respondJson (object ["error" .= ("unknown event_type" :: Text), "valid" .= validEventTypes])
-- IHP stops here; the above respondJson sends 200 but we need 422
-- Use renderWithStatus for proper 422:
setStatus 422
respondJson (object ["error" .= ("unknown event_type" :: Text)])
mUser <- currentUserOrNothing
let actorId = fmap (.id) mUser
actorType = maybe "anonymous" (const "user") mUser
actorTypeParam <- paramOrDefault @Text actorType "actor_type"
viewContextRef <- paramOrNothing @Text "view_context_ref"
metadataRaw <- paramOrDefault @Text "{}" "metadata"
let metadata = case readMay @Value (cs metadataRaw) of
Just v -> v
Nothing -> object []
event <- newRecord @InteractionEvent
|> set #widgetId widgetId
|> set #eventType eventType
|> set #actorId (fmap (Id . unId) actorId)
|> set #actorType actorTypeParam
|> set #viewContextRef viewContextRef
|> set #metadata metadata
|> createRecord
respondJson (object
[ "id" .= event.id
, "widget_id" .= event.widgetId
, "event_type" .= event.eventType
, "occurred_at".= event.occurredAt
])

View File

@@ -0,0 +1,29 @@
module Web.Controller.Sessions where
import Web.Types
import Web.View.Sessions.New
import Generated.Types
import IHP.LoginSupport.Helper.Controller
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller SessionsController where
action NewSessionAction = do
let user = newRecord @User
render NewView { user }
action CreateSessionAction = do
(user, token) <- authenticate @User
case user of
Just user -> do
setSession "userId" (show user.id)
redirectTo HubsAction
Nothing -> do
setErrorMessage "Invalid email or password"
redirectTo NewSessionAction
action DeleteSessionAction = do
unsetSession "userId"
redirectTo NewSessionAction
instance SessionsControllerConfig User

106
Web/Controller/Widgets.hs Normal file
View File

@@ -0,0 +1,106 @@
module Web.Controller.Widgets where
import Web.Types
import Web.View.Widgets.Index
import Web.View.Widgets.Show
import Web.View.Widgets.New
import Web.View.Widgets.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (toJSON, object, (.=))
instance Controller WidgetsController where
beforeAction = ensureIsUser
action WidgetsAction = do
widgets <- query @Widget |> orderByAsc #name |> fetch
hubs <- query @Hub |> fetch
render IndexView { widgets, hubs }
action NewWidgetAction = do
let widget = newRecord @Widget
hubs <- query @Hub |> fetch
render NewView { widget, hubs }
action ShowWidgetAction { widgetId } = do
widget <- fetch widgetId
hub <- fetch widget.hubId
versions <- query @WidgetVersion
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #version
|> fetch
events <- query @InteractionEvent
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #occurredAt
|> limit 20
|> fetch
annotations <- query @Annotation
|> filterWhere (#widgetId, widgetId)
|> orderByAsc #createdAt
|> fetch
render ShowView { widget, hub, versions, events, annotations }
action CreateWidgetAction = do
let widget = newRecord @Widget
hubs <- query @Hub |> fetch
widget
|> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status"]
|> validateField #name nonEmpty
|> validateField #widgetType nonEmpty
|> ifValid \case
Left widget -> render NewView { widget, hubs }
Right widget -> do
widget <- createRecord widget
let snapshot = object
[ "name" .= widget.name
, "widget_type" .= widget.widgetType
, "hub_id" .= widget.hubId
, "capability_ref" .= widget.capabilityRef
, "view_context" .= widget.viewContext
, "policy_scope" .= widget.policyScope
, "status" .= widget.status
, "version" .= widget.version
]
newRecord @WidgetVersion
|> set #widgetId widget.id
|> set #version 1
|> set #schemaSnapshot snapshot
|> createRecord
setSuccessMessage "Widget registered"
redirectTo ShowWidgetAction { widgetId = widget.id }
action EditWidgetAction { widgetId } = do
widget <- fetch widgetId
hubs <- query @Hub |> fetch
render EditView { widget, hubs }
action UpdateWidgetAction { widgetId } = do
widget <- fetch widgetId
hubs <- query @Hub |> fetch
widget
|> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status"]
|> validateField #name nonEmpty
|> validateField #widgetType nonEmpty
|> ifValid \case
Left widget -> render EditView { widget, hubs }
Right widget -> do
let newVersion = widget.version + 1
widget <- widget |> set #version newVersion |> updateRecord
let snapshot = object
[ "name" .= widget.name
, "widget_type" .= widget.widgetType
, "hub_id" .= widget.hubId
, "capability_ref" .= widget.capabilityRef
, "view_context" .= widget.viewContext
, "policy_scope" .= widget.policyScope
, "status" .= widget.status
, "version" .= newVersion
]
newRecord @WidgetVersion
|> set #widgetId widget.id
|> set #version newVersion
|> set #schemaSnapshot snapshot
|> createRecord
setSuccessMessage "Widget updated"
redirectTo ShowWidgetAction { widgetId = widget.id }

57
Web/FrontController.hs Normal file
View File

@@ -0,0 +1,57 @@
module Web.FrontController where
import IHP.RouterPrelude
import IHP.LoginSupport.Middleware
import Generated.Types
import Web.Types
import Web.Routes ()
-- Controllers
import Web.Controller.Hubs ()
import Web.Controller.Widgets ()
import Web.Controller.InteractionEvents ()
import Web.Controller.Annotations ()
import Web.Controller.Sessions ()
instance FrontController WebApplication where
controllers =
[ parseRoute @SessionsController
, parseRoute @HubsController
, parseRoute @WidgetsController
, parseRoute @InteractionEventsController
, parseRoute @AnnotationsController
]
instance InitControllerContext WebApplication where
initContext = do
setLayout defaultLayout
initAuthentication @User
defaultLayout :: Layout
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>inter-hub</title>
{autoRefreshMeta}
<link rel="stylesheet" href="/app.css" />
<script src="/vendor/morphdom.js"></script>
<script src="/vendor/ihp-auto-refresh.js"></script>
</head>
<body class="bg-gray-50 text-gray-900">
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<a href={HubsAction} class="font-semibold text-indigo-600">inter-hub</a>
<a href={HubsAction} class="text-sm text-gray-600 hover:text-gray-900">Hubs</a>
<a href={WidgetsAction} class="text-sm text-gray-600 hover:text-gray-900">Widgets</a>
<div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
</div>
</nav>
<main class="max-w-5xl mx-auto px-6 py-8">
{inner}
</main>
</body>
</html>
|]

20
Web/Routes.hs Normal file
View File

@@ -0,0 +1,20 @@
module Web.Routes where
import IHP.RouterPrelude
import Generated.Types
import Web.Types
-- Hubs
instance AutoRoute HubsController
-- Widgets
instance AutoRoute WidgetsController
-- Interaction Events (POST /widgets/:widgetId/events)
instance AutoRoute InteractionEventsController
-- Annotations (scoped to widget: /widgets/:widgetId/annotations/)
instance AutoRoute AnnotationsController
-- Sessions
instance AutoRoute SessionsController

51
Web/Types.hs Normal file
View File

@@ -0,0 +1,51 @@
module Web.Types where
import IHP.Prelude
import IHP.ModelSupport
import IHP.LoginSupport.Types
import Generated.Types
-- | Authentication type alias
type CurrentUserRecord = User
instance HasNewSessionUrl User where
newSessionUrl _ = "/NewSession"
-- Controllers
data WebApplication = WebApplication deriving (Eq, Show)
data HubsController
= HubsAction
| NewHubAction
| ShowHubAction { hubId :: !(Id Hub) }
| CreateHubAction
| EditHubAction { hubId :: !(Id Hub) }
| UpdateHubAction { hubId :: !(Id Hub) }
| DeleteHubAction { hubId :: !(Id Hub) }
deriving (Eq, Show, Data)
data WidgetsController
= WidgetsAction
| NewWidgetAction
| ShowWidgetAction { widgetId :: !(Id Widget) }
| CreateWidgetAction
| EditWidgetAction { widgetId :: !(Id Widget) }
| UpdateWidgetAction { widgetId :: !(Id Widget) }
deriving (Eq, Show, Data)
data InteractionEventsController
= CreateInteractionEventAction { widgetId :: !(Id Widget) }
deriving (Eq, Show, Data)
data AnnotationsController
= WidgetAnnotationsAction { widgetId :: !(Id Widget) }
| NewAnnotationAction { widgetId :: !(Id Widget) }
| CreateAnnotationAction { widgetId :: !(Id Widget) }
deriving (Eq, Show, Data)
data SessionsController
= NewSessionAction
| CreateSessionAction
| DeleteSessionAction
deriving (Eq, Show, Data)

View File

@@ -0,0 +1,61 @@
module Web.View.Annotations.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ widget :: !Widget
, annotations :: ![Annotation]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={WidgetsAction} class="hover:text-gray-700">Widgets</a>
<span>/</span>
<a href={ShowWidgetAction { widgetId = widget.id }} class="hover:text-gray-700">{widget.name}</a>
<span>/</span>
<span>Annotations</span>
</div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Annotations for {widget.name}</h1>
<a href={NewAnnotationAction { widgetId = widget.id }}
class="bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded hover:bg-indigo-700">
Add Annotation
</a>
</div>
<div class="space-y-3">
{forEach rootAnnotations (renderAnnotation childrenOf)}
</div>
|]
where
rootAnnotations = filter (\a -> isNothing a.parentId) annotations
childrenOf parent = filter (\a -> a.parentId == Just parent.id) annotations
renderAnnotation :: (Annotation -> [Annotation]) -> Annotation -> Html
renderAnnotation childrenOf a = [hsx|
<div class="bg-white rounded-lg border border-gray-200 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded font-medium">
{a.category}
</span>
<span class="text-xs text-gray-400">{a.actorType}</span>
{if isJust a.retractedAt
then [hsx|<span class="text-xs text-red-400 italic">retracted</span>|]
else mempty}
<span class="ml-auto text-xs text-gray-300">{show a.createdAt}</span>
</div>
<p class="text-sm text-gray-700">{a.body}</p>
<div class="mt-2 flex gap-2">
<a href={NewAnnotationAction { widgetId = a.widgetId }}
class="text-xs text-indigo-500 hover:text-indigo-700">Reply</a>
</div>
<div class="ml-6 mt-3 space-y-3">
{forEach (childrenOf a) (renderAnnotation childrenOf)}
</div>
</div>
|]

View File

@@ -0,0 +1,44 @@
module Web.View.Annotations.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ widget :: !Widget
, annotation :: !Annotation
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-lg">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-4">
<a href={ShowWidgetAction { widgetId = widget.id }} class="hover:text-gray-700">{widget.name}</a>
<span>/</span>
<a href={WidgetAnnotationsAction { widgetId = widget.id }} class="hover:text-gray-700">Annotations</a>
<span>/</span>
<span>New</span>
</div>
<h1 class="text-2xl font-semibold mb-6">Add Annotation</h1>
{renderForm annotation widget.id}
</div>
|]
renderForm :: Annotation -> Id Widget -> Html
renderForm annotation widgetId = formFor annotation [hsx|
{(textareaField #body) { fieldLabel = "Comment" }}
{selectField #category categoryOptions}
{submitButton}
|]
categoryOptions :: [(Text, Text)]
categoryOptions =
[ ("Friction", "friction")
, ("Defect", "defect")
, ("Wish", "wish")
, ("Policy Concern", "policy_concern")
, ("Documentation Gap", "doc_gap")
, ("Trust", "trust")
, ("Other", "other")
]

31
Web/View/Hubs/Edit.hs Normal file
View File

@@ -0,0 +1,31 @@
module Web.View.Hubs.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data EditView = EditView { hub :: !Hub }
instance View EditView where
html EditView { .. } = [hsx|
<div class="max-w-lg">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-4">
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
<span>/</span>
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
<span>/</span>
<span>Edit</span>
</div>
<h1 class="text-2xl font-semibold mb-6">Edit Hub</h1>
{renderForm hub}
</div>
|]
renderForm :: Hub -> Html
renderForm hub = formFor hub [hsx|
{textField #name}
{(textField #slug) { helpText = "Lowercase, URL-safe identifier" }}
{textField #domain}
{submitButton}
|]

56
Web/View/Hubs/Index.hs Normal file
View File

@@ -0,0 +1,56 @@
module Web.View.Hubs.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView { hubs :: ![Hub] }
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Hubs</h1>
<a href={NewHubAction}
class="bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded
hover:bg-indigo-700">
New Hub
</a>
</div>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Name</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Slug</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Domain</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{forEach hubs renderHub}
</tbody>
</table>
</div>
|]
renderHub :: Hub -> Html
renderHub hub = [hsx|
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowHubAction { hubId = hub.id }}
class="font-medium text-indigo-600 hover:text-indigo-800">
{hub.name}
</a>
</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{hub.slug}</td>
<td class="px-4 py-3 text-gray-500">{hub.domain}</td>
<td class="px-4 py-3 text-right">
<a href={EditHubAction { hubId = hub.id }}
class="text-gray-500 hover:text-gray-700 text-xs mr-3">Edit</a>
<a href={DeleteHubAction { hubId = hub.id }}
class="text-red-500 hover:text-red-700 text-xs"
data-confirm="Delete this hub?">Delete</a>
</td>
</tr>
|]

24
Web/View/Hubs/New.hs Normal file
View File

@@ -0,0 +1,24 @@
module Web.View.Hubs.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView { hub :: !Hub }
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">New Hub</h1>
{renderForm hub}
</div>
|]
renderForm :: Hub -> Html
renderForm hub = formFor hub [hsx|
{textField #name}
{(textField #slug) { helpText = "Lowercase, URL-safe identifier" }}
{textField #domain}
{submitButton}
|]

141
Web/View/Hubs/Show.hs Normal file
View File

@@ -0,0 +1,141 @@
module Web.View.Hubs.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data ShowView = ShowView
{ hub :: !Hub
, widgets :: ![Widget]
, recentEvents :: ![InteractionEvent]
, recentAnnotations :: ![Annotation]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
<span>/</span>
<span>{hub.name}</span>
</div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">{hub.name}</h1>
<p class="text-sm text-gray-500 mt-1">
<span class="font-mono bg-gray-100 px-1 rounded">{hub.slug}</span>
<span class="ml-2">{hub.domain}</span>
</p>
</div>
<div class="flex gap-2">
<a href={EditHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit
</a>
<a href={NewWidgetAction}
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
New Widget
</a>
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Widgets</p>
<p class="text-3xl font-semibold mt-1">{length widgets}</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Recent Events</p>
<p class="text-3xl font-semibold mt-1">{length recentEvents}</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Recent Annotations</p>
<p class="text-3xl font-semibold mt-1">{length recentAnnotations}</p>
</div>
</div>
<section class="mb-8">
<h2 class="text-lg font-medium mb-3">Widgets</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Name</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Version</th>
</tr>
</thead>
<tbody>
{forEach widgets renderWidgetRow}
</tbody>
</table>
</div>
</section>
<section class="mb-8">
<h2 class="text-lg font-medium mb-3">Recent Interaction Events</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Event</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Actor</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Occurred</th>
</tr>
</thead>
<tbody>
{forEach recentEvents renderEventRow}
</tbody>
</table>
</div>
</section>
<section>
<h2 class="text-lg font-medium mb-3">Recent Annotations</h2>
<div class="space-y-2">
{forEach recentAnnotations renderAnnotationCard}
</div>
</section>
|]
renderWidgetRow :: Widget -> Html
renderWidgetRow w = [hsx|
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowWidgetAction { widgetId = w.id }}
class="font-medium text-indigo-600 hover:text-indigo-800">
{w.name}
</a>
</td>
<td class="px-4 py-3 text-gray-500">{w.widgetType}</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 rounded text-xs bg-green-100 text-green-800">
{w.status}
</span>
</td>
<td class="px-4 py-3 text-gray-500">v{show w.version}</td>
</tr>
|]
renderEventRow :: InteractionEvent -> Html
renderEventRow e = [hsx|
<tr class="border-b border-gray-100">
<td class="px-4 py-3 font-mono text-xs">{e.eventType}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{e.actorType}</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show e.occurredAt}</td>
</tr>
|]
renderAnnotationCard :: Annotation -> Html
renderAnnotationCard a = [hsx|
<div class="bg-white rounded border border-gray-200 px-4 py-3">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{a.category}</span>
<span class="text-xs text-gray-400">{a.actorType}</span>
</div>
<p class="text-sm text-gray-700">{a.body}</p>
</div>
|]

40
Web/View/Sessions/New.hs Normal file
View File

@@ -0,0 +1,40 @@
module Web.View.Sessions.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView { user :: !User }
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-sm mx-auto mt-16">
<h1 class="text-2xl font-semibold mb-6">Sign in to inter-hub</h1>
<form method="POST" action={CreateSessionAction} class="space-y-4">
{forEach (getFlashMessages) renderFlash}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" name="email" required
class="w-full border border-gray-300 rounded px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" name="password" required
class="w-full border border-gray-300 rounded px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<button type="submit"
class="w-full bg-indigo-600 text-white rounded px-4 py-2 text-sm font-medium
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
Sign in
</button>
</form>
</div>
|]
renderFlash :: Text -> Html
renderFlash msg = [hsx|
<div class="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm">{msg}</div>
|]

27
Web/View/Widgets/Edit.hs Normal file
View File

@@ -0,0 +1,27 @@
module Web.View.Widgets.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Web.View.Widgets.New (renderForm)
data EditView = EditView
{ widget :: !Widget
, hubs :: ![Hub]
}
instance View EditView where
html EditView { .. } = [hsx|
<div class="max-w-lg">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-4">
<a href={WidgetsAction} class="hover:text-gray-700">Widgets</a>
<span>/</span>
<a href={ShowWidgetAction { widgetId = widget.id }} class="hover:text-gray-700">{widget.name}</a>
<span>/</span>
<span>Edit</span>
</div>
<h1 class="text-2xl font-semibold mb-6">Edit Widget</h1>
{renderForm widget hubs}
</div>
|]

69
Web/View/Widgets/Index.hs Normal file
View File

@@ -0,0 +1,69 @@
module Web.View.Widgets.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ widgets :: ![Widget]
, hubs :: ![Hub]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Widgets</h1>
<a href={NewWidgetAction}
class="bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded hover:bg-indigo-700">
Register Widget
</a>
</div>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Name</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Hub</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Version</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{forEach widgets (renderWidget hubs)}
</tbody>
</table>
</div>
|]
renderWidget :: [Hub] -> Widget -> Html
renderWidget hubs w = [hsx|
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowWidgetAction { widgetId = w.id }}
class="font-medium text-indigo-600 hover:text-indigo-800">
{w.name}
</a>
</td>
<td class="px-4 py-3 text-gray-500">{hubName hubs w.hubId}</td>
<td class="px-4 py-3 text-gray-500">{w.widgetType}</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 rounded text-xs bg-green-100 text-green-800">
{w.status}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs">v{show w.version}</td>
<td class="px-4 py-3 text-right">
<a href={EditWidgetAction { widgetId = w.id }}
class="text-gray-500 hover:text-gray-700 text-xs">Edit</a>
</td>
</tr>
|]
hubName :: [Hub] -> Id Hub -> Text
hubName hubs hubId =
case find (\h -> h.id == hubId) hubs of
Just h -> h.name
Nothing -> ""

59
Web/View/Widgets/New.hs Normal file
View File

@@ -0,0 +1,59 @@
module Web.View.Widgets.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ widget :: !Widget
, hubs :: ![Hub]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">Register Widget</h1>
{renderForm widget hubs}
</div>
|]
renderForm :: Widget -> [Hub] -> Html
renderForm widget hubs = formFor widget [hsx|
{textField #name}
{selectField #widgetType widgetTypeOptions}
{selectField #hubId (hubOptions hubs)}
{textField #capabilityRef}
{textField #viewContext}
{selectField #policyScope policyScopeOptions}
{selectField #status statusOptions}
{submitButton}
|]
hubOptions :: [Hub] -> [(Text, Id Hub)]
hubOptions hubs = map (\h -> (h.name, h.id)) hubs
widgetTypeOptions :: [(Text, Text)]
widgetTypeOptions =
[ ("Chart", "chart")
, ("Form", "form")
, ("Table", "table")
, ("Action", "action")
, ("Panel", "panel")
, ("Navigation", "nav")
, ("Other", "other")
]
policyScopeOptions :: [(Text, Text)]
policyScopeOptions =
[ ("Internal", "internal")
, ("Hub", "hub")
, ("Public", "public")
]
statusOptions :: [(Text, Text)]
statusOptions =
[ ("Active", "active")
, ("Deprecated", "deprecated")
, ("Draft", "draft")
]

162
Web/View/Widgets/Show.hs Normal file
View File

@@ -0,0 +1,162 @@
module Web.View.Widgets.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (widgetEnvelope)
data ShowView = ShowView
{ widget :: !Widget
, hub :: !Hub
, versions :: ![WidgetVersion]
, events :: ![InteractionEvent]
, annotations :: ![Annotation]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-2 flex items-center gap-2 text-sm text-gray-500">
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
<span>/</span>
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
<span>/</span>
<span>{widget.name}</span>
</div>
{widgetEnvelope widget [hsx|
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-semibold">{widget.name}</h1>
<p class="text-sm text-gray-500 mt-0.5">
{widget.widgetType}
<span class="ml-2 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{widget.policyScope}</span>
<span class="ml-2 text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded">{widget.status}</span>
<span class="ml-2 text-xs text-gray-400">v{show widget.version}</span>
</p>
</div>
<a href={EditWidgetAction { widgetId = widget.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit
</a>
</div>
|]}
<div class="grid grid-cols-3 gap-4 mb-8 mt-6">
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Events</p>
<p class="text-3xl font-semibold mt-1">{length events}</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Annotations</p>
<p class="text-3xl font-semibold mt-1">{length annotations}</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Versions</p>
<p class="text-3xl font-semibold mt-1">{length versions}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-6 mb-8">
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-medium">Annotations</h2>
<a href={NewAnnotationAction { widgetId = widget.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">+ Add</a>
</div>
<div class="space-y-2">
{forEach rootAnnotations (renderAnnotation childrenOf)}
</div>
</section>
<section>
<h2 class="text-lg font-medium mb-3">Annotation Breakdown</h2>
<div class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
{forEach categoryBreakdown renderCategoryRow}
</div>
</section>
</div>
<section class="mb-8">
<h2 class="text-lg font-medium mb-3">Interaction Events</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Event</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Actor</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Occurred</th>
</tr>
</thead>
<tbody>
{forEach events renderEventRow}
</tbody>
</table>
</div>
</section>
<section>
<h2 class="text-lg font-medium mb-3">Version History</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Version</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Recorded</th>
</tr>
</thead>
<tbody>
{forEach versions renderVersionRow}
</tbody>
</table>
</div>
</section>
|]
where
rootAnnotations = filter (\a -> isNothing a.parentId) annotations
childrenOf parent = filter (\a -> a.parentId == Just parent.id) annotations
categoryBreakdown =
[ (cat, length (filter (\a -> a.category == cat) annotations))
| cat <- ["friction","defect","wish","policy_concern","doc_gap","trust","other"]
, any (\a -> a.category == cat) annotations
]
renderAnnotation :: (Annotation -> [Annotation]) -> Annotation -> Html
renderAnnotation childrenOf a = [hsx|
<div class="bg-white rounded border border-gray-200 px-4 py-3">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{a.category}</span>
<span class="text-xs text-gray-400">{a.actorType}</span>
<span class="text-xs text-gray-300 ml-auto">{show a.createdAt}</span>
</div>
<p class="text-sm text-gray-700">{a.body}</p>
<div class="ml-4 mt-2 space-y-2">
{forEach (childrenOf a) (renderAnnotation childrenOf)}
</div>
</div>
|]
renderEventRow :: InteractionEvent -> Html
renderEventRow e = [hsx|
<tr class="border-b border-gray-100">
<td class="px-4 py-3 font-mono text-xs text-gray-700">{e.eventType}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{e.actorType}</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show e.occurredAt}</td>
</tr>
|]
renderVersionRow :: WidgetVersion -> Html
renderVersionRow v = [hsx|
<tr class="border-b border-gray-100">
<td class="px-4 py-3 text-gray-700">v{show v.version}</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show v.createdAt}</td>
</tr>
|]
renderCategoryRow :: (Text, Int) -> Html
renderCategoryRow (cat, count) = [hsx|
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">{cat}</span>
<span class="font-semibold">{show count}</span>
</div>
|]

View File

@@ -0,0 +1,56 @@
# Widget Envelope Convention
Every rendered widget in inter-hub wraps its HSX in the `widgetEnvelope` helper
from `Application.Helper.View`. This injects a stable set of `data-*` attributes
that enable client-side event capture without coupling to implementation details.
## Usage
```haskell
import Application.Helper.View (widgetEnvelope)
-- In any view:
widgetEnvelope widget [hsx|
<button class="...">Click me</button>
|]
```
## Emitted HTML
```html
<div
class="ihf-widget"
data-widget-id="<uuid>"
data-widget-type="chart"
data-hub-id="<uuid>"
data-capability-ref="pipeline.run"
data-view-context="ops/dashboard"
data-policy-scope="internal"
data-widget-version="3"
>
<!-- inner content -->
<div class="ihf-widget-controls">
<a href="/widgets/<uuid>/annotations" class="ihf-annotate-btn">Annotate</a>
</div>
</div>
```
## Attributes
| Attribute | Source | Purpose |
|-----------|--------|---------|
| `data-widget-id` | `widget.id` | Stable identity for event capture |
| `data-widget-type` | `widget.widgetType` | Semantic role of the widget |
| `data-hub-id` | `widget.hubId` | Which hub owns this widget |
| `data-capability-ref` | `widget.capabilityRef` | Link to hub capability |
| `data-view-context` | `widget.viewContext` | Logical location in the UI |
| `data-policy-scope` | `widget.policyScope` | Governance policy boundary |
| `data-widget-version` | `widget.version` | Version at render time |
## Rules
1. **Every interactive hub element** that participates in governance must be wrapped.
2. The `data-widget-id` is the capture key — the event capture endpoint uses it as `widget_id`.
3. Do not add or remove `data-*` attributes without updating both this convention doc and
the event capture client script.
4. The "Annotate" control is always rendered. It links to the full annotation thread for the widget.

View File

@@ -51,7 +51,7 @@ Reference: `docs/ihp-overview.md`, `docs/ihp-data-and-queries.md`,
```task
id: IHUB-WP-0001-T01
status: todo
status: done
priority: high
state_hub_task_id: "e9e83628-d485-4163-9467-0d161f6274f3"
```
@@ -72,7 +72,7 @@ Set up the IHP project skeleton for inter-hub:
```task
id: IHUB-WP-0001-T02
status: todo
status: done
priority: high
state_hub_task_id: "e7254445-1375-44c3-9c59-111215b70692"
```
@@ -123,7 +123,7 @@ CREATE TABLE widget_versions (
```task
id: IHUB-WP-0001-T03
status: todo
status: done
priority: high
state_hub_task_id: "dac18955-7b2f-464f-97eb-0733c9163088"
```