feat(P6/T04): WidgetAdapterSpecsController, registry, widget adapter integration

CRUD for WidgetAdapterSpec (index, show, new/create, edit/update — status+notes only
after creation). Widget new/edit forms expose optional adapter_spec_id select.
Widget show page renders adapter badge with link to spec. Widgets controller
fetches adapter spec for show action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:14:57 +00:00
parent 14779f0768
commit 32bb003f3b
10 changed files with 493 additions and 23 deletions

View File

@@ -0,0 +1,77 @@
module Web.Controller.WidgetAdapterSpecs where
import Web.Types
import Web.View.WidgetAdapterSpecs.Index
import Web.View.WidgetAdapterSpecs.Show
import Web.View.WidgetAdapterSpecs.New
import Web.View.WidgetAdapterSpecs.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller WidgetAdapterSpecsController where
beforeAction = ensureIsUser
action WidgetAdapterSpecsAction = do
specs <- query @WidgetAdapterSpec |> orderByAsc #name |> fetch
envelopes <- query @EnvelopeEmissionContract |> fetch
reportings <- query @InteractionReportingContract |> fetch
render IndexView { specs, envelopes, reportings }
action ShowWidgetAdapterSpecAction { widgetAdapterSpecId } = do
spec <- fetch widgetAdapterSpecId
mEnvelope <- case spec.envelopeContractId of
Nothing -> pure Nothing
Just eid -> fetchOneOrNothing eid
mReporting <- case spec.reportingContractId of
Nothing -> pure Nothing
Just rid -> fetchOneOrNothing rid
widgets <- query @Widget
|> filterWhere (#adapterSpecId, Just widgetAdapterSpecId)
|> orderByAsc #name
|> fetch
render ShowView { spec, mEnvelope, mReporting, widgets }
action NewWidgetAdapterSpecAction = do
let spec = newRecord @WidgetAdapterSpec
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
render NewView { spec, envelopes, reportings }
action CreateWidgetAdapterSpecAction = do
let spec = newRecord @WidgetAdapterSpec
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
spec
|> fill @'["name", "framework", "version", "envelopeContractId", "reportingContractId", "status", "notes"]
|> validateField #name nonEmpty
|> validateField #framework nonEmpty
|> validateField #version nonEmpty
|> validateField #status (isInList ["draft", "active", "deprecated"])
|> ifValid \case
Left spec -> render NewView { spec, envelopes, reportings }
Right spec -> do
spec <- createRecord spec
setSuccessMessage ("Adapter spec '" <> spec.name <> "' registered")
redirectTo ShowWidgetAdapterSpecAction { widgetAdapterSpecId = spec.id }
action EditWidgetAdapterSpecAction { widgetAdapterSpecId } = do
spec <- fetch widgetAdapterSpecId
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
render EditView { spec, envelopes, reportings }
action UpdateWidgetAdapterSpecAction { widgetAdapterSpecId } = do
spec <- fetch widgetAdapterSpecId
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
-- Only status and notes are editable once a spec exists.
spec
|> fill @'["status", "notes"]
|> validateField #status (isInList ["draft", "active", "deprecated"])
|> ifValid \case
Left spec -> render EditView { spec, envelopes, reportings }
Right spec -> do
spec |> updateRecord
setSuccessMessage "Adapter spec updated"
redirectTo ShowWidgetAdapterSpecAction { widgetAdapterSpecId = spec.id }

View File

@@ -22,8 +22,12 @@ instance Controller WidgetsController where
action NewWidgetAction = do
let widget = newRecord @Widget
hubs <- query @Hub |> fetch
render NewView { widget, hubs }
hubs <- query @Hub |> fetch
adapterSpecs <- query @WidgetAdapterSpec
|> filterWhere (#status, "active")
|> orderByAsc #name
|> fetch
render NewView { widget, hubs, adapterSpecs }
action ShowWidgetAction { widgetId } = do
widget <- fetch widgetId
@@ -57,17 +61,21 @@ instance Controller WidgetsController where
allDeployments <- query @DeploymentRecord |> fetch
let cycleCounts = widgetCycleCounts allCandidates allRequirements allDecisions allDeployments
cycleCount = fromMaybe 0 (lookup widgetId cycleCounts)
render ShowView { widget, hub, versions, events, annotations, recentSignals, isRegressed, cycleCount }
mAdapterSpec <- case widget.adapterSpecId of
Nothing -> pure Nothing
Just sid -> fetchOneOrNothing sid
render ShowView { widget, hub, versions, events, annotations, recentSignals, isRegressed, cycleCount, mAdapterSpec }
action CreateWidgetAction = do
let widget = newRecord @Widget
hubs <- query @Hub |> fetch
hubs <- query @Hub |> fetch
adapterSpecs <- query @WidgetAdapterSpec |> filterWhere (#status, "active") |> orderByAsc #name |> fetch
widget
|> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status"]
|> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status", "adapterSpecId"]
|> validateField #name nonEmpty
|> validateField #widgetType nonEmpty
|> ifValid \case
Left widget -> render NewView { widget, hubs }
Left widget -> render NewView { widget, hubs, adapterSpecs }
Right widget -> do
widget <- createRecord widget
let snapshot = object
@@ -89,19 +97,21 @@ instance Controller WidgetsController where
redirectTo ShowWidgetAction { widgetId = widget.id }
action EditWidgetAction { widgetId } = do
widget <- fetch widgetId
hubs <- query @Hub |> fetch
render EditView { widget, hubs }
widget <- fetch widgetId
hubs <- query @Hub |> fetch
adapterSpecs <- query @WidgetAdapterSpec |> filterWhere (#status, "active") |> orderByAsc #name |> fetch
render EditView { widget, hubs, adapterSpecs }
action UpdateWidgetAction { widgetId } = do
widget <- fetch widgetId
hubs <- query @Hub |> fetch
widget <- fetch widgetId
hubs <- query @Hub |> fetch
adapterSpecs <- query @WidgetAdapterSpec |> filterWhere (#status, "active") |> orderByAsc #name |> fetch
widget
|> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status"]
|> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status", "adapterSpecId"]
|> validateField #name nonEmpty
|> validateField #widgetType nonEmpty
|> ifValid \case
Left widget -> render EditView { widget, hubs }
Left widget -> render EditView { widget, hubs, adapterSpecs }
Right widget -> do
let newVersion = widget.version + 1
widget <- widget |> set #version newVersion |> updateRecord

View File

@@ -0,0 +1,72 @@
module Web.View.WidgetAdapterSpecs.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data EditView = EditView
{ spec :: !WidgetAdapterSpec
, envelopes :: ![EnvelopeEmissionContract]
, reportings :: ![InteractionReportingContract]
}
instance View EditView where
html EditView { .. } = [hsx|
<div class="mb-4">
<a href={ShowWidgetAdapterSpecAction { widgetAdapterSpecId = spec.id }}
class="text-sm text-gray-500 hover:text-gray-800">
{spec.name}
</a>
</div>
<h1 class="text-2xl font-semibold mb-2">Edit Adapter Spec</h1>
<p class="text-sm text-gray-500 mb-6">
Only <strong>status</strong> and <strong>notes</strong> can be changed once a spec is registered.
Name, framework, version, and linked contracts are immutable.
</p>
{renderForm spec}
|]
renderForm :: WidgetAdapterSpec -> Html
renderForm spec = formFor spec [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-5 max-w-lg">
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">Name (read-only)</label>
<div class="text-sm text-gray-600 font-mono bg-gray-50 rounded px-3 py-2">{spec.name}</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">Framework (read-only)</label>
<div class="text-sm text-gray-600 bg-gray-50 rounded px-3 py-2">{spec.framework}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">Version (read-only)</label>
<div class="text-sm text-gray-600 font-mono bg-gray-50 rounded px-3 py-2">{spec.version}</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" class="w-full border border-gray-200 rounded px-3 py-2 text-sm">
<option value="draft" selected={spec.status == "draft"}>draft</option>
<option value="active" selected={spec.status == "active"}>active</option>
<option value="deprecated" selected={spec.status == "deprecated"}>deprecated</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
{textareaField #notes}
</div>
<div class="flex gap-3">
{submitButton}
<a href={ShowWidgetAdapterSpecAction { widgetAdapterSpecId = spec.id }}
class="text-sm text-gray-500 border border-gray-200 rounded px-4 py-2 hover:border-gray-400">
Cancel
</a>
</div>
</div>
|]

View File

@@ -0,0 +1,82 @@
module Web.View.WidgetAdapterSpecs.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (adapterStatusBadge)
data IndexView = IndexView
{ specs :: ![WidgetAdapterSpec]
, envelopes :: ![EnvelopeEmissionContract]
, reportings :: ![InteractionReportingContract]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Widget Adapter Specs</h1>
<p class="text-sm text-gray-500 mt-1">
Register adapters that allow non-IHP UI frameworks to participate in IHF.
</p>
</div>
<a href={NewWidgetAdapterSpecAction}
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700">
+ Register Adapter
</a>
</div>
<div class="flex gap-4 mb-5 text-sm">
<a href={EnvelopeEmissionContractsAction}
class="text-gray-500 hover:text-gray-800">Envelope Contracts ({length envelopes})</a>
<a href={InteractionReportingContractsAction}
class="text-gray-500 hover:text-gray-800">Reporting Contracts ({length reportings})</a>
</div>
{if null specs
then [hsx|<p class="text-sm text-gray-400">No adapter specs registered yet.</p>|]
else renderTable specs}
|]
renderTable :: [WidgetAdapterSpec] -> Html
renderTable specs = [hsx|
<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-600">Name</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Framework</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Version</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach specs renderRow}
</tbody>
</table>
</div>
|]
renderRow :: WidgetAdapterSpec -> Html
renderRow s = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowWidgetAdapterSpecAction { widgetAdapterSpecId = s.id }}
class="font-medium text-indigo-600 hover:underline">{s.name}</a>
</td>
<td class="px-4 py-3">
<span class="bg-purple-100 text-purple-800 text-xs px-2 py-0.5 rounded font-medium">
{s.framework}
</span>
</td>
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{s.version}</td>
<td class="px-4 py-3">
<span class={adapterStatusBadge s.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{s.status}
</span>
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show s.createdAt}</td>
</tr>
|]

View File

@@ -0,0 +1,89 @@
module Web.View.WidgetAdapterSpecs.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ spec :: !WidgetAdapterSpec
, envelopes :: ![EnvelopeEmissionContract]
, reportings :: ![InteractionReportingContract]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="mb-4">
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-500 hover:text-gray-800">
Adapter Specs
</a>
</div>
<h1 class="text-2xl font-semibold mb-6">Register Adapter Spec</h1>
{renderForm spec envelopes reportings}
|]
renderForm :: WidgetAdapterSpec -> [EnvelopeEmissionContract] -> [InteractionReportingContract] -> Html
renderForm spec envelopes reportings = formFor spec [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-5 max-w-lg">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name <span class="text-red-500">*</span></label>
{textField #name}
<p class="text-xs text-gray-400 mt-1">Unique identifier, e.g. react-18, vue-3, web-component</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Framework <span class="text-red-500">*</span></label>
{textField #framework}
<p class="text-xs text-gray-400 mt-1">e.g. react, vue, angular, vanilla</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Version <span class="text-red-500">*</span></label>
{textField #version}
<p class="text-xs text-gray-400 mt-1">Adapter spec version, e.g. 1.0</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Envelope Contract</label>
<select name="envelopeContractId" class="w-full border border-gray-200 rounded px-3 py-2 text-sm">
<option value=""> None </option>
{forEach envelopes (\e -> [hsx|
<option value={tshow e.id}>v{e.contractVersion}</option>
|])}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reporting Contract</label>
<select name="reportingContractId" class="w-full border border-gray-200 rounded px-3 py-2 text-sm">
<option value=""> None </option>
{forEach reportings (\r -> [hsx|
<option value={tshow r.id}>v{r.contractVersion}</option>
|])}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status" class="w-full border border-gray-200 rounded px-3 py-2 text-sm">
<option value="draft">draft</option>
<option value="active">active</option>
<option value="deprecated">deprecated</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
{textareaField #notes}
</div>
<div class="flex gap-3">
{submitButton}
<a href={WidgetAdapterSpecsAction}
class="text-sm text-gray-500 border border-gray-200 rounded px-4 py-2 hover:border-gray-400">
Cancel
</a>
</div>
</div>
|]

View File

@@ -0,0 +1,118 @@
module Web.View.WidgetAdapterSpecs.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (adapterStatusBadge)
data ShowView = ShowView
{ spec :: !WidgetAdapterSpec
, mEnvelope :: !(Maybe EnvelopeEmissionContract)
, mReporting :: !(Maybe InteractionReportingContract)
, widgets :: ![Widget]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-4">
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-500 hover:text-gray-800">
Adapter Specs
</a>
</div>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<span class="bg-purple-100 text-purple-800 text-sm font-medium px-3 py-1 rounded">
{spec.framework}
</span>
<h1 class="text-2xl font-semibold">{spec.name}</h1>
<span class={adapterStatusBadge spec.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{spec.status}
</span>
</div>
<a href={EditWidgetAdapterSpecAction { widgetAdapterSpecId = spec.id }}
class="text-sm text-gray-500 border border-gray-200 rounded px-3 py-1 hover:border-gray-400">
Edit status / notes
</a>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="text-xs text-gray-500 mb-1">Spec Version</div>
<div class="font-mono font-medium">{spec.version}</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="text-xs text-gray-500 mb-1">Registered Widgets</div>
<div class="font-medium">{length widgets}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="text-xs text-gray-500 mb-2">Envelope Contract</div>
{renderEnvelopeLink mEnvelope}
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="text-xs text-gray-500 mb-2">Reporting Contract</div>
{renderReportingLink mReporting}
</div>
</div>
{forEach (specNotes spec) (\n -> [hsx|
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4 mb-4 text-sm text-gray-700">
<strong>Notes:</strong> {n}
</div>
|])}
{if null widgets
then [hsx|<p class="text-sm text-gray-400 mt-6">No widgets assigned to this adapter spec.</p>|]
else [hsx|
<h2 class="text-lg font-semibold mb-3 mt-6">Assigned 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-600">Name</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach widgets renderWidgetRow}
</tbody>
</table>
</div>
|]}
|]
renderEnvelopeLink :: Maybe EnvelopeEmissionContract -> Html
renderEnvelopeLink Nothing = [hsx|<span class="text-gray-400 text-sm"></span>|]
renderEnvelopeLink (Just c) = [hsx|
<a href={ShowEnvelopeEmissionContractAction { envelopeEmissionContractId = c.id }}
class="font-mono text-indigo-600 hover:underline text-sm">v{c.contractVersion}</a>
|]
renderReportingLink :: Maybe InteractionReportingContract -> Html
renderReportingLink Nothing = [hsx|<span class="text-gray-400 text-sm"></span>|]
renderReportingLink (Just c) = [hsx|
<a href={ShowInteractionReportingContractAction { interactionReportingContractId = c.id }}
class="font-mono text-indigo-600 hover:underline text-sm">v{c.contractVersion}</a>
|]
renderWidgetRow :: Widget -> Html
renderWidgetRow w = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowWidgetAction { widgetId = w.id }}
class="text-indigo-600 hover:underline">{w.name}</a>
</td>
<td class="px-4 py-3 text-gray-500 text-xs">{w.widgetType}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{w.status}</td>
</tr>
|]
specNotes :: WidgetAdapterSpec -> [Text]
specNotes s = case s.notes of
Just n -> [n]
Nothing -> []

View File

@@ -7,8 +7,9 @@ import IHP.ViewPrelude
import Web.View.Widgets.New (renderForm)
data EditView = EditView
{ widget :: !Widget
, hubs :: ![Hub]
{ widget :: !Widget
, hubs :: ![Hub]
, adapterSpecs :: ![WidgetAdapterSpec]
}
instance View EditView where
@@ -22,6 +23,6 @@ instance View EditView where
<span>Edit</span>
</div>
<h1 class="text-2xl font-semibold mb-6">Edit Widget</h1>
{renderForm widget hubs}
{renderForm widget hubs adapterSpecs}
</div>
|]

View File

@@ -6,20 +6,21 @@ import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ widget :: !Widget
, hubs :: ![Hub]
{ widget :: !Widget
, hubs :: ![Hub]
, adapterSpecs :: ![WidgetAdapterSpec]
}
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}
{renderForm widget hubs adapterSpecs}
</div>
|]
renderForm :: Widget -> [Hub] -> Html
renderForm widget hubs = formFor widget [hsx|
renderForm :: Widget -> [Hub] -> [WidgetAdapterSpec] -> Html
renderForm widget hubs adapterSpecs = formFor widget [hsx|
{textField #name}
{selectField #widgetType widgetTypeOptions}
{selectField #hubId (hubOptions hubs)}
@@ -27,6 +28,15 @@ renderForm widget hubs = formFor widget [hsx|
{textField #viewContext}
{selectField #policyScope policyScopeOptions}
{selectField #status statusOptions}
<div>
<label class="ihp-form-label">Adapter Spec (optional leave blank for native IHP widget)</label>
<select name="adapterSpecId" class="ihp-form-field">
<option value=""> Native IHP widget </option>
{forEach adapterSpecs (\s -> [hsx|
<option value={tshow s.id}>{s.name} ({s.framework} v{s.version})</option>
|])}
</select>
</div>
{submitButton}
|]

View File

@@ -15,6 +15,7 @@ data ShowView = ShowView
, recentSignals :: ![OutcomeSignal]
, isRegressed :: !Bool
, cycleCount :: !Int
, mAdapterSpec :: !(Maybe WidgetAdapterSpec)
}
instance View ShowView where
@@ -54,6 +55,7 @@ instance View ShowView where
<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>
{renderAdapterBadge mAdapterSpec}
</p>
</div>
<a href={EditWidgetAction { widgetId = widget.id }}
@@ -222,3 +224,12 @@ signalTypeClass "regressed" = "bg-red-100 text-red-800"
signalTypeClass "neutral" = "bg-gray-100 text-gray-600"
signalTypeClass "inconclusive" = "bg-yellow-100 text-yellow-800"
signalTypeClass _ = "bg-gray-100 text-gray-600"
renderAdapterBadge :: Maybe WidgetAdapterSpec -> Html
renderAdapterBadge Nothing = mempty
renderAdapterBadge (Just s) = [hsx|
<a href={ShowWidgetAdapterSpecAction { widgetAdapterSpecId = s.id }}
class="ml-2 text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded hover:bg-purple-200">
adapter: {s.name}
</a>
|]

View File

@@ -135,7 +135,7 @@ CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id);
```task
id: IHUB-WP-0006-T02
status: todo
status: done
priority: high
state_hub_task_id: "298af675-550b-480b-bed6-05efc79cd0c9"
```
@@ -163,7 +163,7 @@ against it; contract index/show pages render correctly.
```task
id: IHUB-WP-0006-T03
status: todo
status: done
priority: high
state_hub_task_id: "f2767465-ff00-48be-b2dc-5bf3b179cca9"
```