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
5 changes: 4 additions & 1 deletion apps/web/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export function failure(error: unknown): {
code: string;
fields?: Record<string, string>;
} {
if (error instanceof Error && error.name === "AbortError")
return { ok: false, code: "SIGNWELL_TIMEOUT" };
if (error instanceof ZodError) {
const fields: Record<string, string> = {};
for (const issue of error.issues) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/internal-ops/contracts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/features/internal-ops/contracts/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,18 @@ const errorMessages: Readonly<Record<string, MessageId>> = {
};

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";
Expand Down
87 changes: 13 additions & 74 deletions apps/web/src/features/internal-ops/contracts/signing-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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<ReturnType<typeof useTranslations>>[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 = [],
Expand Down Expand Up @@ -279,10 +213,14 @@ export function SigningPanel({
{t(signingStateLabel(signing.state, signing.cancelCode))}
</StatusBadge>
</div>
<ProgressSteps
className={styles.signingSteps ?? ""}
label={t("operations.contracts.signing.progress")}
steps={steps(signing, t)}
<SigningProgress
subject={signing.documentName}
state={signing.state}
prepared={Boolean(generatedFileId)}
counterpartySigns={signing.counterpartySigns}
{...(signing.approvalRequired
? { approval: signing.approvalState }
: {})}
/>
{signing.testMode ? (
<InlineNotice
Expand Down Expand Up @@ -705,9 +643,7 @@ export function SigningPanel({
open={voiding}
onOpenChange={setVoiding}
title={t("operations.contracts.signing.void.title")}
description={t("operations.contracts.signing.void.description", {
signer: signing.counterpartySigner.name,
})}
description={t("operations.contracts.signing.void.description")}
closeLabel={t("operations.contracts.signing.void.keep")}
trigger={<span hidden />}
footer={
Expand Down Expand Up @@ -752,6 +688,9 @@ export function SigningPanel({
<p className={styles.muted}>
{t("operations.contracts.signing.void.evidence")}
</p>
{signing.state === "awaiting_countersignature" ? (
<p>{t("operations.contracts.signing.void.partialWarning")}</p>
) : null}
{signerChange ? (
<p className={styles.muted}>
{t(
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/features/internal-ops/mnda/dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { SessionExpiredReload } from "../session-expiry";
import styles from "./workspace.module.css";

/**
* Voids a sent MNDA. SignWell stops the request and deletes its copy; the
* Voids an incomplete MNDA. SignWell stops the request and deletes its copy; the
* original PDF and the history stay here. With `signerChange` no reason is
* typed: the void is recorded as "a different person will sign".
*/
Expand Down Expand Up @@ -93,6 +93,9 @@ export function VoidDialog({
}
>
<p className={styles.muted}>{t("operations.mnda.void.evidence")}</p>
{record.state === "awaiting_countersignature" ? (
<p>{t("operations.mnda.void.partialWarning")}</p>
) : null}
{signerChange ? (
<p className={styles.muted}>
{t("operations.mnda.void.signerChangeNote")}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/features/internal-ops/mnda/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export const mndaErrorLabels: Record<MndaErrorCode, MessageId> = {
reminder_cooldown: "operations.mnda.error.reminderCooldown",
busy: "operations.mnda.error.busy",
provider_failed: "operations.mnda.error.providerFailed",
provider_timeout: "operations.mnda.error.providerTimeout",
provider_access: "operations.mnda.error.providerAccess",
provider_rate_limit: "operations.mnda.error.providerRateLimit",
provider_rejected: "operations.mnda.error.providerRejected",
forbidden: "operations.mnda.error.forbidden",
mfa_required: "operations.mnda.error.mfaRequired",
not_configured: "operations.mnda.error.notConfigured",
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/features/internal-ops/mnda/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ export function mndaFailure(error: unknown): MndaResult<never> {
? { fields: [{ field: match.field, code: match.code }] }
: {}),
};
if (
message === "SIGNWELL_TIMEOUT" ||
(error instanceof Error && error.name === "AbortError")
)
return { ok: false, code: "provider_timeout" };
if (["SIGNWELL_HTTP_401", "SIGNWELL_HTTP_403"].includes(message))
return { ok: false, code: "provider_access" };
if (message === "SIGNWELL_HTTP_429")
return { ok: false, code: "provider_rate_limit" };
if (/^SIGNWELL_HTTP_4\d{2}$/.test(message) && message !== "SIGNWELL_HTTP_408")
return { ok: false, code: "provider_rejected" };
if (
message.startsWith("SIGNWELL_") ||
(error instanceof Error && error.name === "AbortError")
Expand Down
Loading
Loading