diff --git a/docs/react-adapter.md b/docs/react-adapter.md new file mode 100644 index 0000000..db0ba8f --- /dev/null +++ b/docs/react-adapter.md @@ -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 ` + + + + + + + + + diff --git a/static/js/ihf-react-adapter.js b/static/js/ihf-react-adapter.js new file mode 100644 index 0000000..2ffe784 --- /dev/null +++ b/static/js/ihf-react-adapter.js @@ -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
; + * } + */ +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 ( + * + * ); + * } + */ +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} 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 }; +} diff --git a/workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md b/workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md index e886de2..452a744 100644 --- a/workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md +++ b/workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md @@ -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" ```