diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts index 12b7bdd3..04dc58b3 100644 --- a/apps/web/.storybook/main.ts +++ b/apps/web/.storybook/main.ts @@ -1,7 +1,10 @@ import type { StorybookConfig } from "@storybook/nextjs-vite"; const config: StorybookConfig = { - stories: ["../../../packages/ui/src/stories/**/*.stories.@(ts|tsx)"], + stories: [ + "../../../packages/ui/src/stories/**/*.stories.@(ts|tsx)", + "../src/storybook/**/*.stories.@(ts|tsx)", + ], addons: [ "@storybook/addon-a11y", "@storybook/addon-docs", diff --git a/apps/web/src/features/internal-ops/contracts/action-result.ts b/apps/web/src/features/internal-ops/contracts/action-result.ts index 228a4e05..0983cc57 100644 --- a/apps/web/src/features/internal-ops/contracts/action-result.ts +++ b/apps/web/src/features/internal-ops/contracts/action-result.ts @@ -18,6 +18,8 @@ export function failure(error: unknown): { code: string; fields?: Record; } { + if (error instanceof Error && error.name === "AbortError") + return { ok: false, code: "SIGNWELL_TIMEOUT" }; if (error instanceof ZodError) { const fields: Record = {}; for (const issue of error.issues) { diff --git a/apps/web/src/features/internal-ops/contracts/actions.ts b/apps/web/src/features/internal-ops/contracts/actions.ts index 5f28883d..28dbca86 100644 --- a/apps/web/src/features/internal-ops/contracts/actions.ts +++ b/apps/web/src/features/internal-ops/contracts/actions.ts @@ -397,7 +397,7 @@ async function assertMayChangeSigning( } /** - * Voids a sent request the counterparty has not signed, or closes one whose + * Voids an incomplete request, including after the counterparty signs, or closes one whose * document was deleted in SignWell, with a typed reason or the code for "a * different person will sign". Sending needs `contract:write`, and so does * voiding; as with MNDAs, someone other than the preparer also needs to be an diff --git a/apps/web/src/features/internal-ops/contracts/client-components.test.tsx b/apps/web/src/features/internal-ops/contracts/client-components.test.tsx index b39517a1..ac180af6 100644 --- a/apps/web/src/features/internal-ops/contracts/client-components.test.tsx +++ b/apps/web/src/features/internal-ops/contracts/client-components.test.tsx @@ -505,47 +505,81 @@ describe("signing panel", () => { expect(screen.queryByRole("button", { name: "Discard draft" })).toBeNull(); }); - it("voids a sent request only with a reason", async () => { - mocks.voidContract.mockResolvedValue({ - ok: true, - value: { state: "canceled" }, - }); - panel({ state: "sent", providerId: "x" }); - fireEvent.click(screen.getByRole("button", { name: "Void" })); - const dialog = await screen.findByRole("dialog"); - expect( - within(dialog).getByText("Alex Example can no longer sign it."), - ).toBeInTheDocument(); - fireEvent.click( - within(dialog).getByRole("button", { name: "Void contract" }), - ); - expect( - await within(dialog).findByText( - "Enter a reason of at least 3 characters.", - ), - ).toBeInTheDocument(); - expect(mocks.voidContract).not.toHaveBeenCalled(); - fireEvent.change(within(dialog).getByRole("textbox"), { - target: { value: "Wrong legal entity" }, - }); - fireEvent.click( - within(dialog).getByRole("button", { name: "Void contract" }), - ); - await waitFor(() => - expect(mocks.voidContract).toHaveBeenCalledWith({ - contractId: fixtureSigningRecord.contractId, - requestNumber: 1, - reason: "Wrong legal entity", - }), - ); - expect(mocks.refresh).toHaveBeenCalled(); - }); + it.each([ + { + state: "sent" as const, + documentType: "contract_template" as const, + counterpartySigns: true, + }, + { + state: "awaiting_countersignature" as const, + documentType: "contract_template" as const, + counterpartySigns: true, + }, + { + state: "awaiting_countersignature" as const, + documentType: "counterparty_paper" as const, + counterpartySigns: true, + }, + { + state: "sent" as const, + documentType: "counterparty_paper" as const, + counterpartySigns: false, + }, + ])( + "voids $documentType / $state only with confirmation and a reason", + async ({ state, documentType, counterpartySigns }) => { + mocks.voidContract.mockResolvedValue({ + ok: true, + value: { state: "canceled" }, + }); + panel({ state, documentType, counterpartySigns, providerId: "x" }); + if (state === "awaiting_countersignature" || !counterpartySigns) + expect(screen.queryByRole("button", { name: "Fix email" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Void" })); + const dialog = await screen.findByRole("dialog"); + if (state === "awaiting_countersignature") + expect( + within(dialog).getByText( + /Their partially signed copy will be deleted/, + ), + ).toBeVisible(); + expect( + within(dialog).getByText( + "No remaining signer can sign this contract after it is voided.", + ), + ).toBeInTheDocument(); + fireEvent.click( + within(dialog).getByRole("button", { name: "Void contract" }), + ); + expect( + await within(dialog).findByText( + "Enter a reason of at least 3 characters.", + ), + ).toBeInTheDocument(); + expect(mocks.voidContract).not.toHaveBeenCalled(); + fireEvent.change(within(dialog).getByRole("textbox"), { + target: { value: "Wrong legal entity" }, + }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Void contract" }), + ); + await waitFor(() => + expect(mocks.voidContract).toHaveBeenCalledWith({ + contractId: fixtureSigningRecord.contractId, + requestNumber: 1, + reason: "Wrong legal entity", + }), + ); + expect(mocks.refresh).toHaveBeenCalled(); + }, + ); - it("offers no void once the counterparty has signed, or to a colleague without approval", () => { - panel({ state: "awaiting_countersignature", providerId: "x" }); + it("offers no void once completed, or to a colleague without approval", () => { + panel({ state: "completed", providerId: "x" }); expect(screen.queryByRole("button", { name: "Void" })).toBeNull(); panel( - { state: "sent", providerId: "x" }, + { state: "awaiting_countersignature", providerId: "x" }, { canApprove: false, isPreparer: false }, ); expect(screen.queryByRole("button", { name: "Void" })).toBeNull(); @@ -739,7 +773,9 @@ describe("signing panel", () => { panel({ state: "sent", providerId: "x" }); fireEvent.click(screen.getByRole("button", { name: "Check status" })); expect( - await screen.findByText(/SignWell did not respond as expected/), + await screen.findByText( + /Commerce could not confirm the result with SignWell/, + ), ).toBeInTheDocument(); expect(mocks.refresh).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/features/internal-ops/contracts/copy.ts b/apps/web/src/features/internal-ops/contracts/copy.ts index 62923c26..bfc120e0 100644 --- a/apps/web/src/features/internal-ops/contracts/copy.ts +++ b/apps/web/src/features/internal-ops/contracts/copy.ts @@ -220,6 +220,18 @@ const errorMessages: Readonly> = { }; export function errorMessage(code: string | null | undefined): MessageId { + if (code === "SIGNWELL_TIMEOUT") + return "operations.contracts.error.providerTimeout"; + if (code === "SIGNWELL_HTTP_401" || code === "SIGNWELL_HTTP_403") + return "operations.contracts.error.providerAccess"; + if (code === "SIGNWELL_HTTP_429") + return "operations.contracts.error.providerRateLimit"; + if ( + code && + /^SIGNWELL_HTTP_4\d{2}$/.test(code) && + code !== "SIGNWELL_HTTP_408" + ) + return "operations.contracts.error.providerRejected"; if (code?.startsWith("SIGNWELL_")) return "operations.contracts.error.provider"; return (code && errorMessages[code]) || "operations.contracts.error.generic"; diff --git a/apps/web/src/features/internal-ops/contracts/signing-panel.tsx b/apps/web/src/features/internal-ops/contracts/signing-panel.tsx index b1af21de..b2592d3e 100644 --- a/apps/web/src/features/internal-ops/contracts/signing-panel.tsx +++ b/apps/web/src/features/internal-ops/contracts/signing-panel.tsx @@ -19,11 +19,9 @@ import { Dialog, InlineNotice, Input, - ProgressSteps, StatusBadge, Textarea, buttonClassName, - type ProgressStep, } from "@clockwork/ui"; import type { MessageId } from "@/src/i18n"; import { useFormattingLocale, useTranslations } from "@/src/i18n/client"; @@ -45,6 +43,7 @@ import { import { SessionExpiredReload } from "../session-expiry"; import { SelfApprovalDialog } from "../self-approval/self-approval-dialog"; import styles from "./contracts.module.css"; +import { SigningProgress } from "../signing-progress"; type Operation = "send" | "sync" | "remind" | "cancel"; @@ -77,71 +76,6 @@ const sentStates = ["sent", "viewed", "awaiting_countersignature"]; export const prepareAgainHref = (signing: ContractSigningRecord) => `/internal/contracts/templates/${signing.templateId}?from=${signing.contractId}` as Route; -function steps( - signing: ContractSigningRecord, - label: (id: Parameters>[0]) => string, -): ProgressStep[] { - const order = [ - "draft", - "preparing", - "ready", - "sending", - "sent", - "viewed", - "awaiting_countersignature", - "completed", - ]; - const reached = order.indexOf(signing.state); - const failed = ["declined", "expired", "canceled", "attention"].includes( - signing.state, - ); - const state = (done: boolean, current: boolean): ProgressStep["state"] => - done ? "complete" : current ? (failed ? "error" : "current") : "upcoming"; - const approved = - signing.approvalState !== "pending" && signing.approvalState !== "rejected"; - const list: ProgressStep[] = [ - { - id: "prepared", - label: label("operations.contracts.signing.step.prepared"), - state: "complete", - }, - ]; - if (signing.approvalRequired) - list.push({ - id: "approved", - label: label("operations.contracts.signing.step.approved"), - state: - signing.approvalState === "rejected" - ? "error" - : state(approved, !approved), - }); - list.push( - { - id: "sent", - label: label("operations.contracts.signing.step.sent"), - state: state(reached >= 4, approved && reached < 4), - }, - ...(signing.counterpartySigns - ? [ - { - id: "counterparty", - label: label("operations.contracts.signing.step.counterparty"), - state: state(reached >= 6, reached >= 4 && reached < 6), - }, - ] - : []), - { - id: "countersigned", - label: label("operations.contracts.signing.step.countersigned"), - // Fil One alone signs counterparty paper signed already. - state: signing.counterpartySigns - ? state(reached === 7, reached === 6) - : state(reached === 7, reached >= 4 && reached < 7), - }, - ); - return list; -} - export function SigningPanel({ signing, previousSigning = [], @@ -279,10 +213,14 @@ export function SigningPanel({ {t(signingStateLabel(signing.state, signing.cancelCode))} - {signing.testMode ? (