feat(sp25): wire live-tail triplet into Acks page
The 999 register opens /api/acks/stream via useTailStream("acks"),
merges the snapshot + tail through useMergedTail("acks", ...) so
new rows appear without a manual refresh, and surfaces the
connection state via <TailStatusPill> in the hero. The TA1 section
gets the same triplet against /api/ta1-acks/stream.
Both pills sit in the page header so the operator can see at a
glance whether the live-tail connection is healthy, without having
to open the drawer or refresh the page. A stalled/error stream
shows a Reconnect button inline.
The Acks test mock adds useTailStream at the module level so the
page renders without opening a real fetch. New test asserts both
TailStatusPills mount in the page tree.
This commit is contained in:
@@ -21,6 +21,21 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SP25: the Acks page now opens two live-tail streams (one per ack
|
||||||
|
// flavor) via `useTailStream`. The hook has its own dedicated test
|
||||||
|
// suite that covers the full lifecycle; here we just want the page to
|
||||||
|
// render against a stubbed hook so the test never opens a real fetch.
|
||||||
|
// The mock matches the production-default state (`live`) so the pill
|
||||||
|
// renders the "Live" label without surfacing a misleading error.
|
||||||
|
vi.mock("@/hooks/useTailStream", () => ({
|
||||||
|
useTailStream: vi.fn(() => ({
|
||||||
|
status: "live",
|
||||||
|
lastEventAt: null,
|
||||||
|
error: null,
|
||||||
|
forceReconnect: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
@@ -567,4 +582,40 @@ describe("Acks", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP25: the page now mounts two live-tail streams (one per ack
|
||||||
|
// flavor) via useTailStream. The mocked hook returns status: "live",
|
||||||
|
// so the page should render TWO TailStatusPills — one in the 999
|
||||||
|
// hero and one in the TA1 section — both showing the human label
|
||||||
|
// "Live" (per `STATUS_LABEL` in `TailStatusPill.tsx`).
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("test_renders_two_tail_status_pills_live", async () => {
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
returned: 0,
|
||||||
|
has_more: false,
|
||||||
|
aggregates: { accepted_count: 0, rejected_count: 0, received_count: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
|
||||||
|
// Wait for the page to settle — both pills need the query to
|
||||||
|
// resolve (and even the TA1 section renders a tail pill even
|
||||||
|
// when items is empty).
|
||||||
|
await settle(
|
||||||
|
() => document.body.textContent?.includes("Live") ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two "Live" labels: one for the 999 stream, one for the TA1
|
||||||
|
// stream. (Plus the description text on the page may say "live"
|
||||||
|
// in another context — check via the surrounding pill markup.)
|
||||||
|
const pills = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="tail-status-pill"]'),
|
||||||
|
);
|
||||||
|
expect(pills.length).toBe(2);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+51
-8
@@ -20,7 +20,10 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
||||||
import { useAcks } from "@/hooks/useAcks";
|
import { useAcks } from "@/hooks/useAcks";
|
||||||
import { useTa1Acks } from "@/hooks/useTa1Acks";
|
import { useTa1Acks } from "@/hooks/useTa1Acks";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||||
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -47,7 +50,20 @@ export function Acks() {
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: (page - 1) * PAGE_SIZE,
|
offset: (page - 1) * PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const items = data?.items ?? [];
|
// SP25: subscribe to /api/acks/stream so new 999 acks (whether from
|
||||||
|
// the SFTP poller or a manual upload) land in the table the moment
|
||||||
|
// they hit the database. The status surfaces in the hero pill so the
|
||||||
|
// operator can see whether the connection is healthy.
|
||||||
|
const {
|
||||||
|
status: tailStatus,
|
||||||
|
lastEventAt: tailLastEventAt,
|
||||||
|
forceReconnect,
|
||||||
|
} = useTailStream("acks");
|
||||||
|
// Merge the page's base list with the live-tail delta. The hook
|
||||||
|
// dedupes by id (first write wins), so a snapshot replay on
|
||||||
|
// reconnect can't double-count an existing row.
|
||||||
|
const mergedItems = useMergedTail("acks", data?.items ?? []);
|
||||||
|
const items = mergedItems;
|
||||||
const totalCount = data?.total ?? 0;
|
const totalCount = data?.total ?? 0;
|
||||||
// Server-side aggregates — sum over the *full* ACK set, not just the
|
// Server-side aggregates — sum over the *full* ACK set, not just the
|
||||||
// visible page. Without this, the KPI strip silently under-reports
|
// visible page. Without this, the KPI strip silently under-reports
|
||||||
@@ -213,6 +229,16 @@ export function Acks() {
|
|||||||
<ShieldCheck className="h-3.5 w-3.5" strokeWidth={1.75} />
|
<ShieldCheck className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||||
{anyRejected ? "Mixed envelope" : "Envelope accepted"}
|
{anyRejected ? "Mixed envelope" : "Envelope accepted"}
|
||||||
</div>
|
</div>
|
||||||
|
{/* SP25: live-tail connection status. Sits next to the
|
||||||
|
envelope-accepted pill so the operator can see at a
|
||||||
|
glance whether the stream is healthy without having to
|
||||||
|
open the drawer. Reconnect button only renders when
|
||||||
|
the stream is in a visibly-failed state. */}
|
||||||
|
<TailStatusPill
|
||||||
|
status={tailStatus}
|
||||||
|
lastEventAt={tailLastEventAt}
|
||||||
|
onReconnect={forceReconnect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hint — same rhythm as Reconciliation, sits under
|
{/* Keyboard hint — same rhythm as Reconciliation, sits under
|
||||||
@@ -604,7 +630,14 @@ function downloadBlob(filename: string, content: string) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function Ta1AcksSection() {
|
function Ta1AcksSection() {
|
||||||
const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 });
|
const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 });
|
||||||
const items = data?.items ?? [];
|
// SP25: same live-tail triplet as the 999 register above, just
|
||||||
|
// bound to /api/ta1-acks/stream and the ta1Acks store slice.
|
||||||
|
const {
|
||||||
|
status: tailStatus,
|
||||||
|
lastEventAt: tailLastEventAt,
|
||||||
|
forceReconnect,
|
||||||
|
} = useTailStream("ta1_acks");
|
||||||
|
const items = useMergedTail("ta1_acks", data?.items ?? []);
|
||||||
|
|
||||||
const totals = items.reduce(
|
const totals = items.reduce(
|
||||||
(acc, t) => {
|
(acc, t) => {
|
||||||
@@ -631,12 +664,22 @@ function Ta1AcksSection() {
|
|||||||
TA1 <span className="italic">envelopes</span>, newest first.
|
TA1 <span className="italic">envelopes</span>, newest first.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
One row per inbound ISA/IEA interchange — the
|
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
||||||
envelope-level sibling of the 999. Gainwell's Colorado
|
One row per inbound ISA/IEA interchange — the
|
||||||
MFT does not ship TA1s today; this section surfaces them
|
envelope-level sibling of the 999. Gainwell's Colorado
|
||||||
when they appear.
|
MFT does not ship TA1s today; this section surfaces them
|
||||||
</p>
|
when they appear.
|
||||||
|
</p>
|
||||||
|
{/* SP25: live-tail status for the TA1 stream. Quietly
|
||||||
|
placed after the description so the eyebrow / display
|
||||||
|
title remains the dominant scan target. */}
|
||||||
|
<TailStatusPill
|
||||||
|
status={tailStatus}
|
||||||
|
lastEventAt={tailLastEventAt}
|
||||||
|
onReconnect={forceReconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-4 pt-2">
|
<div className="grid gap-3 grid-cols-2 md:grid-cols-4 pt-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user