From 2354a269f4ca3a0ec6bac08d4c74e313c2e2872d Mon Sep 17 00:00:00 2001 From: James Kurz Date: Sun, 11 Oct 2026 21:25:00 -0400 Subject: [PATCH 1/3] Add signing progress and void incomplete signature requests --- apps/web/.storybook/main.ts | 5 +- .../internal-ops/contracts/action-result.ts | 2 + .../internal-ops/contracts/actions.ts | 2 +- .../contracts/client-components.test.tsx | 110 ++++++++----- .../features/internal-ops/contracts/copy.ts | 12 ++ .../internal-ops/contracts/signing-panel.tsx | 87 ++--------- .../features/internal-ops/mnda/dialogs.tsx | 5 +- .../src/features/internal-ops/mnda/labels.ts | 4 + .../src/features/internal-ops/mnda/results.ts | 11 ++ .../internal-ops/mnda/workspace.test.tsx | 123 +++++++++++---- .../features/internal-ops/mnda/workspace.tsx | 7 + .../internal-ops/signing-errors.test.ts | 36 +++++ .../internal-ops/signing-progress.module.css | 49 ++++++ .../internal-ops/signing-progress.test.tsx | 126 +++++++++++++++ .../internal-ops/signing-progress.tsx | 89 +++++++++++ .../src/i18n/messages/operations-contracts.ts | 23 ++- apps/web/src/i18n/messages/operations-mnda.ts | 31 +++- apps/web/src/i18n/messages/operations.ts | 16 ++ .../storybook/signing-progress.stories.tsx | 34 ++++ docs/operations/add-a-document-type.md | 8 +- docs/operations/commerce-mnda.md | 34 +++- docs/operations/contract-templates.md | 20 ++- packages/contracts/src/contract-register.ts | 3 +- packages/contracts/src/mnda.test.ts | 3 +- packages/contracts/src/mnda.ts | 7 +- .../integrations/src/esign/signwell.test.ts | 58 +++++++ packages/integrations/src/esign/signwell.ts | 39 ++++- packages/workflows/src/contracts.test.ts | 145 +++++++++++++++--- packages/workflows/src/mnda.test.ts | 128 ++++++++++++++-- packages/workflows/src/signing/engine.ts | 67 ++++++-- 30 files changed, 1055 insertions(+), 229 deletions(-) create mode 100644 apps/web/src/features/internal-ops/signing-errors.test.ts create mode 100644 apps/web/src/features/internal-ops/signing-progress.module.css create mode 100644 apps/web/src/features/internal-ops/signing-progress.test.tsx create mode 100644 apps/web/src/features/internal-ops/signing-progress.tsx create mode 100644 apps/web/src/storybook/signing-progress.stories.tsx 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..f218740e 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(); 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 ? (