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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user