generated from coulomb/repo-seed
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:
@@ -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>
|
||||
|]
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
|
||||
6
Main.hs
6
Main.hs
@@ -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 _ = []
|
||||
|
||||
@@ -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
|
||||
|
||||
48
Web/Controller/Annotations.hs
Normal file
48
Web/Controller/Annotations.hs
Normal 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
74
Web/Controller/Hubs.hs
Normal 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
|
||||
55
Web/Controller/InteractionEvents.hs
Normal file
55
Web/Controller/InteractionEvents.hs
Normal 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
|
||||
])
|
||||
29
Web/Controller/Sessions.hs
Normal file
29
Web/Controller/Sessions.hs
Normal 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
106
Web/Controller/Widgets.hs
Normal 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
57
Web/FrontController.hs
Normal 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
20
Web/Routes.hs
Normal 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
51
Web/Types.hs
Normal 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)
|
||||
61
Web/View/Annotations/Index.hs
Normal file
61
Web/View/Annotations/Index.hs
Normal 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>
|
||||
|]
|
||||
44
Web/View/Annotations/New.hs
Normal file
44
Web/View/Annotations/New.hs
Normal 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
31
Web/View/Hubs/Edit.hs
Normal 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
56
Web/View/Hubs/Index.hs
Normal 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
24
Web/View/Hubs/New.hs
Normal 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
141
Web/View/Hubs/Show.hs
Normal 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
40
Web/View/Sessions/New.hs
Normal 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
27
Web/View/Widgets/Edit.hs
Normal 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
69
Web/View/Widgets/Index.hs
Normal 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
59
Web/View/Widgets/New.hs
Normal 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
162
Web/View/Widgets/Show.hs
Normal 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>
|
||||
|]
|
||||
56
docs/widget-envelope-convention.md
Normal file
56
docs/widget-envelope-convention.md
Normal 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.
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user