Skip to content

Commit cab5ec1

Browse files
committed
feat(contacts): render desktop contact edit and delete actions
1 parent 16e4819 commit cab5ec1

31 files changed

Lines changed: 1266 additions & 3 deletions

apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,4 +763,111 @@ describe("Contacts integration", () => {
763763
expect(within(detailScreen).getByText("2 addresses")).toBeInTheDocument();
764764
expect(screen.queryByText("No saved addresses for Ada")).not.toBeInTheDocument();
765765
});
766+
767+
it("should render edit action for Me and edit/delete actions for saved contacts", async () => {
768+
const { user } = render(
769+
<MemoryRouter initialEntries={["/contacts"]}>
770+
<Routes>
771+
<Route path="/contacts" element={<ContactsScreen />} />
772+
</Routes>
773+
</MemoryRouter>,
774+
{
775+
skipRouter: true,
776+
initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }),
777+
},
778+
);
779+
780+
expect(screen.getByTestId("contacts-detail-edit-action")).toBeVisible();
781+
expect(screen.queryByTestId("contacts-detail-delete-action")).not.toBeInTheDocument();
782+
783+
await user.click(screen.getByTestId("contacts-saved-row-contact-ada"));
784+
785+
expect(screen.getByTestId("contacts-detail-edit-action")).toBeVisible();
786+
expect(screen.getByTestId("contacts-detail-delete-action")).toBeVisible();
787+
});
788+
789+
it("should rename a saved contact from the edit dialog", async () => {
790+
const { user } = render(
791+
<MemoryRouter initialEntries={["/contacts"]}>
792+
<Routes>
793+
<Route path="/contacts" element={<ContactsScreen />} />
794+
</Routes>
795+
</MemoryRouter>,
796+
{
797+
skipRouter: true,
798+
initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }),
799+
},
800+
);
801+
802+
await user.click(screen.getByTestId("contacts-saved-row-contact-ada"));
803+
await user.click(screen.getByTestId("contacts-detail-edit-action"));
804+
805+
expect(screen.getByTestId("contacts-rename-contact-dialog")).toBeVisible();
806+
expect(screen.getByTestId("contacts-rename-contact-confirm")).toBeDisabled();
807+
808+
fireEvent.change(screen.getByTestId("contacts-add-contact-name-input"), {
809+
target: { value: "Alice" },
810+
});
811+
812+
expect(screen.getByTestId("contacts-rename-contact-confirm")).toBeEnabled();
813+
814+
await user.click(screen.getByTestId("contacts-rename-contact-confirm"));
815+
816+
await waitFor(() => {
817+
expect(screen.queryByTestId("contacts-rename-contact-dialog")).not.toBeInTheDocument();
818+
expect(screen.getByTestId("contacts-saved-row-contact-ada")).toHaveTextContent("Alice");
819+
expect(screen.getByTestId("contacts-detail-name")).toHaveTextContent("Alice");
820+
});
821+
});
822+
823+
it("should open the signer dialog before renaming a contact with addresses", async () => {
824+
const { user } = render(
825+
<MemoryRouter initialEntries={["/contacts"]}>
826+
<Routes>
827+
<Route path="/contacts" element={<ContactsScreen />} />
828+
</Routes>
829+
</MemoryRouter>,
830+
{
831+
skipRouter: true,
832+
initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }),
833+
},
834+
);
835+
836+
await user.click(screen.getByTestId("contacts-saved-row-contact-ben"));
837+
await user.click(screen.getByTestId("contacts-detail-edit-action"));
838+
839+
expect(screen.getByTestId("contacts-edit-signer-dialog")).toBeVisible();
840+
expect(screen.queryByTestId("contacts-rename-contact-dialog")).not.toBeInTheDocument();
841+
842+
await user.click(screen.getByTestId("contacts-edit-signer-confirm"));
843+
844+
expect(screen.getByTestId("contacts-rename-contact-dialog")).toBeVisible();
845+
});
846+
847+
it("should delete a saved contact and return to the Me detail pane", async () => {
848+
const { user } = render(
849+
<MemoryRouter initialEntries={["/contacts"]}>
850+
<Routes>
851+
<Route path="/contacts" element={<ContactsScreen />} />
852+
</Routes>
853+
</MemoryRouter>,
854+
{
855+
skipRouter: true,
856+
initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }),
857+
},
858+
);
859+
860+
await user.click(screen.getByTestId("contacts-saved-row-contact-ada"));
861+
await user.click(screen.getByTestId("contacts-detail-delete-action"));
862+
863+
expect(screen.getByTestId("contacts-delete-contact-dialog")).toBeVisible();
864+
865+
await user.click(screen.getByTestId("contacts-delete-contact-confirm"));
866+
867+
await waitFor(() => {
868+
expect(screen.queryByTestId("contacts-delete-contact-dialog")).not.toBeInTheDocument();
869+
expect(screen.queryByTestId("contacts-saved-row-contact-ada")).not.toBeInTheDocument();
870+
expect(screen.getByTestId("contacts-detail-name")).toHaveTextContent("Me");
871+
});
872+
});
766873
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
deleteContact as deleteContactAction,
3+
renameContact as renameContactAction,
4+
selectContactById,
5+
} from "@domain/entity-contact";
6+
import { useMemo } from "react";
7+
import type { ContactDetailActionsPorts } from "@features/flow-contacts";
8+
import { useDispatch, useStore } from "LLD/hooks/redux";
9+
10+
export function useContactsEditDeletePorts(): ContactDetailActionsPorts {
11+
const dispatch = useDispatch();
12+
const store = useStore();
13+
14+
return useMemo<ContactDetailActionsPorts>(
15+
() => ({
16+
edit: {
17+
renameContact: async ({ contactId, name }) => {
18+
dispatch(renameContactAction({ contactId, name }));
19+
const updatedContact = selectContactById(store.getState(), contactId);
20+
21+
if (updatedContact === undefined) {
22+
throw new Error("Contact not found");
23+
}
24+
25+
return updatedContact;
26+
},
27+
},
28+
deletion: {
29+
deleteContact: async contactIdToDelete => {
30+
dispatch(deleteContactAction(contactIdToDelete));
31+
},
32+
},
33+
}),
34+
[dispatch, store],
35+
);
36+
}

apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/ContactsView.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import React from "react";
22
import {
33
ContactAddressDetailDialog,
44
ContactsAddContactDialog,
5+
ContactsDeleteContactDialog,
6+
ContactsEditSignerDialog,
57
ContactsListView,
8+
ContactsRenameContactDialog,
69
type ContactAddressDetailDialogProps,
710
type ContactsAddContactDialogProps,
811
type ContactsListViewProps,
@@ -11,18 +14,21 @@ import {
1114
ContactsAddAddressFlowDialog,
1215
type ContactsAddAddressFlowDialogProps,
1316
} from "./components/ContactsAddAddressFlowDialog";
17+
import type { ContactDetailEditDeleteDialogProps } from "./useContactDetailEditDeleteAdapter";
1418

1519
export type ContactsViewProps = ContactsListViewProps &
1620
Readonly<{
1721
addContactDialog: ContactsAddContactDialogProps;
1822
addAddressFlowDialog: ContactsAddAddressFlowDialogProps;
1923
addressDetailDialog: ContactAddressDetailDialogProps;
24+
editDeleteDialogs: ContactDetailEditDeleteDialogProps;
2025
}>;
2126

2227
export function ContactsView({
2328
addContactDialog,
2429
addAddressFlowDialog,
2530
addressDetailDialog,
31+
editDeleteDialogs,
2632
...pageProps
2733
}: Readonly<ContactsViewProps>) {
2834
return (
@@ -31,6 +37,9 @@ export function ContactsView({
3137
<ContactsAddContactDialog {...addContactDialog} />
3238
<ContactAddressDetailDialog {...addressDetailDialog} />
3339
<ContactsAddAddressFlowDialog {...addAddressFlowDialog} />
40+
<ContactsRenameContactDialog {...editDeleteDialogs.renameDialog} />
41+
<ContactsDeleteContactDialog {...editDeleteDialogs.deleteDialog} />
42+
<ContactsEditSignerDialog {...editDeleteDialogs.signerDialog} />
3443
</>
3544
);
3645
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { INVALID_CONTACT_NAME_ERROR_NAME, type ContactId } from "@domain/entity-contact";
2+
import {
3+
type ContactsDeleteContactDialogProps,
4+
type ContactsEditSignerDialogProps,
5+
type ContactsRenameContactDialogProps,
6+
type ContactDetailActionsLabels,
7+
useContactDetailEditDeleteFlowViewModel,
8+
useRenameContactDialogViewModel,
9+
} from "@features/flow-contacts";
10+
import { useMemo } from "react";
11+
import { useTranslation } from "react-i18next";
12+
import { useContactsEditDeletePorts } from "../../hooks/useContactsEditDeletePorts";
13+
14+
export type ContactDetailEditDeleteDialogProps = Readonly<{
15+
detailActions?: Readonly<{
16+
canDelete: boolean;
17+
labels: ContactDetailActionsLabels;
18+
onEdit: () => void;
19+
onDelete: () => void;
20+
}>;
21+
renameDialog: ContactsRenameContactDialogProps;
22+
deleteDialog: ContactsDeleteContactDialogProps;
23+
signerDialog: ContactsEditSignerDialogProps;
24+
}>;
25+
26+
export function useContactDetailEditDeleteAdapter(
27+
contactId: ContactId | undefined,
28+
onDeleteSuccess: () => void,
29+
): ContactDetailEditDeleteDialogProps {
30+
const { t } = useTranslation();
31+
const ports = useContactsEditDeletePorts();
32+
const flow = useContactDetailEditDeleteFlowViewModel({
33+
contactId: contactId ?? "contact-me",
34+
ports,
35+
onDeleteSuccess,
36+
});
37+
const renameDialogViewModel = useRenameContactDialogViewModel({
38+
contactId: contactId ?? "contact-me",
39+
currentName: flow.contactName,
40+
editPort: ports.edit,
41+
isRequestedOpen: flow.editUiState === "edit-open",
42+
onCloseRequest: flow.onEditClose,
43+
onSaveSuccess: () => undefined,
44+
});
45+
const actionLabels = useMemo<ContactDetailActionsLabels>(
46+
() => ({
47+
editContact: t("contacts.detailActions.editContact"),
48+
deleteContact: t("contacts.detailActions.deleteContact"),
49+
}),
50+
[t],
51+
);
52+
const renameLabels = useMemo(
53+
() => ({
54+
title: t("contacts.editContact.title"),
55+
namePlaceholder: t("contacts.editContact.namePlaceholder"),
56+
namingDisclaimer: t("contacts.editContact.namingDisclaimer"),
57+
confirmName: t("contacts.editContact.confirmName"),
58+
applyChanges: t("contacts.editContact.applyChanges"),
59+
nameValidationErrors: {
60+
[INVALID_CONTACT_NAME_ERROR_NAME]: t("contacts.editContact.invalidNameError"),
61+
},
62+
}),
63+
[t],
64+
);
65+
const deleteLabels = useMemo(
66+
() => ({
67+
title: t("contacts.deleteContact.title"),
68+
description: t("contacts.deleteContact.description"),
69+
confirm: t("contacts.deleteContact.confirm"),
70+
cancel: t("contacts.deleteContact.cancel"),
71+
}),
72+
[t],
73+
);
74+
const signerLabels = useMemo(
75+
() => ({
76+
title: t("contacts.editSigner.title"),
77+
description: t("contacts.editSigner.description"),
78+
confirm: t("contacts.editSigner.confirm"),
79+
cancel: t("contacts.editSigner.cancel"),
80+
}),
81+
[t],
82+
);
83+
84+
return {
85+
detailActions: contactId
86+
? {
87+
canDelete: flow.canDelete,
88+
labels: actionLabels,
89+
onEdit: flow.onEditPress,
90+
onDelete: flow.onDeletePress,
91+
}
92+
: undefined,
93+
renameDialog: {
94+
...renameDialogViewModel,
95+
labels: renameLabels,
96+
},
97+
deleteDialog: {
98+
isOpen: flow.deleteLifecycle.status === "open",
99+
isDeleting: flow.isDeleting,
100+
labels: deleteLabels,
101+
onConfirm: flow.confirmDelete,
102+
onCancel: flow.cancelDelete,
103+
},
104+
signerDialog: {
105+
isOpen: flow.editUiState === "signer-open",
106+
labels: signerLabels,
107+
onConfirm: flow.onSignerConfirm,
108+
onCancel: flow.onSignerCancel,
109+
},
110+
};
111+
}

apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailPaneAdapter.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,25 @@ import {
1515
} from "@features/flow-contacts";
1616
import { MY_WALLET_AVATAR_USER_URL } from "LLD/features/MyWallet/components/UserAvatar/constants";
1717
import { useContactsAddressCurrencyAdapter } from "../../hooks/useContactsAddressCurrencyAdapter";
18+
import { useContactDetailEditDeleteAdapter } from "./useContactDetailEditDeleteAdapter";
1819

1920
export function useContactDetailPaneAdapter(
2021
onAddAddress: (contact: AddAddressContact) => void,
2122
): Readonly<{
2223
detail: ContactDetailViewProps | undefined;
2324
addressDetailDialog: ContactAddressDetailDialogProps;
25+
editDeleteDialogs: ReturnType<typeof useContactDetailEditDeleteAdapter>;
2426
onOpenMe: ContactsListViewProps["onOpenMe"];
2527
onOpenContact: ContactsListViewProps["onOpenContact"];
2628
}> {
2729
const { t } = useTranslation();
2830
const meContact = useContactsMeContact();
2931
const currencyPort = useContactsAddressCurrencyAdapter();
3032
const [detailContactId, setDetailContactId] = useState<ContactId | undefined>(meContact.id);
33+
const onDeleteSuccess = useCallback(() => {
34+
setDetailContactId(meContact.id);
35+
}, [meContact.id]);
36+
const editDeleteDialogs = useContactDetailEditDeleteAdapter(detailContactId, onDeleteSuccess);
3137
const emptyContact = useEmptyContactDetail(detailContactId);
3238
const populatedContactDetail = usePopulatedContactDetail(detailContactId, currencyPort);
3339
const {
@@ -82,6 +88,7 @@ export function useContactDetailPaneAdapter(
8288
onAddAddress: () => onAddAddress(populatedContactDetail.contact),
8389
addressGroups: populatedContactDetail.addressGroups,
8490
onAddressRowPress,
91+
detailActions: editDeleteDialogs.detailActions,
8592
};
8693
}
8794

@@ -93,8 +100,16 @@ export function useContactDetailPaneAdapter(
93100
...baseDetail,
94101
contact: emptyContact,
95102
onAddAddress: () => onAddAddress(emptyContact),
103+
detailActions: editDeleteDialogs.detailActions,
96104
};
97-
}, [emptyContact, labels, onAddAddress, onAddressRowPress, populatedContactDetail]);
105+
}, [
106+
emptyContact,
107+
editDeleteDialogs.detailActions,
108+
labels,
109+
onAddAddress,
110+
onAddressRowPress,
111+
populatedContactDetail,
112+
]);
98113
const addressDetailDialog = useMemo<ContactAddressDetailDialogProps>(
99114
() => ({
100115
isOpen,
@@ -118,6 +133,7 @@ export function useContactDetailPaneAdapter(
118133
return {
119134
detail,
120135
addressDetailDialog,
136+
editDeleteDialogs,
121137
onOpenMe: openContact,
122138
onOpenContact: openContact,
123139
};

apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export type ContactsPageViewModel = Omit<ContactsListViewProps, "onAddContact">
3232
addAddressFlowState: AddAddressFlowState;
3333
addAddressFlowDialog: ContactsAddAddressFlowDialogProps;
3434
addressDetailDialog: ContactAddressDetailDialogProps;
35+
editDeleteDialogs: ReturnType<
36+
typeof import("./useContactDetailEditDeleteAdapter").useContactDetailEditDeleteAdapter
37+
>;
3538
onClearSearch: () => void;
3639
}>;
3740

@@ -122,7 +125,7 @@ export function useContactsViewModel(): ContactsPageViewModel {
122125
updateAddress,
123126
],
124127
);
125-
const { detail, addressDetailDialog, onOpenMe, onOpenContact } =
128+
const { detail, addressDetailDialog, editDeleteDialogs, onOpenMe, onOpenContact } =
126129
useContactDetailPaneAdapter(onAddAddress);
127130
const [isLedgerSyncIntroductionDismissed, setIsLedgerSyncIntroductionDismissed] = useState(false);
128131
const [ledgerSyncStatus] = useState<ContactsLedgerSyncStatus>("ready");
@@ -188,6 +191,7 @@ export function useContactsViewModel(): ContactsPageViewModel {
188191
addAddressFlowState,
189192
addAddressFlowDialog,
190193
addressDetailDialog,
194+
editDeleteDialogs,
191195
viewModel,
192196
labels,
193197
searchQuery,

0 commit comments

Comments
 (0)