diff --git a/fix.md b/fix.md index 2ddff05..8a309e7 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 45cb5da..bc79a69 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 b696f68..6e6365e 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 b964160..886c000 100644 --- a/frontend/src/lib/multisig-context.tsx +++ b/frontend/src/lib/multisig-context.tsx @@ -45,6 +45,7 @@ export interface MultisigContextType { transaction: MultisigTransaction | null; currentStep: MultisigStep; isLoading: boolean; + isPendingConfirmation: boolean; error: string | null; isMounted: boolean; isVisible: boolean; @@ -173,19 +174,26 @@ interface MultisigProviderProps { readonly networkPassphrase: string; } -export function MultisigProvider({ children, networkPassphrase: _networkPassphrase }: MultisigProviderProps) { - const [state, dispatch] = useReducer(multisigReducer, INITIAL_STATE); - - // Always-current snapshot for use inside async callbacks without stale-closure issues. - const stateRef = useRef(state); - stateRef.current = state; +export function MultisigProvider({ children, networkPassphrase }: MultisigProviderProps) { + 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); const clearError = useCallback(() => { dispatch({ type: "CLEAR_ERROR" }); }, []); const resetModal = useCallback(() => { - dispatch({ type: "RESET" }); + setTransaction(null); + setCurrentStep("review"); + setIsLoading(false); + setIsPendingConfirmation(false); + setError(null); + setIsVisible(false); }, []); const setTransactionSafe = useCallback((newTransaction: MultisigTransaction | null) => { @@ -256,27 +264,54 @@ export function MultisigProvider({ children, networkPassphrase: _networkPassphra return; } - const signedWeight = transaction.signers - .filter((s) => s.hasSigned) - .reduce((sum, s) => sum + s.weight, 0); - if (signedWeight < transaction.minSignatures) { - dispatch({ type: "SET_ERROR", payload: "Not enough signatures to submit transaction" }); - return; - } + const previousTransaction = { ...transaction, signers: [...transaction.signers] }; - dispatch({ type: "SET_LOADING", payload: true }); - dispatch({ type: "CLEAR_ERROR" }); - dispatch({ type: "SET_STEP", payload: "processing" }); + try { + clearError(); + + // Verify enough signatures + const signedWeight = transaction.signers.filter(s => s.hasSigned) + .reduce((sum, s) => sum + s.weight, 0); + + if (signedWeight < transaction.minSignatures) { + 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)); + + // Finalize with real transaction hash + const realTxHash = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + setTransactionSafe({ + ...transaction, + status: 'approved' as MultisigApprovalStatus, + submittedTxHash: realTxHash, + }); try { await new Promise((resolve) => setTimeout(resolve, 2000)); const txHash = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; dispatch({ type: "SUBMIT_SUCCESS", txHash }); } catch (err) { + // Revert optimistic update + setTransactionSafe(previousTransaction); const errorMessage = err instanceof Error ? err.message : "Failed to submit transaction"; dispatch({ type: "SET_ERROR", payload: errorMessage }); dispatch({ type: "SET_STEP", payload: "error" }); console.error("Submission error:", err); + } finally { + setIsLoading(false); + setIsPendingConfirmation(false); } }, []); @@ -332,38 +367,44 @@ export function MultisigProvider({ children, networkPassphrase: _networkPassphra }; }, [transaction, currentStep]); - const value: MultisigContextType = useMemo( - () => ({ - transaction, - currentStep, - isLoading, - error, - isMounted, - isVisible, - setTransaction: setTransactionSafe, - setCurrentStep: (step: MultisigStep) => dispatch({ type: "SET_STEP", payload: step }), - signTransaction, - submitTransaction, - resetModal, - clearError, - retryAction, - ...computedValues, - }), - [ - transaction, - currentStep, - isLoading, - error, - isMounted, - isVisible, - setTransactionSafe, - signTransaction, - submitTransaction, - resetModal, - clearError, - retryAction, - computedValues, - ], + const value: MultisigContextType = useMemo(() => ({ + transaction, + currentStep, + isLoading, + isPendingConfirmation, + error, + isMounted, + isVisible, + setTransaction: setTransactionSafe, + setCurrentStep, + signTransaction, + submitTransaction, + resetModal, + clearError, + retryAction, + ...computedValues + }), [ + transaction, + currentStep, + isLoading, + isPendingConfirmation, + error, + isMounted, + isVisible, + setTransactionSafe, + setCurrentStep, + signTransaction, + submitTransaction, + resetModal, + clearError, + retryAction, + computedValues + ]); + + return ( + + {children} + ); return {children}; @@ -380,12 +421,13 @@ export function useMultisig() { } export function useMultisigState() { - const { - transaction, - currentStep, - isLoading, - error, - isMounted, + const { + transaction, + currentStep, + isLoading, + isPendingConfirmation, + error, + isMounted, isVisible, canSign, canSubmit, @@ -400,6 +442,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 d9b2b91..7c4ffd3 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"); + }); + }); + }); });