diff --git a/app/components/editor/EditorHumanCheck.test.tsx b/app/components/editor/EditorHumanCheck.test.tsx new file mode 100644 index 000000000..b26320a50 --- /dev/null +++ b/app/components/editor/EditorHumanCheck.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("~/lib/turnstile", () => ({ useTurnstileWidget: () => undefined })); + +import { EditorHumanCheck } from "./EditorHumanCheck"; +import { useEnsureHuman } from "~/components/OutboundVerification"; + +function Publish({ onResult }: { onResult: (ok: boolean) => void }) { + const ensureHuman = useEnsureHuman(); + return ; +} + +const click = async () => { await act(async () => { fireEvent.click(screen.getByText("publish")); }); }; + +describe("EditorHumanCheck", () => { + it("makes a publish inside a new company's window ask for the check", async () => { + const onResult = vi.fn(); + render( + + + , + ); + await click(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(onResult).not.toHaveBeenCalled(); + }); + + it("lets a publish through at once when there is no window", async () => { + const onResult = vi.fn(); + render( + + + , + ); + await click(); + expect(onResult).toHaveBeenCalledWith(true); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/app/components/editor/EditorHumanCheck.tsx b/app/components/editor/EditorHumanCheck.tsx new file mode 100644 index 000000000..4652b7692 --- /dev/null +++ b/app/components/editor/EditorHumanCheck.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { OutboundVerificationProvider } from "~/components/OutboundVerification"; + +/** + * Mounts the human check for the full-screen editor, which sits outside the + * workspace layout and so has none of its own. `coolingWindow` is what the editor's + * loader read from the session context: non-null only while a new company's first + * 24 hours are open. + */ +export function EditorHumanCheck({ + coolingWindow, + children, +}: { + coolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/editor/PublishModal.tsx b/app/components/editor/PublishModal.tsx index 2a49c8f3f..e0c2c67be 100644 --- a/app/components/editor/PublishModal.tsx +++ b/app/components/editor/PublishModal.tsx @@ -21,6 +21,12 @@ export interface PublishModalProps { publishError: string | null; isSubmitting: boolean; onClose: () => void; + /** + * The instant a new company's cooling window closes, when the host route has it. + * The full-screen editor sits outside the workspace layout, so the session + * context this modal otherwise reads is absent there; it passes its own. + */ + coolingUnlockAtMs?: number | null; /** * Publish the report. `markComplete` also closes the order axis first, and * `extras` carries the fields collected below — the owning route must send them @@ -57,7 +63,7 @@ export interface PublishModalProps { onAutoSignToggle: (checked: boolean) => void; } -export function PublishModal({ open, progress, status, publishError, isSubmitting, onClose, onPublish, autoSign, onAutoSignToggle, isAmendment }: PublishModalProps) { +export function PublishModal({ open, progress, status, publishError, isSubmitting, onClose, onPublish, autoSign, onAutoSignToggle, isAmendment, coolingUnlockAtMs }: PublishModalProps) { // The order lifecycle does not gate publishing (report and order axes are // independent). When the on-site work is not yet marked complete, offer an // advisory choice — do both, or just publish — but never block. @@ -72,7 +78,8 @@ export function PublishModal({ open, progress, status, publishError, isSubmittin // Same `unlockAtMs` the banner reads, so the two cannot name different times; // it is non-null only while the window is open, and the server decides that // (see resolveCoolingWindowForSession) — no clock arithmetic here. - const unlockAtMs = useSessionContext()?.outboundCoolingWindow?.unlockAtMs ?? null; + const sessionUnlockAtMs = useSessionContext()?.outboundCoolingWindow?.unlockAtMs ?? null; + const unlockAtMs = coolingUnlockAtMs ?? sessionUnlockAtMs; const timeZone = useDisplayTimeZone(); const dtFormat = useChromeDateTimeFormat(); diff --git a/app/components/editor/publish-modal-cooling-notice.test.tsx b/app/components/editor/publish-modal-cooling-notice.test.tsx index 69e60d0cc..c270507bf 100644 --- a/app/components/editor/publish-modal-cooling-notice.test.tsx +++ b/app/components/editor/publish-modal-cooling-notice.test.tsx @@ -39,6 +39,12 @@ const props = { }; describe('PublishModal — outbound cooling window', () => { + it('shows the notice from its own prop when the host route has no session context (the full-screen editor)', () => { + unlockAtMs = null; + render(); + expect(screen.getByText(/less than a day old/i)).toBeTruthy(); + }); + it('says nothing when the company is not in a window', () => { unlockAtMs = null; render(); diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx index 9081e0d2b..255a1ef6e 100644 --- a/app/routes/inspection-edit.tsx +++ b/app/routes/inspection-edit.tsx @@ -8,6 +8,7 @@ import { findingKey } from "~/hooks/findings/shared"; import { useDisplayLocale } from "~/hooks/useSessionContext"; import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; import { useEnsureHuman } from "~/components/OutboundVerification"; +import { EditorHumanCheck } from "~/components/editor/EditorHumanCheck"; import { useFindings, type AttachedRepairItem } from "~/hooks/useFindings"; import { usePhotoOps } from "~/hooks/usePhotoOps"; import { useScopeLoader } from "~/hooks/useScopeLoader"; @@ -146,7 +147,17 @@ export { action } from "./inspection-edit/action.server"; /* Component */ /* ------------------------------------------------------------------ */ -export default function InspectionEditPage() { +/** The editor sits outside the workspace layout, so it mounts the human check itself. */ +export default function InspectionEditRoute() { + const { outboundCoolingWindow } = useLoaderData(); + return ( + + + + ); +} + +function InspectionEditPage() { const loaderData = useLoaderData(); const displayLocale = useDisplayLocale(); const fetcher = useFetcher(); @@ -1876,6 +1887,7 @@ export default function InspectionEditPage() { status={state.inspection.status as string} publishError={publishError} isSubmitting={publishBusy} isAmendment={loaderData.nextPublishIsAmendment} + coolingUnlockAtMs={loaderData.outboundCoolingWindow?.unlockAtMs ?? null} onClose={() => { setPublishError(null); state.setShowPublishModal(false); }} onPublish={async (markComplete: boolean, extras: PublishExtras) => { if (!(await ensureHuman())) return; diff --git a/app/routes/inspection-edit/loader.server.test.ts b/app/routes/inspection-edit/loader.server.test.ts index 733b046f0..006033220 100644 --- a/app/routes/inspection-edit/loader.server.test.ts +++ b/app/routes/inspection-edit/loader.server.test.ts @@ -30,6 +30,7 @@ const reportDataGet = vi.fn(); * — so it is the default, and the tests that care set it. */ let statutoryDetailsResponse: Response | null = null; +let sessionContextBody: unknown = null; // a Response body is single-use, so the stub builds one per call vi.mock('~/lib/session.server', () => ({ requireToken: vi.fn().mockResolvedValue('tok'), @@ -64,7 +65,7 @@ vi.mock('~/lib/api-client.server', () => ({ }, }, tags: { index: { $get: async () => null } }, - sessionContext: { context: { $get: async () => null } }, + sessionContext: { context: { $get: async () => (sessionContextBody === null ? null : json(sessionContextBody)) } }, defectCategories: { 'defect-categories': { $get: async () => null } }, })), })); @@ -137,6 +138,7 @@ beforeEach(() => { ratingLevels: [], })); statutoryDetailsResponse = null; // the ordinary inspection: no statutory form + sessionContextBody = null; }); describe('editor loader — item attributes survive the report-data overlay', () => { @@ -269,3 +271,21 @@ describe('editor loader — nextPublishIsAmendment', () => { expect(data.nextPublishIsAmendment).toBe(false); }); }); + +describe('loader — the human check for a new company', () => { + it('passes the open cooling window and the site key through, so the editor can ask for the check', async () => { + sessionContextBody = { outboundCoolingWindow: { unlockAtMs: 1_800_000_000_000, turnstileSiteKey: 'site-key-1' } }; + const data = await loadAll(); + expect(data.outboundCoolingWindow).toEqual({ unlockAtMs: 1_800_000_000_000, turnstileSiteKey: 'site-key-1' }); + }); + + // One load per test: the other stubs hand out single-use Response bodies. + it.each([ + ['the window has closed', { outboundCoolingWindow: null }], + ['the field is absent', {}], + ['the session context could not be read', null], + ])('is null when %s', async (_name, body) => { + sessionContextBody = body; + expect((await loadAll()).outboundCoolingWindow).toBeNull(); + }); +}); diff --git a/app/routes/inspection-edit/loader.server.ts b/app/routes/inspection-edit/loader.server.ts index 550ff8f8e..5465e9d63 100644 --- a/app/routes/inspection-edit/loader.server.ts +++ b/app/routes/inspection-edit/loader.server.ts @@ -207,14 +207,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { let tenantSlug: string | null = null; let videoProvider: "r2" | "stream" = "r2"; let collabEditing = false; + // The full-screen editor is declared outside the workspace layout, so it has no + // session context of its own; this is what lets it ask for the human check. + let outboundCoolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null = null; if (sessRes?.ok) { const sb = await sessRes.json() as { data?: { branding?: { tenantSlug?: string | null }; videoProvider?: "r2" | "stream"; collabEditing?: boolean; + outboundCoolingWindow?: { unlockAtMs: number; turnstileSiteKey: string | null } | null; }; }; + outboundCoolingWindow = sb.data?.outboundCoolingWindow ?? null; tenantSlug = sb.data?.branding?.tenantSlug ?? null; // Plan 7 — resolved video backend provider for this tenant (default 'r2'). // Drives VideoCapture/VideoPlayer branch selection in the editor. @@ -371,5 +376,5 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { // `report_versions.length`; both mean "a version already exists". const nextPublishIsAmendment = isReportPublished(inspection.reportStatus); - return { inspection, schema, results, resultId, ratingLevels, token, tagLibrary, tenantSlug, streamCustomerSubdomain, videoProvider, collabEditing, templateSnapshot, revisionStatus, pcaNarrative, defectCategories, units, unitProgress, unitInspectionMode, compliance, relianceText, commercialPresets, statutoryDetails, statutoryCoverage, nextPublishIsAmendment }; + return { inspection, schema, results, resultId, ratingLevels, token, tagLibrary, tenantSlug, streamCustomerSubdomain, videoProvider, collabEditing, outboundCoolingWindow, templateSnapshot, revisionStatus, pcaNarrative, defectCategories, units, unitProgress, unitInspectionMode, compliance, relianceText, commercialPresets, statutoryDetails, statutoryCoverage, nextPublishIsAmendment }; } diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 705dcf863..633dbeeb3 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -1,5 +1,5 @@ { - "app/routes/inspection-edit.tsx": 2605, + "app/routes/inspection-edit.tsx": 2617, "app/routes/inspector-portal.tsx": 1396, "scripts/verify-statutory-render.mjs": 1283, "server/durable-objects/inspection-doc.ts": 1028,