Lightbox: pin and size to visible viewport when framed
The staff-report iframe is expanded to full content height (no nested scrollbar), so a modal <dialog> centered in its viewport landed in the middle of the whole document and sat off-screen unless the parent page was scrolled there. Cross-origin means the iframe can't read the parent's scroll position itself. Tab.tpl now broadcasts the iframe's visible slice (webform-mw-viewport, rAF-throttled on scroll/resize/load and after each height change), and AttachmentLightbox pins to that slice and fills its height. Falls back to a fixed box when no viewport message arrives (older extension), and standalone mode keeps native centering at a taller 85vh. Extension bumped to 0.3.1 (template change -> re-copy + cv flush on prod). PRODUCTION_CUTOVER.md change log updated.
This commit is contained in:
+50
-4
@@ -164,16 +164,42 @@ fill out the form to that Organization.
|
|||||||
|
|
||||||
## 5. Install the `webform-mw` Civi extension
|
## 5. Install the `webform-mw` Civi extension
|
||||||
|
|
||||||
The extension adds an **Engagement Report** tab to Organization
|
The extension does two jobs:
|
||||||
contact pages that embeds the staff report in an iframe.
|
|
||||||
|
- 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
|
1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's
|
||||||
`[civicrm.extensionsDir]` (usually
|
`[civicrm.extensionsDir]` (usually
|
||||||
`<civi-root>/sites/default/ext/`). The directory must be named
|
`<civi-root>/sites/default/ext/`). The directory must be named
|
||||||
exactly `webform-mw` (matches `<key>` in `info.xml`).
|
exactly `webform-mw` (matches `<key>` in `info.xml`). When
|
||||||
|
**upgrading** an already-installed extension, overwrite the existing
|
||||||
|
directory in place.
|
||||||
|
|
||||||
2. `Administer → System Settings → Extensions → Add new → Refresh`,
|
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`:
|
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**
|
5. Open the target Organization in CiviCRM → **Engagement Report**
|
||||||
tab. The staff report should render the submission you just made.
|
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
|
## Change log
|
||||||
@@ -286,6 +317,21 @@ so the rationale survives.
|
|||||||
- **2026-06-08** — Documented the field-242 "Unknown" default issue
|
- **2026-06-08** — Documented the field-242 "Unknown" default issue
|
||||||
after a production submission was stamped `Stage = "Unknown"`.
|
after a production submission was stamped `Stage = "Unknown"`.
|
||||||
Cleared via `CustomField.update`; see step 2.
|
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
|
- **2026-06-16** — Step 6 split off `CIVI_FRAME_ALLOWED_ORIGINS` as a
|
||||||
separate env from `CIVI_BASE_URL`. Surfaced after the production
|
separate env from `CIVI_BASE_URL`. Surfaced after the production
|
||||||
cutover hit a `frame-ancestors` block: the app's CSP only listed the
|
cutover hit a `frame-ancestors` block: the app's CSP only listed the
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
||||||
</urls>
|
</urls>
|
||||||
<releaseDate>2026-06-05</releaseDate>
|
<releaseDate>2026-06-05</releaseDate>
|
||||||
<version>0.3.0</version>
|
<version>0.3.1</version>
|
||||||
<develStage>beta</develStage>
|
<develStage>beta</develStage>
|
||||||
<compatibility>
|
<compatibility>
|
||||||
<ver>5.50</ver>
|
<ver>5.50</ver>
|
||||||
|
|||||||
@@ -22,7 +22,41 @@
|
|||||||
// Add a little headroom so the report's own bottom padding isn't clipped.
|
// Add a little headroom so the report's own bottom padding isn't clipped.
|
||||||
var h = Math.max(600, Math.floor(d.height) + 24);
|
var h = Math.max(600, Math.floor(d.height) + 24);
|
||||||
frame.style.height = h + 'px';
|
frame.style.height = h + 'px';
|
||||||
|
// Geometry changed — tell the child where its visible slice now is.
|
||||||
|
scheduleViewport();
|
||||||
}, false);
|
}, 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();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{else}
|
{else}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal preview for image and PDF attachments.
|
* Modal preview for image and PDF attachments.
|
||||||
@@ -33,6 +33,44 @@ export function AttachmentLightbox({
|
|||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDialogElement | null>(null);
|
const ref = useRef<HTMLDialogElement | null>(null);
|
||||||
|
|
||||||
|
// When embedded in the CiviCRM tab, the iframe is auto-expanded to its full
|
||||||
|
// content height (no nested scrollbar), so a modal <dialog> — 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 <dialog>'s open state from our prop.
|
// Drive the native <dialog>'s open state from our prop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dlg = ref.current;
|
const dlg = ref.current;
|
||||||
@@ -64,19 +102,35 @@ export function AttachmentLightbox({
|
|||||||
const isImage = mime.startsWith("image/");
|
const isImage = mime.startsWith("image/");
|
||||||
const isPdf = mime === "application/pdf";
|
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 (
|
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.
|
|
||||||
<dialog
|
<dialog
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={onDialogClick}
|
onClick={onDialogClick}
|
||||||
aria-label={`Preview: ${filename}`}
|
aria-label={`Preview: ${filename}`}
|
||||||
|
style={dialogStyle}
|
||||||
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
|
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
|
||||||
>
|
>
|
||||||
<div className="flex h-[640px] max-h-[85vh] flex-col overflow-hidden rounded-md">
|
<div
|
||||||
|
style={panelHeight !== undefined ? { height: panelHeight } : undefined}
|
||||||
|
className={`flex flex-col overflow-hidden rounded-md ${framed ? "" : "h-[85vh] max-h-[860px]"}`}
|
||||||
|
>
|
||||||
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
|
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
|
||||||
<p className="min-w-0 truncate font-display text-sm text-ink">
|
<p className="min-w-0 truncate font-display text-sm text-ink">
|
||||||
{filename}
|
{filename}
|
||||||
|
|||||||
Reference in New Issue
Block a user