From ddfa3a2987588a86694ff8230a04a6e6c7200a06 Mon Sep 17 00:00:00 2001 From: claudiiafg Date: Fri, 31 Jul 2026 11:45:16 +0100 Subject: [PATCH] feat(contacts): render desktop contact edit and delete actions --- .../Contacts.integration.test.tsx | 315 ++++++------------ .../hooks/useContactsEditDeletePorts.ts | 37 ++ .../screens/Contacts/ContactsView.tsx | 9 + .../useContactDetailEditDeleteAdapter.ts | 102 ++++++ .../Contacts/useContactDetailPaneAdapter.ts | 18 +- .../screens/Contacts/useContactsViewModel.ts | 5 +- .../static/i18n/en/app.json | 23 ++ features/flow/contacts/src/index.ts | 2 + .../ContactNameInput/ContactNameInput.web.tsx | 1 + .../steps/Detail/ContactDetailView.web.tsx | 8 +- .../ContactDetailActions.web.tsx | 41 +++ .../ContactsDeleteContactDialog.web.tsx | 67 ++++ .../ContactsDeleteContactDialog/types.ts | 19 ++ .../ContactsEditSignerDialog.web.tsx | 58 ++++ .../ContactsEditSignerDialog/types.ts | 18 + .../flow/contacts/src/steps/Detail/index.ts | 7 + .../flow/contacts/src/steps/Detail/types.ts | 11 + ...useContactDetailEditDeleteFlowViewModel.ts | 132 ++++++++ ...DetailEditDeleteFlowViewModel.web.test.tsx | 130 ++++++++ .../flow/contacts/src/steps/Detail/web.ts | 6 + .../ContactsRenameContactDialog.web.tsx | 77 +++++ .../contacts/src/steps/EditContact/index.ts | 14 + .../src/steps/EditContact/model/controller.ts | 20 ++ .../src/steps/EditContact/model/viewModel.ts | 17 + .../EditContact/model/viewModel.web.test.ts | 26 ++ .../contacts/src/steps/EditContact/types.ts | 45 +++ .../useRenameContactDialogViewModel.ts | 73 ++++ ...seRenameContactDialogViewModel.web.test.ts | 107 ++++++ .../EditContact/useRenameContactViewModel.ts | 32 ++ .../useRenameContactViewModel.web.test.ts | 44 +++ .../contacts/src/steps/EditContact/web.ts | 1 + 31 files changed, 1252 insertions(+), 213 deletions(-) create mode 100644 apps/ledger-live-desktop/src/mvvm/features/Contacts/hooks/useContactsEditDeletePorts.ts create mode 100644 apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailEditDeleteAdapter.ts create mode 100644 features/flow/contacts/src/steps/Detail/components/ContactDetailActions/ContactDetailActions.web.tsx create mode 100644 features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/ContactsDeleteContactDialog.web.tsx create mode 100644 features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/types.ts create mode 100644 features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/ContactsEditSignerDialog.web.tsx create mode 100644 features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/types.ts create mode 100644 features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.ts create mode 100644 features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.web.test.tsx create mode 100644 features/flow/contacts/src/steps/EditContact/ContactsRenameContactDialog.web.tsx create mode 100644 features/flow/contacts/src/steps/EditContact/index.ts create mode 100644 features/flow/contacts/src/steps/EditContact/model/controller.ts create mode 100644 features/flow/contacts/src/steps/EditContact/model/viewModel.ts create mode 100644 features/flow/contacts/src/steps/EditContact/model/viewModel.web.test.ts create mode 100644 features/flow/contacts/src/steps/EditContact/types.ts create mode 100644 features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.ts create mode 100644 features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.web.test.ts create mode 100644 features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.ts create mode 100644 features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.web.test.ts create mode 100644 features/flow/contacts/src/steps/EditContact/web.ts diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx b/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx index 5caa858b8f59..eb6973af1740 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx @@ -68,6 +68,22 @@ function contactsPageInitialState(extra: Record = {}) { }; } +const populatedContactsPageState = { contacts: { contacts: mockPopulatedContacts() } }; + +function renderContactsScreen(extraInitialState: Record = {}) { + return render( + + + } /> + + , + { + skipRouter: true, + initialState: contactsPageInitialState(extraInitialState), + }, + ); +} + function ContactsViewModelProbe({ contactId, contactType, @@ -179,17 +195,7 @@ describe("Contacts integration", () => { }); it("should render the empty Contacts list when lwdContacts is enabled", async () => { - render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState(), - }, - ); + renderContactsScreen(); expect(screen.getByTestId("contacts-page")).toBeVisible(); expect(screen.getByTestId("contacts-page-header")).toBeVisible(); @@ -211,34 +217,14 @@ describe("Contacts integration", () => { }); it("should render the default Me contact when the persisted Contacts state has none", () => { - render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: [] } }), - }, - ); + renderContactsScreen({ contacts: { contacts: [] } }); expect(screen.getByTestId("contacts-page")).toBeVisible(); expect(screen.getByTestId("contacts-me-row")).toHaveTextContent("Me"); }); it("should render saved contacts in alphabetical order when contacts exist", () => { - render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + renderContactsScreen(populatedContactsPageState); expect(screen.getByTestId("contacts-page")).toBeVisible(); expect(screen.getByTestId("contacts-add-contact")).toBeVisible(); @@ -259,19 +245,7 @@ describe("Contacts integration", () => { }); it("should save a contact from the add-contact CTA", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: withFlagOverrides({ - lwdContacts: { enabled: true, params: { newBadge: false } }, - }), - }, - ); + const { user } = renderContactsScreen(); await user.click(screen.getByTestId("contacts-add-contact")); @@ -299,17 +273,7 @@ describe("Contacts integration", () => { }); it("should filter saved contacts when searching", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.type(screen.getByTestId("contacts-list-search"), "Ben"); @@ -319,17 +283,7 @@ describe("Contacts integration", () => { }); it("should render the no-results state when the search query has no match", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.type(screen.getByTestId("contacts-list-search"), "unknown"); @@ -339,19 +293,9 @@ describe("Contacts integration", () => { }); it("should show the one-time feature introduction on first visit and complete it from Try contacts", async () => { - const { user, store } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ - settings: { hasDismissedContactsFeatureIntroduction: false }, - }), - }, - ); + const { user, store } = renderContactsScreen({ + settings: { hasDismissedContactsFeatureIntroduction: false }, + }); expect(screen.getByTestId("contacts-feature-introduction-dialog")).toBeVisible(); @@ -365,19 +309,9 @@ describe("Contacts integration", () => { it("should defer the feature introduction on Maybe later without persisting dismissal", async () => { mockNavigate.mockClear(); - const { user, store } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ - settings: { hasDismissedContactsFeatureIntroduction: false }, - }), - }, - ); + const { user, store } = renderContactsScreen({ + settings: { hasDismissedContactsFeatureIntroduction: false }, + }); expect(screen.getByTestId("contacts-feature-introduction-dialog")).toBeVisible(); @@ -388,19 +322,7 @@ describe("Contacts integration", () => { }); it("should render populated Me detail on load when populated contacts are persisted", () => { - render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ - contacts: { contacts: mockPopulatedContacts() }, - }), - }, - ); + renderContactsScreen(populatedContactsPageState); expect(screen.getByTestId("contacts-detail-name")).toHaveTextContent("Me"); expect( @@ -417,17 +339,7 @@ describe("Contacts integration", () => { }); it("should render the default Me detail state on load", () => { - render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState(), - }, - ); + renderContactsScreen(); expect(screen.getByTestId("contacts-detail-screen")).toBeVisible(); expect(screen.getByTestId("contacts-detail-me-avatar")).toBeVisible(); @@ -437,21 +349,7 @@ describe("Contacts integration", () => { }); it("should render the Me empty detail state when Me is selected after another contact", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ - contacts: { - contacts: mockPopulatedContacts(), - }, - }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.click(screen.getByTestId("contacts-saved-row-contact-ada")); await user.click(screen.getByTestId("contacts-me-row")); @@ -461,17 +359,7 @@ describe("Contacts integration", () => { }); it("should render a saved contact empty detail state when an empty contact is selected", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.click(screen.getByTestId("contacts-saved-row-contact-ada")); @@ -485,17 +373,7 @@ describe("Contacts integration", () => { }); it("should open MAD from the real Add Address CTA with the eligible network ids", async () => { - const { store, user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState(), - }, - ); + const { store, user } = renderContactsScreen(); await user.click(screen.getByTestId("contacts-me-row")); await user.click(screen.getByTestId("contacts-detail-add-address")); @@ -509,17 +387,7 @@ describe("Contacts integration", () => { }); it("should keep one Contacts dialog mounted from currency selection to address entry", async () => { - const { store, user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState(), - }, - ); + const { store, user } = renderContactsScreen(); await user.click(screen.getByTestId("contacts-me-row")); await user.click(screen.getByTestId("contacts-detail-add-address")); @@ -670,17 +538,7 @@ describe("Contacts integration", () => { }); it("should render populated contact detail when a contact with addresses is selected", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.click(screen.getByTestId("contacts-saved-row-contact-ben")); @@ -695,17 +553,7 @@ describe("Contacts integration", () => { }); it("should open the address detail dialog when an address row is clicked", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.click(screen.getByTestId("contacts-saved-row-contact-ben")); await user.click(screen.getByTestId("contacts-detail-address-row-address-ethereum")); @@ -717,17 +565,7 @@ describe("Contacts integration", () => { }); it("should close the address detail dialog when switching contacts", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.click(screen.getByTestId("contacts-saved-row-contact-ben")); await user.click(screen.getByTestId("contacts-detail-address-row-address-ethereum")); @@ -740,17 +578,7 @@ describe("Contacts integration", () => { }); it("should switch populated detail when selecting another contact with addresses", async () => { - const { user } = render( - - - } /> - - , - { - skipRouter: true, - initialState: contactsPageInitialState({ contacts: { contacts: mockPopulatedContacts() } }), - }, - ); + const { user } = renderContactsScreen(populatedContactsPageState); await user.click(screen.getByTestId("contacts-saved-row-contact-ada")); expect(screen.getByTestId("contacts-detail-screen")).toBeVisible(); @@ -763,4 +591,71 @@ describe("Contacts integration", () => { expect(within(detailScreen).getByText("2 addresses")).toBeInTheDocument(); expect(screen.queryByText("No saved addresses for Ada")).not.toBeInTheDocument(); }); + + it("should render edit action for Me and edit/delete actions for saved contacts", async () => { + const { user } = renderContactsScreen(populatedContactsPageState); + + expect(screen.getByTestId("contacts-detail-edit-action")).toBeVisible(); + expect(screen.queryByTestId("contacts-detail-delete-action")).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("contacts-saved-row-contact-ada")); + + expect(screen.getByTestId("contacts-detail-edit-action")).toBeVisible(); + expect(screen.getByTestId("contacts-detail-delete-action")).toBeVisible(); + }); + + it("should rename a saved contact from the edit dialog", async () => { + const { user } = renderContactsScreen(populatedContactsPageState); + + await user.click(screen.getByTestId("contacts-saved-row-contact-ada")); + await user.click(screen.getByTestId("contacts-detail-edit-action")); + + expect(screen.getByTestId("contacts-rename-contact-dialog")).toBeVisible(); + expect(screen.getByTestId("contacts-rename-contact-confirm")).toBeDisabled(); + + fireEvent.change(screen.getByTestId("contacts-add-contact-name-input"), { + target: { value: "Alice" }, + }); + + expect(screen.getByTestId("contacts-rename-contact-confirm")).toBeEnabled(); + + await user.click(screen.getByTestId("contacts-rename-contact-confirm")); + + await waitFor(() => { + expect(screen.queryByTestId("contacts-rename-contact-dialog")).not.toBeInTheDocument(); + expect(screen.getByTestId("contacts-saved-row-contact-ada")).toHaveTextContent("Alice"); + expect(screen.getByTestId("contacts-detail-name")).toHaveTextContent("Alice"); + }); + }); + + it("should open the signer dialog before renaming a contact with addresses", async () => { + const { user } = renderContactsScreen(populatedContactsPageState); + + await user.click(screen.getByTestId("contacts-saved-row-contact-ben")); + await user.click(screen.getByTestId("contacts-detail-edit-action")); + + expect(screen.getByTestId("contacts-edit-signer-dialog")).toBeVisible(); + expect(screen.queryByTestId("contacts-rename-contact-dialog")).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("contacts-edit-signer-confirm")); + + expect(screen.getByTestId("contacts-rename-contact-dialog")).toBeVisible(); + }); + + it("should delete a saved contact and return to the Me detail pane", async () => { + const { user } = renderContactsScreen(populatedContactsPageState); + + await user.click(screen.getByTestId("contacts-saved-row-contact-ada")); + await user.click(screen.getByTestId("contacts-detail-delete-action")); + + expect(screen.getByTestId("contacts-delete-contact-dialog")).toBeVisible(); + + await user.click(screen.getByTestId("contacts-delete-contact-confirm")); + + await waitFor(() => { + expect(screen.queryByTestId("contacts-delete-contact-dialog")).not.toBeInTheDocument(); + expect(screen.queryByTestId("contacts-saved-row-contact-ada")).not.toBeInTheDocument(); + expect(screen.getByTestId("contacts-detail-name")).toHaveTextContent("Me"); + }); + }); }); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/hooks/useContactsEditDeletePorts.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/hooks/useContactsEditDeletePorts.ts new file mode 100644 index 000000000000..fc6e8b7f4dbb --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/hooks/useContactsEditDeletePorts.ts @@ -0,0 +1,37 @@ +import { + deleteContact as deleteContactAction, + parseContactName, + renameContact as renameContactAction, + selectContactById, +} from "@domain/entity-contact"; +import { useMemo } from "react"; +import type { ContactDetailActionsPorts } from "@features/flow-contacts"; +import { useDispatch, useStore } from "LLD/hooks/redux"; + +export function useContactsEditDeletePorts(): ContactDetailActionsPorts { + const dispatch = useDispatch(); + const store = useStore(); + + return useMemo( + () => ({ + edit: { + renameContact: async ({ contactId, name }) => { + dispatch(renameContactAction({ contactId, name: parseContactName(name) })); + const updatedContact = selectContactById(store.getState(), contactId); + + if (updatedContact === undefined) { + throw new Error("Contact not found"); + } + + return updatedContact; + }, + }, + deletion: { + deleteContact: async contactIdToDelete => { + dispatch(deleteContactAction(contactIdToDelete)); + }, + }, + }), + [dispatch, store], + ); +} diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/ContactsView.tsx b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/ContactsView.tsx index 664ceb0658c2..711d4ea7e6fd 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/ContactsView.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/ContactsView.tsx @@ -2,7 +2,10 @@ import React from "react"; import { ContactAddressDetailDialog, ContactsAddContactDialog, + ContactsDeleteContactDialog, + ContactsEditSignerDialog, ContactsListView, + ContactsRenameContactDialog, type ContactAddressDetailDialogProps, type ContactsAddContactDialogProps, type ContactsListViewProps, @@ -11,18 +14,21 @@ import { ContactsAddAddressFlowDialog, type ContactsAddAddressFlowDialogProps, } from "./components/ContactsAddAddressFlowDialog"; +import type { ContactDetailEditDeleteDialogProps } from "./useContactDetailEditDeleteAdapter"; export type ContactsViewProps = ContactsListViewProps & Readonly<{ addContactDialog: ContactsAddContactDialogProps; addAddressFlowDialog: ContactsAddAddressFlowDialogProps; addressDetailDialog: ContactAddressDetailDialogProps; + editDeleteDialogs: ContactDetailEditDeleteDialogProps; }>; export function ContactsView({ addContactDialog, addAddressFlowDialog, addressDetailDialog, + editDeleteDialogs, ...pageProps }: Readonly) { return ( @@ -31,6 +37,9 @@ export function ContactsView({ + + + ); } diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailEditDeleteAdapter.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailEditDeleteAdapter.ts new file mode 100644 index 000000000000..0a14886f72b4 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailEditDeleteAdapter.ts @@ -0,0 +1,102 @@ +import { + ContactIdSchema, + INVALID_CONTACT_NAME_ERROR_NAME, + type ContactId, +} from "@domain/entity-contact"; +import { + type ContactsDeleteContactDialogProps, + type ContactsEditSignerDialogProps, + type ContactsRenameContactDialogProps, + type ContactDetailActionsLabels, + useContactDetailEditDeleteFlowViewModel, + useRenameContactDialogViewModel, +} from "@features/flow-contacts"; +import { useTranslation } from "react-i18next"; +import { useContactsEditDeletePorts } from "../../hooks/useContactsEditDeletePorts"; + +export type ContactDetailEditDeleteDialogProps = Readonly<{ + detailActions?: Readonly<{ + canDelete: boolean; + labels: ContactDetailActionsLabels; + onEdit: () => void; + onDelete: () => void; + }>; + renameDialog: ContactsRenameContactDialogProps; + deleteDialog: ContactsDeleteContactDialogProps; + signerDialog: ContactsEditSignerDialogProps; +}>; + +export function useContactDetailEditDeleteAdapter( + contactId: ContactId | undefined, + onDeleteSuccess: () => void, +): ContactDetailEditDeleteDialogProps { + const { t } = useTranslation(); + const ports = useContactsEditDeletePorts(); + const resolvedContactId = contactId ?? ContactIdSchema.parse("contact-me"); + const flow = useContactDetailEditDeleteFlowViewModel({ + contactId: resolvedContactId, + ports, + onDeleteSuccess, + }); + const renameDialogViewModel = useRenameContactDialogViewModel({ + contactId: resolvedContactId, + currentName: flow.contactName, + editPort: ports.edit, + isRequestedOpen: flow.editUiState === "edit-open", + onCloseRequest: flow.onEditClose, + onSaveSuccess: () => undefined, + }); + const actionLabels: ContactDetailActionsLabels = { + editContact: t("contacts.detailActions.editContact"), + deleteContact: t("contacts.detailActions.deleteContact"), + }; + const renameLabels = { + title: t("contacts.editContact.title"), + namePlaceholder: t("contacts.editContact.namePlaceholder"), + namingDisclaimer: t("contacts.editContact.namingDisclaimer"), + applyChanges: t("contacts.editContact.applyChanges"), + nameValidationErrors: { + [INVALID_CONTACT_NAME_ERROR_NAME]: t("contacts.editContact.invalidNameError"), + }, + }; + const deleteLabels = { + title: t("contacts.deleteContact.title"), + description: t("contacts.deleteContact.description"), + confirm: t("contacts.deleteContact.confirm"), + cancel: t("contacts.deleteContact.cancel"), + }; + const signerLabels = { + title: t("contacts.editSigner.title"), + description: t("contacts.editSigner.description"), + confirm: t("contacts.editSigner.confirm"), + cancel: t("contacts.editSigner.cancel"), + }; + + return { + detailActions: contactId + ? { + canDelete: flow.canDelete, + labels: actionLabels, + onEdit: flow.onEditPress, + onDelete: flow.onDeletePress, + } + : undefined, + renameDialog: { + ...renameDialogViewModel, + labels: renameLabels, + }, + deleteDialog: { + isOpen: flow.deleteLifecycle.status === "open", + isDeleting: flow.isDeleting, + labels: deleteLabels, + onConfirm: flow.confirmDelete, + onCancel: flow.cancelDelete, + }, + signerDialog: { + isOpen: flow.editUiState === "signer-open", + labels: signerLabels, + onConfirm: flow.onSignerConfirm, + onCancel: flow.onSignerCancel, + }, + }; +} diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailPaneAdapter.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailPaneAdapter.ts index 1ced9a939c37..4f5535bcd940 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailPaneAdapter.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactDetailPaneAdapter.ts @@ -15,12 +15,14 @@ import { } from "@features/flow-contacts"; import { MY_WALLET_AVATAR_USER_URL } from "LLD/features/MyWallet/components/UserAvatar/constants"; import { useContactsAddressCurrencyAdapter } from "../../hooks/useContactsAddressCurrencyAdapter"; +import { useContactDetailEditDeleteAdapter } from "./useContactDetailEditDeleteAdapter"; export function useContactDetailPaneAdapter( onAddAddress: (contact: AddAddressContact) => void, ): Readonly<{ detail: ContactDetailViewProps | undefined; addressDetailDialog: ContactAddressDetailDialogProps; + editDeleteDialogs: ReturnType; onOpenMe: ContactsListViewProps["onOpenMe"]; onOpenContact: ContactsListViewProps["onOpenContact"]; }> { @@ -28,6 +30,10 @@ export function useContactDetailPaneAdapter( const meContact = useContactsMeContact(); const currencyPort = useContactsAddressCurrencyAdapter(); const [detailContactId, setDetailContactId] = useState(meContact.id); + const onDeleteSuccess = useCallback(() => { + setDetailContactId(meContact.id); + }, [meContact.id]); + const editDeleteDialogs = useContactDetailEditDeleteAdapter(detailContactId, onDeleteSuccess); const emptyContact = useEmptyContactDetail(detailContactId); const populatedContactDetail = usePopulatedContactDetail(detailContactId, currencyPort); const { @@ -82,6 +88,7 @@ export function useContactDetailPaneAdapter( onAddAddress: () => onAddAddress(populatedContactDetail.contact), addressGroups: populatedContactDetail.addressGroups, onAddressRowPress, + detailActions: editDeleteDialogs.detailActions, }; } @@ -93,8 +100,16 @@ export function useContactDetailPaneAdapter( ...baseDetail, contact: emptyContact, onAddAddress: () => onAddAddress(emptyContact), + detailActions: editDeleteDialogs.detailActions, }; - }, [emptyContact, labels, onAddAddress, onAddressRowPress, populatedContactDetail]); + }, [ + emptyContact, + editDeleteDialogs.detailActions, + labels, + onAddAddress, + onAddressRowPress, + populatedContactDetail, + ]); const addressDetailDialog = useMemo( () => ({ isOpen, @@ -118,6 +133,7 @@ export function useContactDetailPaneAdapter( return { detail, addressDetailDialog, + editDeleteDialogs, onOpenMe: openContact, onOpenContact: openContact, }; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts index 4c8c411a5bf5..1ac260cbdfcb 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts @@ -25,6 +25,7 @@ import { useContactsFeatureIntroductionPreference } from "../../hooks/useContact import { useContactsCurrencySelectionAdapter } from "../../hooks/useContactsCurrencySelectionAdapter"; import { useContactsAddressValidationAdapter } from "../../hooks/useContactsAddressValidationAdapter"; import { useContactDetailPaneAdapter } from "./useContactDetailPaneAdapter"; +import { useContactDetailEditDeleteAdapter } from "./useContactDetailEditDeleteAdapter"; import type { ContactsAddAddressFlowDialogProps } from "./components/ContactsAddAddressFlowDialog"; export type ContactsPageViewModel = Omit & @@ -32,6 +33,7 @@ export type ContactsPageViewModel = Omit addAddressFlowState: AddAddressFlowState; addAddressFlowDialog: ContactsAddAddressFlowDialogProps; addressDetailDialog: ContactAddressDetailDialogProps; + editDeleteDialogs: ReturnType; onClearSearch: () => void; }>; @@ -122,7 +124,7 @@ export function useContactsViewModel(): ContactsPageViewModel { updateAddress, ], ); - const { detail, addressDetailDialog, onOpenMe, onOpenContact } = + const { detail, addressDetailDialog, editDeleteDialogs, onOpenMe, onOpenContact } = useContactDetailPaneAdapter(onAddAddress); const [isLedgerSyncIntroductionDismissed, setIsLedgerSyncIntroductionDismissed] = useState(false); const [ledgerSyncStatus] = useState("ready"); @@ -188,6 +190,7 @@ export function useContactsViewModel(): ContactsPageViewModel { addAddressFlowState, addAddressFlowDialog, addressDetailDialog, + editDeleteDialogs, viewModel, labels, searchQuery, diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 6800678c1634..a801f9c166ed 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -9335,6 +9335,29 @@ "delete": "Delete", "networkTag": "{{name}} Network" }, + "detailActions": { + "editContact": "Edit contact", + "deleteContact": "Delete contact" + }, + "editContact": { + "title": "Edit contact", + "namePlaceholder": "Contact name", + "namingDisclaimer": "For your privacy, avoid full names and surnames. Use a nickname or just a first name + initial, e.g. 'John S.'", + "applyChanges": "Apply changes", + "invalidNameError": "Special characters are not allowed." + }, + "deleteContact": { + "title": "Delete contact?", + "description": "Deleting this contact will erase all associated addresses.", + "confirm": "Delete", + "cancel": "Cancel" + }, + "editSigner": { + "title": "Confirm on your device", + "description": "Connect and unlock your Ledger device to confirm this change.", + "confirm": "Continue", + "cancel": "Cancel" + }, "ledgerSyncIntroduction": { "description": "Your contacts are end-to-end encrypted with your Ledger and synced across your devices, only you can unlock them.", "dismiss": "Got it" diff --git a/features/flow/contacts/src/index.ts b/features/flow/contacts/src/index.ts index 09fb04185900..f21ae6f684dd 100644 --- a/features/flow/contacts/src/index.ts +++ b/features/flow/contacts/src/index.ts @@ -2,6 +2,8 @@ export * from "./steps/List"; export * from "./steps/List/web"; export * from "./steps/AddContact"; export * from "./steps/AddContact/web"; +export * from "./steps/EditContact"; +export * from "./steps/EditContact/web"; export * from "./steps/AddAddress"; export * from "./steps/AddAddress/web"; export * from "./steps/Introduction"; diff --git a/features/flow/contacts/src/steps/AddContact/components/ContactNameInput/ContactNameInput.web.tsx b/features/flow/contacts/src/steps/AddContact/components/ContactNameInput/ContactNameInput.web.tsx index 7e1dc6ee1695..a7ff4968f996 100644 --- a/features/flow/contacts/src/steps/AddContact/components/ContactNameInput/ContactNameInput.web.tsx +++ b/features/flow/contacts/src/steps/AddContact/components/ContactNameInput/ContactNameInput.web.tsx @@ -25,6 +25,7 @@ export function ContactNameInput({ maxCount={CONTACT_NAME_MAX_LENGTH} helperText={errorMessage} status={errorMessage ? "error" : undefined} + className="mt-2" /> ); } diff --git a/features/flow/contacts/src/steps/Detail/ContactDetailView.web.tsx b/features/flow/contacts/src/steps/Detail/ContactDetailView.web.tsx index e091cea72142..3e32952f64d2 100644 --- a/features/flow/contacts/src/steps/Detail/ContactDetailView.web.tsx +++ b/features/flow/contacts/src/steps/Detail/ContactDetailView.web.tsx @@ -1,5 +1,6 @@ import React from "react"; import type { ContactDetailViewProps } from "./types"; +import { ContactDetailActions } from "./components/ContactDetailActions/ContactDetailActions.web"; import { ContactDetailAddressList } from "./components/ContactDetailAddressList/ContactDetailAddressList.web"; import { ContactDetailEmptyState } from "./components/ContactDetailEmptyState.web"; import { ContactDetailHeader } from "./components/ContactDetailHeader.web"; @@ -11,11 +12,16 @@ export function ContactDetailView({ onAddAddress, addressGroups, onAddressRowPress, + detailActions, }: ContactDetailViewProps): React.ReactNode { const hasPopulatedAddresses = addressGroups !== undefined && onAddressRowPress !== undefined; return ( -
+
+ {detailActions ? : null} void; + onDelete: () => void; +}>; + +export function ContactDetailActions({ + canDelete, + labels, + onEdit, + onDelete, +}: ContactDetailActionsProps): React.ReactNode { + return ( +
+ + {canDelete ? ( + + ) : null} +
+ ); +} diff --git a/features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/ContactsDeleteContactDialog.web.tsx b/features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/ContactsDeleteContactDialog.web.tsx new file mode 100644 index 000000000000..243a459bb273 --- /dev/null +++ b/features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/ContactsDeleteContactDialog.web.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { + Button, + Dialog, + DialogBody, + DialogContent, + DialogHeader, + Spot, +} from "@ledgerhq/lumen-ui-react"; +import { Trash } from "@ledgerhq/lumen-ui-react/symbols"; +import type { ContactsDeleteContactDialogProps } from "./types"; + +export function ContactsDeleteContactDialog({ + isOpen, + isDeleting, + labels, + onConfirm, + onCancel, +}: ContactsDeleteContactDialogProps): React.ReactNode { + const handleOpenChange = (open: boolean) => { + if (!open) { + onCancel(); + } + }; + + return ( + + + + +
+ +
+

{labels.title}

+

{labels.description}

+
+
+
+ + +
+
+
+
+ ); +} diff --git a/features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/types.ts b/features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/types.ts new file mode 100644 index 000000000000..13b61bb64b4b --- /dev/null +++ b/features/flow/contacts/src/steps/Detail/components/ContactsDeleteContactDialog/types.ts @@ -0,0 +1,19 @@ +export type ContactsDeleteContactDialogLabels = Readonly<{ + title: string; + description: string; + confirm: string; + cancel: string; +}>; + +export type ContactsDeleteContactDialogProps = Readonly<{ + isOpen: boolean; + isDeleting: boolean; + labels: ContactsDeleteContactDialogLabels; + onConfirm: () => Promise; + onCancel: () => void; +}>; + +export type ContactsDeleteContactDrawerProps = ContactsDeleteContactDialogProps & + Readonly<{ + bottomInset?: number; + }>; diff --git a/features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/ContactsEditSignerDialog.web.tsx b/features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/ContactsEditSignerDialog.web.tsx new file mode 100644 index 000000000000..ca48126a1fa1 --- /dev/null +++ b/features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/ContactsEditSignerDialog.web.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { + Button, + Dialog, + DialogBody, + DialogContent, + DialogHeader, + Spot, +} from "@ledgerhq/lumen-ui-react"; +import { ShieldCheck } from "@ledgerhq/lumen-ui-react/symbols"; +import type { ContactsEditSignerDialogProps } from "./types"; + +export function ContactsEditSignerDialog({ + isOpen, + labels, + onConfirm, + onCancel, +}: ContactsEditSignerDialogProps): React.ReactNode { + const handleOpenChange = (open: boolean) => { + if (!open) { + onCancel(); + } + }; + + return ( + + + + +
+ +
+

{labels.title}

+

{labels.description}

+
+
+
+ + +
+
+
+
+ ); +} diff --git a/features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/types.ts b/features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/types.ts new file mode 100644 index 000000000000..2a1fb6630816 --- /dev/null +++ b/features/flow/contacts/src/steps/Detail/components/ContactsEditSignerDialog/types.ts @@ -0,0 +1,18 @@ +export type ContactsEditSignerDialogLabels = Readonly<{ + title: string; + description: string; + confirm: string; + cancel: string; +}>; + +export type ContactsEditSignerDialogProps = Readonly<{ + isOpen: boolean; + labels: ContactsEditSignerDialogLabels; + onConfirm: () => void; + onCancel: () => void; +}>; + +export type ContactsEditSignerDrawerProps = ContactsEditSignerDialogProps & + Readonly<{ + bottomInset?: number; + }>; diff --git a/features/flow/contacts/src/steps/Detail/index.ts b/features/flow/contacts/src/steps/Detail/index.ts index 43b3a5010479..d3c7511b564a 100644 --- a/features/flow/contacts/src/steps/Detail/index.ts +++ b/features/flow/contacts/src/steps/Detail/index.ts @@ -2,6 +2,12 @@ export { useEmptyContactDetail } from "./useEmptyContactDetail"; export { usePopulatedContactDetail } from "./usePopulatedContactDetail"; export { useContactAddressDetailDialog } from "./useContactAddressDetailDialog"; export { useContactDetailActionsViewModel } from "./useContactDetailActionsViewModel"; +export { useContactDetailEditDeleteFlowViewModel } from "./useContactDetailEditDeleteFlowViewModel"; +export type { + ContactDetailEditUiState, + UseContactDetailEditDeleteFlowViewModelOptions, + UseContactDetailEditDeleteFlowViewModelResult, +} from "./useContactDetailEditDeleteFlowViewModel"; export type { UseContactDetailActionsViewModelResult } from "./useContactDetailActionsViewModel"; export { useContactAddressDetail } from "./useContactAddressDetail"; export { useContactAddressDetailActionsViewModel } from "./useContactAddressDetailActionsViewModel"; @@ -67,6 +73,7 @@ export type { ContactDetailEditIntent, ContactDetailLabels, ContactDetailViewProps, + ContactDetailActionsLabels, PopulatedContactDetailViewModel, } from "./types"; export type { diff --git a/features/flow/contacts/src/steps/Detail/types.ts b/features/flow/contacts/src/steps/Detail/types.ts index 70c8fcf59147..9835fd4f1163 100644 --- a/features/flow/contacts/src/steps/Detail/types.ts +++ b/features/flow/contacts/src/steps/Detail/types.ts @@ -68,6 +68,11 @@ export type ContactDetailLabels = Readonly<{ formatAddressCount: (count: number) => string; }>; +export type ContactDetailActionsLabels = Readonly<{ + editContact: string; + deleteContact: string; +}>; + export type ContactDetailViewProps = Readonly<{ contact: Contact; labels: ContactDetailLabels; @@ -76,6 +81,12 @@ export type ContactDetailViewProps = Readonly<{ onOpenLedgerWalletAddresses?: () => void; addressGroups?: readonly ContactDetailAddressNetworkGroup[]; onAddressRowPress?: (intent: ContactDetailAddressRowIntent) => void; + detailActions?: Readonly<{ + canDelete: boolean; + labels: ContactDetailActionsLabels; + onEdit: () => void; + onDelete: () => void; + }>; }>; export type ContactAddressDetailAsset = Readonly<{ diff --git a/features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.ts b/features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.ts new file mode 100644 index 000000000000..c58c4af14359 --- /dev/null +++ b/features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.ts @@ -0,0 +1,132 @@ +import { selectContactById, type ContactId } from "@domain/entity-contact"; +import { useCallback, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import type { ContactDetailActionsPorts } from "./model/ports"; +import { useContactDetailActionsViewModel } from "./useContactDetailActionsViewModel"; + +export type ContactDetailEditUiState = "closed" | "signer-open" | "edit-open"; + +export type UseContactDetailEditDeleteFlowViewModelOptions = Readonly<{ + contactId: ContactId; + ports: ContactDetailActionsPorts; + onDeleteSuccess?: () => void; +}>; + +type ContactsStateRoot = Parameters[0]; + +export type UseContactDetailEditDeleteFlowViewModelResult = ReturnType< + typeof useContactDetailActionsViewModel +> & + Readonly<{ + canDelete: boolean; + contactName: string; + editUiState: ContactDetailEditUiState; + isActionsMenuOpen: boolean; + isDeleting: boolean; + onEditPress: () => void; + onDeletePress: () => void; + onSignerConfirm: () => void; + onSignerCancel: () => void; + onEditClose: () => void; + onOpenActionsMenu: () => void; + onCloseActionsMenu: () => void; + }>; + +export function useContactDetailEditDeleteFlowViewModel({ + contactId, + ports, + onDeleteSuccess, +}: UseContactDetailEditDeleteFlowViewModelOptions): UseContactDetailEditDeleteFlowViewModelResult { + const contact = useSelector((state: ContactsStateRoot) => selectContactById(state, contactId)); + const { + cancelDelete, + confirmDelete: confirmDeleteAction, + deleteLifecycle, + openDelete, + editIntent, + deleteIntent, + isSignerRequiredForEdit, + } = useContactDetailActionsViewModel(contactId, ports); + const [editUiState, setEditUiState] = useState("closed"); + const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const canDelete = contact !== undefined && !contact.isMe; + const contactName = contact?.name ?? ""; + + const onEditPress = useCallback(() => { + setIsActionsMenuOpen(false); + + if (isSignerRequiredForEdit) { + setEditUiState("signer-open"); + return; + } + + setEditUiState("edit-open"); + }, [isSignerRequiredForEdit]); + + const onDeletePress = useCallback(() => { + setIsActionsMenuOpen(false); + openDelete(); + }, [openDelete]); + + const onSignerConfirm = useCallback(() => { + setEditUiState("edit-open"); + }, []); + + const onSignerCancel = useCallback(() => { + setEditUiState("closed"); + }, []); + + const onEditClose = useCallback(() => { + setEditUiState("closed"); + }, []); + + const onOpenActionsMenu = useCallback(() => { + setIsActionsMenuOpen(true); + }, []); + + const onCloseActionsMenu = useCallback(() => { + setIsActionsMenuOpen(false); + }, []); + + const confirmDelete = useCallback(async () => { + setIsDeleting(true); + + try { + await confirmDeleteAction(); + } finally { + setIsDeleting(false); + } + }, [confirmDeleteAction]); + + useEffect(() => { + if (deleteLifecycle.status !== "success") { + return; + } + + onDeleteSuccess?.(); + cancelDelete(); + }, [cancelDelete, deleteLifecycle.status, onDeleteSuccess]); + + return { + editIntent, + deleteIntent, + deleteLifecycle, + isSignerRequiredForEdit, + openDelete, + cancelDelete, + confirmDelete, + canDelete, + contactName, + editUiState, + isActionsMenuOpen, + isDeleting, + onEditPress, + onDeletePress, + onSignerConfirm, + onSignerCancel, + onEditClose, + onOpenActionsMenu, + onCloseActionsMenu, + }; +} diff --git a/features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.web.test.tsx b/features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.web.test.tsx new file mode 100644 index 000000000000..f4c5fde3e0f1 --- /dev/null +++ b/features/flow/contacts/src/steps/Detail/useContactDetailEditDeleteFlowViewModel.web.test.tsx @@ -0,0 +1,130 @@ +import { createElement, type ReactNode } from "react"; +import { configureStore } from "@reduxjs/toolkit"; +import { act, renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { contactsSlice } from "@domain/entity-contact"; +import { + mockContact, + mockContactWithAddress, + mockMeContact, +} from "@domain/entity-contact/schema.mock"; +import type { ContactDetailActionsPorts } from "./model/ports"; +import { useContactDetailEditDeleteFlowViewModel } from "./useContactDetailEditDeleteFlowViewModel"; + +function createPorts( + overrides: Partial = {}, +): ContactDetailActionsPorts { + return { + edit: { + renameContact: jest.fn(), + }, + deletion: { + deleteContact: jest.fn().mockResolvedValue(undefined), + }, + ...overrides, + }; +} + +function makeWrapper(contacts: ReturnType["contacts"]) { + const store = configureStore({ + reducer: { contacts: contactsSlice.reducer }, + preloadedState: { contacts: { contacts } }, + }); + + return function Wrapper({ children }: { readonly children: ReactNode }) { + return createElement(Provider, { store, children }); + }; +} + +describe("useContactDetailEditDeleteFlowViewModel", () => { + it("should open the edit dialog directly when no signer is required", () => { + const contact = mockContact(); + const Wrapper = makeWrapper([mockMeContact(), contact]); + const { result } = renderHook( + () => + useContactDetailEditDeleteFlowViewModel({ + contactId: contact.id, + ports: createPorts(), + }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current.onEditPress(); + }); + + expect(result.current.editUiState).toBe("edit-open"); + expect(result.current.isActionsMenuOpen).toBe(false); + }); + + it("should open the signer dialog when editing a contact with addresses", () => { + const contact = mockContactWithAddress(); + const Wrapper = makeWrapper([mockMeContact(), contact]); + const { result } = renderHook( + () => + useContactDetailEditDeleteFlowViewModel({ + contactId: contact.id, + ports: createPorts(), + }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current.onEditPress(); + }); + + expect(result.current.editUiState).toBe("signer-open"); + + act(() => { + result.current.onSignerConfirm(); + }); + + expect(result.current.editUiState).toBe("edit-open"); + }); + + it("should prevent deleting the Me contact", () => { + const meContact = mockMeContact(); + const Wrapper = makeWrapper([meContact]); + const { result } = renderHook( + () => + useContactDetailEditDeleteFlowViewModel({ + contactId: meContact.id, + ports: createPorts(), + }), + { wrapper: Wrapper }, + ); + + expect(result.current.canDelete).toBe(false); + }); + + it("should open delete confirmation and notify success after delete", async () => { + const contact = mockContact(); + const onDeleteSuccess = jest.fn(); + const deleteContact = jest.fn().mockResolvedValue(undefined); + const Wrapper = makeWrapper([mockMeContact(), contact]); + const { result } = renderHook( + () => + useContactDetailEditDeleteFlowViewModel({ + contactId: contact.id, + ports: createPorts({ deletion: { deleteContact } }), + onDeleteSuccess, + }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current.onDeletePress(); + }); + + expect(result.current.deleteLifecycle).toEqual({ status: "open", contactId: contact.id }); + + await act(async () => { + await result.current.confirmDelete(); + }); + + expect(deleteContact).toHaveBeenCalledWith(contact.id); + expect(onDeleteSuccess).toHaveBeenCalledTimes(1); + expect(result.current.deleteLifecycle).toEqual({ status: "idle" }); + expect(result.current.isDeleting).toBe(false); + }); +}); diff --git a/features/flow/contacts/src/steps/Detail/web.ts b/features/flow/contacts/src/steps/Detail/web.ts index 283365a106fc..0b5f55386e8b 100644 --- a/features/flow/contacts/src/steps/Detail/web.ts +++ b/features/flow/contacts/src/steps/Detail/web.ts @@ -1,2 +1,8 @@ export { ContactDetailView } from "./ContactDetailView.web"; export { ContactAddressDetailDialog } from "./components/ContactAddressDetailDialog/ContactAddressDetailDialog.web"; +export { ContactDetailActions } from "./components/ContactDetailActions/ContactDetailActions.web"; +export type { ContactDetailActionsProps } from "./components/ContactDetailActions/ContactDetailActions.web"; +export { ContactsDeleteContactDialog } from "./components/ContactsDeleteContactDialog/ContactsDeleteContactDialog.web"; +export { ContactsEditSignerDialog } from "./components/ContactsEditSignerDialog/ContactsEditSignerDialog.web"; +export type { ContactsDeleteContactDialogProps } from "./components/ContactsDeleteContactDialog/types"; +export type { ContactsEditSignerDialogProps } from "./components/ContactsEditSignerDialog/types"; diff --git a/features/flow/contacts/src/steps/EditContact/ContactsRenameContactDialog.web.tsx b/features/flow/contacts/src/steps/EditContact/ContactsRenameContactDialog.web.tsx new file mode 100644 index 000000000000..8e7ffbd0b21b --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/ContactsRenameContactDialog.web.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Button, + Dialog, + DialogBody, + DialogContent, + DialogHeader, +} from "@ledgerhq/lumen-ui-react"; +import { ContactNameInput } from "../AddContact/components/ContactNameInput/ContactNameInput.web"; +import { ContactsAddContactNamingDisclaimer } from "../AddContact/ContactsAddContactNamingDisclaimer.web"; +import type { ContactsRenameContactDialogProps } from "./types"; + +const NAMING_DISCLAIMER_ID = "contacts-rename-contact-naming-disclaimer"; + +export function ContactsRenameContactDialog({ + isOpen, + isConfirmEnabled, + isSaving, + draftName, + invalidNameError, + labels, + onClose, + onDraftNameChange, + onConfirm, +}: ContactsRenameContactDialogProps): React.ReactNode { + const nameValidationError = + invalidNameError === null + ? undefined + : labels.nameValidationErrors[invalidNameError]; + + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose(); + } + }; + + return ( + + + + +
+ + +
+ +
+
+
+ ); +} diff --git a/features/flow/contacts/src/steps/EditContact/index.ts b/features/flow/contacts/src/steps/EditContact/index.ts new file mode 100644 index 000000000000..31444a3046fa --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/index.ts @@ -0,0 +1,14 @@ +export { useRenameContactViewModel } from "./useRenameContactViewModel"; +export type { UseRenameContactViewModelResult } from "./useRenameContactViewModel"; +export { useRenameContactDialogViewModel } from "./useRenameContactDialogViewModel"; +export type { UseRenameContactDialogViewModelOptions } from "./useRenameContactDialogViewModel"; +export { createRenameContactViewModel } from "./model/viewModel"; +export { createRenameContactController } from "./model/controller"; +export type { RenameContactController } from "./model/controller"; +export type { + ContactsRenameContactDialogProps, + ContactsRenameContactDrawerProps, + ContactsRenameContactLabels, + RenameContactDialogViewModel, + RenameContactViewModel, +} from "./types"; diff --git a/features/flow/contacts/src/steps/EditContact/model/controller.ts b/features/flow/contacts/src/steps/EditContact/model/controller.ts new file mode 100644 index 000000000000..36d1845f6d0a --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/model/controller.ts @@ -0,0 +1,20 @@ +import { parseContactName, type Contact } from "@domain/entity-contact"; +import type { ContactEditPort } from "../../Detail/model/ports"; +import { createRenameContactViewModel } from "./viewModel"; +import type { RenameContactViewModel } from "../types"; + +export type RenameContactController = Readonly<{ + getViewModel: (draftName: string, currentName: string) => RenameContactViewModel; + save: (contactId: Contact["id"], draftName: string) => Promise; +}>; + +export function createRenameContactController(editPort: ContactEditPort): RenameContactController { + return { + getViewModel: createRenameContactViewModel, + save: async (contactId, draftName) => { + const name = parseContactName(draftName); + + return editPort.renameContact({ contactId, name }); + }, + }; +} diff --git a/features/flow/contacts/src/steps/EditContact/model/viewModel.ts b/features/flow/contacts/src/steps/EditContact/model/viewModel.ts new file mode 100644 index 000000000000..60633a39bf3b --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/model/viewModel.ts @@ -0,0 +1,17 @@ +import { getContactNameValidationError } from "@domain/entity-contact"; +import type { RenameContactViewModel } from "../types"; + +export function createRenameContactViewModel( + draftName: string, + currentName: string, +): RenameContactViewModel { + const trimmedDraftName = draftName.trim(); + const invalidNameError = getContactNameValidationError(draftName); + const hasChanged = trimmedDraftName !== currentName.trim(); + + return { + draftName, + invalidNameError, + isConfirmEnabled: hasChanged && trimmedDraftName.length > 0 && invalidNameError === null, + }; +} diff --git a/features/flow/contacts/src/steps/EditContact/model/viewModel.web.test.ts b/features/flow/contacts/src/steps/EditContact/model/viewModel.web.test.ts new file mode 100644 index 000000000000..1968c04a2bfc --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/model/viewModel.web.test.ts @@ -0,0 +1,26 @@ +import { ContactNameSchema } from "@domain/entity-contact"; +import { createRenameContactViewModel } from "./viewModel"; + +describe("createRenameContactViewModel", () => { + it("disables confirm when the draft name matches the current name", () => { + expect(createRenameContactViewModel("Ada", "Ada").isConfirmEnabled).toBe(false); + }); + + it("enables confirm when the draft name changed and is valid", () => { + expect(createRenameContactViewModel("Ben", "Ada").isConfirmEnabled).toBe(true); + }); + + it("disables confirm for invalid names", () => { + expect(createRenameContactViewModel("Ada!", "Ben").isConfirmEnabled).toBe(false); + }); + + it("accepts trimmed valid renames", () => { + const result = createRenameContactViewModel( + ContactNameSchema.parse("Charlie"), + ContactNameSchema.parse("Ada"), + ); + + expect(result.isConfirmEnabled).toBe(true); + expect(result.invalidNameError).toBeNull(); + }); +}); diff --git a/features/flow/contacts/src/steps/EditContact/types.ts b/features/flow/contacts/src/steps/EditContact/types.ts new file mode 100644 index 000000000000..5cc4edd9b0de --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/types.ts @@ -0,0 +1,45 @@ +import type { ContactId, ContactNameValidationErrorName } from "@domain/entity-contact"; +import type { ContactEditPort } from "../Detail/model/ports"; + +export type RenameContactViewModel = Readonly<{ + draftName: string; + invalidNameError: ContactNameValidationErrorName | null; + isConfirmEnabled: boolean; +}>; + +export type RenameContactDialogViewModel = RenameContactViewModel & + Readonly<{ + isOpen: boolean; + isSaving: boolean; + onOpen: () => void; + onClose: () => void; + onDraftNameChange: (name: string) => void; + onConfirm: () => Promise; + }>; + +export type UseRenameContactDialogViewModelOptions = Readonly<{ + contactId: ContactId; + currentName: string; + editPort: ContactEditPort; + onSaveSuccess: () => void; +}>; + +export type ContactsRenameContactLabels = Readonly<{ + title: string; + namePlaceholder: string; + namingDisclaimer: string; + applyChanges: string; + nameValidationErrors: Readonly>; +}>; + +export type ContactsRenameContactDialogProps = RenameContactDialogViewModel & + Readonly<{ + labels: ContactsRenameContactLabels; + }>; + +export type ContactsRenameContactDrawerProps = RenameContactDialogViewModel & + Readonly<{ + bottomInset?: number; + keyboardInset?: number; + labels: ContactsRenameContactLabels; + }>; diff --git a/features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.ts b/features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.ts new file mode 100644 index 000000000000..0ea72c11c82d --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from "react"; +import { CONTACT_NAME_MAX_LENGTH } from "../AddContact/model/constants"; +import { useRenameContactViewModel } from "./useRenameContactViewModel"; +import type { RenameContactDialogViewModel, UseRenameContactDialogViewModelOptions } from "./types"; + +export function useRenameContactDialogViewModel({ + contactId, + currentName, + editPort, + isRequestedOpen, + onCloseRequest, + onSaveSuccess, +}: UseRenameContactDialogViewModelOptions & Readonly<{ + isRequestedOpen: boolean; + onCloseRequest: () => void; +}>): RenameContactDialogViewModel { + const [draftName, setDraftName] = useState(currentName); + const [isSaving, setIsSaving] = useState(false); + const { invalidNameError, isConfirmEnabled, save } = useRenameContactViewModel( + contactId, + currentName, + draftName, + editPort, + ); + + useEffect(() => { + if (isRequestedOpen) { + setDraftName(currentName); + } + }, [currentName, isRequestedOpen]); + + const onClose = useCallback(() => { + onCloseRequest(); + setDraftName(currentName); + }, [currentName, onCloseRequest]); + + const onDraftNameChange = useCallback( + (name: string) => setDraftName(name.slice(0, CONTACT_NAME_MAX_LENGTH)), + [], + ); + + const onConfirm = useCallback(async () => { + if (!isConfirmEnabled || isSaving) { + return; + } + + setIsSaving(true); + + try { + await save(); + onSaveSuccess(); + onCloseRequest(); + } catch { + return; + } finally { + setIsSaving(false); + } + }, [isConfirmEnabled, isSaving, onCloseRequest, onSaveSuccess, save]); + + return { + isOpen: isRequestedOpen, + isSaving, + draftName, + invalidNameError, + isConfirmEnabled: isConfirmEnabled && !isSaving, + onOpen: () => undefined, + onClose, + onDraftNameChange, + onConfirm, + }; +} + +export type { UseRenameContactDialogViewModelOptions }; diff --git a/features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.web.test.ts b/features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.web.test.ts new file mode 100644 index 000000000000..23083332be75 --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/useRenameContactDialogViewModel.web.test.ts @@ -0,0 +1,107 @@ +import { act, renderHook } from "@testing-library/react"; +import { contact } from "@domain/entity-contact"; +import { mockContact } from "@domain/entity-contact/schema.mock"; +import type { ContactEditPort } from "../Detail/model/ports"; +import { useRenameContactDialogViewModel } from "./useRenameContactDialogViewModel"; + +describe("useRenameContactDialogViewModel", () => { + const savedContact = mockContact(); + + function createEditPort(overrides: Partial = {}): ContactEditPort { + return { + renameContact: jest.fn().mockResolvedValue( + contact({ + id: savedContact.id, + isMe: false, + name: "Ben", + addresses: [], + }), + ), + ...overrides, + }; + } + + it("should reset the draft when the dialog opens", () => { + const onCloseRequest = jest.fn(); + const onSaveSuccess = jest.fn(); + const { result, rerender } = renderHook( + ({ isRequestedOpen }) => + useRenameContactDialogViewModel({ + contactId: savedContact.id, + currentName: "Ada", + editPort: createEditPort(), + isRequestedOpen, + onCloseRequest, + onSaveSuccess, + }), + { initialProps: { isRequestedOpen: false } }, + ); + + act(() => { + result.current.onDraftNameChange("Changed"); + }); + expect(result.current.draftName).toBe("Changed"); + + rerender({ isRequestedOpen: true }); + expect(result.current.draftName).toBe("Ada"); + }); + + it("should save, notify success, and close when confirm succeeds", async () => { + const onCloseRequest = jest.fn(); + const onSaveSuccess = jest.fn(); + const renameContact = jest.fn().mockResolvedValue( + contact({ + id: savedContact.id, + isMe: false, + name: "Ben", + addresses: [], + }), + ); + const { result } = renderHook(() => + useRenameContactDialogViewModel({ + contactId: savedContact.id, + currentName: "Ada", + editPort: createEditPort({ renameContact }), + isRequestedOpen: true, + onCloseRequest, + onSaveSuccess, + }), + ); + + act(() => { + result.current.onDraftNameChange("Ben"); + }); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(renameContact).toHaveBeenCalledWith({ contactId: savedContact.id, name: "Ben" }); + expect(onSaveSuccess).toHaveBeenCalledTimes(1); + expect(onCloseRequest).toHaveBeenCalledTimes(1); + expect(result.current.isSaving).toBe(false); + }); + + it("should reset the draft and close when cancel is requested", () => { + const onCloseRequest = jest.fn(); + const onSaveSuccess = jest.fn(); + const { result } = renderHook(() => + useRenameContactDialogViewModel({ + contactId: savedContact.id, + currentName: "Ada", + editPort: createEditPort(), + isRequestedOpen: true, + onCloseRequest, + onSaveSuccess, + }), + ); + + act(() => { + result.current.onDraftNameChange("Ben"); + result.current.onClose(); + }); + + expect(onCloseRequest).toHaveBeenCalledTimes(1); + expect(result.current.draftName).toBe("Ada"); + }); +}); diff --git a/features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.ts b/features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.ts new file mode 100644 index 000000000000..80b5eadab715 --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.ts @@ -0,0 +1,32 @@ +import type { ContactId } from "@domain/entity-contact"; +import { useCallback, useMemo } from "react"; +import { createRenameContactController } from "./model/controller"; +import type { ContactEditPort } from "../Detail/model/ports"; +import type { RenameContactViewModel } from "./types"; + +export type UseRenameContactViewModelResult = RenameContactViewModel & + Readonly<{ + save: () => Promise; + }>; + +export function useRenameContactViewModel( + contactId: ContactId, + currentName: string, + draftName: string, + editPort: ContactEditPort, +): UseRenameContactViewModelResult { + const controller = useMemo(() => createRenameContactController(editPort), [editPort]); + const viewModel = useMemo( + () => controller.getViewModel(draftName, currentName), + [controller, currentName, draftName], + ); + const save = useCallback( + () => controller.save(contactId, draftName), + [contactId, controller, draftName], + ); + + return { + ...viewModel, + save, + }; +} diff --git a/features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.web.test.ts b/features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.web.test.ts new file mode 100644 index 000000000000..b1cb41f510fe --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/useRenameContactViewModel.web.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook } from "@testing-library/react"; +import { contact } from "@domain/entity-contact"; +import { mockContact } from "@domain/entity-contact/schema.mock"; +import type { ContactEditPort } from "../Detail/model/ports"; +import { useRenameContactViewModel } from "./useRenameContactViewModel"; + +describe("useRenameContactViewModel", () => { + it("should disable confirm when the draft name matches the current name", () => { + const savedContact = mockContact(); + const editPort: ContactEditPort = { + renameContact: jest.fn(), + }; + const { result } = renderHook(() => + useRenameContactViewModel(savedContact.id, "Ada", "Ada", editPort), + ); + + expect(result.current.isConfirmEnabled).toBe(false); + expect(result.current.invalidNameError).toBeNull(); + }); + + it("should enable confirm and save through the injected edit port", async () => { + const savedContact = mockContact(); + const renamedContact = contact({ + id: savedContact.id, + isMe: false, + name: "Ben", + addresses: [], + }); + const renameContact = jest.fn().mockResolvedValue(renamedContact); + const { result } = renderHook(() => + useRenameContactViewModel(savedContact.id, "Ada", "Ben", { renameContact }), + ); + + expect(result.current.isConfirmEnabled).toBe(true); + + let savedContactResult: Awaited> | undefined; + await act(async () => { + savedContactResult = await result.current.save(); + }); + + expect(renameContact).toHaveBeenCalledWith({ contactId: savedContact.id, name: "Ben" }); + expect(savedContactResult).toEqual(renamedContact); + }); +}); diff --git a/features/flow/contacts/src/steps/EditContact/web.ts b/features/flow/contacts/src/steps/EditContact/web.ts new file mode 100644 index 000000000000..12f867784916 --- /dev/null +++ b/features/flow/contacts/src/steps/EditContact/web.ts @@ -0,0 +1 @@ +export { ContactsRenameContactDialog } from "./ContactsRenameContactDialog.web";