Skip to content

Commit f2ed111

Browse files
Sotatek-DukeVuVu Van Duc
andauthored
feat(ui): Set individual name in legacy multisig identifiers (#1449)
* feat(ui): set individual name for legacy group identifier * feat(ui): update placeholder --------- Co-authored-by: Vu Van Duc <[email protected]>
1 parent 03c5ced commit f2ed111

File tree

10 files changed

+536
-55
lines changed

10 files changed

+536
-55
lines changed

src/locales/en/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,17 @@
587587
}
588588
}
589589
},
590+
"setgroup": {
591+
"title": "Set name",
592+
"text": "Add a name for other members to easily recognize you",
593+
"cancel": "Cancel",
594+
"confirm": "Confirm",
595+
"input": {
596+
"label": "Username",
597+
"placeholder": "e.g. JohnSmith25"
598+
},
599+
"alert": "No name was set when this group was created, which means other members can’t recognize who you are. Setting a name helps everyone know who's who."
600+
},
590601
"profiledetails": {
591602
"done": "Done",
592603
"identifierdetail": {

src/ui/App.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import { i18n } from "../i18n";
1818
import { Routes } from "../routes";
1919
import { initializeFreeRASP, ThreatCheck } from "../security/freerasp";
2020
import { useAppDispatch, useAppSelector } from "../store/hooks";
21-
import { getShowProfileState } from "../store/reducers/profileCache";
21+
import {
22+
getCurrentProfile,
23+
getShowProfileState,
24+
} from "../store/reducers/profileCache";
2225
import {
2326
getGlobalLoading,
2427
getInitializationPhase,
@@ -30,6 +33,7 @@ import { ToastStack } from "./components/CustomToast/ToastStack";
3033
import { GenericError, NoWitnessAlert } from "./components/Error";
3134
import { InputRequest } from "./components/InputRequest";
3235
import { ProfileStateModal } from "./components/ProfileStateModal";
36+
import { SetGroupName } from "./components/SetGroupName";
3337
import { SidePage } from "./components/SidePage";
3438
import {
3539
ANDROID_MIN_VERSION,
@@ -49,6 +53,18 @@ import { compareVersion } from "./utils/version";
4953

5054
setupIonicReact();
5155

56+
const SetGroupNameWrapper = () => {
57+
const currentProfile = useAppSelector(getCurrentProfile);
58+
59+
const isGroupProfile =
60+
!!currentProfile?.identity.groupMetadata ||
61+
!!currentProfile?.identity.groupMemberPre;
62+
63+
if (!isGroupProfile || currentProfile.identity.groupUsername) return;
64+
65+
return <SetGroupName identifier={currentProfile.identity} />;
66+
};
67+
5268
const InitPhase = ({ initPhase }: { initPhase: InitializationPhase }) => {
5369
const showProfileState = useAppSelector(getShowProfileState);
5470

@@ -78,6 +94,7 @@ const InitPhase = ({ initPhase }: { initPhase: InitializationPhase }) => {
7894
<ProfileStateModal />
7995
<LockPage />
8096
</IonReactRouter>
97+
<SetGroupNameWrapper />
8198
<AppOffline />
8299
</>
83100
);

src/ui/components/ProfileDetailsModal/ProfileDetailsModal.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,6 @@ const ProfileDetailsModal = ({
101101
});
102102

103103
const handleDelete = async () => {
104-
handleClose();
105-
setHidden(true);
106-
107104
try {
108105
setVerifyIsOpen(false);
109106
const filterId = profile
@@ -112,9 +109,19 @@ const ProfileDetailsModal = ({
112109
? profileId
113110
: undefined;
114111

112+
setHidden(true);
115113
await deleteIdentifier();
116114
if (defaultProfile?.identity.id === filterId) {
117-
await setRecentProfileAsDefault();
115+
const nextIdentifier = await setRecentProfileAsDefault();
116+
// If the user upgrades to app version 1.2 and, after deleting a profile,
117+
// the next profile is a group profile without a username, then close the profiles screen and display the “set profile name” screen.
118+
const isGroup =
119+
!!nextIdentifier?.groupMetadata || !!nextIdentifier?.groupMemberPre;
120+
if (isGroup && !nextIdentifier.groupUsername) {
121+
setIsOpen(false, true);
122+
} else {
123+
handleClose();
124+
}
118125
}
119126
dispatch(setToastMsg(ToastMsgType.IDENTIFIER_DELETED));
120127
dispatch(removeProfile(filterId || ""));

src/ui/components/ProfileDetailsModal/ProfileDetailsModal.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ interface IdentifierDetailModalProps {
44
restrictedOptions?: boolean;
55
showProfiles?: (value: boolean) => void;
66
isOpen: boolean;
7-
setIsOpen: (value: boolean) => void;
7+
setIsOpen: (value: boolean, closeProfiles?: boolean) => void;
88
}
99

1010
export type { IdentifierDetailModalProps };
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
.set-group-name {
2+
.page-header {
3+
ion-toolbar {
4+
--background: transparent;
5+
}
6+
}
7+
8+
.page-footer {
9+
padding: 1.5rem 0;
10+
}
11+
12+
.scrollable-page-content {
13+
.group-info {
14+
display: flex;
15+
align-items: center;
16+
margin-bottom: 1.5rem;
17+
18+
.group-name {
19+
margin: 0;
20+
line-height: 1.5rem;
21+
font-weight: 500;
22+
margin-left: 1rem;
23+
}
24+
}
25+
26+
.text {
27+
font-weight: 500;
28+
line-height: 1.5rem;
29+
margin-bottom: 1.5rem;
30+
text-align: center;
31+
width: 80%;
32+
}
33+
34+
.error-message {
35+
text-align: left;
36+
}
37+
38+
.indentifier-input {
39+
margin-bottom: 1.5rem;
40+
41+
ion-label {
42+
margin-top: 0;
43+
}
44+
45+
&.has-error {
46+
margin-bottom: 0;
47+
ion-item.custom-input .input-line {
48+
border-color: var(--ion-color-danger);
49+
}
50+
}
51+
52+
.label-stacked {
53+
font-size: 1rem;
54+
}
55+
}
56+
57+
@media screen and (min-width: 250px) and (max-width: 370px) {
58+
.group-info {
59+
margin-bottom: 1rem;
60+
61+
.group-name {
62+
font-size: 0.875rem;
63+
line-height: 1rem;
64+
}
65+
}
66+
.indentifier-input {
67+
margin-bottom: 1rem;
68+
69+
.text {
70+
font-weight: 500;
71+
line-height: 1rem;
72+
margin: 0 0 1rem;
73+
}
74+
75+
.label-stacked {
76+
font-size: 0.875rem;
77+
}
78+
}
79+
}
80+
}
81+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { IonInput, IonLabel } from "@ionic/react";
2+
import { AnyAction, Store } from "@reduxjs/toolkit";
3+
import { fireEvent, render, waitFor } from "@testing-library/react";
4+
import { act } from "react";
5+
import { Provider } from "react-redux";
6+
import EN_TRANSLATIONS from "../../../locales/en/en.json";
7+
import { multisignIdentifierFix } from "../../__fixtures__/filteredIdentifierFix";
8+
import { identifierFix } from "../../__fixtures__/identifierFix";
9+
import { profileCacheFixData } from "../../__fixtures__/storeDataFix";
10+
import { makeTestStore } from "../../utils/makeTestStore";
11+
import { CustomInputProps } from "../CustomInput/CustomInput.types";
12+
import { TabsRoutePath } from "../navigation/TabsMenu";
13+
import { SetGroupName } from "./SetGroupName";
14+
15+
const updateMock = jest.fn();
16+
17+
jest.mock("../../../core/agent/agent", () => ({
18+
Agent: {
19+
agent: {
20+
identifiers: {
21+
updateGroupUsername: () => updateMock(() => Promise.resolve(true)),
22+
},
23+
},
24+
},
25+
}));
26+
27+
jest.mock("@ionic/react", () => ({
28+
...jest.requireActual("@ionic/react"),
29+
IonModal: ({ children }: { children: any }) => children,
30+
}));
31+
32+
jest.mock("../CustomInput", () => ({
33+
CustomInput: (props: CustomInputProps) => {
34+
return (
35+
<>
36+
<IonLabel
37+
position="stacked"
38+
data-testid={`${props.title?.toLowerCase().replace(" ", "-")}-title`}
39+
>
40+
{props.title}
41+
{props.optional && (
42+
<span className="custom-input-optional">(optional)</span>
43+
)}
44+
</IonLabel>
45+
<IonInput
46+
data-testid={props.dataTestId}
47+
onIonInput={(e) => {
48+
props.onChangeInput(e.detail.value as string);
49+
}}
50+
/>
51+
</>
52+
);
53+
},
54+
}));
55+
56+
const testIdentifier = { ...multisignIdentifierFix[0] };
57+
delete testIdentifier.groupUsername;
58+
59+
describe("Set individual name", () => {
60+
const dispatchMock = jest.fn();
61+
let mockedStore: Store<unknown, AnyAction>;
62+
63+
beforeEach(() => {
64+
updateMock.mockImplementation(() => Promise.resolve(true));
65+
});
66+
67+
beforeAll(() => {
68+
const initialState = {
69+
stateCache: {
70+
routes: [TabsRoutePath.CREDENTIALS],
71+
authentication: {
72+
loggedIn: true,
73+
time: Date.now(),
74+
passcodeIsSet: true,
75+
passwordIsSet: true,
76+
},
77+
},
78+
profilesCache: profileCacheFixData,
79+
};
80+
mockedStore = {
81+
...makeTestStore(initialState),
82+
dispatch: dispatchMock,
83+
};
84+
});
85+
86+
test("render", async () => {
87+
const { getByTestId, getByText } = render(
88+
<Provider store={mockedStore}>
89+
<SetGroupName identifier={identifierFix[0]} />
90+
</Provider>
91+
);
92+
93+
expect(getByTestId("edit-member-name-input")).toBeVisible();
94+
expect(getByText(EN_TRANSLATIONS.setgroup.title)).toBeVisible();
95+
expect(getByText(EN_TRANSLATIONS.setgroup.alert)).toBeVisible();
96+
expect(getByText(EN_TRANSLATIONS.setgroup.text)).toBeVisible();
97+
});
98+
99+
test("set name", async () => {
100+
const { getByTestId } = render(
101+
<Provider store={mockedStore}>
102+
<SetGroupName identifier={identifierFix[0]} />
103+
</Provider>
104+
);
105+
106+
act(() => {
107+
fireEvent(
108+
getByTestId("edit-member-name-input"),
109+
new CustomEvent("ionInput", { detail: { value: "Duke" } })
110+
);
111+
});
112+
113+
await waitFor(() => {
114+
expect(
115+
getByTestId("primary-button-set-group-name").getAttribute("disabled")
116+
).toBe("false");
117+
});
118+
119+
act(() => {
120+
fireEvent.click(getByTestId("primary-button-set-group-name"));
121+
});
122+
123+
await waitFor(() => {
124+
expect(updateMock).toBeCalledTimes(1);
125+
});
126+
});
127+
128+
test("Display error when display name invalid", async () => {
129+
const { getByTestId, getByText } = render(
130+
<Provider store={mockedStore}>
131+
<SetGroupName identifier={identifierFix[0]} />
132+
</Provider>
133+
);
134+
135+
act(() => {
136+
fireEvent(
137+
getByTestId("edit-member-name-input"),
138+
new CustomEvent("ionInput", { detail: { value: "" } })
139+
);
140+
});
141+
142+
await waitFor(() => {
143+
expect(getByText(EN_TRANSLATIONS.nameerror.onlyspace)).toBeVisible();
144+
});
145+
146+
act(() => {
147+
fireEvent(
148+
getByTestId("edit-member-name-input"),
149+
new CustomEvent("ionInput", {
150+
detail: {
151+
value:
152+
"Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke Duke",
153+
},
154+
})
155+
);
156+
});
157+
158+
await waitFor(() => {
159+
expect(getByText(EN_TRANSLATIONS.nameerror.maxlength)).toBeVisible();
160+
});
161+
162+
act(() => {
163+
fireEvent(
164+
getByTestId("edit-member-name-input"),
165+
new CustomEvent("ionInput", { detail: { value: "Duke@@" } })
166+
);
167+
});
168+
169+
await waitFor(() => {
170+
expect(getByText(EN_TRANSLATIONS.nameerror.hasspecialchar)).toBeVisible();
171+
});
172+
});
173+
});

0 commit comments

Comments
 (0)