diff --git a/src/components/AddressAutocomplete/AddressAutocomplete.test.tsx b/src/components/AddressAutocomplete/AddressAutocomplete.test.tsx index 7056fa8b75..15e97890c7 100644 --- a/src/components/AddressAutocomplete/AddressAutocomplete.test.tsx +++ b/src/components/AddressAutocomplete/AddressAutocomplete.test.tsx @@ -1,42 +1,31 @@ import { FormProvider, useForm } from "react-hook-form"; -import { - AddressAutocomplete, - KnownAccountsAutocomplete, - getSuggestions, -} from "./AddressAutocomplete"; -import { - mockContractContact, - mockImplicitAddress, - mockImplicitContact, -} from "../../mocks/factories"; -import { fireEvent, render, renderHook, screen, within } from "../../mocks/testUtils"; +import { AddressAutocomplete } from "./AddressAutocomplete"; +import { mockImplicitAddress, mockImplicitContact } from "../../mocks/factories"; +import { fireEvent, render, screen, within } from "../../mocks/testUtils"; import { Contact } from "../../types/Contact"; -import { GHOSTNET } from "../../types/Network"; import { contactsActions } from "../../utils/redux/slices/contactsSlice"; -import { networksActions } from "../../utils/redux/slices/networks"; import { store } from "../../utils/redux/store"; type FormFields = { destination: string }; -const fixture = ({ - defaultDestination = "", - allowUnknown = true, - contacts = [mockImplicitContact(0), mockImplicitContact(1), mockImplicitContact(2)], - label = "", - keepValid, -}: { +const TestComponent: React.FC<{ defaultDestination?: string; contacts?: Contact[]; allowUnknown?: boolean; label?: string; keepValid?: boolean; +}> = ({ + defaultDestination = "", + allowUnknown = true, + contacts = [mockImplicitContact(0), mockImplicitContact(1), mockImplicitContact(2)], + label = "", + keepValid, }) => { - const view = renderHook(() => - useForm({ defaultValues: { destination: defaultDestination } }) - ); - render( - + const form = useForm({ defaultValues: { destination: defaultDestination } }); + + return ( + ", () => { it("should set the real input when a valid pkh is entered by the user", () => { - fixture({}); + render(); const rawInput = screen.getByLabelText("destination"); const realInput = screen.getByTestId("real-address-input-destination"); @@ -61,7 +50,7 @@ describe("", () => { }); test("the input is never shown when keepValid is set to true, but suggestions are available", () => { - fixture({ defaultDestination: mockImplicitContact(0).pkh, keepValid: true }); + render(); const realInput = screen.getByTestId("real-address-input-destination"); expect(realInput).toHaveValue(mockImplicitContact(0).pkh); @@ -70,7 +59,7 @@ describe("", () => { // right icon expect(screen.queryByTestId("clear-input-button")).not.toBeInTheDocument(); - expect(screen.getByTestId("chevron-icon")).toBeInTheDocument(); + expect(screen.getByTestId("chevron-icon")).toBeVisible(); fireEvent.click(screen.getByTestId(/selected-address-tile-/)); expect(realInput).toHaveValue(mockImplicitContact(0).pkh); @@ -81,12 +70,12 @@ describe("", () => { }); it("hides suggestions by default", () => { - fixture({}); + render(); expect(screen.queryByTestId("suggestions-list")).not.toBeInTheDocument(); }); it("shows suggestions when the input is focused", () => { - fixture({}); + render(); const rawInput = screen.getByLabelText("destination"); fireEvent.focus(rawInput); @@ -97,7 +86,7 @@ describe("", () => { }); it("hides suggestions if input is an exact suggestion", () => { - fixture({}); + render(); const rawInput = screen.getByLabelText("destination"); @@ -109,7 +98,7 @@ describe("", () => { it("displays suggestions if user input has suggestions", () => { store.dispatch(contactsActions.upsert(mockImplicitContact(0))); store.dispatch(contactsActions.upsert(mockImplicitContact(1))); - fixture({}); + render(); const rawInput = screen.getByLabelText("destination"); expect(rawInput).toBeEnabled(); @@ -120,21 +109,23 @@ describe("", () => { const suggestionsContainer = screen.getByTestId("suggestions-list"); const suggestions = within(suggestionsContainer).getAllByRole("listitem"); expect(suggestions).toHaveLength(3); - expect(within(suggestionsContainer).getByText(mockImplicitContact(0).name)).toBeInTheDocument(); - expect(within(suggestionsContainer).getByText(mockImplicitContact(1).name)).toBeInTheDocument(); + expect(within(suggestionsContainer).getByText(mockImplicitContact(0).name)).toBeVisible(); + expect(within(suggestionsContainer).getByText(mockImplicitContact(1).name)).toBeVisible(); // this one is unknown and its full address will be shows - expect(within(suggestionsContainer).getByText(mockImplicitContact(2).pkh)).toBeInTheDocument(); + expect(within(suggestionsContainer).getByText(mockImplicitContact(2).pkh)).toBeVisible(); }); - test("choosing a suggestions submits sets the address and hides suggestions", () => { + test("choosing a suggestion sets the address and hides suggestions", () => { store.dispatch(contactsActions.upsert({ ...mockImplicitContact(1), name: "Abcd" })); - fixture({ - contacts: [ - { ...mockImplicitContact(1), name: "Abcd" }, - mockImplicitContact(2), - mockImplicitContact(3), - ], - }); + render( + + ); const rawInput = screen.getByLabelText("destination"); expect(rawInput).toBeEnabled(); @@ -152,8 +143,23 @@ describe("", () => { expect(suggestionsContainer).not.toBeInTheDocument(); }); + it("works correctly with contacts having the same name", () => { + const contacts = [ + { ...mockImplicitContact(0), name: "Same Name" }, + { ...mockImplicitContact(1), name: "Same Name" }, + ]; + render(); + + fireEvent.focus(screen.getByLabelText("destination")); + fireEvent.mouseDown(screen.getByTestId(`suggestion-${contacts[1].pkh}`)); + + expect(screen.getByTestId("real-address-input-destination")).toHaveValue( + mockImplicitContact(1).pkh + ); + }); + it("displays default address, and does not display any suggestions", () => { - fixture({ defaultDestination: mockImplicitContact(1).pkh }); + render(); const realInput = screen.getByTestId("real-address-input-destination"); @@ -164,7 +170,7 @@ describe("", () => { }); test("when allowUnknown is false it doesn't set the value to an unknown address even if it's valid", () => { - fixture({ allowUnknown: false, contacts: [mockImplicitContact(1)] }); + render(); const rawInput = screen.getByLabelText("destination"); const realInput = screen.getByTestId("real-address-input-destination"); fireEvent.change(rawInput, { target: { value: mockImplicitContact(2).pkh } }); @@ -172,51 +178,24 @@ describe("", () => { expect(rawInput).toHaveValue(mockImplicitContact(2).pkh); expect(realInput).toHaveValue(""); }); -}); - -describe("getSuggestions", () => { - it("returns all contacts if input is empty", () => { - expect(getSuggestions("", [mockImplicitContact(0), mockImplicitContact(1)])).toEqual([ - mockImplicitContact(0), - mockImplicitContact(1), - ]); - }); - - it("returns all contacts if input is a substring of a contact's name", () => { - expect( - getSuggestions("cd", [ - { ...mockImplicitContact(0), name: "abcd" }, - { ...mockImplicitContact(1), name: "efgh" }, - ]) - ).toEqual([{ ...mockImplicitContact(0), name: "abcd" }]); - }); - - it("returns an empty result if nothing matches the input", () => { - expect( - getSuggestions("de", [ - { ...mockImplicitContact(0), name: "abcd" }, - { ...mockImplicitContact(1), name: "efgh" }, - ]) - ).toEqual([]); - }); describe("right icon", () => { it("shows a chevron when the input is empty", () => { - fixture({}); - expect(screen.getByTestId("chevron-icon")).toBeInTheDocument(); + render(); + expect(screen.getByTestId("chevron-icon")).toBeVisible(); expect(screen.queryByTestId("clear-input-button")).not.toBeInTheDocument(); }); it("shows a clear button when the input is not empty", () => { - fixture({}); + render(); const input = screen.getByLabelText("destination"); fireEvent.change(input, { target: { value: "123" } }); expect(screen.queryByTestId("chevron-icon")).not.toBeInTheDocument(); - expect(screen.getByTestId("clear-input-button")).toBeInTheDocument(); + expect(screen.getByTestId("clear-input-button")).toBeVisible(); }); it("clears input and shows suggestions when clear input button is clicked", () => { - fixture({}); + render(); const input = screen.getByLabelText("destination"); fireEvent.focus(input); fireEvent.change(input, { target: { value: "Contact" } }); @@ -230,55 +209,10 @@ describe("getSuggestions", () => { expect(input).toHaveValue(""); expect(screen.queryByTestId("clear-input-button")).not.toBeInTheDocument(); - expect(screen.getByTestId("chevron-icon")).toBeInTheDocument(); + expect(screen.getByTestId("chevron-icon")).toBeVisible(); expect( within(screen.getByTestId("suggestions-list")).queryAllByRole("listitem") ).toHaveLength(3); }); }); }); - -describe("", () => { - it("returns all implicit contacts", () => { - const contact1 = mockImplicitContact(1); - const contact2 = mockImplicitContact(2); - store.dispatch(contactsActions.upsert(contact1)); - store.dispatch(contactsActions.upsert(contact2)); - - const view = renderHook(() => useForm({ defaultValues: { destination: "" } })); - render( - - - - ); - - fireEvent.focus(screen.getByLabelText("destination")); - const suggestions = within(screen.getByTestId("suggestions-list")).queryAllByRole("heading"); - expect(suggestions).toHaveLength(2); - expect(suggestions[0]).toHaveTextContent(contact1.name); - expect(suggestions[1]).toHaveTextContent(contact2.name); - }); - - it("returns contract contacts for selected network only", () => { - store.dispatch(networksActions.setCurrent(GHOSTNET)); - const contact1 = mockContractContact(0, "ghostnet"); - const contact2 = mockContractContact(1, "mainnet"); - const contact3 = mockContractContact(2, "ghostnet"); - store.dispatch(contactsActions.upsert(contact1)); - store.dispatch(contactsActions.upsert(contact2)); - store.dispatch(contactsActions.upsert(contact3)); - - const view = renderHook(() => useForm({ defaultValues: { destination: "" } })); - render( - - - - ); - - fireEvent.focus(screen.getByLabelText("destination")); - const suggestions = within(screen.getByTestId("suggestions-list")).queryAllByRole("heading"); - expect(suggestions).toHaveLength(2); - expect(suggestions[0]).toHaveTextContent(contact1.name); - expect(suggestions[1]).toHaveTextContent(contact3.name); - }); -}); diff --git a/src/components/AddressAutocomplete/AddressAutocomplete.tsx b/src/components/AddressAutocomplete/AddressAutocomplete.tsx index 316c9e79ce..21ac0e0d9b 100644 --- a/src/components/AddressAutocomplete/AddressAutocomplete.tsx +++ b/src/components/AddressAutocomplete/AddressAutocomplete.tsx @@ -6,12 +6,13 @@ import { Input, InputGroup, InputRightElement, - StyleProps, } from "@chakra-ui/react"; import { get } from "lodash"; import { useId, useState } from "react"; -import { FieldValues, Path, RegisterOptions, useFormContext } from "react-hook-form"; +import { FieldValues, Path, useFormContext } from "react-hook-form"; +import { BaseProps } from "./BaseProps"; +import { getSuggestions } from "./getSuggestions"; import { Suggestions } from "./Suggestions"; import { ChevronDownIcon, XMark } from "../../assets/icons"; import colors from "../../style/colors"; @@ -19,7 +20,6 @@ import { Account } from "../../types/Account"; import { isAddressValid, parsePkh } from "../../types/Address"; import { Contact } from "../../types/Contact"; import { useBakerList } from "../../utils/hooks/assetsHooks"; -import { useContactsForSelectedNetwork } from "../../utils/hooks/contactsHooks"; import { useAllAccounts, useGetOwnedSignersForAccount, @@ -27,32 +27,6 @@ import { } from "../../utils/hooks/getAccountDataHooks"; import { AddressTile } from "../AddressTile/AddressTile"; -// is needed to be compatible with the useForm's type parameter (FormData) -// > makes sure that we can pass in only valid inputName that exists in FormData -export type BaseProps> = { - isDisabled?: boolean; - isLoading?: boolean; - inputName: U; - allowUnknown: boolean; - label: string; - // do not set the actual input value to an empty string when the user selects an unknown address or in the mid of typing - // this is useful when the input is used as a select box - // it is assumed that there is at least one valid suggestion present and one of them is selected - // TODO: make a separate selector component for that - keepValid?: boolean; - onUpdate?: (value: string) => void; - validate?: RegisterOptions["validate"]; - style?: StyleProps; - size?: "default" | "short"; - hideBalance?: boolean; // defaults to false -}; - -export const getSuggestions = (inputValue: string, contacts: Contact[]): Contact[] => - contacts.filter( - contact => - !inputValue.trim() || contact.name.toLowerCase().includes(inputValue.trim().toLowerCase()) - ); - export const AddressAutocomplete = >({ contacts, isDisabled, @@ -223,19 +197,6 @@ const CrossButton = (props: IconProps) => ( /> ); -export const KnownAccountsAutocomplete = >( - props: BaseProps -) => { - const contacts = useContactsForSelectedNetwork(); - - const accounts = useAllAccounts().map(account => ({ - name: account.label, - pkh: account.address.pkh, - })); - - return ; -}; - export const OwnedImplicitAccountsAutocomplete = >( props: BaseProps ) => { diff --git a/src/components/AddressAutocomplete/BaseProps.ts b/src/components/AddressAutocomplete/BaseProps.ts new file mode 100644 index 0000000000..c528971aa7 --- /dev/null +++ b/src/components/AddressAutocomplete/BaseProps.ts @@ -0,0 +1,23 @@ +import { StyleProps } from "@chakra-ui/react"; +import { FieldValues, Path, RegisterOptions } from "react-hook-form"; + +// is needed to be compatible with the useForm's type parameter (FormData) +// > makes sure that we can pass in only valid inputName that exists in FormData + +export type BaseProps> = { + isDisabled?: boolean; + isLoading?: boolean; + inputName: U; + allowUnknown: boolean; + label: string; + // do not set the actual input value to an empty string when the user selects an unknown address or in the mid of typing + // this is useful when the input is used as a select box + // it is assumed that there is at least one valid suggestion present and one of them is selected + // TODO: make a separate selector component for that + keepValid?: boolean; + onUpdate?: (value: string) => void; + validate?: RegisterOptions["validate"]; + style?: StyleProps; + size?: "default" | "short"; + hideBalance?: boolean; // defaults to false +}; diff --git a/src/components/AddressAutocomplete/KnownAccountsAutocomplete.test.tsx b/src/components/AddressAutocomplete/KnownAccountsAutocomplete.test.tsx new file mode 100644 index 0000000000..397efc0b77 --- /dev/null +++ b/src/components/AddressAutocomplete/KnownAccountsAutocomplete.test.tsx @@ -0,0 +1,56 @@ +import { FormProvider, useForm } from "react-hook-form"; + +import { KnownAccountsAutocomplete } from "./KnownAccountsAutocomplete"; +import { mockContractContact, mockImplicitContact } from "../../mocks/factories"; +import { fireEvent, render, renderHook, screen, within } from "../../mocks/testUtils"; +import { GHOSTNET } from "../../types/Network"; +import { contactsActions } from "../../utils/redux/slices/contactsSlice"; +import { networksActions } from "../../utils/redux/slices/networks"; +import { store } from "../../utils/redux/store"; + +type FormFields = { destination: string }; + +describe("", () => { + it("returns all implicit contacts", () => { + const contact1 = mockImplicitContact(1); + const contact2 = mockImplicitContact(2); + store.dispatch(contactsActions.upsert(contact1)); + store.dispatch(contactsActions.upsert(contact2)); + + const view = renderHook(() => useForm({ defaultValues: { destination: "" } })); + render( + + + + ); + + fireEvent.focus(screen.getByLabelText("destination")); + const suggestions = within(screen.getByTestId("suggestions-list")).queryAllByRole("heading"); + expect(suggestions).toHaveLength(2); + expect(suggestions[0]).toHaveTextContent(contact1.name); + expect(suggestions[1]).toHaveTextContent(contact2.name); + }); + + it("returns contract contacts for selected network only", () => { + store.dispatch(networksActions.setCurrent(GHOSTNET)); + const contact1 = mockContractContact(0, "ghostnet"); + const contact2 = mockContractContact(1, "mainnet"); + const contact3 = mockContractContact(2, "ghostnet"); + store.dispatch(contactsActions.upsert(contact1)); + store.dispatch(contactsActions.upsert(contact2)); + store.dispatch(contactsActions.upsert(contact3)); + + const view = renderHook(() => useForm({ defaultValues: { destination: "" } })); + render( + + + + ); + + fireEvent.focus(screen.getByLabelText("destination")); + const suggestions = within(screen.getByTestId("suggestions-list")).queryAllByRole("heading"); + expect(suggestions).toHaveLength(2); + expect(suggestions[0]).toHaveTextContent(contact1.name); + expect(suggestions[1]).toHaveTextContent(contact3.name); + }); +}); diff --git a/src/components/AddressAutocomplete/KnownAccountsAutocomplete.tsx b/src/components/AddressAutocomplete/KnownAccountsAutocomplete.tsx new file mode 100644 index 0000000000..60c2375600 --- /dev/null +++ b/src/components/AddressAutocomplete/KnownAccountsAutocomplete.tsx @@ -0,0 +1,18 @@ +import { FieldValues, Path } from "react-hook-form"; +import { useContactsForSelectedNetwork } from "../../utils/hooks/contactsHooks"; +import { useAllAccounts } from "../../utils/hooks/getAccountDataHooks"; +import { AddressAutocomplete } from "./AddressAutocomplete"; +import { BaseProps } from "./BaseProps"; + +export const KnownAccountsAutocomplete = >( + props: BaseProps +) => { + const contacts = useContactsForSelectedNetwork(); + + const accounts = useAllAccounts().map(account => ({ + name: account.label, + pkh: account.address.pkh, + })); + + return ; +}; diff --git a/src/components/AddressAutocomplete/Suggestions.tsx b/src/components/AddressAutocomplete/Suggestions.tsx index b62750a09e..d0d9a1e270 100644 --- a/src/components/AddressAutocomplete/Suggestions.tsx +++ b/src/components/AddressAutocomplete/Suggestions.tsx @@ -39,10 +39,11 @@ export const Suggestions = ({ { // onMouseDown is the only way for this to fire before the onBlur callback of the Input // https://stackoverflow.com/a/28963938/6797267 - onChange(contact.name); + onChange(contact.pkh); }} > { + it("returns all contacts if input is empty", () => { + expect(getSuggestions("", [mockImplicitContact(0), mockImplicitContact(1)])).toEqual([ + mockImplicitContact(0), + mockImplicitContact(1), + ]); + }); + + it("returns all contacts if input is a substring of a contact's name", () => { + expect( + getSuggestions("cd", [ + { ...mockImplicitContact(0), name: "abcd" }, + { ...mockImplicitContact(1), name: "efgh" }, + ]) + ).toEqual([{ ...mockImplicitContact(0), name: "abcd" }]); + }); + + it("returns an empty result if nothing matches the input", () => { + expect( + getSuggestions("de", [ + { ...mockImplicitContact(0), name: "abcd" }, + { ...mockImplicitContact(1), name: "efgh" }, + ]) + ).toEqual([]); + }); +}); diff --git a/src/components/AddressAutocomplete/getSuggestions.ts b/src/components/AddressAutocomplete/getSuggestions.ts new file mode 100644 index 0000000000..7c15dd70ce --- /dev/null +++ b/src/components/AddressAutocomplete/getSuggestions.ts @@ -0,0 +1,7 @@ +import { Contact } from "../../types/Contact"; + +export const getSuggestions = (inputValue: string, contacts: Contact[]): Contact[] => + contacts.filter( + contact => + !inputValue.trim() || contact.name.toLowerCase().includes(inputValue.trim().toLowerCase()) + ); diff --git a/src/components/AddressAutocomplete/index.tsx b/src/components/AddressAutocomplete/index.tsx index dfc6b78596..92d93821ea 100644 --- a/src/components/AddressAutocomplete/index.tsx +++ b/src/components/AddressAutocomplete/index.tsx @@ -1 +1,2 @@ export * from "./AddressAutocomplete"; +export * from "./KnownAccountsAutocomplete";