Skip to content

Commit

Permalink
Add AddContactModal
Browse files Browse the repository at this point in the history
  • Loading branch information
OKendigelyan committed Nov 6, 2024
1 parent 4e0fdc1 commit f2b2190
Show file tree
Hide file tree
Showing 4 changed files with 386 additions and 2 deletions.
251 changes: 251 additions & 0 deletions apps/web/src/components/AddContactModal/AddContactModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
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 { AddContactModal } from "./AddContactModal";
import { act, renderInModal, 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("<AddContactModal />", () => {
describe("on adding contact", () => {
const contractPkh = mockContractAddress(0).pkh;

describe.each([
{ testCase: "new contact", modalComponent: <AddContactModal /> },
{
testCase: "pre-set contact",
modalComponent: <AddContactModal pkh={mockImplicitAddress(0).pkh} />,
},
])("for $testCase", ({ modalComponent }) => {
it("shows correct title & button label for new contact", async () => {
await renderInModal(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 renderInModal(modalComponent, store);

expect(screen.getByLabelText("Address")).toBeEnabled();
expect(screen.getByLabelText("Name")).toBeEnabled();
});

it("validates updated address", async () => {
const user = userEvent.setup();
await renderInModal(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 renderInModal(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 renderInModal(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 renderInModal(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 renderInModal(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 renderInModal(<AddContactModal pkh={mockImplicitAddress(0).pkh} />, store);

expect(screen.getByLabelText("Address")).toHaveValue(mockImplicitAddress(0).pkh);
});

it("validates initial address field", async () => {
const user = userEvent.setup();
await renderInModal(<AddContactModal pkh="invalid pkh" />, 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 renderInModal(<AddContactModal pkh={contact1.pkh} />, 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 renderInModal(<AddContactModal pkh={contractPkh} />, 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 renderInModal(<AddContactModal pkh={contractPkh} />, 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({});
});
});
});
});
131 changes: 131 additions & 0 deletions apps/web/src/components/AddContactModal/AddContactModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
Button,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@chakra-ui/react";
import { useDynamicModalContext } 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 { ModalCloseButton } from "../CloseButton";

export const AddContactModal: FC<{
pkh?: string;
}> = ({ pkh }) => {
const { handleAsyncAction } = useAsyncActionHandler();
const dispatch = useAppDispatch();
const { onClose } = useDynamicModalContext();
const availableNetworks = useAvailableNetworks();

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),
})
);
});
} else {
dispatch(contactsActions.upsert({ ...newContact, network: undefined }));
}
onClose();
reset();
};

const {
handleSubmit,
formState: { isValid, errors },
register,
reset,
} = useForm<Contact>({
mode: "onBlur",
defaultValues: { pkh },
});

const onSubmit = ({ name, pkh }: Contact) => {
void onSubmitContact({ name: name.trim(), pkh });
};

const validatePkh = useValidateNewContactPkh();
const validateName = useValidateName();

return (
<ModalContent>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>
<Heading size="xl">Add Contact</Heading>
<ModalCloseButton />
</ModalHeader>
<ModalBody gap="24px">
<FormControl isInvalid={!!errors.name}>
<FormLabel>Name</FormLabel>
<Input
type="text"
{...register("name", {
required: "Name is required",
validate: validateName,
})}
placeholder="Enter contact's name"
/>
{errors.name && (
<FormErrorMessage data-testid="name-error">{errors.name.message}</FormErrorMessage>
)}
</FormControl>
<FormControl isInvalid={!!errors.pkh}>
<FormLabel>Address</FormLabel>
<Input
type="text"
{...register("pkh", {
required: "Address is required",
validate: validatePkh,
})}
placeholder="Enter contact's tz address"
/>
{errors.pkh && (
<FormErrorMessage data-testid="address-error">{errors.pkh.message}</FormErrorMessage>
)}
</FormControl>
</ModalBody>
<ModalFooter>
<Button
width="100%"
fontWeight="600"
data-testid="confirmation-button"
isDisabled={!isValid}
size="lg"
type="submit"
variant="primary"
>
Add to Address Book
</Button>
</ModalFooter>
</form>
</ModalContent>
);
};
1 change: 1 addition & 0 deletions apps/web/src/components/AddContactModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./AddContactModal";
Loading

1 comment on commit f2b2190

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title Lines Statements Branches Functions
apps/desktop Coverage: 84%
83.87% (1763/2102) 79.33% (837/1055) 78.42% (447/570)
apps/web Coverage: 84%
83.87% (1763/2102) 79.33% (837/1055) 78.42% (447/570)
packages/components Coverage: 97%
97.84% (182/186) 96.51% (83/86) 87.03% (47/54)
packages/core Coverage: 81%
82.22% (222/270) 71.73% (99/138) 81.96% (50/61)
packages/crypto Coverage: 100%
100% (43/43) 90.9% (10/11) 100% (7/7)
packages/data-polling Coverage: 97%
95.27% (141/148) 87.5% (21/24) 92.85% (39/42)
packages/multisig Coverage: 98%
98.47% (129/131) 85.71% (18/21) 100% (35/35)
packages/social-auth Coverage: 100%
100% (21/21) 100% (11/11) 100% (3/3)
packages/state Coverage: 85%
84.71% (798/942) 81.33% (170/209) 78.77% (297/377)
packages/tezos Coverage: 86%
85.57% (89/104) 89.47% (17/19) 82.75% (24/29)
packages/tzkt Coverage: 86%
84.05% (58/69) 81.25% (13/16) 76.92% (30/39)

Please sign in to comment.