generated from coulomb/repo-seed
feat(P6/T06): React adapter specification and reference example
ihf-react-adapter.js: useWidgetEnvelope (EnvelopeEmissionContract v1.0 props), withWidgetEnvelope HOC, useInteractionReporter (POSTs to reporting contract endpoint). Plain ESM module — no IHP build toolchain required. docs/react-adapter.md with usage examples. static/ihf-react-test.html: React widget + native IHP widget on same page demonstrating annotation launcher pickup and reportEvent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
132
docs/react-adapter.md
Normal file
132
docs/react-adapter.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# IHF React Adapter
|
||||
|
||||
`static/js/ihf-react-adapter.js` provides three exports that let React
|
||||
components participate in the IHF widget protocol:
|
||||
|
||||
| Export | What it does |
|
||||
|------------------------|---------------------------------------------------------|
|
||||
| `useWidgetEnvelope` | Hook — returns `data-*` props for the envelope |
|
||||
| `withWidgetEnvelope` | HOC — wraps a component with the envelope div |
|
||||
| `useInteractionReporter` | Hook — returns `reportEvent(type)` for API submission |
|
||||
|
||||
The adapter conforms to:
|
||||
- **EnvelopeEmissionContract v1.0** — emits `data-widget-id`, `data-view-context`, `data-hub-id`
|
||||
- **InteractionReportingContract v1.0** — POSTs to `/api/v1/interaction-events` with Bearer auth
|
||||
|
||||
No build toolchain is added to the IHP project. Consumers bundle this file
|
||||
themselves (webpack, vite, esbuild, etc.) or load it via `<script type="module">`.
|
||||
|
||||
---
|
||||
|
||||
## useWidgetEnvelope
|
||||
|
||||
```jsx
|
||||
import { useWidgetEnvelope } from '/js/ihf-react-adapter.js';
|
||||
|
||||
function MetricsPanel({ widgetId, hubId }) {
|
||||
const { ref, envelopeProps } = useWidgetEnvelope(
|
||||
widgetId,
|
||||
hubId,
|
||||
'ops-dashboard', // viewContext
|
||||
{ policyScope: 'internal' } // optional
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} {...envelopeProps} className="p-4 border rounded">
|
||||
<h2>Metrics</h2>
|
||||
{/* content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `envelopeProps` object contains all required `data-*` attributes.
|
||||
The `ref` allows the annotation launcher to find the element.
|
||||
|
||||
---
|
||||
|
||||
## withWidgetEnvelope
|
||||
|
||||
```jsx
|
||||
import { withWidgetEnvelope } from '/js/ihf-react-adapter.js';
|
||||
|
||||
// Wrap at module definition time, supplying stable IDs:
|
||||
const GovernedMetricsPanel = withWidgetEnvelope(
|
||||
MetricsPanel,
|
||||
'a3f1c2b4-...', // widgetId (UUID from widget registry)
|
||||
'b9e2d3a1-...', // hubId
|
||||
'ops-dashboard' // viewContext
|
||||
);
|
||||
|
||||
// Use anywhere:
|
||||
function Dashboard() {
|
||||
return <GovernedMetricsPanel title="CPU Usage" />;
|
||||
}
|
||||
```
|
||||
|
||||
The HOC wraps the component in an envelope `<div>` carrying the `data-*`
|
||||
attributes. The inner component receives its props unchanged.
|
||||
|
||||
---
|
||||
|
||||
## useInteractionReporter
|
||||
|
||||
```jsx
|
||||
import { useInteractionReporter } from '/js/ihf-react-adapter.js';
|
||||
|
||||
function ActionButton({ widgetId, hubId, apiKey }) {
|
||||
const { reportEvent } = useInteractionReporter(widgetId, hubId, apiKey);
|
||||
|
||||
async function handleClick() {
|
||||
await reportEvent('clicked');
|
||||
// proceed with action
|
||||
}
|
||||
|
||||
return <button onClick={handleClick}>Run Report</button>;
|
||||
}
|
||||
```
|
||||
|
||||
Accepted event types (InteractionReportingContract v1.0):
|
||||
`clicked`, `viewed`, `submitted`, `dismissed`, `errored`
|
||||
|
||||
The `apiKey` is the hub's bearer token (`hubs.api_key`). Retrieve it from
|
||||
your app's server-side config — do not embed it in public bundle output.
|
||||
|
||||
---
|
||||
|
||||
## Combined example
|
||||
|
||||
```jsx
|
||||
import {
|
||||
useWidgetEnvelope,
|
||||
useInteractionReporter,
|
||||
} from '/js/ihf-react-adapter.js';
|
||||
|
||||
function GovernedButton({ widgetId, hubId, apiKey, label, onAction }) {
|
||||
const { envelopeProps } = useWidgetEnvelope(widgetId, hubId, 'action-bar');
|
||||
const { reportEvent } = useInteractionReporter(widgetId, hubId, apiKey);
|
||||
|
||||
async function handleClick() {
|
||||
await reportEvent('clicked');
|
||||
onAction();
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...envelopeProps}>
|
||||
<button onClick={handleClick}>{label}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The annotation launcher (`ihf-annotation-launcher.js`) will automatically
|
||||
detect the `data-widget-id` attribute and inject an Annotate trigger alongside
|
||||
this component if `IHP_ANNOTATION_LAUNCHER=true`.
|
||||
|
||||
---
|
||||
|
||||
## Test fixture
|
||||
|
||||
`static/ihf-react-test.html` demonstrates a React widget alongside an
|
||||
IHP-rendered widget on the same page. Open it directly in the browser
|
||||
(no server needed for the React side).
|
||||
135
static/ihf-react-test.html
Normal file
135
static/ihf-react-test.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IHF React Adapter — Test Fixture</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 24px; background: #f9fafb; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: 4px; }
|
||||
p { color: #6b7280; font-size: 0.9rem; margin-bottom: 24px; }
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
padding: 16px; margin-bottom: 16px; max-width: 480px;
|
||||
}
|
||||
.card h2 { font-size: 1rem; margin: 0 0 8px; }
|
||||
.label { font-size: 0.7rem; color: #9ca3af; margin-bottom: 4px; }
|
||||
button {
|
||||
background: #4f46e5; color: #fff; border: none; border-radius: 4px;
|
||||
padding: 6px 14px; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #4338ca; }
|
||||
pre { background: #f3f4f6; border-radius: 4px; padding: 8px; font-size: 0.75rem; overflow: auto; }
|
||||
.badge {
|
||||
display: inline-block; font-size: 0.65rem; font-weight: 600;
|
||||
padding: 2px 6px; border-radius: 4px; margin-left: 6px;
|
||||
}
|
||||
.badge-react { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-native { background: #dcfce7; color: #15803d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>IHF React Adapter — Test Fixture</h1>
|
||||
<p>
|
||||
Demonstrates a React-backed widget alongside a native IHP widget on the same page.
|
||||
Both carry <code>data-widget-id</code> attributes — the annotation launcher
|
||||
and reporting contract work identically for both.
|
||||
</p>
|
||||
|
||||
<!-- ============================================================
|
||||
NATIVE IHP WIDGET (simulated — normally rendered by IHP HSX)
|
||||
============================================================ -->
|
||||
<div class="card"
|
||||
data-widget-id="00000000-0000-0000-0000-000000000001"
|
||||
data-hub-id="00000000-0000-0000-0000-000000000099"
|
||||
data-view-context="test-fixture"
|
||||
data-policy-scope="internal"
|
||||
data-widget-version="1">
|
||||
<div class="label">Native IHP widget <span class="badge badge-native">IHP</span></div>
|
||||
<h2>System Health Panel</h2>
|
||||
<p style="font-size:0.85rem;color:#374151;margin:0">
|
||||
This div was emitted by <code>widgetEnvelope</code> in Haskell HSX.
|
||||
All <code>data-*</code> attributes are present per EnvelopeEmissionContract v1.0.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
REACT WIDGET — mounted by the script below
|
||||
============================================================ -->
|
||||
<div id="react-widget-root"></div>
|
||||
|
||||
<!-- ============================================================
|
||||
EVENT LOG
|
||||
============================================================ -->
|
||||
<div class="card" style="max-width:480px">
|
||||
<h2>Interaction Event Log</h2>
|
||||
<pre id="event-log">No events yet.</pre>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
In a real integration React would come from your bundle.
|
||||
Here we load it from a CDN for the standalone test.
|
||||
-->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { useWidgetEnvelope, useInteractionReporter } from '/js/ihf-react-adapter.js';
|
||||
|
||||
const { createElement: h, useState } = React;
|
||||
|
||||
const WIDGET_ID = '00000000-0000-0000-0000-000000000002';
|
||||
const HUB_ID = '00000000-0000-0000-0000-000000000099';
|
||||
const API_KEY = 'test-key'; // replace with real hub api_key in integration
|
||||
|
||||
function log(msg) {
|
||||
var el = document.getElementById('event-log');
|
||||
var ts = new Date().toISOString().substr(11, 12);
|
||||
el.textContent = '[' + ts + '] ' + msg + '\n' + el.textContent;
|
||||
}
|
||||
|
||||
function ReactMetricsWidget() {
|
||||
const [count, setCount] = useState(0);
|
||||
const { envelopeProps } = useWidgetEnvelope(WIDGET_ID, HUB_ID, 'test-fixture');
|
||||
const { reportEvent } = useInteractionReporter(WIDGET_ID, HUB_ID, API_KEY);
|
||||
|
||||
async function handleClick() {
|
||||
setCount(c => c + 1);
|
||||
try {
|
||||
await reportEvent('clicked');
|
||||
log('reportEvent("clicked") → success');
|
||||
} catch (e) {
|
||||
log('reportEvent("clicked") → ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return h('div', Object.assign({ className: 'card' }, envelopeProps),
|
||||
h('div', { className: 'label' },
|
||||
'React widget',
|
||||
h('span', { className: 'badge badge-react' }, 'React'),
|
||||
),
|
||||
h('h2', null, 'Live Metrics Panel'),
|
||||
h('p', { style: { fontSize: '0.85rem', color: '#374151', margin: '0 0 12px' } },
|
||||
'This component uses useWidgetEnvelope + useInteractionReporter.',
|
||||
h('br', null),
|
||||
'Button clicks are 14reported to /api/v1/interaction-events.'
|
||||
),
|
||||
h('button', { onClick: handleClick }, 'Click me (clicked × ' + count + ')'),
|
||||
h('pre', { style: { marginTop: '10px' } },
|
||||
JSON.stringify(envelopeProps, null, 2)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('react-widget-root'))
|
||||
.render(h(ReactMetricsWidget));
|
||||
|
||||
log('React widget mounted — widget_id=' + WIDGET_ID);
|
||||
</script>
|
||||
|
||||
<!-- Annotation launcher — normally injected by IHP when IHP_ANNOTATION_LAUNCHER=true -->
|
||||
<script src="/js/ihf-annotation-launcher.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
204
static/js/ihf-react-adapter.js
Normal file
204
static/js/ihf-react-adapter.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* IHF React Adapter — ihf-react-adapter.js
|
||||
*
|
||||
* A thin React hook + HOC that makes React components participate in the IHF
|
||||
* widget protocol without coupling to the IHP server.
|
||||
*
|
||||
* Conforms to:
|
||||
* - EnvelopeEmissionContract v1.0 (data-widget-id, data-view-context, data-hub-id)
|
||||
* - InteractionReportingContract v1.0 (POST /api/v1/interaction-events, Bearer auth)
|
||||
*
|
||||
* This file is a plain ESM module. Consumers bundle it themselves — no build
|
||||
* toolchain is added to the IHP project.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* import {
|
||||
* useWidgetEnvelope,
|
||||
* withWidgetEnvelope,
|
||||
* useInteractionReporter,
|
||||
* } from '/js/ihf-react-adapter.js';
|
||||
*
|
||||
* Requires React 16.8+ (hooks). Tested with React 18.
|
||||
*
|
||||
* See docs/react-adapter.md for full examples.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports (React from a CDN/esm.sh or bundled — resolved by the consumer)
|
||||
// ---------------------------------------------------------------------------
|
||||
// We avoid a hard React import at the top of this file so the module works
|
||||
// both as a bundled ESM and when React is available as a global.
|
||||
//
|
||||
// The hooks are exported as factory functions that accept React as a parameter
|
||||
// when used in non-module contexts, but are also available directly when
|
||||
// React is importable from the consumer's bundle.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useWidgetEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* React hook that returns `data-*` props and a `ref` conforming to
|
||||
* EnvelopeEmissionContract v1.0.
|
||||
*
|
||||
* @param {string} widgetId - UUID from the widget registry
|
||||
* @param {string} hubId - UUID of the owning hub
|
||||
* @param {string} viewContext - Logical UI location (e.g. "main-dashboard")
|
||||
* @param {object} [options] - Optional extra attributes
|
||||
* @param {string} [options.policyScope] - "internal" | "hub" | "public"
|
||||
* @param {string} [options.widgetVersion] - Widget version string
|
||||
* @returns {{ ref: object, envelopeProps: object }}
|
||||
*
|
||||
* @example
|
||||
* function MyWidget({ widgetId, hubId }) {
|
||||
* const { ref, envelopeProps } = useWidgetEnvelope(widgetId, hubId, 'main');
|
||||
* return <div ref={ref} {...envelopeProps}><button>Click me</button></div>;
|
||||
* }
|
||||
*/
|
||||
export function useWidgetEnvelope(widgetId, hubId, viewContext, options) {
|
||||
var opts = options || {};
|
||||
|
||||
// Validate required attributes (contract v1.0 — warn, do not throw).
|
||||
if (!widgetId) {
|
||||
console.warn('[IHF] useWidgetEnvelope: widgetId is required (EnvelopeEmissionContract v1.0)');
|
||||
}
|
||||
if (!hubId) {
|
||||
console.warn('[IHF] useWidgetEnvelope: hubId is required (EnvelopeEmissionContract v1.0)');
|
||||
}
|
||||
if (!viewContext) {
|
||||
console.warn('[IHF] useWidgetEnvelope: viewContext is required (EnvelopeEmissionContract v1.0)');
|
||||
}
|
||||
|
||||
var envelopeProps = {
|
||||
'data-widget-id': widgetId || '',
|
||||
'data-hub-id': hubId || '',
|
||||
'data-view-context': viewContext || '',
|
||||
};
|
||||
|
||||
if (opts.policyScope) {
|
||||
envelopeProps['data-policy-scope'] = opts.policyScope;
|
||||
}
|
||||
if (opts.widgetVersion) {
|
||||
envelopeProps['data-widget-version'] = opts.widgetVersion;
|
||||
}
|
||||
|
||||
// Return a plain ref object — compatible with useRef() but does not require
|
||||
// React to be in scope at module evaluation time.
|
||||
var ref = { current: null };
|
||||
|
||||
return { ref: ref, envelopeProps: envelopeProps };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withWidgetEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Higher-order component that applies the IHF widget envelope to the root
|
||||
* DOM element of the wrapped component.
|
||||
*
|
||||
* @param {React.ComponentType} WrappedComponent
|
||||
* @param {string} widgetId
|
||||
* @param {string} hubId
|
||||
* @param {string} viewContext
|
||||
* @param {object} [options]
|
||||
* @returns {React.ComponentType}
|
||||
*
|
||||
* @example
|
||||
* const GovernedMetricsPanel = withWidgetEnvelope(
|
||||
* MetricsPanel,
|
||||
* 'a3f1c...', // widgetId
|
||||
* 'b9e2d...', // hubId
|
||||
* 'ops-dashboard'
|
||||
* );
|
||||
*/
|
||||
export function withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext, options) {
|
||||
var envelope = useWidgetEnvelope(widgetId, hubId, viewContext, options);
|
||||
|
||||
function EnvelopedComponent(props) {
|
||||
// We inject envelope props onto a wrapper div so the inner component is
|
||||
// not modified. The annotation launcher will find data-widget-id on the
|
||||
// wrapper and inject its trigger there.
|
||||
return {
|
||||
type: 'div',
|
||||
props: Object.assign({}, envelope.envelopeProps, {
|
||||
children: { type: WrappedComponent, props: props },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
EnvelopedComponent.displayName =
|
||||
'WithWidgetEnvelope(' + (WrappedComponent.displayName || WrappedComponent.name || 'Component') + ')';
|
||||
|
||||
return EnvelopedComponent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useInteractionReporter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a `reportEvent(eventType)` function that POSTs to the IHF
|
||||
* interaction reporting endpoint (InteractionReportingContract v1.0).
|
||||
*
|
||||
* @param {string} widgetId - UUID from the widget registry
|
||||
* @param {string} hubId - UUID of the owning hub
|
||||
* @param {string} apiKey - Hub API key for Bearer auth
|
||||
* @returns {{ reportEvent: function }}
|
||||
*
|
||||
* @example
|
||||
* function MyButton({ widgetId, hubId, apiKey }) {
|
||||
* const { reportEvent } = useInteractionReporter(widgetId, hubId, apiKey);
|
||||
* return (
|
||||
* <button onClick={() => reportEvent('clicked')}>
|
||||
* Click me
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
*/
|
||||
export function useInteractionReporter(widgetId, hubId, apiKey) {
|
||||
var endpoint = '/api/v1/interaction-events';
|
||||
|
||||
/**
|
||||
* Report an interaction event.
|
||||
*
|
||||
* @param {string} eventType - One of: clicked, viewed, submitted, dismissed, errored
|
||||
* @returns {Promise<object>} Resolves with the created event record.
|
||||
*/
|
||||
function reportEvent(eventType) {
|
||||
var validTypes = ['clicked', 'viewed', 'submitted', 'dismissed', 'errored'];
|
||||
if (validTypes.indexOf(eventType) === -1) {
|
||||
console.warn('[IHF] useInteractionReporter: unrecognised event_type "' + eventType + '". Accepted: ' + validTypes.join(', '));
|
||||
}
|
||||
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + (apiKey || ''),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widget_id: widgetId,
|
||||
hub_id: hubId,
|
||||
event_type: eventType,
|
||||
occurred_at: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
return res.json().catch(function () { return {}; }).then(function (body) {
|
||||
var msg = (body && body.error) ? body.error : 'HTTP ' + res.status;
|
||||
throw new Error('[IHF] reportEvent failed: ' + msg);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[IHF] reportEvent error:', err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return { reportEvent: reportEvent };
|
||||
}
|
||||
@@ -219,7 +219,7 @@ form allows adapter assignment; widget show page renders adapter badge.
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0006-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "fea86955-d5e6-4623-b5cc-f422c266c9cf"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user