generated from coulomb/repo-seed
feat(P6/T05): cross-framework annotation launcher JS widget
ihf-annotation-launcher.js: vanilla JS, no framework dependency. Scans DOM for data-widget-id elements, injects Annotate trigger, opens inline form, POSTs to /widgets/:widgetId/annotations. Works in React/Vue-rendered pages via MutationObserver. Feature-gated by IHP_ANNOTATION_LAUNCHER=true env var (Config.hs AnnotationLauncherEnabled, FrontController layout conditional). Docs: docs/annotation-launcher.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,13 @@ module Config where
|
||||
import IHP.Prelude
|
||||
import IHP.Environment
|
||||
import IHP.FrameworkConfig
|
||||
import System.Environment (lookupEnv)
|
||||
|
||||
-- | Feature flag: set IHP_ANNOTATION_LAUNCHER=true to inject the JS launcher.
|
||||
newtype AnnotationLauncherEnabled = AnnotationLauncherEnabled Bool deriving (Typeable)
|
||||
|
||||
config :: ConfigBuilder
|
||||
config = do
|
||||
-- See https://ihp.digitallyinduced.com/Guide/config.html
|
||||
-- for what you can do here
|
||||
pure ()
|
||||
launcherEnv <- liftIO (lookupEnv "IHP_ANNOTATION_LAUNCHER")
|
||||
option (AnnotationLauncherEnabled (launcherEnv == Just "true"))
|
||||
pure ()
|
||||
|
||||
@@ -2,9 +2,11 @@ module Web.FrontController where
|
||||
|
||||
import IHP.RouterPrelude
|
||||
import IHP.LoginSupport.Middleware
|
||||
import IHP.ControllerPrelude (getAppConfig)
|
||||
import Generated.Types
|
||||
import Web.Types
|
||||
import Web.Routes ()
|
||||
import Config (AnnotationLauncherEnabled (..))
|
||||
|
||||
-- Controllers
|
||||
import Web.Controller.Hubs ()
|
||||
@@ -47,6 +49,13 @@ instance InitControllerContext WebApplication where
|
||||
setLayout defaultLayout
|
||||
initAuthentication @User
|
||||
|
||||
annotationLauncherScript :: (?context :: ControllerContext) => Html
|
||||
annotationLauncherScript =
|
||||
let AnnotationLauncherEnabled enabled = getAppConfig @AnnotationLauncherEnabled
|
||||
in if enabled
|
||||
then [hsx|<script src="/js/ihf-annotation-launcher.js"></script>|]
|
||||
else mempty
|
||||
|
||||
defaultLayout :: Layout
|
||||
defaultLayout inner = [hsx|
|
||||
<!DOCTYPE html>
|
||||
@@ -59,6 +68,7 @@ defaultLayout inner = [hsx|
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<script src="/vendor/morphdom.js"></script>
|
||||
<script src="/vendor/ihp-auto-refresh.js"></script>
|
||||
{annotationLauncherScript}
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900">
|
||||
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
|
||||
79
docs/annotation-launcher.md
Normal file
79
docs/annotation-launcher.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# IHF Annotation Launcher
|
||||
|
||||
`static/js/ihf-annotation-launcher.js` is a self-contained vanilla JS module
|
||||
that injects an **Annotate** button adjacent to every element that carries a
|
||||
`data-widget-id` attribute.
|
||||
|
||||
It works in IHP server-rendered pages **and** in React/Vue pages where IHP does
|
||||
not own the DOM — the launcher relies solely on the presence of `data-widget-id`,
|
||||
not on any framework-specific structure.
|
||||
|
||||
## Activation
|
||||
|
||||
Set the environment variable before starting the server:
|
||||
|
||||
```bash
|
||||
IHP_ANNOTATION_LAUNCHER=true devenv up
|
||||
```
|
||||
|
||||
This causes IHP to emit the following tag in every page's `<head>`:
|
||||
|
||||
```html
|
||||
<script src="/js/ihf-annotation-launcher.js"></script>
|
||||
```
|
||||
|
||||
When `IHP_ANNOTATION_LAUNCHER` is absent or not `"true"`, the script is not
|
||||
loaded.
|
||||
|
||||
## Data attributes
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|--------------------|----------|--------------------------------------------------|
|
||||
| `data-widget-id` | Yes | UUID of the widget (from the widget registry) |
|
||||
| `data-hub-id` | No | UUID of the hub — read from element or nearest ancestor |
|
||||
| `data-view-context`| No | Logical UI location (informational) |
|
||||
|
||||
## Usage in React
|
||||
|
||||
Because the launcher uses `MutationObserver`, it will pick up React-rendered
|
||||
elements after mount:
|
||||
|
||||
```jsx
|
||||
function MyWidget({ widgetId, hubId }) {
|
||||
return (
|
||||
<div data-widget-id={widgetId} data-hub-id={hubId}>
|
||||
<button>Do something</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
No other integration is required. The launcher injects the Annotate button and
|
||||
handles POST submission to `/widgets/:widgetId/annotations`.
|
||||
|
||||
## Programmatic re-scan
|
||||
|
||||
If your SPA does client-side navigation without full page reloads, call:
|
||||
|
||||
```js
|
||||
window.IHFAnnotationLauncher.scan();
|
||||
```
|
||||
|
||||
## Submission
|
||||
|
||||
The launcher POSTs `application/x-www-form-urlencoded` to:
|
||||
|
||||
```
|
||||
POST /widgets/:widgetId/annotations
|
||||
```
|
||||
|
||||
Fields submitted: `body`, `category`, `severity` (fixed `low`), CSRF token.
|
||||
|
||||
Responses:
|
||||
- `200` or redirect → success (form closes with confirmation)
|
||||
- Any non-OK response → inline error message
|
||||
|
||||
## CSRF
|
||||
|
||||
The launcher reads the CSRF token from `<meta name="csrf-token" content="...">`.
|
||||
IHP emits this tag automatically in authenticated layouts.
|
||||
260
static/js/ihf-annotation-launcher.js
Normal file
260
static/js/ihf-annotation-launcher.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* IHF Annotation Launcher — ihf-annotation-launcher.js
|
||||
*
|
||||
* A self-contained vanilla JS module (no framework dependency) that injects
|
||||
* an "Annotate" trigger adjacent to every element carrying a `data-widget-id`
|
||||
* attribute. Works in IHP server-rendered pages and in React/Vue pages where
|
||||
* IHP does not own the DOM.
|
||||
*
|
||||
* Activation: include this script in the page layout (or load it dynamically).
|
||||
* It is gated by the IHP_ANNOTATION_LAUNCHER environment variable on the server,
|
||||
* which controls whether the <script> tag is emitted at all.
|
||||
*
|
||||
* Data attributes read:
|
||||
* data-widget-id — UUID of the widget (required)
|
||||
* data-hub-id — UUID of the owning hub (read from element or nearest ancestor)
|
||||
*
|
||||
* The annotation form POSTs to /annotations (IHP's existing AnnotationsController).
|
||||
*
|
||||
* Usage (static HTML test):
|
||||
* <div data-widget-id="<uuid>" data-hub-id="<uuid>" data-view-context="main">
|
||||
* <button>My Widget</button>
|
||||
* </div>
|
||||
* <script src="/js/ihf-annotation-launcher.js"></script>
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var LAUNCHER_CLASS = 'ihf-launcher-injected';
|
||||
var TRIGGER_CLASS = 'ihf-annotate-trigger';
|
||||
var FORM_CLASS = 'ihf-annotate-form';
|
||||
|
||||
var CATEGORY_OPTIONS = [
|
||||
{ value: 'friction', label: 'Friction' },
|
||||
{ value: 'suggestion', label: 'Suggestion' },
|
||||
{ value: 'confusing', label: 'Confusing' },
|
||||
{ value: 'praise', label: 'Praise' },
|
||||
{ value: 'bug', label: 'Bug' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
/** Find data-hub-id from element or nearest ancestor. */
|
||||
function resolveHubId(el) {
|
||||
var cur = el;
|
||||
while (cur && cur !== document.body) {
|
||||
var v = cur.getAttribute('data-hub-id');
|
||||
if (v) return v;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the inline annotation form element. */
|
||||
function buildForm(widgetId, hubId, trigger) {
|
||||
var form = document.createElement('div');
|
||||
form.className = FORM_CLASS;
|
||||
form.style.cssText = [
|
||||
'position:absolute',
|
||||
'z-index:9999',
|
||||
'background:#fff',
|
||||
'border:1px solid #d1d5db',
|
||||
'border-radius:8px',
|
||||
'padding:12px',
|
||||
'box-shadow:0 4px 16px rgba(0,0,0,0.12)',
|
||||
'width:280px',
|
||||
'font-family:sans-serif',
|
||||
'font-size:13px',
|
||||
].join(';');
|
||||
|
||||
var categoryOptions = CATEGORY_OPTIONS.map(function (o) {
|
||||
return '<option value="' + o.value + '">' + o.label + '</option>';
|
||||
}).join('');
|
||||
|
||||
form.innerHTML = [
|
||||
'<div style="font-weight:600;margin-bottom:8px;color:#374151">Add Annotation</div>',
|
||||
'<textarea id="ihf-body" placeholder="Describe your feedback…"',
|
||||
' style="width:100%;height:70px;resize:vertical;border:1px solid #d1d5db;',
|
||||
' border-radius:4px;padding:6px;font-size:12px;box-sizing:border-box"></textarea>',
|
||||
'<select id="ihf-category"',
|
||||
' style="width:100%;margin-top:6px;border:1px solid #d1d5db;border-radius:4px;',
|
||||
' padding:5px;font-size:12px">',
|
||||
categoryOptions,
|
||||
'</select>',
|
||||
'<div style="display:flex;gap:6px;margin-top:8px">',
|
||||
' <button id="ihf-submit"',
|
||||
' style="background:#4f46e5;color:#fff;border:none;border-radius:4px;',
|
||||
' padding:5px 12px;font-size:12px;cursor:pointer">Submit</button>',
|
||||
' <button id="ihf-cancel"',
|
||||
' style="background:transparent;border:1px solid #d1d5db;border-radius:4px;',
|
||||
' padding:5px 10px;font-size:12px;cursor:pointer">Cancel</button>',
|
||||
' <span id="ihf-msg" style="align-self:center;font-size:11px;color:#6b7280"></span>',
|
||||
'</div>',
|
||||
].join('');
|
||||
|
||||
// Position below the trigger button.
|
||||
function reposition() {
|
||||
var rect = trigger.getBoundingClientRect();
|
||||
form.style.top = (window.scrollY + rect.bottom + 4) + 'px';
|
||||
form.style.left = (window.scrollX + rect.left) + 'px';
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.style.position = 'absolute';
|
||||
reposition();
|
||||
|
||||
var bodyEl = form.querySelector('#ihf-body');
|
||||
var catEl = form.querySelector('#ihf-category');
|
||||
var submitBtn = form.querySelector('#ihf-submit');
|
||||
var cancelBtn = form.querySelector('#ihf-cancel');
|
||||
var msgEl = form.querySelector('#ihf-msg');
|
||||
|
||||
function closeForm() {
|
||||
if (form.parentNode) form.parentNode.removeChild(form);
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', closeForm);
|
||||
|
||||
// Close on outside click.
|
||||
function onOutside(e) {
|
||||
if (!form.contains(e.target) && e.target !== trigger) {
|
||||
closeForm();
|
||||
document.removeEventListener('mousedown', onOutside);
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
document.addEventListener('mousedown', onOutside);
|
||||
}, 10);
|
||||
|
||||
submitBtn.addEventListener('click', function () {
|
||||
var body = bodyEl.value.trim();
|
||||
var category = catEl.value;
|
||||
|
||||
if (!body) {
|
||||
msgEl.textContent = 'Please enter feedback.';
|
||||
msgEl.style.color = '#ef4444';
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
msgEl.textContent = 'Saving…';
|
||||
msgEl.style.color = '#6b7280';
|
||||
|
||||
// Fetch CSRF token from meta tag if present (IHP injects it).
|
||||
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||
|
||||
// Build form-encoded body matching IHP's AnnotationsController.
|
||||
var payload = [
|
||||
'body=' + encodeURIComponent(body),
|
||||
'category=' + encodeURIComponent(category),
|
||||
'severity=low',
|
||||
'IHFToken=' + encodeURIComponent(csrfToken),
|
||||
].join('&');
|
||||
|
||||
fetch('/widgets/' + widgetId + '/annotations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: payload,
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok || res.redirected) {
|
||||
msgEl.textContent = '✓ Saved';
|
||||
msgEl.style.color = '#16a34a';
|
||||
setTimeout(closeForm, 800);
|
||||
} else {
|
||||
return res.text().then(function (t) {
|
||||
throw new Error('Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
msgEl.textContent = 'Error: ' + err.message;
|
||||
msgEl.style.color = '#ef4444';
|
||||
});
|
||||
});
|
||||
|
||||
bodyEl.focus();
|
||||
return form;
|
||||
}
|
||||
|
||||
/** Inject an Annotate trigger button adjacent to a widget envelope. */
|
||||
function injectTrigger(el) {
|
||||
if (el.classList.contains(LAUNCHER_CLASS)) return;
|
||||
el.classList.add(LAUNCHER_CLASS);
|
||||
|
||||
var widgetId = el.getAttribute('data-widget-id');
|
||||
var hubId = resolveHubId(el);
|
||||
|
||||
if (!widgetId) return;
|
||||
|
||||
var trigger = document.createElement('button');
|
||||
trigger.className = TRIGGER_CLASS;
|
||||
trigger.textContent = '+ Annotate';
|
||||
trigger.setAttribute('type', 'button');
|
||||
trigger.setAttribute('title', 'Add annotation for widget ' + widgetId);
|
||||
trigger.style.cssText = [
|
||||
'font-size:11px',
|
||||
'color:#6b7280',
|
||||
'background:transparent',
|
||||
'border:1px solid #e5e7eb',
|
||||
'border-radius:4px',
|
||||
'padding:2px 8px',
|
||||
'cursor:pointer',
|
||||
'margin-top:4px',
|
||||
].join(';');
|
||||
|
||||
trigger.addEventListener('mouseover', function () { trigger.style.color = '#4f46e5'; trigger.style.borderColor = '#a5b4fc'; });
|
||||
trigger.addEventListener('mouseout', function () { trigger.style.color = '#6b7280'; trigger.style.borderColor = '#e5e7eb'; });
|
||||
|
||||
var activeForm = null;
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (activeForm && activeForm.parentNode) {
|
||||
activeForm.parentNode.removeChild(activeForm);
|
||||
activeForm = null;
|
||||
return;
|
||||
}
|
||||
activeForm = buildForm(widgetId, hubId, trigger);
|
||||
});
|
||||
|
||||
el.appendChild(trigger);
|
||||
}
|
||||
|
||||
/** Scan the DOM for widget envelopes and inject triggers. */
|
||||
function scan() {
|
||||
var envelopes = document.querySelectorAll('[data-widget-id]');
|
||||
for (var i = 0; i < envelopes.length; i++) {
|
||||
injectTrigger(envelopes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scan on DOM ready.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
} else {
|
||||
scan();
|
||||
}
|
||||
|
||||
// Re-scan when IHP AutoRefresh updates the DOM (morphdom fires a custom event).
|
||||
document.addEventListener('ihp:morphdom:updated', scan);
|
||||
|
||||
// Also observe mutations for React/Vue SPA rendering.
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
var shouldScan = false;
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
if (mutations[i].addedNodes.length > 0) { shouldScan = true; break; }
|
||||
}
|
||||
if (shouldScan) scan();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// Expose for programmatic re-scanning.
|
||||
window.IHFAnnotationLauncher = { scan: scan };
|
||||
})();
|
||||
@@ -190,7 +190,7 @@ an `InteractionEvent`; invalid payloads return `422`; contract show page renders
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0006-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user