Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/components/editor/EditorHumanCheck.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <button type="button" onClick={async () => onResult(await ensureHuman())}>publish</button>;
}

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(
<EditorHumanCheck coolingWindow={{ unlockAtMs: Date.now() + 3_600_000, turnstileSiteKey: "k" }}>
<Publish onResult={onResult} />
</EditorHumanCheck>,
);
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(
<EditorHumanCheck coolingWindow={null}>
<Publish onResult={onResult} />
</EditorHumanCheck>,
);
await click();
expect(onResult).toHaveBeenCalledWith(true);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
25 changes: 25 additions & 0 deletions app/components/editor/EditorHumanCheck.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<OutboundVerificationProvider
windowOpen={coolingWindow != null}
siteKey={coolingWindow?.turnstileSiteKey ?? null}
>
{children}
</OutboundVerificationProvider>
);
}
11 changes: 9 additions & 2 deletions app/components/editor/PublishModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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();

Expand Down
6 changes: 6 additions & 0 deletions app/components/editor/publish-modal-cooling-notice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PublishModal {...props} coolingUnlockAtMs={Date.now() + 3_600_000} />);
expect(screen.getByText(/less than a day old/i)).toBeTruthy();
});

it('says nothing when the company is not in a window', () => {
unlockAtMs = null;
render(<PublishModal {...props} />);
Expand Down
14 changes: 13 additions & 1 deletion app/routes/inspection-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof loader>();
return (
<EditorHumanCheck coolingWindow={outboundCoolingWindow}>
<InspectionEditPage />
</EditorHumanCheck>
);
}

function InspectionEditPage() {
const loaderData = useLoaderData<typeof loader>();
const displayLocale = useDisplayLocale();
const fetcher = useFetcher();
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion app/routes/inspection-edit/loader.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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 } },
})),
}));
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
7 changes: 6 additions & 1 deletion app/routes/inspection-edit/loader.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
}
2 changes: 1 addition & 1 deletion scripts/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading