Files
inter-hub/static/js/ihf-react-adapter.js
Bernd Worsch 803376b09a 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>
2026-03-29 21:19:26 +00:00

205 lines
7.0 KiB
JavaScript

/**
* 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 };
}