From 605c48a9aa4403760c4d3b938417747a9a6ca7bc Mon Sep 17 00:00:00 2001 From: Sotatek-DukeVu <162310763+Sotatek-DukeVu@users.noreply.github.com> Date: Thu, 19 Dec 2024 17:03:46 +0700 Subject: [PATCH] feat(ui): display alert when no witness availablity (#876) Co-authored-by: Vu Van Duc --- src/locales/en/en.json | 4 + src/store/reducers/stateCache/stateCache.ts | 8 + .../reducers/stateCache/stateCache.types.ts | 1 + src/ui/App.test.tsx | 192 +++++++++++++++++- src/ui/App.tsx | 3 +- .../components/AppWrapper/AppWrapper.test.tsx | 4 +- src/ui/components/AppWrapper/AppWrapper.tsx | 26 +++ .../CreateIdentifier.test.tsx | 76 ++++++- .../CreateIdentifier/CreateIdentifier.tsx | 18 +- src/ui/components/Error/NoWitnessAlert.tsx | 28 +++ src/ui/components/Error/index.ts | 1 + 11 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 src/ui/components/Error/NoWitnessAlert.tsx diff --git a/src/locales/en/en.json b/src/locales/en/en.json index 54e1502d0..f86dfa83b 100644 --- a/src/locales/en/en.json +++ b/src/locales/en/en.json @@ -1810,6 +1810,10 @@ "text": "Something went wrong. Please try again.", "button": "OK" }, + "nowitnesserror": { + "text": "Your SSI agent is misconfigured. For additional information, contact your SSI Cloud Agent provider.", + "button": "OK" + }, "readmore": { "more": "Read more", "less": "Read less" diff --git a/src/store/reducers/stateCache/stateCache.ts b/src/store/reducers/stateCache/stateCache.ts index 902f0cf8a..6f207c35b 100644 --- a/src/store/reducers/stateCache/stateCache.ts +++ b/src/store/reducers/stateCache/stateCache.ts @@ -165,6 +165,9 @@ const stateCacheSlice = createSlice({ showConnections: (state, action: PayloadAction) => { state.showConnections = action.payload; }, + showNoWitnessAlert: (state, action: PayloadAction) => { + state.showNoWitnessAlert = action.payload; + }, }, }); @@ -190,6 +193,7 @@ const { showGenericError, showConnections, removeToastMessage, + showNoWitnessAlert } = stateCacheSlice.actions; const getStateCache = (state: RootState) => state.stateCache; @@ -214,6 +218,8 @@ const getShowCommonError = (state: RootState) => state.stateCache.showGenericError; const getShowConnections = (state: RootState) => state.stateCache.showConnections; +const getShowNoWitnessAlert = (state: RootState) => + state.stateCache.showNoWitnessAlert; const getToastMgs = (state: RootState) => state.stateCache.toastMsgs; export type { @@ -223,6 +229,8 @@ export type { }; export { + showNoWitnessAlert, + getShowNoWitnessAlert, dequeueIncomingRequest, enqueueIncomingRequest, getAuthentication, diff --git a/src/store/reducers/stateCache/stateCache.types.ts b/src/store/reducers/stateCache/stateCache.types.ts index a53e6f3d4..66e9d59e0 100644 --- a/src/store/reducers/stateCache/stateCache.types.ts +++ b/src/store/reducers/stateCache/stateCache.types.ts @@ -57,6 +57,7 @@ interface StateCacheProps { queueIncomingRequest: QueueProps; cameraDirection?: LensFacing; showGenericError?: boolean; + showNoWitnessAlert?: boolean; showConnections: boolean; toastMsgs: ToastStackItem[]; } diff --git a/src/ui/App.test.tsx b/src/ui/App.test.tsx index ad3ff0844..e38d2cc28 100644 --- a/src/ui/App.test.tsx +++ b/src/ui/App.test.tsx @@ -3,14 +3,16 @@ import { render, waitFor } from "@testing-library/react"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import configureStore from "redux-mock-store"; +import { IdentifierService } from "../core/agent/services"; import Eng_Trans from "../locales/en/en.json"; import { TabsRoutePath } from "../routes/paths"; import { store } from "../store"; -import { showGenericError } from "../store/reducers/stateCache"; +import { showGenericError, showNoWitnessAlert } from "../store/reducers/stateCache"; import { App } from "./App"; import { OperationType } from "./globals/types"; const mockInitDatabase = jest.fn(); +const getAvailableWitnessesMock = jest.fn(); jest.mock("../core/agent/agent", () => ({ Agent: { @@ -27,6 +29,7 @@ jest.mock("../core/agent/agent", () => ({ getIdentifiers: jest.fn().mockResolvedValue([]), syncKeriaIdentifiers: jest.fn(), onIdentifierAdded: jest.fn(), + getAvailableWitnesses: () => getAvailableWitnessesMock() }, connections: { getConnections: jest.fn().mockResolvedValue([]), @@ -224,6 +227,7 @@ describe("App", () => { isNativeMock.mockImplementation(() => false); mockInitDatabase.mockClear(); getPlatformsMock.mockImplementation(() => ["android"]); + getAvailableWitnessesMock.mockClear(); }); test("Mobile header hidden when app not in preview mode", async () => { @@ -519,3 +523,189 @@ describe("App", () => { }); }); }); + +describe("Witness availability", () => { + test("No witness availability", async () => { + getAvailableWitnessesMock.mockImplementation(() => Promise.resolve([])); + + const initialState = { + stateCache: { + isOnline: true, + routes: [{ path: TabsRoutePath.ROOT }], + authentication: { + loggedIn: true, + userName: "", + time: Date.now(), + passcodeIsSet: true, + seedPhraseIsSet: true, + passwordIsSet: false, + passwordIsSkipped: true, + ssiAgentIsSet: true, + recoveryWalletProgress: false, + loginAttempt: { + attempts: 0, + lockedUntil: Date.now(), + }, + }, + toastMsgs: [], + queueIncomingRequest: { + isProcessing: false, + queues: [], + isPaused: false, + }, + }, + seedPhraseCache: { + seedPhrase: "", + bran: "", + }, + identifiersCache: { + identifiers: [], + favourites: [], + multiSigGroup: { + groupId: "", + connections: [], + }, + }, + credsCache: { creds: [], favourites: [] }, + credsArchivedCache: { creds: [] }, + connectionsCache: { + connections: {}, + multisigConnections: {}, + }, + walletConnectionsCache: { + walletConnections: [], + connectedWallet: null, + pendingConnection: null, + }, + viewTypeCache: { + identifier: { + viewType: null, + favouriteIndex: 0, + }, + credential: { + viewType: null, + favouriteIndex: 0, + }, + }, + biometricsCache: { + enabled: false, + }, + ssiAgentCache: { + bootUrl: "", + connectUrl: "", + }, + notificationsCache: { + notifications: [], + }, + }; + + const storeMocked = { + ...mockStore(initialState), + dispatch: dispatchMock, + }; + + render( + + + + + + ); + + await waitFor(() => { + expect(dispatchMock).toBeCalledWith(showNoWitnessAlert(true)); + }); + }); + + test("Throw error", async () => { + getAvailableWitnessesMock.mockImplementation(() => Promise.reject(new Error(IdentifierService.MISCONFIGURED_AGENT_CONFIGURATION))); + + const initialState = { + stateCache: { + isOnline: true, + routes: [{ path: TabsRoutePath.ROOT }], + authentication: { + loggedIn: true, + userName: "", + time: Date.now(), + passcodeIsSet: true, + seedPhraseIsSet: true, + passwordIsSet: false, + passwordIsSkipped: true, + ssiAgentIsSet: true, + recoveryWalletProgress: false, + loginAttempt: { + attempts: 0, + lockedUntil: Date.now(), + }, + }, + toastMsgs: [], + queueIncomingRequest: { + isProcessing: false, + queues: [], + isPaused: false, + }, + }, + seedPhraseCache: { + seedPhrase: "", + bran: "", + }, + identifiersCache: { + identifiers: [], + favourites: [], + multiSigGroup: { + groupId: "", + connections: [], + }, + }, + credsCache: { creds: [], favourites: [] }, + credsArchivedCache: { creds: [] }, + connectionsCache: { + connections: {}, + multisigConnections: {}, + }, + walletConnectionsCache: { + walletConnections: [], + connectedWallet: null, + pendingConnection: null, + }, + viewTypeCache: { + identifier: { + viewType: null, + favouriteIndex: 0, + }, + credential: { + viewType: null, + favouriteIndex: 0, + }, + }, + biometricsCache: { + enabled: false, + }, + ssiAgentCache: { + bootUrl: "", + connectUrl: "", + }, + notificationsCache: { + notifications: [], + }, + }; + + const storeMocked = { + ...mockStore(initialState), + dispatch: dispatchMock, + }; + + render( + + + + + + ); + + await waitFor(() => { + expect(dispatchMock).toBeCalledWith(showNoWitnessAlert(true)); + }); + }); +}); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2fec4cd58..84d18d791 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -22,7 +22,7 @@ import { import { AppOffline } from "./components/AppOffline"; import { AppWrapper } from "./components/AppWrapper"; import { ToastStack } from "./components/CustomToast/ToastStack"; -import { GenericError } from "./components/Error"; +import { GenericError, NoWitnessAlert } from "./components/Error"; import { InputRequest } from "./components/InputRequest"; import { SidePage } from "./components/SidePage"; import { OperationType } from "./globals/types"; @@ -140,6 +140,7 @@ const App = () => { + diff --git a/src/ui/components/AppWrapper/AppWrapper.test.tsx b/src/ui/components/AppWrapper/AppWrapper.test.tsx index 1ac0e342e..636c8ac0a 100644 --- a/src/ui/components/AppWrapper/AppWrapper.test.tsx +++ b/src/ui/components/AppWrapper/AppWrapper.test.tsx @@ -26,7 +26,6 @@ import { store } from "../../../store"; import { updateOrAddConnectionCache } from "../../../store/reducers/connectionsCache"; import { updateOrAddCredsCache } from "../../../store/reducers/credsCache"; import { updateIsPending } from "../../../store/reducers/identifiersCache"; -import { setNotificationsCache } from "../../../store/reducers/notificationsCache"; import { setQueueIncomingRequest, setToastMsg, @@ -66,6 +65,7 @@ jest.mock("../../../core/agent/agent", () => ({ getIdentifiers: jest.fn().mockResolvedValue([]), syncKeriaIdentifiers: jest.fn(), onIdentifierAdded: jest.fn(), + getAvailableWitnesses: jest.fn() }, multiSigs: { getMultisigIcpDetails: jest.fn().mockResolvedValue({}), @@ -378,4 +378,4 @@ describe("Signify operation state changed handler", () => { setToastMsg(ToastMsgType.IDENTIFIER_UPDATED) ); }); -}); +}); \ No newline at end of file diff --git a/src/ui/components/AppWrapper/AppWrapper.tsx b/src/ui/components/AppWrapper/AppWrapper.tsx index 60be4702f..cd02d58e9 100644 --- a/src/ui/components/AppWrapper/AppWrapper.tsx +++ b/src/ui/components/AppWrapper/AppWrapper.tsx @@ -54,6 +54,7 @@ import { setPauseQueueIncomingRequest, setQueueIncomingRequest, setToastMsg, + showNoWitnessAlert, } from "../../../store/reducers/stateCache"; import { IncomingRequestType } from "../../../store/reducers/stateCache/stateCache.types"; import { @@ -79,6 +80,7 @@ import { } from "../../../core/agent/event.types"; import { IdentifiersFilters } from "../../pages/Identifiers/Identifiers.types"; import { CredentialsFilters } from "../../pages/Credentials/Credentials.types"; +import { IdentifierService } from "../../../core/agent/services"; const connectionStateChangedHandler = async ( event: ConnectionStateChangedEvent, @@ -193,6 +195,30 @@ const AppWrapper = (props: { children: ReactNode }) => { [dispatch] ); + const checkWitness = useCallback(async () => { + if(!authentication.ssiAgentIsSet || !isOnline) return; + + try { + const witness = await Agent.agent.identifiers.getAvailableWitnesses(); + + if (witness.length === 0) + throw new Error(IdentifierService.NO_WITNESSES_AVAILABLE) + + } catch(e) { + const errorMessage = (e as Error).message; + if(errorMessage.includes(IdentifierService.NO_WITNESSES_AVAILABLE) || errorMessage.includes(IdentifierService.MISCONFIGURED_AGENT_CONFIGURATION)) { + dispatch(showNoWitnessAlert(true)); + return; + } + + throw e; + } + }, [authentication.ssiAgentIsSet, dispatch, isOnline]); + + useEffect(() => { + checkWitness(); + }, [checkWitness]) + useEffect(() => { initApp(); }, []); diff --git a/src/ui/components/CreateIdentifier/CreateIdentifier.test.tsx b/src/ui/components/CreateIdentifier/CreateIdentifier.test.tsx index 5ce6e955b..2badeda1a 100644 --- a/src/ui/components/CreateIdentifier/CreateIdentifier.test.tsx +++ b/src/ui/components/CreateIdentifier/CreateIdentifier.test.tsx @@ -9,6 +9,7 @@ import { ConnectionDetails } from "../../../core/agent/agent.types"; import { IdentifierService } from "../../../core/agent/services"; import EN_TRANSLATION from "../../../locales/en/en.json"; import { setMultiSigGroupCache } from "../../../store/reducers/identifiersCache"; +import { showNoWitnessAlert } from "../../../store/reducers/stateCache"; import { connectionsFix } from "../../__fixtures__/connectionsFix"; import { CustomInputProps } from "../CustomInput/CustomInput.types"; import { TabsRoutePath } from "../navigation/TabsMenu"; @@ -27,10 +28,7 @@ jest.mock("@ionic/react", () => ({ const mockGetMultisigConnection = jest.fn((args: any) => Promise.resolve([] as ConnectionDetails[]) ); -const createIdentifierMock = jest.fn((args: unknown) => ({ - identifier: "mock-id", - isPending: true, -})); +const createIdentifierMock = jest.fn(); const markIdentifierPendingCreateMock = jest.fn((args: unknown) => ({})); jest.mock("../../../core/agent/agent", () => ({ @@ -105,6 +103,10 @@ describe("Create Identifier modal", () => { mockGetMultisigConnection.mockImplementation((): any => Promise.resolve([] as ConnectionDetails[]) ); + createIdentifierMock.mockImplementation((args: unknown) => ({ + identifier: "mock-id", + isPending: true, + })); }); const initialState = { @@ -439,4 +441,70 @@ describe("Create Identifier modal", () => { ).toBeVisible(); }); }); + + test("No witness availability", async () => { + createIdentifierMock.mockImplementation(() => Promise.reject(new Error(IdentifierService.NO_WITNESSES_AVAILABLE))); + + const { getByTestId } = render( + + + + ); + + const displayNameInput = getByTestId("display-name-input"); + + act(() => { + fireEvent.click(getByTestId("color-1")); + fireEvent.click(getByTestId("identifier-theme-selector-item-1")); + ionFireEvent.ionInput(displayNameInput, "Test"); + }); + + await waitFor(() => { + expect(getByTestId("color-1").classList.contains("selected")); + }) + + act(() => { + fireEvent.click(getByTestId("primary-button-create-identifier-modal")); + }); + + await waitFor(() => { + expect(dispatchMock).toBeCalledWith(showNoWitnessAlert(true)); + }); + }); + + test("Misconfigured agent", async () => { + createIdentifierMock.mockImplementation(() => Promise.reject(new Error(IdentifierService.MISCONFIGURED_AGENT_CONFIGURATION))); + + const { getByTestId } = render( + + + + ); + + const displayNameInput = getByTestId("display-name-input"); + + act(() => { + fireEvent.click(getByTestId("color-1")); + fireEvent.click(getByTestId("identifier-theme-selector-item-1")); + ionFireEvent.ionInput(displayNameInput, "Test"); + }); + + await waitFor(() => { + expect(getByTestId("color-1").classList.contains("selected")); + }) + + act(() => { + fireEvent.click(getByTestId("primary-button-create-identifier-modal")); + }); + + await waitFor(() => { + expect(dispatchMock).toBeCalledWith(showNoWitnessAlert(true)); + }); + }); }); diff --git a/src/ui/components/CreateIdentifier/CreateIdentifier.tsx b/src/ui/components/CreateIdentifier/CreateIdentifier.tsx index ea25f9b63..78527fad1 100644 --- a/src/ui/components/CreateIdentifier/CreateIdentifier.tsx +++ b/src/ui/components/CreateIdentifier/CreateIdentifier.tsx @@ -18,16 +18,15 @@ import { IdentifierShortDetails, } from "../../../core/agent/services/identifier.types"; import { i18n } from "../../../i18n"; -import { useAppDispatch, useAppSelector } from "../../../store/hooks"; +import { useAppDispatch } from "../../../store/hooks"; import { - getIdentifiersCache, - setIdentifiersCache, - setMultiSigGroupCache, + setMultiSigGroupCache } from "../../../store/reducers/identifiersCache"; import { MultiSigGroup } from "../../../store/reducers/identifiersCache/identifiersCache.types"; import { setCurrentOperation, setToastMsg, + showNoWitnessAlert, } from "../../../store/reducers/stateCache"; import { OperationType, ToastMsgType } from "../../globals/types"; import { useOnlineStatusEffect } from "../../hooks"; @@ -80,7 +79,6 @@ const CreateIdentifier = ({ MultiSigGroup | undefined >(); - const identifiersData = useAppSelector(getIdentifiersCache); const [openAIDInfo, setOpenAIDInfo] = useState(false); const [duplicateName, setDuplicateName] = useState(false); @@ -190,13 +188,17 @@ const CreateIdentifier = ({ ) ); } catch (e) { - if ( - (e as Error).message.includes(IdentifierService.IDENTIFIER_NAME_TAKEN) - ) { + const errorMessage = (e as Error).message; + if (errorMessage.includes(IdentifierService.IDENTIFIER_NAME_TAKEN)) { setDuplicateName(true); return; } + if(errorMessage.includes(IdentifierService.NO_WITNESSES_AVAILABLE) || errorMessage.includes(IdentifierService.MISCONFIGURED_AGENT_CONFIGURATION)) { + dispatch(showNoWitnessAlert(true)); + return; + } + showError("Unable to create identifier", e, dispatch); } finally { setBlur && setBlur(false); diff --git a/src/ui/components/Error/NoWitnessAlert.tsx b/src/ui/components/Error/NoWitnessAlert.tsx new file mode 100644 index 000000000..f677e2f56 --- /dev/null +++ b/src/ui/components/Error/NoWitnessAlert.tsx @@ -0,0 +1,28 @@ +import { useCallback } from "react"; +import { i18n } from "../../../i18n"; +import { useAppDispatch, useAppSelector } from "../../../store/hooks"; +import { getShowNoWitnessAlert, showNoWitnessAlert } from "../../../store/reducers/stateCache"; +import { Alert } from "../Alert"; + +const NoWitnessAlert = () => { + const dispatch = useAppDispatch(); + const isShowNoWitnessAlert = useAppSelector(getShowNoWitnessAlert); + + const closeAlert = useCallback(() => { + dispatch(showNoWitnessAlert(false)) + }, [dispatch]) + + return ( + + ); +}; + +export { NoWitnessAlert }; diff --git a/src/ui/components/Error/index.ts b/src/ui/components/Error/index.ts index 3f44b41c1..615f4ceaf 100644 --- a/src/ui/components/Error/index.ts +++ b/src/ui/components/Error/index.ts @@ -1 +1,2 @@ export * from "./GenericError"; +export * from "./NoWitnessAlert"; \ No newline at end of file