diff --git a/PRODUCTION_CUTOVER.md b/PRODUCTION_CUTOVER.md index d44c05d..a5b2043 100644 --- a/PRODUCTION_CUTOVER.md +++ b/PRODUCTION_CUTOVER.md @@ -164,16 +164,42 @@ fill out the form to that Organization. ## 5. Install the `webform-mw` Civi extension -The extension adds an **Engagement Report** tab to Organization -contact pages that embeds the staff report in an iframe. +The extension does two jobs: + +- adds an **Engagement Report** tab to Organization contact pages that + embeds the staff report in an iframe; and +- exposes the **file-upload proxy route** `civicrm/webform-mw/upload` + (handled by `CRM_WebformMw_Page_Upload`) that the app's `/api/upload` + POSTs binary files to. This route was **added in v0.3.0** — a CRM + running an older version has the report tab but every form file + upload returns **502 Bad Gateway** (the app can't reach the route, so + `/api/upload` fails closed). **Production must be on v0.3.0 or later.** 1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's `[civicrm.extensionsDir]` (usually `/sites/default/ext/`). The directory must be named - exactly `webform-mw` (matches `` in `info.xml`). + exactly `webform-mw` (matches `` in `info.xml`). When + **upgrading** an already-installed extension, overwrite the existing + directory in place. 2. `Administer → System Settings → Extensions → Add new → Refresh`, - then **Install** next to "WebForm-mw". + then **Install** next to "WebForm-mw" (first install) or run the + **Upgrade** action if one is offered. If neither, **Disable** then + **Enable** the extension. + + **Then flush caches** — `cv flush`, or + **Administer → System Settings → Cleanup Caches**. New menu routes + (like `civicrm/webform-mw/upload`) are only registered after the + router is rebuilt; copying files without a flush leaves the upload + route 404ing and uploads 502ing. + + Confirm the upload route resolves (a `400` means the route is live; + a `404`/login redirect means the flush didn't take): + + ```bash + curl -s -X POST https://crm.fci.coop/civicrm/webform-mw/upload + # → {"error":"Missing file part"} ✓ route exists + ``` 3. Configure — add to `civicrm.settings.php`: @@ -276,6 +302,11 @@ Run these against the production deploy: 5. Open the target Organization in CiviCRM → **Engagement Report** tab. The staff report should render the submission you just made. +6. On the form, attach a file to any file field. It should upload + without error. A **502** here means the `webform-mw` extension on + this CRM is older than v0.3.0 (or the post-upgrade cache flush was + skipped) — see step 5. + --- ## Change log @@ -286,6 +317,21 @@ so the rationale survives. - **2026-06-08** — Documented the field-242 "Unknown" default issue after a production submission was stamped `Stage = "Unknown"`. Cleared via `CustomField.update`; see step 2. +- **2026-06-18** — Extension bumped to **v0.3.1** (lightbox fix). The + staff-report iframe is expanded to full content height, so the + attachment lightbox (a modal inside the iframe) centered in the whole + document and sat off-screen unless the parent page was scrolled to the + middle. `Tab.tpl` now broadcasts the iframe's visible slice + (`webform-mw-viewport`) and the app pins/sizes the lightbox to it. + **Re-copy the extension to prod and `cv flush`** (template change) — + same procedure as step 5; no settings change. +- **2026-06-16** — Step 5 now states the file-upload feature requires + extension **v0.3.0+** (the `civicrm/webform-mw/upload` proxy route) + and documents the upgrade-vs-first-install path plus the mandatory + cache flush. Surfaced when production uploads returned **502**: prod + Civi still had v0.1.0, which has the Engagement Report tab but not + the upload route, so `/api/upload` couldn't reach it and failed + closed. Added a post-deploy upload check as step 6 of section 8. - **2026-06-16** — Step 6 split off `CIVI_FRAME_ALLOWED_ORIGINS` as a separate env from `CIVI_BASE_URL`. Surfaced after the production cutover hit a `frame-ancestors` block: the app's CSP only listed the diff --git a/civi-extension/webform-mw/info.xml b/civi-extension/webform-mw/info.xml index 5da6438..f4b6e8e 100644 --- a/civi-extension/webform-mw/info.xml +++ b/civi-extension/webform-mw/info.xml @@ -17,7 +17,7 @@ https://github.com/joelbrock/WebForm-mw 2026-06-05 - 0.3.0 + 0.3.1 beta 5.50 diff --git a/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl b/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl index f519244..8e5d70e 100644 --- a/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl +++ b/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl @@ -22,7 +22,41 @@ // Add a little headroom so the report's own bottom padding isn't clipped. var h = Math.max(600, Math.floor(d.height) + 24); frame.style.height = h + 'px'; + // Geometry changed — tell the child where its visible slice now is. + scheduleViewport(); }, false); + + // The iframe is expanded to full content height, so the report has no + // internal scroll context. A modal opened inside it (the attachment + // lightbox) would center in the full iframe and sit off-screen. Broadcast + // the iframe's currently-visible region (in the child's own content + // coordinates) so the lightbox can pin and size itself to it. The child + // listens for 'webform-mw-viewport'. + var ticking = false; + var raf = window.requestAnimationFrame || function (cb) { return setTimeout(cb, 16); }; + function postViewport() { + ticking = false; + if (!frame.contentWindow) return; + var r = frame.getBoundingClientRect(); + var winH = window.innerHeight || document.documentElement.clientHeight; + var visibleTop = Math.max(0, r.top); + var visibleBottom = Math.min(winH, r.bottom); + frame.contentWindow.postMessage({ + type: 'webform-mw-viewport', + // px from the iframe's content top down to the first visible row. + top: Math.max(0, -r.top), + height: Math.max(0, visibleBottom - visibleTop) + }, APP_ORIGIN || '*'); + } + function scheduleViewport() { + if (ticking) return; + ticking = true; + raf(postViewport); + } + window.addEventListener('scroll', scheduleViewport, { passive: true }); + window.addEventListener('resize', scheduleViewport); + window.addEventListener('load', scheduleViewport); + scheduleViewport(); })(); {else} diff --git a/components/report/AttachmentLightbox.tsx b/components/report/AttachmentLightbox.tsx index fe620eb..b428057 100644 --- a/components/report/AttachmentLightbox.tsx +++ b/components/report/AttachmentLightbox.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; /** * Modal preview for image and PDF attachments. @@ -33,6 +33,44 @@ export function AttachmentLightbox({ }) { const ref = useRef(null); + // When embedded in the CiviCRM tab, the iframe is auto-expanded to its full + // content height (no nested scrollbar), so a modal — which centers + // in *its* viewport — lands in the vertical middle of the whole document and + // is off-screen unless the parent page happens to be scrolled there. The + // iframe can't read the parent's scroll position (cross-origin), so the + // extension's tab template broadcasts the iframe's currently-visible slice + // as `webform-mw-viewport` messages. We pin the dialog to that slice and + // size it to fill the visible height. Standalone (non-embedded) keeps the + // native viewport centering. + const [framed, setFramed] = useState(false); + const vpRef = useRef<{ top: number; height: number } | null>(null); + const [vp, setVp] = useState<{ top: number; height: number } | null>(null); + + useEffect(() => { + setFramed(window.parent !== window); + }, []); + + useEffect(() => { + const onMsg = (e: MessageEvent) => { + if (e.source !== window.parent) return; + const d = e.data as { type?: string; top?: number; height?: number } | null; + if (!d || d.type !== "webform-mw-viewport") return; + const next = { top: Number(d.top) || 0, height: Number(d.height) || 0 }; + vpRef.current = next; + // Only reflect into render state while open — many FileLinks mount a + // (closed) lightbox each, and we don't want every one re-rendering on + // each scroll frame. + if (open) setVp(next); + }; + window.addEventListener("message", onMsg); + return () => window.removeEventListener("message", onMsg); + }, [open]); + + // Seed from the latest known viewport the moment we open. + useEffect(() => { + if (open) setVp(vpRef.current); + }, [open]); + // Drive the native 's open state from our prop. useEffect(() => { const dlg = ref.current; @@ -64,19 +102,35 @@ export function AttachmentLightbox({ const isImage = mime.startsWith("image/"); const isPdf = mime === "application/pdf"; + // Vertical placement + height. + // - framed + known viewport: pin to the visible slice and fill it. + // - framed but no viewport yet (e.g. an older extension that doesn't + // broadcast): fall back to a safe fixed box so we never balloon to the + // full multi-thousand-pixel iframe height. + // - standalone: native viewport centering, tall enough for documents. + const margin = 16; + const dialogStyle: React.CSSProperties | undefined = + framed && vp + ? { top: Math.max(8, vp.top + margin), bottom: "auto", marginTop: 0, marginBottom: 0 } + : undefined; + const panelHeight = framed + ? vp + ? Math.max(360, vp.height - margin * 2) + : 640 + : undefined; + return ( - // Sizing note: this dialog opens inside the staff-report iframe, which - // auto-grows to fit content (often 2000–4000 px tall). "h-full"/"vh" - // values inside that iframe resolve to the full iframe document, so the - // dialog would balloon. Cap to a fixed pixel box that fits comfortably - // on a typical laptop and still gives PDFs/images enough room. -
+

{filename}