From b6bc9b4f115523465327fb9e07fdcaf8d62f39dd Mon Sep 17 00:00:00 2001 From: razeprasine Date: Thu, 25 Jun 2026 20:14:16 +0100 Subject: [PATCH] fies issue 946 --- fix.md | 10 +- .../components/MultisigApprovalModal.test.tsx | 24 ++++ .../src/components/MultisigApprovalModal.tsx | 97 +++++++++----- frontend/src/lib/multisig-context.tsx | 37 ++++-- frontend/src/lib/multisig-optimistic.test.tsx | 122 +++++++++++++++++- 5 files changed, 243 insertions(+), 47 deletions(-) diff --git a/fix.md b/fix.md index 2ddff059..8a309e77 100644 --- a/fix.md +++ b/fix.md @@ -1,9 +1,9 @@ -[Frontend] Improve screen reader support for Multi-sig Approval Modal + [Frontend] Enable optimistic updates in Multi-sig Approval Modal Repo Avatar emdevelopa/Stellar_Payment_API Description This task involves UX enhancement for the Multi-sig Approval Modal module. -The goal is to improve screen reader support for Multi-sig Approval Modal to improve the platform's styling, user interactions, and overall accessibility. +The goal is to enable optimistic updates in Multi-sig Approval Modal to improve the platform's styling, user interactions, and overall accessibility. Requirements and context @@ -14,15 +14,15 @@ Specifically focused on frontend UX enhancement and responsiveness Suggested execution Fork the repo and create a branch -git checkout -b feature/fe-improve-screen-reader-support-for-multi-sig-approval-modal +git checkout -b feature/fe-enable-optimistic-updates-in-multi-sig-approval-modal Implement changes Review existing component in Multi-sig Approval Modal -Apply changes: Improve screen reader support for Multi-sig Approval Modal +Apply changes: Enable optimistic updates in Multi-sig Approval Modal Use clean CSS or tailwind variables for styling Maintain state transitions and visual feedback Test and commit Test mobile responsiveness and interactive states Check accessibility (a11y) using standard audits -Include screenshots or gifs in the PR +Include screenshots or gifs in the PR \ No newline at end of file diff --git a/frontend/src/components/MultisigApprovalModal.test.tsx b/frontend/src/components/MultisigApprovalModal.test.tsx index 45cb5da8..bc79a699 100644 --- a/frontend/src/components/MultisigApprovalModal.test.tsx +++ b/frontend/src/components/MultisigApprovalModal.test.tsx @@ -281,6 +281,30 @@ describe("MultisigApprovalModal Component", () => { }); describe("Confirmation State", () => { + it("shows optimistic pending state on submit before finalising", async () => { + renderWithProvider(); + + const signButtons = screen.getAllByText("Sign"); + fireEvent.click(signButtons[0]); + + await waitFor(() => { + expect(screen.getByText("50%")).toBeInTheDocument(); + }, { timeout: 2000 }); + + fireEvent.click(signButtons[1]); + + await waitFor(() => { + expect(screen.getByText("Submit Transaction")).toBeInTheDocument(); + }, { timeout: 2000 }); + + fireEvent.click(screen.getByText("Submit Transaction")); + + // Optimistic state appears immediately + expect(screen.getByText("Transaction Submitted")).toBeInTheDocument(); + expect(screen.getByText("Awaiting network confirmation...")).toBeInTheDocument(); + expect(screen.getByText("Confirming...")).toBeInTheDocument(); + }); + it("shows confirmation after successful submission", async () => { renderWithProvider(); diff --git a/frontend/src/components/MultisigApprovalModal.tsx b/frontend/src/components/MultisigApprovalModal.tsx index b696f682..6e6365e2 100644 --- a/frontend/src/components/MultisigApprovalModal.tsx +++ b/frontend/src/components/MultisigApprovalModal.tsx @@ -80,6 +80,7 @@ export default function MultisigApprovalModal({ requiredSignatures, progress, isExpired, + isPendingConfirmation, timeRemaining, } = useMultisigState(); @@ -155,10 +156,10 @@ export default function MultisigApprovalModal({ }, [isOpen]); const handleClose = useCallback(() => { - if (isLoading) return; + if (isLoading || isPendingConfirmation) return; resetModal(); onClose(); - }, [isLoading, resetModal, onClose]); + }, [isLoading, isPendingConfirmation, resetModal, onClose]); const handleSign = useCallback(async (signerId: string) => { try { @@ -347,36 +348,72 @@ export default function MultisigApprovalModal({ const ConfirmStep = () => (
-
- - - -
-
-

Transaction Approved

-

- Your multi-signature transaction has been successfully submitted -

-
- {transaction?.submittedTxHash && ( -
-

Transaction Hash

-
- - {transaction.submittedTxHash} - - + {isPendingConfirmation ? ( + <> + ); diff --git a/frontend/src/lib/multisig-context.tsx b/frontend/src/lib/multisig-context.tsx index b79d67bb..47de7b5b 100644 --- a/frontend/src/lib/multisig-context.tsx +++ b/frontend/src/lib/multisig-context.tsx @@ -37,6 +37,7 @@ export interface MultisigContextType { transaction: MultisigTransaction | null; currentStep: MultisigStep; isLoading: boolean; + isPendingConfirmation: boolean; error: string | null; isMounted: boolean; isVisible: boolean; @@ -71,6 +72,7 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid const [transaction, setTransaction] = useState(null); const [currentStep, setCurrentStep] = useState("review"); const [isLoading, setIsLoading] = useState(false); + const [isPendingConfirmation, setIsPendingConfirmation] = useState(false); const [error, setError] = useState(null); const [isMounted, setIsMounted] = useState(false); const [isVisible, setIsVisible] = useState(false); @@ -83,6 +85,7 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid setTransaction(null); setCurrentStep("review"); setIsLoading(false); + setIsPendingConfirmation(false); setError(null); setIsVisible(false); }, []); @@ -188,10 +191,10 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid return; } + const previousTransaction = { ...transaction, signers: [...transaction.signers] }; + try { - setIsLoading(true); clearError(); - setCurrentStep("processing"); // Verify enough signatures const signedWeight = transaction.signers.filter(s => s.hasSigned) @@ -201,27 +204,37 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid throw new Error("Not enough signatures to submit transaction"); } + // Optimistic update: immediately show confirm step with a pending tx hash + const pendingTxHash = `tx_pending_${Date.now()}`; + setIsPendingConfirmation(true); + setCurrentStep("confirm"); + setTransactionSafe({ + ...transaction, + status: 'approved' as MultisigApprovalStatus, + submittedTxHash: pendingTxHash, + }); + // Simulate submission process await new Promise(resolve => setTimeout(resolve, 2000)); - // Update transaction with submitted hash - const mockTxHash = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const updatedTransaction = { + // Finalize with real transaction hash + const realTxHash = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + setTransactionSafe({ ...transaction, status: 'approved' as MultisigApprovalStatus, - submittedTxHash: mockTxHash - }; - - setTransactionSafe(updatedTransaction); - setCurrentStep("confirm"); + submittedTxHash: realTxHash, + }); } catch (err) { + // Revert optimistic update + setTransactionSafe(previousTransaction); const errorMessage = err instanceof Error ? err.message : "Failed to submit transaction"; setError(errorMessage); setCurrentStep("error"); console.error("Submission error:", err); } finally { setIsLoading(false); + setIsPendingConfirmation(false); } }, [transaction, clearError, setTransactionSafe]); @@ -288,6 +301,7 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid transaction, currentStep, isLoading, + isPendingConfirmation, error, isMounted, isVisible, @@ -303,6 +317,7 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid transaction, currentStep, isLoading, + isPendingConfirmation, error, isMounted, isVisible, @@ -336,6 +351,7 @@ export function useMultisigState() { transaction, currentStep, isLoading, + isPendingConfirmation, error, isMounted, isVisible, @@ -352,6 +368,7 @@ export function useMultisigState() { transaction, currentStep, isLoading, + isPendingConfirmation, error, isMounted, isVisible, diff --git a/frontend/src/lib/multisig-optimistic.test.tsx b/frontend/src/lib/multisig-optimistic.test.tsx index d9b2b911..7c4ffd38 100644 --- a/frontend/src/lib/multisig-optimistic.test.tsx +++ b/frontend/src/lib/multisig-optimistic.test.tsx @@ -29,22 +29,29 @@ const tx: MultisigTransaction = { status: "pending", }; +function cannedTx(overrides: Partial = {}): MultisigTransaction { + return { ...tx, ...overrides }; +} + function Consumer() { - const { transaction, signedCount, currentStep, error } = useMultisigState(); - const { setTransaction, signTransaction } = useMultisigActions(); + const { transaction, signedCount, currentStep, isPendingConfirmation, error } = useMultisigState(); + const { setTransaction, signTransaction, submitTransaction } = useMultisigActions(); const s1 = transaction?.signers.find((s) => s.id === "s1"); const s2 = transaction?.signers.find((s) => s.id === "s2"); return (
{signedCount} {currentStep} + {String(isPendingConfirmation)} {error || "no-error"} {String(!!s1?.hasSigned)} {String(!!s1?.signature)} {String(!!s2?.hasSigned)} + {transaction?.submittedTxHash || "none"} +
); } @@ -102,4 +109,115 @@ describe("Multi-sig optimistic updates (#797)", () => { fireEvent.click(screen.getByText("sign-s1")); await waitFor(() => expect(screen.getByTestId("error")).not.toHaveTextContent("no-error")); }); + + describe("Optimistic submission", () => { + it("immediately advances to confirm step on submit", async () => { + renderModal(); + fireEvent.click(screen.getByText("set-tx")); + await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0")); + + // Sign both signers to meet threshold + fireEvent.click(screen.getByText("sign-s1")); + await waitFor( + () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + fireEvent.click(screen.getByText("sign-s2")); + await waitFor( + () => expect(screen.getByTestId("s2-signed")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + // Submit optimistically — should skip "processing" and land on "confirm" immediately + fireEvent.click(screen.getByText("submit-tx")); + + await waitFor(() => { + expect(screen.getByTestId("step")).toHaveTextContent("confirm"); + }); + }); + + it("sets isPendingConfirmation true on submit and false after settlement", async () => { + renderModal(); + fireEvent.click(screen.getByText("set-tx")); + await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0")); + + fireEvent.click(screen.getByText("sign-s1")); + await waitFor( + () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + fireEvent.click(screen.getByText("sign-s2")); + await waitFor( + () => expect(screen.getByTestId("s2-signed")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + fireEvent.click(screen.getByText("submit-tx")); + + // Optimistic: pending confirmation is true immediately + await waitFor(() => { + expect(screen.getByTestId("pending-confirmation")).toHaveTextContent("true"); + }); + + // Settled: pending confirmation flips to false + await waitFor( + () => expect(screen.getByTestId("pending-confirmation")).toHaveTextContent("false"), + { timeout: 3000 }, + ); + }); + + it("shows a pending tx hash during optimistic phase and real hash after settlement", async () => { + renderModal(); + fireEvent.click(screen.getByText("set-tx")); + await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0")); + + fireEvent.click(screen.getByText("sign-s1")); + await waitFor( + () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + fireEvent.click(screen.getByText("sign-s2")); + await waitFor( + () => expect(screen.getByTestId("s2-signed")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + fireEvent.click(screen.getByText("submit-tx")); + + // Optimistic: a pending tx hash is set + await waitFor(() => { + expect(screen.getByTestId("tx-hash")).toMatch(/^tx_pending_/); + }); + + // Settled: a real tx hash replaces the pending one + await waitFor( + () => expect(screen.getByTestId("tx-hash")).toMatch(/^tx_(?!pending_)/), + { timeout: 3000 }, + ); + }); + + it("rolls back to error step when submission fails", async () => { + // Force a failure by not setting a transaction before calling submit + renderModal(); + + fireEvent.click(screen.getByText("set-tx")); + await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0")); + + fireEvent.click(screen.getByText("sign-s1")); + await waitFor( + () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"), + { timeout: 2000 }, + ); + + // Submit without enough signatures — should fail + fireEvent.click(screen.getByText("submit-tx")); + + await waitFor(() => { + expect(screen.getByTestId("error")).not.toHaveTextContent("no-error"); + }); + }); + }); });