Files
WebForm-mw/components/StaffReportView.tsx
T
Joel Brock 38f4738eca Staff report: fix infinite iframe-grow loop when framed in Civi
The framed report posts its content height to the parent so the Civi tab
can resize the iframe to fit. Two pieces interacted badly:

  - The root layout sets html.h-full and body.min-h-full, so documentElement
    and body heights track the iframe's viewport height.
  - The parent template sets iframe.height = postedHeight + 24 every time
    a height message arrives.

The combination produced an unbounded feedback loop: parent grows the
iframe by 24px, viewport grows, document height grows, ResizeObserver
fires, we post the new height, parent grows by another 24px. The outer
CiviCRM page scrollbar visibly shrank each cycle.

Fix on the report side (no extension change needed): when framed, override
html height to auto and body min-height to 0 so the document decouples
from the viewport. Observe body (the actual content), measure
body.scrollHeight, and skip posting when the value is unchanged. Original
styles are restored on unmount so route changes back to the standalone
view still work.
2026-06-09 17:14:54 -07:00

638 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import type {
FieldConfig,
FieldHistoryEntry,
SelectOption,
StageSectionConfig,
StaffReportField,
StaffReportPayload,
StaffReportSection,
} from "@/types/form";
import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart";
import { DateTimeline } from "./report/DateTimeline";
import {
FormattedValue,
formatShortDate,
formatLongDate,
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface StaffReportViewProps {
org: number;
authKey: string;
/** CIVI_BASE_URL, used to build outbound file links. */
civiBaseUrl: string;
/** True when the page is being embedded in a CiviCRM tab via iframe. */
framed?: boolean;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: StaffReportPayload };
const STAGE_OPTION_GROUP_ID = 75;
export function StaffReportView({
org,
authKey,
civiBaseUrl,
framed = false,
}: StaffReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch(
`/api/staff/report?org=${encodeURIComponent(String(org))}&key=${encodeURIComponent(authKey)}`,
{ cache: "no-store" },
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
if (alive)
setLoad({
kind: "error",
message: body.error ?? `Request failed (${res.status})`,
});
return;
}
const data = (await res.json()) as StaffReportPayload;
if (alive) setLoad({ kind: "ready", data });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (alive) setLoad({ kind: "error", message: msg });
}
})();
return () => {
alive = false;
};
}, [org, authKey]);
// When embedded, post our content height to the parent so the Civi tab's
// iframe can resize to fit (no nested scrollbars). The receiving script
// lives in the WebForm-mw Civi extension's tab template.
//
// The root layout sets `html.h-full` and `body.min-h-full`, which tie
// document height to the iframe's viewport height. Combined with the
// parent setting `iframe.height = postedHeight + 24` on every message,
// that creates an unbounded feedback loop (viewport grows -> measured
// height grows -> parent grows the iframe -> repeat). Inside the iframe
// we decouple html/body from the viewport, measure `body.scrollHeight`
// (the actual content), observe the body, and skip duplicate posts.
useEffect(() => {
if (!framed || typeof window === "undefined") return;
if (window.parent === window) return;
const html = document.documentElement;
const body = document.body;
const prevHtmlHeight = html.style.height;
const prevBodyMinHeight = body.style.minHeight;
html.style.height = "auto";
body.style.minHeight = "0";
let lastHeight = -1;
const post = () => {
const h = body.scrollHeight;
if (h === lastHeight) return;
lastHeight = h;
window.parent.postMessage({ type: "webform-mw-height", height: h }, "*");
};
post();
const ro = new ResizeObserver(post);
ro.observe(body);
window.addEventListener("load", post);
return () => {
ro.disconnect();
window.removeEventListener("load", post);
html.style.height = prevHtmlHeight;
body.style.minHeight = prevBodyMinHeight;
};
}, [framed, load]);
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
const { data } = load;
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />;
const checkInSection = data.sections.find((s) => s.groupName === "Check_in_data__organizing_");
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME);
const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
const stageLabel = data.currentStage
? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
?? data.currentStage
: "—";
const dateRange = computeDateRange(data.activities.map((a) => a.date));
return (
<article className="space-y-10">
<header className="space-y-4">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Staff report · Internal use only
</p>
<h1 className="font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
{data.orgName}
</h1>
<dl className="grid grid-cols-2 gap-6 text-sm sm:grid-cols-4">
<Stat label="Current stage" value={stageLabel} />
<Stat label="Submissions" value={String(data.activities.length)} />
<Stat
label="Date range"
value={
dateRange
? `${formatShortDate(dateRange.from)} ${formatShortDate(dateRange.to)}`
: "—"
}
/>
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
</dl>
<div className="h-px bg-rule" />
</header>
<SectionAnchorNav
sections={data.sections}
hasActivities={data.activities.length > 0}
framed={framed}
/>
{membersField && membersField.history.length > 0 ? (
<MembershipChart
membersHistory={membersField.history}
goalHistory={goalField?.history}
/>
) : null}
<StaffDateTimeline data={data} />
{data.sections.map((section) => (
<StaffSection
key={section.groupName}
section={section}
options={data.options}
civiBaseUrl={civiBaseUrl}
/>
))}
<ActivityTable activities={data.activities} options={data.options} />
</article>
);
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-[0.16em] text-ink-soft">{label}</dt>
<dd className="mt-1 text-[15px] text-ink">{value}</dd>
</div>
);
}
/**
* Horizontal anchor strip — one chip per section + Submissions.
*
* Sticky in standalone mode; non-sticky when embedded in a CiviCRM tab
* (the iframe auto-resizes to fit content so there's no internal scroll
* for `sticky` to engage against).
*/
function SectionAnchorNav({
sections,
hasActivities,
framed,
}: {
sections: StaffReportSection[];
hasActivities: boolean;
framed: boolean;
}) {
const items = sections.map((s) => ({
href: `#section-${s.groupName}`,
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
}));
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur";
return (
<nav
aria-label="Section navigation"
className={`${stickyCls} -mx-4 border-y border-rule bg-paper/95 px-4 py-2 sm:-mx-6 sm:px-6`}
>
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
{items.map((it) => (
<li key={it.href}>
<a
href={it.href}
className="inline-block rounded-full border border-rule bg-paper px-2.5 py-0.5 text-ink-soft hover:border-leaf-700 hover:text-leaf-800"
>
{it.label}
</a>
</li>
))}
</ul>
</nav>
);
}
function StaffSection({
section,
options,
civiBaseUrl,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
const [showEmpty, setShowEmpty] = useState(false);
// Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields
// out into a single tabular display matching the form's matrix layout.
const isStage5 = section.groupName === "Stage_5";
const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null;
const filledOutsideMatrix =
matrixFields
? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name))
: filled;
return (
<section
id={`section-${section.groupName}`}
aria-labelledby={`heading-${section.groupName}`}
className="space-y-3 scroll-mt-16"
>
<h2
id={`heading-${section.groupName}`}
className="font-display text-2xl font-medium text-ink"
>
{section.groupTitle}
</h2>
<p className="text-[12px] uppercase tracking-[0.16em] text-ink-soft">
{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "}
{filled.length} with data
</p>
{matrixFields ? (
<Y1MatrixTable
rows={matrixFields.rows}
quarters={matrixFields.quarters}
options={options}
/>
) : null}
{filledOutsideMatrix.length > 0 ? (
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
{filledOutsideMatrix.map((f) => (
<CompactFieldRow
key={f.descriptor.name}
field={f}
options={options}
civiBaseUrl={civiBaseUrl}
/>
))}
</ul>
) : null}
{empty.length > 0 ? (
<div className="rounded-md border border-rule bg-paper">
<button
type="button"
onClick={() => setShowEmpty((v) => !v)}
className="flex w-full items-center justify-between px-3 py-1.5 text-[12px] uppercase tracking-[0.14em] text-ink-soft hover:text-ink"
>
<span>{empty.length} empty field{empty.length === 1 ? "" : "s"}</span>
<span aria-hidden>{showEmpty ? "▾" : "▸"}</span>
</button>
{showEmpty ? (
<ul className="divide-y divide-rule border-t border-rule">
{empty.map((f) => (
<li
key={f.descriptor.name}
className="flex items-baseline justify-between px-3 py-1 text-[13px] text-ink-soft"
>
<span>{f.descriptor.label}</span>
<span></span>
</li>
))}
</ul>
) : null}
</div>
) : null}
</section>
);
}
/**
* Compact, latest-only row. If the field has multiple history entries, a
* muted "N earlier entries" toggle reveals the rest inline.
*
* File-typed values render as an outbound link to CiviCRM rather than a
* proxied download — staff are already logged into Civi when they arrive
* here, and the server doesn't need to broker bytes.
*/
function CompactFieldRow({
field,
options,
civiBaseUrl,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
}) {
const [open, setOpen] = useState(false);
const latest = field.history[0];
const earlier = field.history.slice(1);
return (
<li className="px-3 py-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
<div className="text-[13px] text-ink-soft">
<FieldValue field={field} entry={latest} options={options} civiBaseUrl={civiBaseUrl} />
{latest.date ? (
<span className="ml-2 text-[11px] text-ink-soft">
· {formatShortDate(latest.date)}
</span>
) : null}
</div>
</div>
{earlier.length > 0 ? (
<div className="mt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-[11px] uppercase tracking-[0.14em] text-leaf-700 hover:underline"
>
{open ? "Hide" : `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
</button>
{open ? (
<ul className="mt-1 space-y-1 border-l border-rule pl-3">
{earlier.map((e, i) => (
<li
key={`${e.activityId}-${e.date}-${i}`}
className="flex items-baseline justify-between gap-x-4 text-[12px] text-ink-soft"
>
<FieldValue field={field} entry={e} options={options} civiBaseUrl={civiBaseUrl} />
<span className="text-[11px] text-ink-soft">{formatShortDate(e.date)}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
</li>
);
}
function FieldValue({
field,
entry,
options,
civiBaseUrl,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as { id?: number | string; file_name?: string } | null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
// Civi serves uploaded files at /civicrm/file?reset=1&id=<id>.
// The staff member is already authenticated to Civi (they came from
// there); the browser sends their session cookie automatically.
const href = civiBaseUrl
? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`
: "#";
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
);
}
return (
<FormattedValue value={entry.value} field={fieldConfigFor(field)} options={options} />
);
}
function fieldConfigFor(f: StaffReportField): FieldConfig {
return {
name: f.descriptor.name,
label: f.descriptor.label,
type: renderToFieldType(f.descriptor.render),
optionGroupId: f.descriptor.optionGroupId,
};
}
function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldConfig["type"] {
switch (r) {
case "currency":
return "currency";
case "date":
case "datetime":
return "date";
case "select":
return "select";
case "multiselect":
return "multiselect";
case "file":
return "file";
case "longtext":
return "textarea";
case "boolean":
return "boolean";
case "number":
return "number";
default:
return "text";
}
}
/**
* Stage 5 Y1 matrix: detect fields whose names match Y1_Q<n>_<metric> and
* group them into a read-only table mirroring the form's matrix layout.
* The metric set is whatever's actually present in the data so a Civi
* schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically.
*/
interface Y1MatrixRow {
metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
label: string; // human label from the first field's descriptor (sans Y1_Q<n>_ prefix)
byQuarter: Map<number, StaffReportField>;
}
interface Y1MatrixData {
rows: Y1MatrixRow[];
quarters: number[];
usedNames: Set<string>;
}
function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null {
const re = /^Y1_Q(\d+)_(.+)$/;
const used = new Set<string>();
const byMetric = new Map<string, Map<number, StaffReportField>>();
const quartersSet = new Set<number>();
const metricLabel = new Map<string, string>();
for (const f of filled) {
const m = re.exec(f.descriptor.name);
if (!m) continue;
const quarter = Number(m[1]);
const metric = m[2];
used.add(f.descriptor.name);
quartersSet.add(quarter);
if (!byMetric.has(metric)) byMetric.set(metric, new Map());
byMetric.get(metric)!.set(quarter, f);
if (!metricLabel.has(metric)) {
// Strip "Y1 Q<n> " prefix variants from the label if present.
const cleaned = f.descriptor.label
.replace(/^Y1\s*Q\d+\s*/i, "")
.replace(/_/g, " ")
.trim();
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
}
}
if (byMetric.size === 0) return null;
const quarters = Array.from(quartersSet).sort((a, b) => a - b);
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({
metric,
label: metricLabel.get(metric) ?? metric,
byQuarter,
}));
return { rows, quarters, usedNames: used };
}
function Y1MatrixTable({
rows,
quarters,
options,
}: {
rows: Y1MatrixRow[];
quarters: number[];
options: Record<number, SelectOption[]>;
}) {
return (
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
<table className="min-w-full text-sm">
<caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
Year 1 quarterly · latest values
</caption>
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
<tr>
<th className="px-3 py-2 font-medium">Metric</th>
{quarters.map((q) => (
<th key={q} className="px-3 py-2 font-medium">Q{q}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-rule">
{rows.map((row) => (
<tr key={row.metric}>
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
{quarters.map((q) => {
const f = row.byQuarter.get(q);
const latest = f?.history[0];
return (
<td key={q} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
{f && latest ? (
<FormattedValue
value={latest.value}
field={fieldConfigFor(f)}
options={options}
/>
) : (
"—"
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
function ActivityTable({
activities,
options,
}: {
activities: StaffReportPayload["activities"];
options: Record<number, SelectOption[]>;
}) {
if (activities.length === 0) {
return (
<section id="section-submissions" className="space-y-3 scroll-mt-16">
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<p className="text-sm text-ink-soft">No submissions recorded for this organization yet.</p>
</section>
);
}
const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? [];
const stageLabel = (v: string | null | undefined) =>
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
return (
<section id="section-submissions" className="space-y-3 scroll-mt-16">
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<div className="overflow-x-auto rounded-md border border-rule">
<table className="min-w-full text-sm">
<thead className="bg-paper text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
<tr>
<th className="px-3 py-2">Date</th>
<th className="px-3 py-2">Stage snapshot</th>
<th className="px-3 py-2">Subject</th>
<th className="px-3 py-2">Submitted by</th>
<th className="px-3 py-2">Activity id</th>
</tr>
</thead>
<tbody className="divide-y divide-rule">
{activities.map((a) => (
<tr key={a.id}>
<td className="px-3 py-2 text-ink">{formatShortDate(a.date)}</td>
<td className="px-3 py-2 text-ink">{stageLabel(a.stage ?? null)}</td>
<td className="px-3 py-2 text-ink">{a.subject ?? "—"}</td>
<td className="px-3 py-2 text-ink">{a.submittedBy ?? "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-ink-soft">{a.id}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
const fieldConfigs: FieldConfig[] = [];
for (const section of data.sections) {
if (section.groupKind !== "activity") continue;
for (const f of section.fields) {
if (f.descriptor.render !== "date" && f.descriptor.render !== "datetime") continue;
if (f.history.length === 0) continue;
fieldHistory[f.descriptor.name] = f.history;
fieldConfigs.push(fieldConfigFor(f));
}
}
if (fieldConfigs.length === 0) return null;
const sections: StageSectionConfig[] = [
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
];
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
}
// formatLongDate retained for symmetry with other report views; unused here
// now that file rows are compact links.
void formatLongDate;