loopable / web /src /pages /PageSurface.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
7127075 verified
Raw
History Blame Contribute Delete
4.56 kB
// ---------------------------------------------------------------------------
// pages / PageSurface.tsx β€” EXIT wave 2 (W2-7).
//
// The stateful half: fetch a Y1 envelope, keep the controls honest, hold the
// drill panel. `PageView` stays pure so it can be reasoned about (and gated)
// without a network; everything that can fail lives here.
//
// ⚠ CONTROL STATE COMES FROM THE RESPONSE, NOT FROM THE CLICK (Y1 rule 9). The
// select is rendered from `controls[].value` in the payload the server just
// sent, so a `bu` the server rejected or coerced cannot look accepted β€” the
// picker visibly snaps back. Optimistically holding the clicked value would
// show a Royal-only user the word "All" over Royal-only numbers, which is the
// single most expensive lie this page could tell.
//
// ⚠ An in-flight refetch keeps the OLD page on screen, dimmed, rather than
// blanking to a spinner. The first `/pages/sales` per scope is genuinely slow
// (it runs `sales.validate()`), and a page that empties itself on every BU
// change reads as broken long before it reads as loading.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useState } from "react";
import { UNAUTHORIZED_EVENT, signal } from "../apiContract";
import type { DrillDescriptor, PageEnvelope } from "../ui/types";
import { DrillPanel } from "./DrillPanel";
import { PageView } from "./PageView";
import { fetchPage } from "./pageApi";
type State =
| { phase: "loading" }
| { phase: "ready"; page: PageEnvelope }
| { phase: "error"; message: string };
export function PageSurface({ pageKey, label }: { pageKey: string; label: string }) {
const [state, setState] = useState<State>({ phase: "loading" });
const [params, setParams] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [drill, setDrill] = useState<DrillDescriptor | null>(null);
// `params` is serialised into the dependency so a NEW object with the same
// contents does not refetch β€” the same reason the shell keys its nav effect
// on the username rather than the session object.
const paramKey = JSON.stringify(params);
useEffect(() => {
let dead = false;
setBusy(true);
void fetchPage(pageKey, JSON.parse(paramKey) as Record<string, string>).then((r) => {
if (dead) return;
setBusy(false);
if (r.ok) {
setState({ phase: "ready", page: r.page });
return;
}
// 401 is not "this page is broken", it is "the session died under us".
// The shell owns that transition; conflating the two would leave a
// signed-out user staring at an error panel about Sales.
if (r.status === 401) {
signal(UNAUTHORIZED_EVENT);
return;
}
setState({ phase: "error", message: r.message });
});
return () => {
dead = true;
};
}, [pageKey, paramKey]);
// Changing a control resets the drill: a panel describing last month's
// Fisch revenue, still open over Royal numbers, is a wrong answer that looks
// like a right one.
const onControl = useCallback((key: string, value: string) => {
setDrill(null);
setParams((p) => ({ ...p, [key]: value }));
}, []);
if (state.phase === "loading") {
return (
<div className="pg-state">
{/* ⚠ THE TITLE STAYS, THE SENTENCE GOES (wave 17 item 3/R6). R6 deletes
loading COPY β€” "Loading live figures from Odoo. The first read of a
new scope takes a moment." was an apology for a wait the mark states
without excusing. The heading is not loading copy: it is this page's
name, already chosen in the rail, and dropping it would make arrival
a title POP-IN on a surface that had been anonymous. */}
<h1>{label}</h1>
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
</div>
);
}
if (state.phase === "error") {
return (
<div className="pg-state pg-error">
<h1>{label}</h1>
<p>{state.message}</p>
<button
className="login-submit shell-retry"
type="button"
onClick={() => setParams((p) => ({ ...p }))}
>
Try again
</button>
</div>
);
}
return (
<div className={busy ? "pg-host is-busy" : "pg-host"}>
<PageView page={state.page} onControl={onControl} onOpen={setDrill} busy={busy} />
{drill ? <DrillPanel drill={drill} onClose={() => setDrill(null)} /> : null}
</div>
);
}