From c8cbbf56b4caa4aefada3828fc47ae04e24d9f98 Mon Sep 17 00:00:00 2001 From: OKendigelyan Date: Mon, 19 Aug 2024 14:41:56 +0100 Subject: [PATCH] Add address book menu (#1743) --- .../AdvancedSettingsAccordion.test.tsx | 6 +- .../components/UpsertContactModal.test.tsx | 24 +- .../AccountSelectorPopover.tsx | 2 +- .../ActionsDropdown/ActionsDropdown.tsx | 10 +- .../AdvancedSettingsAccordion.test.tsx | 6 +- .../CopyAddressButton/CopyAddressButton.tsx | 4 +- .../src/components/Menu/AddressBookMenu.tsx | 14 - .../Menu/AddressBookMenu/AddressBookMenu.tsx | 127 ++++++ .../AddressBookMenu/DeleteContactModal.tsx | 66 ++++ .../AddressBookMenu/EditContactMenu.test.tsx | 372 ++++++++++++++++++ .../Menu/AddressBookMenu/EditContactMenu.tsx | 121 ++++++ .../{ => AdvancedMenu}/AdvancedMenu.test.tsx | 8 +- .../Menu/{ => AdvancedMenu}/AdvancedMenu.tsx | 10 +- .../Menu/{ => AppsMenu}/AppsMenu.test.tsx | 2 +- .../Menu/{ => AppsMenu}/AppsMenu.tsx | 4 +- .../ChangePasswordMenu.test.tsx | 2 +- .../ChangePasswordMenu.tsx | 4 +- .../ErrorLogsMenu.test.tsx | 2 +- .../{ => ErrorLogsMenu}/ErrorLogsMenu.tsx | 6 +- apps/web/src/components/Menu/Menu.test.tsx | 6 +- apps/web/src/components/Menu/Menu.tsx | 6 +- .../EditNetworkMenu.test.tsx | 2 +- .../{ => NetworkMenu}/EditNetworkMenu.tsx | 2 +- .../{ => NetworkMenu}/NetworkMenu.test.tsx | 2 +- .../Menu/{ => NetworkMenu}/NetworkMenu.tsx | 8 +- .../web/src/components/beacon/BeaconPeers.tsx | 10 +- apps/web/src/styles/theme.ts | 5 - 27 files changed, 752 insertions(+), 79 deletions(-) delete mode 100644 apps/web/src/components/Menu/AddressBookMenu.tsx create mode 100644 apps/web/src/components/Menu/AddressBookMenu/AddressBookMenu.tsx create mode 100644 apps/web/src/components/Menu/AddressBookMenu/DeleteContactModal.tsx create mode 100644 apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.test.tsx create mode 100644 apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.tsx rename apps/web/src/components/Menu/{ => AdvancedMenu}/AdvancedMenu.test.tsx (77%) rename apps/web/src/components/Menu/{ => AdvancedMenu}/AdvancedMenu.tsx (68%) rename apps/web/src/components/Menu/{ => AppsMenu}/AppsMenu.test.tsx (94%) rename apps/web/src/components/Menu/{ => AppsMenu}/AppsMenu.tsx (85%) rename apps/web/src/components/Menu/{ => ChangePasswordMenu}/ChangePasswordMenu.test.tsx (99%) rename apps/web/src/components/Menu/{ => ChangePasswordMenu}/ChangePasswordMenu.tsx (95%) rename apps/web/src/components/Menu/{ => ErrorLogsMenu}/ErrorLogsMenu.test.tsx (96%) rename apps/web/src/components/Menu/{ => ErrorLogsMenu}/ErrorLogsMenu.tsx (92%) rename apps/web/src/components/Menu/{ => NetworkMenu}/EditNetworkMenu.test.tsx (99%) rename apps/web/src/components/Menu/{ => NetworkMenu}/EditNetworkMenu.tsx (98%) rename apps/web/src/components/Menu/{ => NetworkMenu}/NetworkMenu.test.tsx (99%) rename apps/web/src/components/Menu/{ => NetworkMenu}/NetworkMenu.tsx (92%) diff --git a/apps/desktop/src/components/AdvancedSettingsAccordion.test.tsx b/apps/desktop/src/components/AdvancedSettingsAccordion.test.tsx index 3508b22ca0..f2cac22346 100644 --- a/apps/desktop/src/components/AdvancedSettingsAccordion.test.tsx +++ b/apps/desktop/src/components/AdvancedSettingsAccordion.test.tsx @@ -37,7 +37,7 @@ describe("", () => { }); it("updates fee value on change", async () => { - const user = userEvent; + const user = userEvent.setup(); render(); await act(() => user.click(screen.getByRole("button", { name: "Advanced" }))); @@ -58,7 +58,7 @@ describe("", () => { }); it("updates gas limit value on change", async () => { - const user = userEvent; + const user = userEvent.setup(); render(); await act(() => user.click(screen.getByRole("button", { name: "Advanced" }))); @@ -70,7 +70,7 @@ describe("", () => { }); it("updates storage limit value on change", async () => { - const user = userEvent; + const user = userEvent.setup(); render(); await act(() => user.click(screen.getByRole("button", { name: "Advanced" }))); diff --git a/apps/desktop/src/components/UpsertContactModal.test.tsx b/apps/desktop/src/components/UpsertContactModal.test.tsx index 644a939bd1..0d716a3544 100644 --- a/apps/desktop/src/components/UpsertContactModal.test.tsx +++ b/apps/desktop/src/components/UpsertContactModal.test.tsx @@ -53,7 +53,7 @@ describe("", () => { }); it("validates updated address", async () => { - const user = userEvent; + const user = userEvent.setup(); render(modalComponent, { store }); const addressInput = screen.getByLabelText("Address"); @@ -68,7 +68,7 @@ describe("", () => { }); it("checks the name is unique", async () => { - const user = userEvent; + const user = userEvent.setup(); store.dispatch(contactsActions.upsert(contact2)); render(modalComponent, { store }); @@ -86,7 +86,7 @@ describe("", () => { }); it("adds contact to address book", async () => { - const user = userEvent; + const user = userEvent.setup(); store.dispatch(contactsActions.upsert(contact2)); render(modalComponent, { store }); @@ -116,7 +116,7 @@ describe("", () => { jest .mocked(getNetworksForContracts) .mockResolvedValue(new Map([[contractPkh, "ghostnet"]])); - const user = userEvent; + const user = userEvent.setup(); render(modalComponent, { store }); // Set name @@ -143,7 +143,7 @@ describe("", () => { it("shows error toast on unknown network for contract addresses", async () => { jest.mocked(getNetworksForContracts).mockResolvedValue(new Map()); - const user = userEvent; + const user = userEvent.setup(); render(modalComponent, { store }); // Set name @@ -182,7 +182,7 @@ describe("", () => { }); it("validates initial address field", async () => { - const user = userEvent; + const user = userEvent.setup(); render( ", () => { }); it("adds contact to address book with pre-filled address", async () => { - const user = userEvent; + const user = userEvent.setup(); store.dispatch(contactsActions.upsert(contact2)); render( ", () => { jest .mocked(getNetworksForContracts) .mockResolvedValue(new Map([[contractPkh, "ghostnet"]])); - const user = userEvent; + const user = userEvent.setup(); render( ", () => { it("shows error toast on unknown network for contract addresses", async () => { jest.mocked(getNetworksForContracts).mockResolvedValue(new Map()); - const user = userEvent; + const user = userEvent.setup(); render( ", () => { }); it("checks the name was updated", async () => { - const user = userEvent; + const user = userEvent.setup(); render(, { store }); await act(() => user.click(screen.getByLabelText("Name"))); @@ -329,7 +329,7 @@ describe("", () => { }); it("checks the name is unique", async () => { - const user = userEvent; + const user = userEvent.setup(); store.dispatch(contactsActions.upsert(contact2)); render(, { store }); @@ -347,7 +347,7 @@ describe("", () => { }); it("updates contact in address book", async () => { - const user = userEvent; + const user = userEvent.setup(); store.dispatch(contactsActions.upsert(contact2)); render(, { store }); diff --git a/apps/web/src/components/AccountSelectorModal/AccountSelectorPopover.tsx b/apps/web/src/components/AccountSelectorModal/AccountSelectorPopover.tsx index 82d13fa462..4664120565 100644 --- a/apps/web/src/components/AccountSelectorModal/AccountSelectorPopover.tsx +++ b/apps/web/src/components/AccountSelectorModal/AccountSelectorPopover.tsx @@ -82,7 +82,7 @@ export const AccountSelectorPopover = ({ account }: AccountSelectorPopoverProps) variant="dropdownOption" > - + View in TzKT diff --git a/apps/web/src/components/ActionsDropdown/ActionsDropdown.tsx b/apps/web/src/components/ActionsDropdown/ActionsDropdown.tsx index c138b733b3..0d237806ba 100644 --- a/apps/web/src/components/ActionsDropdown/ActionsDropdown.tsx +++ b/apps/web/src/components/ActionsDropdown/ActionsDropdown.tsx @@ -1,4 +1,4 @@ -import { Popover, PopoverBody, PopoverContent, PopoverTrigger, Portal } from "@chakra-ui/react"; +import { Popover, PopoverBody, PopoverContent, PopoverTrigger } from "@chakra-ui/react"; import { type PropsWithChildren, type ReactNode } from "react"; import { useColor } from "../../styles/useColor"; @@ -13,11 +13,9 @@ export const ActionsDropdown = ({ actions, children }: PropsWithChildren {children} - - - {actions} - - + + {actions} + ); }; diff --git a/apps/web/src/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.test.tsx b/apps/web/src/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.test.tsx index 47475238d4..410e712cf5 100644 --- a/apps/web/src/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.test.tsx +++ b/apps/web/src/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.test.tsx @@ -37,7 +37,7 @@ describe("", () => { }); it("updates fee value on change", async () => { - const user = userEvent; + const user = userEvent.setup(); render(); await act(() => user.click(screen.getByRole("button", { name: "Advanced" }))); @@ -58,7 +58,7 @@ describe("", () => { }); it("updates gas limit value on change", async () => { - const user = userEvent; + const user = userEvent.setup(); render(); await act(() => user.click(screen.getByRole("button", { name: "Advanced" }))); @@ -70,7 +70,7 @@ describe("", () => { }); it("updates storage limit value on change", async () => { - const user = userEvent; + const user = userEvent.setup(); render(); await act(() => user.click(screen.getByRole("button", { name: "Advanced" }))); diff --git a/apps/web/src/components/CopyAddressButton/CopyAddressButton.tsx b/apps/web/src/components/CopyAddressButton/CopyAddressButton.tsx index d1dad27746..bebee4c23c 100644 --- a/apps/web/src/components/CopyAddressButton/CopyAddressButton.tsx +++ b/apps/web/src/components/CopyAddressButton/CopyAddressButton.tsx @@ -12,9 +12,11 @@ export const CopyAddressButton = memo( return ( diff --git a/apps/web/src/components/Menu/AddressBookMenu.tsx b/apps/web/src/components/Menu/AddressBookMenu.tsx deleted file mode 100644 index 40119db03d..0000000000 --- a/apps/web/src/components/Menu/AddressBookMenu.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Button, Divider } from "@chakra-ui/react"; - -import { DrawerContentWrapper } from "./DrawerContentWrapper"; -import { EmptyMessage } from "../EmptyMessage"; - -export const AddressBookMenu = () => ( - - - - - -); diff --git a/apps/web/src/components/Menu/AddressBookMenu/AddressBookMenu.tsx b/apps/web/src/components/Menu/AddressBookMenu/AddressBookMenu.tsx new file mode 100644 index 0000000000..bcacc4e897 --- /dev/null +++ b/apps/web/src/components/Menu/AddressBookMenu/AddressBookMenu.tsx @@ -0,0 +1,127 @@ +import { + Box, + Button, + Center, + Divider, + Flex, + Heading, + IconButton, + Text, + VStack, + useBreakpointValue, +} from "@chakra-ui/react"; +import { useDynamicDrawerContext, useDynamicModalContext } from "@umami/components"; +import { type Contact } from "@umami/core"; +import { useSortedContacts } from "@umami/state"; + +import { DeleteContactModal } from "./DeleteContactModal"; +import { EditContactMenu } from "./EditContactMenu"; +import { EditIcon, ThreeDotsIcon, TrashIcon } from "../../../assets/icons"; +import { useColor } from "../../../styles/useColor"; +import { ActionsDropdown } from "../../ActionsDropdown"; +import { CopyAddressButton } from "../../CopyAddressButton"; +import { EmptyMessage } from "../../EmptyMessage"; +import { DrawerContentWrapper } from "../DrawerContentWrapper"; + +type ContactItemProps = { + contact: Contact; +}; + +const ContactItem = ({ contact }: ContactItemProps) => { + const { openWith: openDrawer } = useDynamicDrawerContext(); + const { openWith: openModal } = useDynamicModalContext(); + const color = useColor(); + + const isLongAddress = useBreakpointValue({ base: false, md: true }); + + const actions = ( + + + + + ); + + return ( +
+ +
+ + {contact.name} + + +
+
+ + } /> + +
+ ); +}; + +export const AddressBookMenu = () => { + const { openWith } = useDynamicDrawerContext(); + const contacts = useSortedContacts(); + + return ( + + + + {contacts.length ? ( + } + spacing="0" + > + {contacts.map(contact => ( + + ))} + + ) : ( + + )} + + ); +}; diff --git a/apps/web/src/components/Menu/AddressBookMenu/DeleteContactModal.tsx b/apps/web/src/components/Menu/AddressBookMenu/DeleteContactModal.tsx new file mode 100644 index 0000000000..42df4cab67 --- /dev/null +++ b/apps/web/src/components/Menu/AddressBookMenu/DeleteContactModal.tsx @@ -0,0 +1,66 @@ +import { + Button, + Center, + Heading, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + useBreakpointValue, +} from "@chakra-ui/react"; +import { useDynamicModalContext } from "@umami/components"; +import { type Contact } from "@umami/core"; +import { contactsActions, useAppDispatch } from "@umami/state"; + +import { useColor } from "../../../styles/useColor"; +import { ModalCloseButton } from "../../CloseButton"; +import { CopyAddressButton } from "../../CopyAddressButton"; + +type DeleteContactModalProps = { + contact: Contact; +}; + +export const DeleteContactModal = ({ contact }: DeleteContactModalProps) => { + const color = useColor(); + const dispatch = useAppDispatch(); + const { onClose } = useDynamicModalContext(); + const onDeleteContact = () => { + dispatch(contactsActions.remove(contact.pkh)); + onClose(); + }; + + const isLongAddress = useBreakpointValue({ base: false, md: true }); + + return ( + + + Delete Contact + + + +
+ + Are you sure you want to delete this contact? + + + {contact.name} + + +
+
+ + + +
+ ); +}; diff --git a/apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.test.tsx b/apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.test.tsx new file mode 100644 index 0000000000..c5e5306af8 --- /dev/null +++ b/apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.test.tsx @@ -0,0 +1,372 @@ +import { mockImplicitContact } from "@umami/core"; +import { getNetworksForContracts } from "@umami/multisig"; +import { type UmamiStore, contactsActions, makeStore, mockToast } from "@umami/state"; +import { mockContractAddress, mockImplicitAddress } from "@umami/tezos"; + +import { EditContactMenu } from "./EditContactMenu"; +import { act, renderInDrawer, screen, userEvent, waitFor } from "../../../testUtils"; + +jest.mock("@umami/multisig", () => ({ + ...jest.requireActual("@umami/multisig"), + getNetworksForContracts: jest.fn(), +})); + +const contact1 = mockImplicitContact(1); +const contact2 = mockImplicitContact(2); + +let store: UmamiStore; + +beforeEach(() => { + store = makeStore(); +}); + +describe("", () => { + describe("on adding contact", () => { + const contractPkh = mockContractAddress(0).pkh; + + describe.each([ + { testCase: "new contact", modalComponent: }, + { + testCase: "pre-set contact", + modalComponent: ( + + ), + }, + ])("for $testCase", ({ modalComponent }) => { + it("shows correct title & button label for new contact", async () => { + await renderInDrawer(modalComponent, store); + + expect(screen.getByRole("dialog")).toHaveTextContent("Add Contact"); + expect(screen.getByTestId("confirmation-button")).toHaveTextContent("Add to Address Book"); + }); + + it("has editable address & name fields", async () => { + await renderInDrawer(modalComponent, store); + + expect(screen.getByLabelText("Address")).toBeEnabled(); + expect(screen.getByLabelText("Name")).toBeEnabled(); + }); + + it("validates updated address", async () => { + const user = userEvent.setup(); + await renderInDrawer(modalComponent, store); + + const addressInput = screen.getByLabelText("Address"); + await act(() => user.clear(addressInput)); + await act(() => user.type(addressInput, "invalid pkh")); + // click outside of address input to trigger blur event + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(screen.getByTestId("address-error")).toHaveTextContent("Invalid address") + ); + }); + + it("checks the name is unique", async () => { + const user = userEvent.setup(); + store.dispatch(contactsActions.upsert(contact2)); + await renderInDrawer(modalComponent, store); + + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, contact2.name)); + // click outside of address input to trigger blur event + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(screen.getByTestId("name-error")).toHaveTextContent( + "Name must be unique across all accounts and contacts" + ) + ); + }); + + it("adds contact to address book", async () => { + const user = userEvent.setup(); + store.dispatch(contactsActions.upsert(contact2)); + await renderInDrawer(modalComponent, store); + + // Set name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Test Contact")); + // Set address + const addressInput = screen.getByLabelText("Address"); + await act(() => user.clear(addressInput)); + await act(() => user.type(addressInput, mockImplicitAddress(5).pkh)); + // Submit + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(store.getState().contacts).toEqual({ + [contact2.pkh]: contact2, + [mockImplicitAddress(5).pkh]: { + name: "Test Contact", + pkh: mockImplicitAddress(5).pkh, + }, + }) + ); + }); + + it("fetches network for contract addresses", async () => { + jest + .mocked(getNetworksForContracts) + .mockResolvedValue(new Map([[contractPkh, "ghostnet"]])); + const user = userEvent.setup(); + await renderInDrawer(modalComponent, store); + + // Set name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Test Contact")); + // Set address + const addressInput = screen.getByLabelText("Address"); + await act(() => user.clear(addressInput)); + await act(() => user.type(addressInput, contractPkh)); + // Submit + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(store.getState().contacts).toEqual({ + [contractPkh]: { + name: "Test Contact", + pkh: contractPkh, + network: "ghostnet", + }, + }) + ); + }); + + it("shows error toast on unknown network for contract addresses", async () => { + jest.mocked(getNetworksForContracts).mockResolvedValue(new Map()); + const user = userEvent.setup(); + await renderInDrawer(modalComponent, store); + + // Set name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Test Contact")); + // Set address + const addressInput = screen.getByLabelText("Address"); + await act(() => user.clear(addressInput)); + await act(() => user.type(addressInput, contractPkh)); + // Submit + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + expect(mockToast).toHaveBeenCalledWith({ + description: `Network not found for contract ${contractPkh}`, + status: "error", + isClosable: true, + }); + expect(store.getState().contacts).toEqual({}); + }); + }); + + describe("for pre-set contact", () => { + it("has pre-filled address field", async () => { + await renderInDrawer( + , + store + ); + + expect(screen.getByLabelText("Address")).toHaveValue(mockImplicitAddress(0).pkh); + }); + + it("validates initial address field", async () => { + const user = userEvent.setup(); + await renderInDrawer( + , + store + ); + + await act(() => user.click(screen.getByLabelText("Address"))); + // click outside of address input to trigger blur event + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(screen.getByTestId("address-error")).toHaveTextContent("Invalid address") + ); + }); + + it("adds contact to address book with pre-filled address", async () => { + const user = userEvent.setup(); + store.dispatch(contactsActions.upsert(contact2)); + await renderInDrawer( + , + store + ); + + // Set name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Test Contact")); + // Submit + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(store.getState().contacts).toEqual({ + [contact2.pkh]: contact2, + [contact1.pkh]: { + name: "Test Contact", + pkh: contact1.pkh, + }, + }) + ); + }); + + it("fetches network for contract addresses", async () => { + jest + .mocked(getNetworksForContracts) + .mockResolvedValue(new Map([[contractPkh, "ghostnet"]])); + const user = userEvent.setup(); + await renderInDrawer( + , + store + ); + + // Set name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Test Contact")); + // Submit + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(store.getState().contacts).toEqual({ + [contractPkh]: { + name: "Test Contact", + pkh: contractPkh, + network: "ghostnet", + }, + }) + ); + }); + + it("shows error toast on unknown network for contract addresses", async () => { + jest.mocked(getNetworksForContracts).mockResolvedValue(new Map()); + const user = userEvent.setup(); + await renderInDrawer( + , + store + ); + + // Set name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Test Contact")); + // Submit + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + expect(mockToast).toHaveBeenCalledWith({ + description: `Network not found for contract ${contractPkh}`, + status: "error", + isClosable: true, + }); + expect(store.getState().contacts).toEqual({}); + }); + }); + }); + + describe("on editing contact", () => { + beforeEach(() => { + store.dispatch(contactsActions.upsert(contact1)); + }); + + it("shows correct title & button label", async () => { + await renderInDrawer(, store); + + expect(screen.getByRole("dialog")).toHaveTextContent("Edit Contact"); + expect(screen.getByTestId("confirmation-button")).toHaveTextContent("Update"); + }); + + it("has uneditable address field", async () => { + await renderInDrawer(, store); + + expect(screen.getByLabelText("Address")).toHaveValue(contact1.pkh); + expect(screen.getByLabelText("Address")).toBeDisabled(); + }); + + it("checks the name was updated", async () => { + const user = userEvent.setup(); + await renderInDrawer(, store); + + await act(() => user.click(screen.getByLabelText("Name"))); + // click outside of address input to trigger blur event + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(screen.getByTestId("name-error")).toHaveTextContent("Name was not changed") + ); + }); + + it("checks the name is unique", async () => { + const user = userEvent.setup(); + store.dispatch(contactsActions.upsert(contact2)); + await renderInDrawer(, store); + + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, contact2.name)); + // click outside of address input to trigger blur event + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(screen.getByTestId("name-error")).toHaveTextContent( + "Name must be unique across all accounts and contacts" + ) + ); + }); + + it("updates contact in address book", async () => { + const user = userEvent.setup(); + store.dispatch(contactsActions.upsert(contact2)); + await renderInDrawer(, store); + + // Update name + const nameInput = screen.getByLabelText("Name"); + await act(() => user.clear(nameInput)); + await act(() => user.type(nameInput, "Updated Name")); + // click outside of address input to trigger blur event + await act(() => user.click(screen.getByTestId("confirmation-button"))); + + await waitFor(() => + expect(store.getState().contacts).toEqual({ + [contact2.pkh]: contact2, + [contact1.pkh]: { + name: "Updated Name", + pkh: contact1.pkh, + }, + }) + ); + }); + }); +}); diff --git a/apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.tsx b/apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.tsx new file mode 100644 index 0000000000..63008a2dd9 --- /dev/null +++ b/apps/web/src/components/Menu/AddressBookMenu/EditContactMenu.tsx @@ -0,0 +1,121 @@ +import { Button, FormControl, FormErrorMessage, FormLabel, Input, VStack } from "@chakra-ui/react"; +import { useDynamicDrawerContext } from "@umami/components"; +import { type Contact } from "@umami/core"; +import { getNetworksForContracts } from "@umami/multisig"; +import { + contactsActions, + useAppDispatch, + useAsyncActionHandler, + useAvailableNetworks, + useValidateName, + useValidateNewContactPkh, +} from "@umami/state"; +import { isValidContractPkh } from "@umami/tezos"; +import { type FC } from "react"; +import { useForm } from "react-hook-form"; + +import { DrawerContentWrapper } from "../DrawerContentWrapper"; + +/** + * Modal used for both adding new contacts & editing existing contacts. + * + * Contact is checked for having unique name & pkh (among all accounts & other contacts) before being added. + * + * @param contact - optional / partial data for creating new contact, or full data for editing existing contact. + */ +export const EditContactMenu: FC<{ + contact?: Contact; +}> = ({ contact }) => { + const { handleAsyncAction } = useAsyncActionHandler(); + const dispatch = useAppDispatch(); + const { goBack } = useDynamicDrawerContext(); + const availableNetworks = useAvailableNetworks(); + + // When editing existing contact, its name & pkh are known and provided to the modal. + const isEdit = !!(contact?.pkh && contact.name); + + const onSubmitContact = async (newContact: Contact) => { + if (isValidContractPkh(newContact.pkh)) { + await handleAsyncAction(async () => { + const contractsWithNetworks = await getNetworksForContracts(availableNetworks, [ + newContact.pkh, + ]); + if (!contractsWithNetworks.has(newContact.pkh)) { + throw new Error(`Network not found for contract ${newContact.pkh}`); + } + dispatch( + contactsActions.upsert({ + ...newContact, + network: contractsWithNetworks.get(newContact.pkh), + }) + ); + goBack(); + }); + } else { + dispatch(contactsActions.upsert({ ...newContact, network: undefined })); + goBack(); + } + }; + + const { + handleSubmit, + formState: { isValid, errors }, + register, + } = useForm({ + mode: "onBlur", + defaultValues: contact, + }); + + const validatePkh = useValidateNewContactPkh(); + const validateName = useValidateName(contact?.name); + + return ( + +
onSubmitContact({ name: name.trim(), pkh }))}> + + + Name + + {errors.name && ( + {errors.name.message} + )} + + + Address + true : validatePkh, + })} + disabled={isEdit} + placeholder="Enter contact's tz address" + variant={isEdit ? "filled" : undefined} + /> + {errors.pkh && ( + {errors.pkh.message} + )} + + + +
+
+ ); +}; diff --git a/apps/web/src/components/Menu/AdvancedMenu.test.tsx b/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.test.tsx similarity index 77% rename from apps/web/src/components/Menu/AdvancedMenu.test.tsx rename to apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.test.tsx index 834b463038..df2e2eb9ea 100644 --- a/apps/web/src/components/Menu/AdvancedMenu.test.tsx +++ b/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.test.tsx @@ -1,8 +1,8 @@ import { AdvancedMenu } from "./AdvancedMenu"; -import { ChangePasswordMenu } from "./ChangePasswordMenu"; -import { ErrorLogsMenu } from "./ErrorLogsMenu"; -import { NetworkMenu } from "./NetworkMenu"; -import { dynamicDrawerContextMock, renderInDrawer, screen, userEvent } from "../../testUtils"; +import { dynamicDrawerContextMock, renderInDrawer, screen, userEvent } from "../../../testUtils"; +import { ChangePasswordMenu } from "../ChangePasswordMenu/ChangePasswordMenu"; +import { ErrorLogsMenu } from "../ErrorLogsMenu/ErrorLogsMenu"; +import { NetworkMenu } from "../NetworkMenu/NetworkMenu"; describe("", () => { it("renders advanced menu items correctly", async () => { diff --git a/apps/web/src/components/Menu/AdvancedMenu.tsx b/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx similarity index 68% rename from apps/web/src/components/Menu/AdvancedMenu.tsx rename to apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx index 42127279ce..c19ba8ad78 100644 --- a/apps/web/src/components/Menu/AdvancedMenu.tsx +++ b/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx @@ -1,10 +1,10 @@ import { useDynamicDrawerContext } from "@umami/components"; -import { ChangePasswordMenu } from "./ChangePasswordMenu"; -import { ErrorLogsMenu } from "./ErrorLogsMenu"; -import { GenericMenu } from "./GenericMenu"; -import { NetworkMenu } from "./NetworkMenu"; -import { AlertCircleIcon, LockIcon, RadioIcon } from "../../assets/icons"; +import { AlertCircleIcon, LockIcon, RadioIcon } from "../../../assets/icons"; +import { ChangePasswordMenu } from "../ChangePasswordMenu/ChangePasswordMenu"; +import { ErrorLogsMenu } from "../ErrorLogsMenu/ErrorLogsMenu"; +import { GenericMenu } from "../GenericMenu"; +import { NetworkMenu } from "../NetworkMenu/NetworkMenu"; export const AdvancedMenu = () => { const { openWith } = useDynamicDrawerContext(); diff --git a/apps/web/src/components/Menu/AppsMenu.test.tsx b/apps/web/src/components/Menu/AppsMenu/AppsMenu.test.tsx similarity index 94% rename from apps/web/src/components/Menu/AppsMenu.test.tsx rename to apps/web/src/components/Menu/AppsMenu/AppsMenu.test.tsx index 00585e7194..2bc727db03 100644 --- a/apps/web/src/components/Menu/AppsMenu.test.tsx +++ b/apps/web/src/components/Menu/AppsMenu/AppsMenu.test.tsx @@ -1,7 +1,7 @@ import { WalletClient } from "@umami/state"; import { AppsMenu } from "./AppsMenu"; -import { act, renderInDrawer, screen, userEvent } from "../../testUtils"; +import { act, renderInDrawer, screen, userEvent } from "../../../testUtils"; describe("", () => { it("calls addPeer on button click with the copied text", async () => { diff --git a/apps/web/src/components/Menu/AppsMenu.tsx b/apps/web/src/components/Menu/AppsMenu/AppsMenu.tsx similarity index 85% rename from apps/web/src/components/Menu/AppsMenu.tsx rename to apps/web/src/components/Menu/AppsMenu/AppsMenu.tsx index 936742aa37..8a43458f19 100644 --- a/apps/web/src/components/Menu/AppsMenu.tsx +++ b/apps/web/src/components/Menu/AppsMenu/AppsMenu.tsx @@ -1,8 +1,8 @@ import { Button, Divider, Text } from "@chakra-ui/react"; import { useAddPeer } from "@umami/state"; -import { DrawerContentWrapper } from "./DrawerContentWrapper"; -import { BeaconPeers } from "../beacon"; +import { BeaconPeers } from "../../beacon"; +import { DrawerContentWrapper } from "../DrawerContentWrapper"; export const AppsMenu = () => { const addPeer = useAddPeer(); diff --git a/apps/web/src/components/Menu/ChangePasswordMenu.test.tsx b/apps/web/src/components/Menu/ChangePasswordMenu/ChangePasswordMenu.test.tsx similarity index 99% rename from apps/web/src/components/Menu/ChangePasswordMenu.test.tsx rename to apps/web/src/components/Menu/ChangePasswordMenu/ChangePasswordMenu.test.tsx index 54a4bbf010..6581a5a946 100644 --- a/apps/web/src/components/Menu/ChangePasswordMenu.test.tsx +++ b/apps/web/src/components/Menu/ChangePasswordMenu/ChangePasswordMenu.test.tsx @@ -1,7 +1,7 @@ import { changeMnemonicPassword } from "@umami/state"; import { ChangePasswordMenu } from "./ChangePasswordMenu"; -import { fireEvent, renderInDrawer, screen, userEvent, waitFor } from "../../testUtils"; +import { fireEvent, renderInDrawer, screen, userEvent, waitFor } from "../../../testUtils"; jest.mock("@umami/state", () => ({ ...jest.requireActual("@umami/state"), diff --git a/apps/web/src/components/Menu/ChangePasswordMenu.tsx b/apps/web/src/components/Menu/ChangePasswordMenu/ChangePasswordMenu.tsx similarity index 95% rename from apps/web/src/components/Menu/ChangePasswordMenu.tsx rename to apps/web/src/components/Menu/ChangePasswordMenu/ChangePasswordMenu.tsx index db69ae0351..92a1ade60b 100644 --- a/apps/web/src/components/Menu/ChangePasswordMenu.tsx +++ b/apps/web/src/components/Menu/ChangePasswordMenu/ChangePasswordMenu.tsx @@ -2,8 +2,8 @@ import { Button, Divider, VStack, useToast } from "@chakra-ui/react"; import { changeMnemonicPassword, useAppDispatch, useAsyncActionHandler } from "@umami/state"; import { FormProvider, useForm } from "react-hook-form"; -import { DrawerContentWrapper } from "./DrawerContentWrapper"; -import { PasswordInput } from "../PasswordInput"; +import { PasswordInput } from "../../PasswordInput"; +import { DrawerContentWrapper } from "../DrawerContentWrapper"; type ChangePasswordMenuValues = { currentPassword: string; diff --git a/apps/web/src/components/Menu/ErrorLogsMenu.test.tsx b/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.test.tsx similarity index 96% rename from apps/web/src/components/Menu/ErrorLogsMenu.test.tsx rename to apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.test.tsx index ea769fa820..c25122081d 100644 --- a/apps/web/src/components/Menu/ErrorLogsMenu.test.tsx +++ b/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.test.tsx @@ -2,7 +2,7 @@ import { type ErrorContext } from "@umami/core"; import { type UmamiStore, errorsActions, makeStore } from "@umami/state"; import { ErrorLogsMenu } from "./ErrorLogsMenu"; -import { renderInDrawer, screen, userEvent } from "../../testUtils"; +import { renderInDrawer, screen, userEvent } from "../../../testUtils"; let store: UmamiStore; diff --git a/apps/web/src/components/Menu/ErrorLogsMenu.tsx b/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx similarity index 92% rename from apps/web/src/components/Menu/ErrorLogsMenu.tsx rename to apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx index 32152f6f4a..885923c29e 100644 --- a/apps/web/src/components/Menu/ErrorLogsMenu.tsx +++ b/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx @@ -1,9 +1,9 @@ import { Box, Button, Divider, Flex, Heading, Link, Text, VStack } from "@chakra-ui/react"; import { errorsActions, useAppDispatch, useAppSelector } from "@umami/state"; -import { DrawerContentWrapper } from "./DrawerContentWrapper"; -import { useColor } from "../../styles/useColor"; -import { EmptyMessage } from "../EmptyMessage"; +import { useColor } from "../../../styles/useColor"; +import { EmptyMessage } from "../../EmptyMessage"; +import { DrawerContentWrapper } from "../DrawerContentWrapper"; export const ErrorLogsMenu = () => { const color = useColor(); diff --git a/apps/web/src/components/Menu/Menu.test.tsx b/apps/web/src/components/Menu/Menu.test.tsx index 6ba29848c3..0b36851c1d 100644 --- a/apps/web/src/components/Menu/Menu.test.tsx +++ b/apps/web/src/components/Menu/Menu.test.tsx @@ -1,9 +1,9 @@ import { useColorMode } from "@chakra-ui/system"; import { downloadBackupFile } from "@umami/state"; -import { AddressBookMenu } from "./AddressBookMenu"; -import { AdvancedMenu } from "./AdvancedMenu"; -import { AppsMenu } from "./AppsMenu"; +import { AddressBookMenu } from "./AddressBookMenu/AddressBookMenu"; +import { AdvancedMenu } from "./AdvancedMenu/AdvancedMenu"; +import { AppsMenu } from "./AppsMenu/AppsMenu"; import { LogoutModal } from "./LogoutModal"; import { Menu } from "./Menu"; import { diff --git a/apps/web/src/components/Menu/Menu.tsx b/apps/web/src/components/Menu/Menu.tsx index 25a8759829..1a453ce5fd 100644 --- a/apps/web/src/components/Menu/Menu.tsx +++ b/apps/web/src/components/Menu/Menu.tsx @@ -3,9 +3,9 @@ import { useColorMode } from "@chakra-ui/system"; import { useDynamicDrawerContext, useDynamicModalContext } from "@umami/components"; import { downloadBackupFile } from "@umami/state"; -import { AddressBookMenu } from "./AddressBookMenu"; -import { AdvancedMenu } from "./AdvancedMenu"; -import { AppsMenu } from "./AppsMenu"; +import { AddressBookMenu } from "./AddressBookMenu/AddressBookMenu"; +import { AdvancedMenu } from "./AdvancedMenu/AdvancedMenu"; +import { AppsMenu } from "./AppsMenu/AppsMenu"; import { GenericMenu } from "./GenericMenu"; import { LogoutModal } from "./LogoutModal"; import { diff --git a/apps/web/src/components/Menu/EditNetworkMenu.test.tsx b/apps/web/src/components/Menu/NetworkMenu/EditNetworkMenu.test.tsx similarity index 99% rename from apps/web/src/components/Menu/EditNetworkMenu.test.tsx rename to apps/web/src/components/Menu/NetworkMenu/EditNetworkMenu.test.tsx index ebe9ba0a0c..7a6c5fca7b 100644 --- a/apps/web/src/components/Menu/EditNetworkMenu.test.tsx +++ b/apps/web/src/components/Menu/NetworkMenu/EditNetworkMenu.test.tsx @@ -2,7 +2,7 @@ import { type UmamiStore, makeStore, networksActions } from "@umami/state"; import { GHOSTNET, MAINNET } from "@umami/tezos"; import { EditNetworkMenu } from "./EditNetworkMenu"; -import { act, fireEvent, renderInDrawer, screen, userEvent, waitFor } from "../../testUtils"; +import { act, fireEvent, renderInDrawer, screen, userEvent, waitFor } from "../../../testUtils"; const customNetwork = { ...GHOSTNET, name: "custom" }; diff --git a/apps/web/src/components/Menu/EditNetworkMenu.tsx b/apps/web/src/components/Menu/NetworkMenu/EditNetworkMenu.tsx similarity index 98% rename from apps/web/src/components/Menu/EditNetworkMenu.tsx rename to apps/web/src/components/Menu/NetworkMenu/EditNetworkMenu.tsx index e25a25b5ff..35d4453fba 100644 --- a/apps/web/src/components/Menu/EditNetworkMenu.tsx +++ b/apps/web/src/components/Menu/NetworkMenu/EditNetworkMenu.tsx @@ -4,7 +4,7 @@ import { networksActions, useAppDispatch, useAvailableNetworks } from "@umami/st import { type Network } from "@umami/tezos"; import { useForm } from "react-hook-form"; -import { DrawerContentWrapper } from "./DrawerContentWrapper"; +import { DrawerContentWrapper } from "../DrawerContentWrapper"; type EditNetworkMenuProps = { network?: Network; diff --git a/apps/web/src/components/Menu/NetworkMenu.test.tsx b/apps/web/src/components/Menu/NetworkMenu/NetworkMenu.test.tsx similarity index 99% rename from apps/web/src/components/Menu/NetworkMenu.test.tsx rename to apps/web/src/components/Menu/NetworkMenu/NetworkMenu.test.tsx index f1f4c2c884..e5eb1a8a63 100644 --- a/apps/web/src/components/Menu/NetworkMenu.test.tsx +++ b/apps/web/src/components/Menu/NetworkMenu/NetworkMenu.test.tsx @@ -9,7 +9,7 @@ import { screen, userEvent, within, -} from "../../testUtils"; +} from "../../../testUtils"; jest.mock("@umami/state", () => ({ ...jest.requireActual("@umami/state"), diff --git a/apps/web/src/components/Menu/NetworkMenu.tsx b/apps/web/src/components/Menu/NetworkMenu/NetworkMenu.tsx similarity index 92% rename from apps/web/src/components/Menu/NetworkMenu.tsx rename to apps/web/src/components/Menu/NetworkMenu/NetworkMenu.tsx index 8616a22a6d..47481a57e2 100644 --- a/apps/web/src/components/Menu/NetworkMenu.tsx +++ b/apps/web/src/components/Menu/NetworkMenu/NetworkMenu.tsx @@ -19,11 +19,11 @@ import { } from "@umami/state"; import { type Network, isDefault } from "@umami/tezos"; -import { DrawerContentWrapper } from "./DrawerContentWrapper"; import { EditNetworkMenu } from "./EditNetworkMenu"; -import { EditIcon, ThreeDotsIcon, TrashIcon } from "../../assets/icons"; -import { useColor } from "../../styles/useColor"; -import { ActionsDropdown } from "../ActionsDropdown"; +import { EditIcon, ThreeDotsIcon, TrashIcon } from "../../../assets/icons"; +import { useColor } from "../../../styles/useColor"; +import { ActionsDropdown } from "../../ActionsDropdown"; +import { DrawerContentWrapper } from "../DrawerContentWrapper"; type NetworkMenuItemProps = { network: Network; diff --git a/apps/web/src/components/beacon/BeaconPeers.tsx b/apps/web/src/components/beacon/BeaconPeers.tsx index 4b4a1d56ab..c77aa4f76e 100644 --- a/apps/web/src/components/beacon/BeaconPeers.tsx +++ b/apps/web/src/components/beacon/BeaconPeers.tsx @@ -5,7 +5,7 @@ import { parsePkh } from "@umami/tezos"; import capitalize from "lodash/capitalize"; import { useEffect, useState } from "react"; -import { StubIcon as TrashIcon } from "../../assets/icons"; +import { CodeSandboxIcon, StubIcon as TrashIcon } from "../../assets/icons"; import { AddressPill } from "../../components/AddressPill/AddressPill"; import { useColor } from "../../styles/useColor"; import { EmptyMessage } from "../EmptyMessage"; @@ -88,7 +88,13 @@ const PeerRow = ({ peerInfo }: { peerInfo: ExtendedPeerInfo }) => { data-testid="peer-row" > - +
+ } + src={peerInfo.icon} + /> +
{peerInfo.name} diff --git a/apps/web/src/styles/theme.ts b/apps/web/src/styles/theme.ts index 27c3a7086d..185681b6d4 100644 --- a/apps/web/src/styles/theme.ts +++ b/apps/web/src/styles/theme.ts @@ -319,11 +319,6 @@ const theme = extendTheme({ }, }, Popover: { - baseStyle: { - popper: { - zIndex: 9999, - }, - }, variants: { dropdown: { content: {