generated from coulomb/repo-seed
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>
205 lines
7.0 KiB
JavaScript
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 };
|
|
}
|