From f341e22877a20046a40da247a0d44d8caefe53a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rard=20Dethier?= Date: Mon, 13 Nov 2023 15:36:43 +0100 Subject: [PATCH 1/2] feat: move extrinsic submission to context. logion-network/logion-internal#1061 --- src/ClientExtrinsicSubmitter.tsx | 86 ++----- src/ExtrinsicSubmissionResult.tsx | 7 +- src/ExtrinsicSubmitter.test.tsx | 195 +++++++------- src/ExtrinsicSubmitter.tsx | 54 ++-- src/loc/CloseLocButton.test.tsx | 3 + src/loc/ImportItems.test.tsx | 4 +- src/loc/LocCreationDialog.test.tsx | 6 +- src/loc/LocPublishButton.test.tsx | 43 +++- src/loc/RequestVoteButton.test.tsx | 20 +- src/loc/VoidLocButton.test.tsx | 16 +- src/loc/VoidLocButton.tsx | 15 +- src/loc/VoidLocReplaceExistingButton.test.tsx | 35 ++- .../issuer/IssuerSelectionCheckbox.test.tsx | 1 + src/logion-chain/LogionChainContext.tsx | 237 +++++++++++++++++- src/logion-chain/__mocks__/LogionChainMock.ts | 57 ++++- src/settings/ChainData.test.tsx | 41 ++- src/test/Util.ts | 5 + .../CreateProtectionRequestForm.test.tsx | 2 - .../ProtectionRecoveryRequest.test.tsx | 8 +- 19 files changed, 598 insertions(+), 237 deletions(-) diff --git a/src/ClientExtrinsicSubmitter.tsx b/src/ClientExtrinsicSubmitter.tsx index d7ea43b5..3d3cc8e1 100644 --- a/src/ClientExtrinsicSubmitter.tsx +++ b/src/ClientExtrinsicSubmitter.tsx @@ -1,11 +1,10 @@ -import { ISubmittableResult } from '@logion/client'; import { useEffect, useState } from 'react'; -import ExtrinsicSubmissionResult, { isSuccessful } from './ExtrinsicSubmissionResult'; +import ExtrinsicSubmissionResult from './ExtrinsicSubmissionResult'; +import { useLogionChain } from './logion-chain'; +import { Call, CallCallback } from './logion-chain/LogionChainContext'; -export type CallCallback = (result: ISubmittableResult) => void; - -export type Call = (callback: CallCallback) => Promise; +export type { Call, CallCallback }; export interface Props { successMessage?: string | JSX.Element, @@ -15,78 +14,31 @@ export interface Props { slim?: boolean, } -interface State { - result: ISubmittableResult | null; - error: any; - submitted: boolean; - notified: boolean; - callEnded: boolean; - call?: Call; - setState: (newState: State) => void; -} - -const INITIAL_STATE: State = { - result: null, - error: null, - submitted: false, - notified: false, - callEnded: false, - setState: () => {}, -}; - -let persistentState: State = INITIAL_STATE; - -function setPersistentState(stateUpdate: Partial) { - persistentState = { - ...persistentState, - ...stateUpdate - }; - persistentState.setState(persistentState); -} - -export function resetPersistenState() { - persistentState = INITIAL_STATE; -} - export default function ClientExtrinsicSubmitter(props: Props) { - const [ state, setState ] = useState(persistentState); - if(setState !== persistentState.setState) { - persistentState.setState = setState; - } + const { extrinsicSubmissionState, submitCall, resetSubmissionState } = useLogionChain(); + const [ notified, setNotified ] = useState(false); useEffect(() => { - if(props.call !== undefined && (!state.submitted || (state.notified && props.call !== state.call))) { - setPersistentState({ - ...INITIAL_STATE, - call: props.call, - submitted: true, - setState, - }); - (async function() { - try { - await props.call!((callbackResult: ISubmittableResult) => setPersistentState({ result: callbackResult })); - setPersistentState({ callEnded: true }); - } catch(e) { - console.log(e); - setPersistentState({ callEnded: true, error: e}); - } - })(); + if(props.call !== undefined && extrinsicSubmissionState.canSubmit()) { + submitCall(props.call); } - }, [ state, props ]); + }, [ extrinsicSubmissionState, submitCall, props ]); useEffect(() => { - if (state.result !== null && isSuccessful(state.result) && !state.notified && props.onSuccess && state.callEnded) { - setPersistentState({ notified: true }); + if (extrinsicSubmissionState.isSuccessful() && !notified && props.onSuccess) { + setNotified(true); + resetSubmissionState(); props.onSuccess(); } - }, [ state, props ]); + }, [ extrinsicSubmissionState, props, notified, resetSubmissionState ]); useEffect(() => { - if (state.error !== null && !state.notified && props.onError) { - setPersistentState({ notified: true }); + if (extrinsicSubmissionState.isError() && !notified && props.onError) { + setNotified(true); + resetSubmissionState(); props.onError(); } - }, [ state, props ]); + }, [ extrinsicSubmissionState, props, notified, resetSubmissionState ]); if(props.call === undefined) { return null; @@ -94,8 +46,8 @@ export default function ClientExtrinsicSubmitter(props: Props) { return ( diff --git a/src/ExtrinsicSubmissionResult.tsx b/src/ExtrinsicSubmissionResult.tsx index 3dbcf760..9c98e874 100644 --- a/src/ExtrinsicSubmissionResult.tsx +++ b/src/ExtrinsicSubmissionResult.tsx @@ -1,16 +1,11 @@ -import { ISubmittableResult } from '@logion/client'; import Spinner from 'react-bootstrap/Spinner'; import { SignedTransaction } from './logion-chain/Signature'; -import { SIGN_AND_SEND_STRATEGY } from './logion-chain/LogionChainContext'; +import { isSuccessful } from './logion-chain/LogionChainContext'; import Alert from './common/Alert'; import './ExtrinsicSubmissionResult.css'; -export function isSuccessful(result: ISubmittableResult): boolean { - return !result.dispatchError && SIGN_AND_SEND_STRATEGY.canUnsub(result); -} - export interface Props { result?: SignedTransaction | null, error: any, diff --git a/src/ExtrinsicSubmitter.test.tsx b/src/ExtrinsicSubmitter.test.tsx index 8b8e13ff..045f1967 100644 --- a/src/ExtrinsicSubmitter.test.tsx +++ b/src/ExtrinsicSubmitter.test.tsx @@ -1,46 +1,107 @@ -import { screen, render, waitFor, act } from '@testing-library/react'; -import { SignedTransaction } from './logion-chain/Signature'; +import { ISubmittableResult } from "@logion/client"; +import { screen, render, waitFor } from '@testing-library/react'; -import { mockSubmittableResult } from './logion-chain/__mocks__/SignatureMock'; import ExtrinsicSubmitter, { SignAndSubmit } from './ExtrinsicSubmitter'; - -test("Submitter empty with null signAndSubmit", () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - - render(); - - expect(screen.getByRole('generic')).toBeEmptyDOMElement(); - expect(onSuccess).not.toBeCalled(); - expect(onError).not.toBeCalled(); -}); - -test("Submitter initially showing submitting", async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = buildSignAndSubmitMock(); - - render(); - - await waitFor(() => expect(screen.getByText("Submitting...")).toBeInTheDocument()); - expect(onSuccess).not.toBeCalled(); - expect(onError).not.toBeCalled(); +import { expectSubmitting } from "./test/Util"; +import { FAILED_SUBMISSION, NO_SUBMISSION, PENDING_SUBMISSION, SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from "./logion-chain/__mocks__/LogionChainMock"; + +jest.mock("./logion-chain"); + +describe("ExtrinsicSubmitter", () => { + + it("is empty with null signAndSubmit", () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + + render(); + + expect(screen.getByRole('generic')).toBeEmptyDOMElement(); + expect(onSuccess).not.toBeCalled(); + expect(onError).not.toBeCalled(); + }); + + it("is initially showing submitting", async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const result = buildSignAndSubmitMock(); + + setExtrinsicSubmissionState(NO_SUBMISSION); + render(); + + await waitFor(() => expectSubmitting()); + expect(onSuccess).not.toBeCalled(); + expect(onError).not.toBeCalled(); + }); + + it("shows error and calls onError", async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const result = buildSignAndSubmitMock(); + + setExtrinsicSubmissionState(FAILED_SUBMISSION); + render(); + + await waitFor(() => expect(screen.getByText("Submission failed: error")).toBeInTheDocument()); + expect(onSuccess).not.toBeCalled(); + expect(onError).toBeCalled(); + }); + + it("shows progress", async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const result = buildSignAndSubmitMock(); + + setExtrinsicSubmissionState(PENDING_SUBMISSION); + render(); + + await waitFor(() => expect(screen.getByText("Current status: undefined")).toBeInTheDocument()); + expect(onSuccess).not.toBeCalled(); + expect(onError).not.toBeCalled(); + }); + + it("shows success and calls onSuccess", async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const result = buildSignAndSubmitMock(); + + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); + render(); + + await waitFor(() => expect(screen.getByText("Submission successful.")).toBeInTheDocument()); + expect(onSuccess).toBeCalledWith("extrinsicId"); + expect(onError).not.toBeCalled(); + }); }); interface SignAndSubmitMock { signAndSubmit: SignAndSubmit, - setResult: React.Dispatch> | null, - setError: React.Dispatch> | null, + setResult: ((result: ISubmittableResult | null) => void) | null, + setError: ((error: unknown) => void) | null, } function buildSignAndSubmitMock(): SignAndSubmitMock { @@ -56,63 +117,3 @@ function buildSignAndSubmitMock(): SignAndSubmitMock { } return result; } - -test("Submitter shows error and calls onError", async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = buildSignAndSubmitMock(); - - render(); - - await waitFor(() => expect(result.setError).not.toBeNull()); - act(() => result.setError!("error")); - - await waitFor(() => expect(screen.getByText("Submission failed: error")).toBeInTheDocument()); - expect(onSuccess).not.toBeCalled(); - expect(onError).toBeCalled(); -}); - -test("Submitter shows progress", async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = buildSignAndSubmitMock(); - - render(); - - await waitFor(() => expect(result.setResult).not.toBeNull()); - act(() => result.setResult!(mockSubmittableResult(false))); - - await waitFor(() => expect(screen.getByText("Current status: undefined")).toBeInTheDocument()); - expect(onSuccess).not.toBeCalled(); - expect(onError).not.toBeCalled(); -}); - -test("Submitter shows success and calls onSuccess", async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = buildSignAndSubmitMock(); - - render(); - - await waitFor(() => expect(result.setResult).not.toBeNull()); - act(() => result.setResult!(mockSubmittableResult(true))); - - await waitFor(() => expect(screen.getByText("Submission successful.")).toBeInTheDocument()); - expect(onSuccess).toBeCalledWith("extrinsicId"); - expect(onError).not.toBeCalled(); -}); diff --git a/src/ExtrinsicSubmitter.tsx b/src/ExtrinsicSubmitter.tsx index e78bba02..f5d9c1e6 100644 --- a/src/ExtrinsicSubmitter.tsx +++ b/src/ExtrinsicSubmitter.tsx @@ -1,13 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { - SignedTransaction, -} from './logion-chain/Signature'; +import ExtrinsicSubmissionResult from './ExtrinsicSubmissionResult'; +import { useLogionChain } from './logion-chain'; +import { SignAndSubmit } from './logion-chain/LogionChainContext'; -import ExtrinsicSubmissionResult, { isSuccessful } from './ExtrinsicSubmissionResult'; -import { flushSync } from 'react-dom'; - -export type SignAndSubmit = ((setResult: React.Dispatch>, setError: React.Dispatch>) => void) | null; +export type { SignAndSubmit }; export interface Props { id: string, @@ -19,45 +16,30 @@ export interface Props { } export default function ExtrinsicSubmitter(props: Props) { - const [ result, setResult ] = useState(null); - const [ error, setError ] = useState(null); - const [ submitted, setSubmitted ] = useState(false); - const [ notified, setNotified ] = useState(false); + const { extrinsicSubmissionState, submitSignAndSubmit, resetSubmissionState } = useLogionChain(); + const [ notified, setNotified ] = useState(false); useEffect(() => { - if(!submitted && props.signAndSubmit !== null) { - flushSync(() => setSubmitted(true)); - const signAndSubmit = props.signAndSubmit; - (async function() { - setResult(null); - setError(null); - signAndSubmit(setResult, setError); - })(); + if(props.signAndSubmit !== null && !notified && extrinsicSubmissionState.canSubmit()) { + submitSignAndSubmit(props.signAndSubmit); } - }, [ setResult, setError, props, submitted ]); + }, [ extrinsicSubmissionState, props, submitSignAndSubmit, notified ]); useEffect(() => { - if (result !== null && isSuccessful(result) && !notified) { + if (extrinsicSubmissionState.isSuccessful() && !notified && props.onSuccess) { setNotified(true); + resetSubmissionState(); props.onSuccess(props.id); } - }, [ result, notified, setNotified, props ]); + }, [ extrinsicSubmissionState, props, notified, resetSubmissionState ]); useEffect(() => { - if (error !== null && !notified) { + if (extrinsicSubmissionState.isError() && !notified && props.onError) { setNotified(true); + resetSubmissionState(); props.onError(props.id); } - }, [ notified, setNotified, props, error ]); - - useEffect(() => { - if(submitted && props.signAndSubmit === null) { - setSubmitted(false); - setNotified(false); - setResult(null); - setError(null); - } - }, [ setResult, setError, props, submitted ]); + }, [ extrinsicSubmissionState, props, notified, resetSubmissionState ]); if(props.signAndSubmit === null) { return null; @@ -65,8 +47,8 @@ export default function ExtrinsicSubmitter(props: Props) { return ( diff --git a/src/loc/CloseLocButton.test.tsx b/src/loc/CloseLocButton.test.tsx index b83e4288..f091addb 100644 --- a/src/loc/CloseLocButton.test.tsx +++ b/src/loc/CloseLocButton.test.tsx @@ -9,6 +9,7 @@ import CloseLocButton from './CloseLocButton'; import { OpenLoc } from 'src/__mocks__/LogionClientMock'; import { mockSubmittableResult } from 'src/logion-chain/__mocks__/SignatureMock'; import { LocItem, MetadataItem } from './LocItem'; +import { FAILED_SUBMISSION, SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from 'src/logion-chain/__mocks__/LogionChainMock'; jest.mock("../logion-chain"); jest.mock("./LocContext"); @@ -30,6 +31,7 @@ describe("CloseLocButton", () => { }) it("closes when closeable", async () => { + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); renderGivenLoc(true, false); await clickByName(/Close LOC/); @@ -51,6 +53,7 @@ describe("CloseLocButton", () => { it("shows message on error", async () => { closeLocMock = failureCloseLocMock; + setExtrinsicSubmissionState(FAILED_SUBMISSION); renderGivenLoc(true, false); await clickByName(/Close LOC/); diff --git a/src/loc/ImportItems.test.tsx b/src/loc/ImportItems.test.tsx index 40c93d4a..2a068374 100644 --- a/src/loc/ImportItems.test.tsx +++ b/src/loc/ImportItems.test.tsx @@ -7,7 +7,7 @@ import { CollectionItem, Fees, UUID } from "@logion/node-api"; import { H256 } from "@logion/node-api/dist/types/interfaces"; import { LogionClient, AddCollectionItemParams, EstimateFeesAddCollectionItemParams } from "@logion/client"; import { mockSubmittableResult } from "../logion-chain/__mocks__/SignatureMock"; -import { setClientMock } from 'src/logion-chain/__mocks__/LogionChainMock'; +import { SUCCESSFUL_SUBMISSION, setClientMock, setExtrinsicSubmissionState } from 'src/logion-chain/__mocks__/LogionChainMock'; import { It, Mock } from 'moq.ts'; import { SubmittableExtrinsic } from '@polkadot/api/promise/types'; import { Compact, u128 } from "@polkadot/types-codec"; @@ -21,6 +21,7 @@ jest.mock("./UserLocContext"); describe("ImportItems", () => { it("imports all CSV", async () => { + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); const collection = await uploadCsv(); await waitFor(() => screen.getAllByRole("button", { name: /Import all/ })); @@ -38,6 +39,7 @@ describe("ImportItems", () => { }); it("imports one CSV row", async () => { + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); const collection = await uploadCsv(); await waitFor(() => expect(screen.getAllByRole("button", { name: /Import/ }).length).toBe(5)); diff --git a/src/loc/LocCreationDialog.test.tsx b/src/loc/LocCreationDialog.test.tsx index 29ce7176..147fa38d 100644 --- a/src/loc/LocCreationDialog.test.tsx +++ b/src/loc/LocCreationDialog.test.tsx @@ -8,10 +8,10 @@ import { TEST_WALLET_USER } from "../wallet-user/TestData"; import LocCreationDialog from "./LocCreationDialog"; import { setAuthenticatedUser } from "src/common/__mocks__/ModelMock"; -import { LocData, OpenLoc, OpenLocParams, BlockchainSubmissionParams, LocsState, LogionClient } from "@logion/client"; +import { LocData, OpenLoc, OpenLocParams, BlockchainSubmissionParams, LogionClient } from "@logion/client"; import { mockSubmittableResult } from "../logion-chain/__mocks__/SignatureMock"; import { setLocsState } from "../legal-officer/__mocks__/LegalOfficerContextMock"; -import { setClientMock } from "../logion-chain/__mocks__/LogionChainMock"; +import { SUCCESSFUL_SUBMISSION, setClientMock, setExtrinsicSubmissionState } from "../logion-chain/__mocks__/LogionChainMock"; jest.mock("../logion-chain/Signature"); jest.mock("../common/CommonContext"); @@ -49,6 +49,7 @@ async function createsWithUserIdentity(locType: LocType, requesterIdentityLoc: s }; mockLegalOfficerCreateLoc(requestFragment.requesterLocId || undefined); + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); render( { @@ -30,6 +33,7 @@ describe("LocPublishButton", () => { } ); const confirm = jest.fn(); + setExtrinsicSubmissionState(NO_SUBMISSION); render( { feesEstimator={ async () => new Fees({ inclusionFee: 42n }) } />); + // When publishing + await clickByName(content => /publish/i.test(content)); + await waitFor(() => screen.getByText("Estimated fees (LGNT)")); + await waitFor(() => screen.getByRole("button", { name: "Publish" })); + await clickByName("Publish"); + + // Then item published + await waitFor(() => expectSubmitting()); + expect(confirm).toBeCalled(); + }); + + it("shows success", async () => { + // Given item to publish + const locItem = new MetadataItem( + { + type: "Data", + status: "DRAFT", + submitter: mockValidPolkadotAccountId("data-submitter"), + timestamp: null, + newItem: false, + template: false, + }, + { + name: HashString.fromValue("data-name"), + value: HashString.fromValue("data-value"), + } + ); + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); + render( { + callback(mockSubmittableResult(true)); + return current; + }} + feesEstimator={ async () => new Fees({ inclusionFee: 42n }) } + />); + // When publishing await clickByName(content => /publish/i.test(content)); const dialog = screen.getByRole("dialog"); @@ -50,7 +92,6 @@ describe("LocPublishButton", () => { // Then item published await waitFor(() => screen.getByText(/successful/i)); - expect(confirm).toBeCalled(); // Then dialog closable with OK await clickByName("OK"); diff --git a/src/loc/RequestVoteButton.test.tsx b/src/loc/RequestVoteButton.test.tsx index 73f79ca2..58a9f8e6 100644 --- a/src/loc/RequestVoteButton.test.tsx +++ b/src/loc/RequestVoteButton.test.tsx @@ -4,23 +4,38 @@ import RequestVoteButton from "./RequestVoteButton"; import { mockSubmittableResult } from "src/logion-chain/__mocks__/SignatureMock"; import { ClosedLoc } from "src/__mocks__/@logion/client"; import { setLocState } from "./__mocks__/LocContextMock"; +import { FAILED_SUBMISSION, NO_SUBMISSION, SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from "src/logion-chain/__mocks__/LogionChainMock"; +import { expectSubmitting } from "src/test/Util"; jest.mock("./LocContext"); jest.mock("../logion-chain"); describe("RequestVoteButton", () => { - it("successfully creates a vote", async () => { + it("submits vote", async () => { const locState = new ClosedLoc(); setLocState(locState); locState.legalOfficer.requestVote = async (params: any) => { params.callback(mockSubmittableResult(true)); return VOTE_ID; }; + setExtrinsicSubmissionState(NO_SUBMISSION); render(); await clickByName((_, element) => /Request a vote/.test(element.textContent || "")); await clickByName("Request a vote"); - await waitFor(() => screen.getByText("42")); + await waitFor(() => expectSubmitting()); + }); + + it("successfully creates a vote", async () => { + const locState = new ClosedLoc(); + setLocState(locState); + locState.legalOfficer.requestVote = async (params: any) => { + params.callback(mockSubmittableResult(true)); + return VOTE_ID; + }; + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); + render(); + await clickByName((_, element) => /Request a vote/.test(element.textContent || "")); const dialog = screen.getByRole("dialog"); await clickByName("Close"); await waitFor(() => expect(dialog).not.toBeVisible()); @@ -33,6 +48,7 @@ describe("RequestVoteButton", () => { params.callback(mockSubmittableResult(false, "ExtrinsicFailed", true)); throw new Error(); }; + setExtrinsicSubmissionState(FAILED_SUBMISSION); render(); await clickByName((_, element) => /Request a vote/.test(element.textContent || "")); await clickByName("Request a vote"); diff --git a/src/loc/VoidLocButton.test.tsx b/src/loc/VoidLocButton.test.tsx index f6f85db5..e25088bb 100644 --- a/src/loc/VoidLocButton.test.tsx +++ b/src/loc/VoidLocButton.test.tsx @@ -7,6 +7,8 @@ import VoidLocButton from "./VoidLocButton"; import { mockSubmittableResult } from "src/logion-chain/__mocks__/SignatureMock"; import { OpenLoc } from "src/__mocks__/LogionClientMock"; import { setLocState } from "./__mocks__/LocContextMock"; +import { NO_SUBMISSION, SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from "src/logion-chain/__mocks__/LogionChainMock"; +import { expectSubmitting } from "src/test/Util"; jest.mock("../common/CommonContext"); jest.mock("./LocContext"); @@ -30,6 +32,19 @@ describe("VoidLocButton", () => { } as LocData); locState.legalOfficer.voidLoc = voidLocMock; setLocState(locState); + setExtrinsicSubmissionState(NO_SUBMISSION); + await renderAndOpenDialog(); + + const button = screen.getAllByRole("button", { name: "Void LOC" })[1]; + await typeByLabel("Reason", "Because"); + await userEvent.click(button); + + expect(called).toBe(true); + await waitFor(() => expectSubmitting()); + }); + + it("shows success LOC", async () => { + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); const dialog = await renderAndOpenDialog(); const button = screen.getAllByRole("button", { name: "Void LOC" })[1]; @@ -37,7 +52,6 @@ describe("VoidLocButton", () => { await userEvent.click(button); await waitFor(() => expect(dialog!).not.toBeVisible()); - expect(called).toBe(true); }); it("does not void LOC on cancel", async () => { diff --git a/src/loc/VoidLocButton.tsx b/src/loc/VoidLocButton.tsx index fef4f8e8..72b13b1c 100644 --- a/src/loc/VoidLocButton.tsx +++ b/src/loc/VoidLocButton.tsx @@ -98,15 +98,12 @@ export default function VoidLocButton() { /> } colors={ colorTheme.dialog } /> - { - call !== undefined && - setVisible(false) } - onError={ () => setSubmissionFailed(true) } - /> - } + setSubmissionFailed(true) } + /> ); diff --git a/src/loc/VoidLocReplaceExistingButton.test.tsx b/src/loc/VoidLocReplaceExistingButton.test.tsx index 820441e2..735560c7 100644 --- a/src/loc/VoidLocReplaceExistingButton.test.tsx +++ b/src/loc/VoidLocReplaceExistingButton.test.tsx @@ -4,12 +4,13 @@ import userEvent from "@testing-library/user-event"; import { clickByName, typeByLabel } from "src/tests"; import VoidLocReplaceExistingButton from "./VoidLocReplaceExistingButton"; -import { setupQueriesGetLegalOfficerCase } from "src/test/Util"; +import { expectSubmitting, setupQueriesGetLegalOfficerCase } from "src/test/Util"; import { UUID } from "@logion/node-api"; import { setupApiMock, OPEN_IDENTITY_LOC, OPEN_IDENTITY_LOC_ID } from "src/__mocks__/LogionMock"; import { mockSubmittableResult } from "src/logion-chain/__mocks__/SignatureMock"; import { OpenLoc } from "src/__mocks__/LogionClientMock"; import { setLocState } from "./__mocks__/LocContextMock"; +import { SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from "src/logion-chain/__mocks__/LogionChainMock"; jest.mock("../common/CommonContext"); jest.mock("./LocContext"); @@ -36,17 +37,45 @@ describe("VoidLocReplaceExistingButton", () => { setupApiMock(api => { setupQueriesGetLegalOfficerCase(api, UUID.fromDecimalStringOrThrow(OPEN_IDENTITY_LOC_ID), OPEN_IDENTITY_LOC); }); - const dialog = await renderAndOpenDialog(); + await renderAndOpenDialog(); const button = screen.getAllByRole("button", { name: "Void and replace by an EXISTING LOC" })[1]; await typeByLabel("Reason", "Because"); await typeByLabel("Existing LOC ID", OPEN_IDENTITY_LOC_ID); await userEvent.click(button); - await waitFor(() => expect(dialog!).not.toBeVisible()); + await waitFor(() => expectSubmitting()); expect(called).toBe(true); }); + it("closes dialog on success", async () => { + let called = false; + const voidLocMock = async (params: any) => { + called = true; + params.callback(mockSubmittableResult(true)); + return params.locState; + }; + const locState = new OpenLoc(); + locState.data = () => ({ + locType: "Identity", + status: "OPEN", + } as LocData); + locState.legalOfficer.voidLoc = voidLocMock; + setLocState(locState); + setupApiMock(api => { + setupQueriesGetLegalOfficerCase(api, UUID.fromDecimalStringOrThrow(OPEN_IDENTITY_LOC_ID), OPEN_IDENTITY_LOC); + }); + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); + const dialog = await renderAndOpenDialog(); + + const button = screen.getAllByRole("button", { name: "Void and replace by an EXISTING LOC" })[1]; + await typeByLabel("Reason", "Because"); + await typeByLabel("Existing LOC ID", OPEN_IDENTITY_LOC_ID); + await userEvent.click(button); + + await waitFor(() => expect(dialog).not.toBeVisible()); + }); + it("does not void LOC on cancel", async () => { let called = false; const dialog = await renderAndOpenDialog(); diff --git a/src/loc/issuer/IssuerSelectionCheckbox.test.tsx b/src/loc/issuer/IssuerSelectionCheckbox.test.tsx index 4a79aaf0..eb45e3f0 100644 --- a/src/loc/issuer/IssuerSelectionCheckbox.test.tsx +++ b/src/loc/issuer/IssuerSelectionCheckbox.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { VerifiedIssuerWithSelect } from "@logion/client"; jest.mock("../LocContext"); +jest.mock("../../logion-chain"); describe("IssuerSelectionCheckbox", () => { diff --git a/src/logion-chain/LogionChainContext.tsx b/src/logion-chain/LogionChainContext.tsx index 64b7876d..85cc9a26 100644 --- a/src/logion-chain/LogionChainContext.tsx +++ b/src/logion-chain/LogionChainContext.tsx @@ -5,7 +5,8 @@ import { LogionClient, DefaultSignAndSendStrategy, Token, - RawSigner + RawSigner, + ISubmittableResult, } from '@logion/client'; import { allMetamaskAccounts, @@ -38,8 +39,109 @@ type ConsumptionStatus = 'PENDING' | 'STARTING' | 'STARTED'; export const SIGN_AND_SEND_STRATEGY = new DefaultSignAndSendStrategy(); +export function isSuccessful(result: ISubmittableResult): boolean { + return !result.dispatchError && SIGN_AND_SEND_STRATEGY.canUnsub(result); +} + export type AxiosFactory = (legalOfficerAddress: string | undefined, token?: Token) => AxiosInstance; +export type CallCallback = (result: ISubmittableResult) => void; + +export type Call = (callback: CallCallback) => Promise; + +export type SignAndSubmit = ((setResult: (result: ISubmittableResult | null) => void, setError: (error: unknown) => void) => void) | null; + +export class ExtrinsicSubmissionState { + + constructor() { + this._result = null; + this._error = null; + this._submitted = false; + this._callEnded = false; + } + + private _result: ISubmittableResult | null; + private _error: any; + private _submitted: boolean; + private _callEnded: boolean; + + canSubmit() { + return !this.submitted || this.callEnded; + } + + get submitted() { + return this._submitted; + } + + get callEnded() { + return this._callEnded; + } + + get result() { + return this._result; + } + + get error() { + return this._error; + } + + submit() { + if(!this.canSubmit()) { + throw new Error("Cannot submit new call"); + } + const state = new ExtrinsicSubmissionState(); + state._result = null; + state._error = null; + state._submitted = true; + state._callEnded = false; + return state; + } + + withResult(result: ISubmittableResult) { + if(this._callEnded) { + throw new Error("Call already ended"); + } + const state = new ExtrinsicSubmissionState(); + state._result = result; + state._error = this._error; + state._submitted = this._submitted; + state._callEnded = this._callEnded; + return state; + } + + end(error: unknown) { + if(this._callEnded) { + throw new Error("Call already ended"); + } + const state = new ExtrinsicSubmissionState(); + state._result = this._result; + state._error = error; + state._submitted = this._submitted; + state._callEnded = true; + return state; + } + + isSuccessful(): boolean { + return this.callEnded && this.error === null; + } + + isError(): boolean { + return this.callEnded && this.error !== null; + } + + resetAndKeepResult() { + if(!this._callEnded) { + throw new Error("Call not yet ended"); + } + const state = new ExtrinsicSubmissionState(); + state._result = this._result; + state._error = this._error; + state._submitted = false; + state._callEnded = false; + return state; + } +} + export interface LogionChainContextType { api: LogionNodeApiClass | null, injectedAccountsConsumptionState: ConsumptionStatus @@ -62,6 +164,10 @@ export interface LogionChainContextType { saveOfficer?: (legalOfficer: LegalOfficer) => Promise, reconnect: () => void, tryEnableMetaMask: () => Promise, + extrinsicSubmissionState: ExtrinsicSubmissionState; + submitCall: (call: Call) => Promise; + submitSignAndSubmit: (signAndSubmit: SignAndSubmit) => void; + resetSubmissionState: () => void; } export interface FullLogionChainContextType extends LogionChainContextType { @@ -91,6 +197,10 @@ const initState = (): FullLogionChainContextType => ({ authenticateAddress: (_: ValidAccountId) => Promise.reject(), reconnect: () => {}, tryEnableMetaMask: async () => {}, + extrinsicSubmissionState: new ExtrinsicSubmissionState(), + submitCall: () => Promise.reject(), + submitSignAndSubmit: () => {}, + resetSubmissionState: () => {}, }); type ActionType = 'SET_SELECT_ADDRESS' @@ -113,6 +223,13 @@ type ActionType = 'SET_SELECT_ADDRESS' | 'RECONNECT' | 'SET_RECONNECT' | 'SET_TRY_ENABLE_METAMASK' + | 'SET_SUBMIT_CALL' + | 'SUBMIT_EXTRINSIC' + | 'SET_SUBMITTABLE_RESULT' + | 'SET_SUBMISSION_ENDED' + | 'SET_SUBMIT_SIGN_AND_SUBMIT' + | 'SET_RESET_SUBMISSION_STATE' + | 'RESET_SUBMISSION_STATE' ; interface Action { @@ -136,6 +253,11 @@ interface Action { registeredLegalOfficers?: Set; reconnect?: () => void; tryEnableMetaMask?: () => Promise; + submitCall?: (call: Call) => Promise; + result?: ISubmittableResult | null; + extrinsicSubmissionError?: unknown; + submitSignAndSubmit?: (signAndSubmit: SignAndSubmit) => void; + resetSubmissionState?: () => void; } function buildAxiosFactory(authenticatedClient?: LogionClient): AxiosFactory { @@ -160,6 +282,7 @@ function buildAxiosFactory(authenticatedClient?: LogionClient): AxiosFactory { } const reducer: Reducer = (state: FullLogionChainContextType, action: Action): FullLogionChainContextType => { + console.log(action.type) switch (action.type) { case 'CONNECT_INIT': return { ...state, connecting: true }; @@ -310,6 +433,45 @@ const reducer: Reducer = (state: FullLogionC ...state, tryEnableMetaMask: action.tryEnableMetaMask!, }; + case 'SET_SUBMIT_CALL': + return { + ...state, + submitCall: action.submitCall!, + }; + case 'SUBMIT_EXTRINSIC': + if(state.extrinsicSubmissionState.canSubmit()) { + return { + ...state, + extrinsicSubmissionState: state.extrinsicSubmissionState.submit(), + }; + } else { + return state; + } + case 'SET_SUBMITTABLE_RESULT': + return { + ...state, + extrinsicSubmissionState: state.extrinsicSubmissionState.withResult(action.result!), + }; + case 'SET_SUBMISSION_ENDED': + return { + ...state, + extrinsicSubmissionState: state.extrinsicSubmissionState.end(action.error || null), + }; + case 'SET_SUBMIT_SIGN_AND_SUBMIT': + return { + ...state, + submitSignAndSubmit: action.submitSignAndSubmit!, + }; + case 'SET_RESET_SUBMISSION_STATE': + return { + ...state, + resetSubmissionState: action.resetSubmissionState!, + }; + case 'RESET_SUBMISSION_STATE': + return { + ...state, + extrinsicSubmissionState: state.extrinsicSubmissionState.resetAndKeepResult(), + }; default: /* istanbul ignore next */ throw new Error(`Unknown type: ${action.type}`); @@ -601,6 +763,79 @@ const LogionChainContextProvider = (props: LogionChainContextProviderProps): JSX } }, [ state.tryEnableMetaMask, tryEnableMetaMask ]); + const submitCall = useCallback(async (call: Call) => { + if(state.extrinsicSubmissionState.canSubmit()) { + dispatch({ + type: 'SUBMIT_EXTRINSIC', + }); + (async function() { + try { + await call((callbackResult: ISubmittableResult) => dispatch({ + type: 'SET_SUBMITTABLE_RESULT', + result: callbackResult, + })); + dispatch({ type: 'SET_SUBMISSION_ENDED' }); + } catch(e) { + console.log(e); + dispatch({ + type: 'SET_SUBMISSION_ENDED', + extrinsicSubmissionError: e, + }); + } + })(); + } + }, [ state.extrinsicSubmissionState ]); + + useEffect(() => { + if(state.submitCall !== submitCall) { + dispatch({ + type: 'SET_SUBMIT_CALL', + submitCall, + }); + } + }, [ state.submitCall, submitCall ]); + + const submitSignAndSubmit = useCallback((signAndSubmit: SignAndSubmit) => { + if(signAndSubmit && state.extrinsicSubmissionState.canSubmit()) { + dispatch({ + type: 'SUBMIT_EXTRINSIC', + }); + signAndSubmit( + (result) => { + if(result !== null) { + dispatch({ type: "SET_SUBMITTABLE_RESULT", result }); + if(!result.dispatchError && SIGN_AND_SEND_STRATEGY.canUnsub(result)) { + dispatch({ type: "SET_SUBMISSION_ENDED" }); + } + } + }, + (extrinsicSubmissionError) => dispatch({ type: "SET_SUBMISSION_ENDED", extrinsicSubmissionError }), + ); + } + }, [ state.extrinsicSubmissionState ]); + + useEffect(() => { + if(state.submitSignAndSubmit !== submitSignAndSubmit) { + dispatch({ + type: 'SET_SUBMIT_SIGN_AND_SUBMIT', + submitSignAndSubmit, + }); + } + }, [ state.submitSignAndSubmit, submitSignAndSubmit ]); + + const resetSubmissionState = useCallback(() => { + dispatch({ type: 'RESET_SUBMISSION_STATE' }); + }, [ ]); + + useEffect(() => { + if(state.resetSubmissionState !== resetSubmissionState) { + dispatch({ + type: 'SET_RESET_SUBMISSION_STATE', + resetSubmissionState, + }); + } + }, [ state.resetSubmissionState, resetSubmissionState ]); + return {props.children} ; diff --git a/src/logion-chain/__mocks__/LogionChainMock.ts b/src/logion-chain/__mocks__/LogionChainMock.ts index 28ce78e8..25b10501 100644 --- a/src/logion-chain/__mocks__/LogionChainMock.ts +++ b/src/logion-chain/__mocks__/LogionChainMock.ts @@ -8,6 +8,7 @@ import Accounts, { Account } from 'src/common/types/Accounts'; import { LogionClient } from '@logion/client/dist/LogionClient.js'; import { LogionClient as LogionClientMock } from '../../__mocks__/LogionClientMock'; import { api } from "src/__mocks__/LogionMock"; +import { Call } from "../LogionChainContext"; export const LogionChainContextProvider = (props: any) => null; @@ -89,6 +90,56 @@ let signer = { signAndSend, }; +export const NO_SUBMISSION: unknown = { + canSubmit: () => true, + isSuccessful: () => false, + isError: () => false, + error: null, + result: null, +}; + +let extrinsicSubmissionState: unknown = NO_SUBMISSION; + +export const SUCCESSFUL_SUBMISSION: unknown = { + canSubmit: () => true, + isSuccessful: () => true, + isError: () => false, + error: null, + result: { + status: { + isFinalized: true, + } + }, +}; + +export const FAILED_SUBMISSION: unknown = { + canSubmit: () => false, + isSuccessful: () => false, + isError: () => true, + error: "error", + result: { + status: { + isFinalized: false, + } + }, +}; + +export const PENDING_SUBMISSION: unknown = { + canSubmit: () => false, + isSuccessful: () => false, + isError: () => false, + error: null, + result: { + status: { + isFinalized: false, + } + }, +}; + +export function setExtrinsicSubmissionState(state: unknown) { + extrinsicSubmissionState = state; +} + export function useLogionChain() { if(context) { return context; @@ -113,7 +164,11 @@ export function useLogionChain() { [DEFAULT_LEGAL_OFFICER.address]: { iDenfy: false, } - } + }, + extrinsicSubmissionState, + resetSubmissionState: () => {}, + submitSignAndSubmit: () => {}, + submitCall: (call: Call) => call(() => {}), }; } } diff --git a/src/settings/ChainData.test.tsx b/src/settings/ChainData.test.tsx index 5755fa90..6f7513f8 100644 --- a/src/settings/ChainData.test.tsx +++ b/src/settings/ChainData.test.tsx @@ -3,14 +3,14 @@ import { LegalOfficerData } from "@logion/node-api"; import { PalletLoAuthorityListLegalOfficerData } from "@polkadot/types/lookup"; import { setOnchainSettings, refreshOnchainSettings } from "src/legal-officer/__mocks__/LegalOfficerContextMock"; -import { DEFAULT_LEGAL_OFFICER_ACCOUNT, setCurrentAddress } from "src/logion-chain/__mocks__/LogionChainMock"; -import { finalizeSubmission } from "src/logion-chain/__mocks__/SignatureMock"; +import { DEFAULT_LEGAL_OFFICER_ACCOUNT, NO_SUBMISSION, SUCCESSFUL_SUBMISSION, setCurrentAddress, setExtrinsicSubmissionState } from "src/logion-chain/__mocks__/LogionChainMock"; import { clickByName, typeByLabel } from "src/tests"; import ChainData from "./ChainData"; import { setupApiMock } from "src/__mocks__/LogionMock"; import { SubmittableExtrinsic } from "@polkadot/api-base/types"; import { It, Mock } from "moq.ts"; +import { expectSubmitting } from "src/test/Util"; jest.mock("src/legal-officer/LegalOfficerContext"); jest.mock("src/logion-chain"); @@ -39,7 +39,7 @@ describe("ChainData", () => { expect(screen.getByLabelText("Node ID")).toHaveValue(settings.hostData?.nodeId); }); - it("saves successfully", async () => { + it("submits successfully", async () => { setCurrentAddress(DEFAULT_LEGAL_OFFICER_ACCOUNT); const settings: LegalOfficerData = { @@ -60,13 +60,44 @@ describe("ChainData", () => { const submittable = new Mock>(); api.setup(instance => instance.polkadot.tx.loAuthorityList.updateLegalOfficer(It.IsAny(), It.IsAny())).returns(submittable.object()); }); + setExtrinsicSubmissionState(NO_SUBMISSION); render(); await waitFor(() => expect(screen.getByLabelText("Node Base URL")).toHaveValue(settings.hostData?.baseUrl)); await typeByLabel("Node Base URL", "https://another-node.logion.network"); await act(() => clickByName("Publish to blockchain")); - act(() => finalizeSubmission()); - expect(refreshOnchainSettings).toBeCalled(); + await waitFor(() => expectSubmitting()); + }) + + it("saves on submission success", async () => { + setCurrentAddress(DEFAULT_LEGAL_OFFICER_ACCOUNT); + + const settings: LegalOfficerData = { + isHost: true, + hostData: { + nodeId: "12D3KooWBmAwcd4PJNJvfV89HwE48nwkRmAgo8Vy3uQEyNNHBox2", + baseUrl: "https://node.logion.network", + region: "Europe", + } + }; + setOnchainSettings(settings); + setupApiMock(api => { + api.setup(instance => instance.queries.getAvailableRegions()).returns([ "Europe" ]); + api.setup(instance => instance.queries.getDefaultRegion()).returns("Europe"); + + const data = new Mock(); + api.setup(instance => instance.adapters.toPalletLoAuthorityListLegalOfficerDataHost(It.IsAny())).returns(data.object()); + const submittable = new Mock>(); + api.setup(instance => instance.polkadot.tx.loAuthorityList.updateLegalOfficer(It.IsAny(), It.IsAny())).returns(submittable.object()); + }); + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); + render(); + await waitFor(() => expect(screen.getByLabelText("Node Base URL")).toHaveValue(settings.hostData?.baseUrl)); + + await typeByLabel("Node Base URL", "https://another-node.logion.network"); + await act(() => clickByName("Publish to blockchain")); + + await waitFor(() => expect(refreshOnchainSettings).toBeCalled()); }) }); diff --git a/src/test/Util.ts b/src/test/Util.ts index 9ac294ea..7cc3e2de 100644 --- a/src/test/Util.ts +++ b/src/test/Util.ts @@ -1,4 +1,5 @@ import { UUID, LogionNodeApiClass, LegalOfficerCase } from "@logion/node-api"; +import { screen } from "@testing-library/dom"; import { It, Mock } from "moq.ts"; export function ItIsUuid(expected: UUID): UUID { @@ -13,3 +14,7 @@ export function setupQueriesGetLegalOfficerCase(api: Mock, l api.setup(instance => instance.queries.getLegalOfficerCase) .returns(uuid => Promise.resolve(uuid.toString() === locId.toString() ? Promise.resolve(loc) : undefined)); } + +export function expectSubmitting() { + expect(screen.getByText("Submitting...")).toBeInTheDocument(); +} diff --git a/src/wallet-user/trust-protection/CreateProtectionRequestForm.test.tsx b/src/wallet-user/trust-protection/CreateProtectionRequestForm.test.tsx index 0ac634ce..edd20c72 100644 --- a/src/wallet-user/trust-protection/CreateProtectionRequestForm.test.tsx +++ b/src/wallet-user/trust-protection/CreateProtectionRequestForm.test.tsx @@ -15,7 +15,6 @@ import { resetSubmitting } from "../../logion-chain/__mocks__/SignatureMock"; import { TEST_WALLET_USER2 } from "../TestData"; import CreateProtectionRequestForm from "./CreateProtectionRequestForm"; -import { resetPersistenState } from "src/ClientExtrinsicSubmitter"; test("renders", () => { const tree = shallowRender() @@ -98,7 +97,6 @@ describe("CreateProtectionRequestForm", () => { it("should call submit when form is correctly filled for recovery and no recovery already in progress", async () => { resetSubmitting(); - resetPersistenState(); render(); diff --git a/src/wallet-user/trust-protection/ProtectionRecoveryRequest.test.tsx b/src/wallet-user/trust-protection/ProtectionRecoveryRequest.test.tsx index bdbf7297..f81516a3 100644 --- a/src/wallet-user/trust-protection/ProtectionRecoveryRequest.test.tsx +++ b/src/wallet-user/trust-protection/ProtectionRecoveryRequest.test.tsx @@ -20,7 +20,8 @@ import { } from './TestData'; import { DEFAULT_SHARED_STATE, setProtectionState, activateProtection } from '../__mocks__/UserContextMock'; import { AcceptedProtection, ActiveProtection, ClaimedRecovery, PendingProtection, PendingRecovery } from '@logion/client'; -import { GUILLAUME, PATRICK, twoLegalOfficers } from 'src/common/TestData'; +import { twoLegalOfficers } from 'src/common/TestData'; +import { SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from 'src/logion-chain/__mocks__/LogionChainMock'; describe("ProtectionRecoveryRequest", () => { @@ -84,7 +85,7 @@ describe("ProtectionRecoveryRequest", () => { expect(tree).toMatchSnapshot(); }); - it("Activation of accepted protection request", async () => { + it("activates accepted protection request", async () => { const state = new AcceptedProtection({ ...DEFAULT_SHARED_STATE, acceptedProtectionRequests: ACCEPTED_PROTECTION_REQUESTS, @@ -95,12 +96,13 @@ describe("ProtectionRecoveryRequest", () => { selectedLegalOfficers: twoLegalOfficers }); setProtectionState(state); + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); render(); const activateButton = screen.getByRole('button', {name: "Activate"}); await userEvent.click(activateButton); - await waitFor(() => expect(activateProtection).toBeCalled()); + await waitFor(() => expect(screen.getByText("Protection successfully activated.")).toBeInTheDocument()); }); it("protection request", () => { From 3c0c59916da8ec36e64f0fc31386995bbb117a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rard=20Dethier?= Date: Tue, 14 Nov 2023 07:12:07 +0100 Subject: [PATCH 2/2] chore: fix CI (tentative). --- src/loc/VoidLocReplaceExistingButton.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loc/VoidLocReplaceExistingButton.test.tsx b/src/loc/VoidLocReplaceExistingButton.test.tsx index 735560c7..b851821e 100644 --- a/src/loc/VoidLocReplaceExistingButton.test.tsx +++ b/src/loc/VoidLocReplaceExistingButton.test.tsx @@ -45,7 +45,7 @@ describe("VoidLocReplaceExistingButton", () => { await userEvent.click(button); await waitFor(() => expectSubmitting()); - expect(called).toBe(true); + await waitFor(() => expect(called).toBe(true)); }); it("closes dialog on success", async () => {