import { useCallback, useMemo, useState, type ReactNode } from "react"; import { ApiError, errorCode } from "../api/client"; import type { Guarded, SignBody } from "../api/types"; import { ReasonSheet, type ReasonOptions } from "./ReasonSheet"; import { SignatureSheet } from "./SignatureSheet"; type Ask = | { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void } | { kind: "sign"; meaning: string; sub: string | undefined; attempt: (sign: SignBody) => Promise; resolve: (r: { ok: true; value: unknown } | { ok: false }) => void; reject: (err: unknown) => void; }; export interface RunOptions { /** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */ sign?: string | undefined; /** Shown next to the sheet titles, like the job name. */ sub?: string | undefined; } export interface Guard { /** * Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`. * On 400 signature_required it opens the signature sheet and retries with `sign`. * Resolves undefined when the user cancels. Other errors are thrown. */ run: (fn: (g: Guarded) => Promise, opts?: RunOptions) => Promise; /** Asks for a reason. Null when cancelled. */ askReason: (opts: ReasonOptions) => Promise; /** The open sheet, if any. Render it once in the screen. */ sheet: ReactNode; } export const IT_REASON: ReasonOptions = { title: "IT reason", label: "Why IT is making this change", note: "You are changing this as IT. The reason is recorded with the change.", min: 8, submitLabel: "Continue", }; function meaningOf(err: unknown): string { if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) { const m = (err.body as { meaning: unknown }).meaning; if (typeof m === "string" && m) return m; } return "Sign this step"; } /** The IT reason and signature retries, as sheets. */ export function useGuard(): Guard { const [ask, setAsk] = useState(null); const askReason = useCallback( (opts: ReasonOptions) => new Promise((resolve) => { setAsk({ kind: "reason", opts, resolve }); }), [], ); const askSign = useCallback( (meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise) => new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => { setAsk({ kind: "sign", meaning, sub, attempt, resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void, reject, }); }), [], ); const run = useCallback( async (fn: (g: Guarded) => Promise, opts: RunOptions = {}): Promise => { let itReason: string | undefined; let meaning = opts.sign; for (let round = 0; round < 4; round++) { const call = (sign?: SignBody) => { const g: Guarded = {}; if (itReason) g.it_reason = itReason; if (sign) g.sign = sign; return fn(g); }; try { if (meaning) { const r = await askSign(meaning, opts.sub, call); return r.ok ? r.value : undefined; } return await call(); } catch (err) { const code = errorCode(err); if (code === "it_reason_required" && !itReason) { const r = await askReason({ ...IT_REASON, sub: opts.sub }); if (r === null) return undefined; itReason = r; continue; } if (code === "signature_required" && !meaning) { meaning = meaningOf(err); continue; } throw err; } } return undefined; }, [askReason, askSign], ); let sheet: ReactNode = null; if (ask?.kind === "reason") { sheet = ( { setAsk(null); ask.resolve(r); }} onCancel={() => { setAsk(null); ask.resolve(null); }} /> ); } else if (ask?.kind === "sign") { sheet = ( { const value = await ask.attempt(sign); setAsk(null); ask.resolve({ ok: true, value }); }} onFail={(err) => { setAsk(null); ask.reject(err); }} onCancel={() => { setAsk(null); ask.resolve({ ok: false }); }} /> ); } return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]); }