Skip to content

Commit

Permalink
Fix autosuggestions when contacts have the same name
Browse files Browse the repository at this point in the history
  • Loading branch information
serjonya-trili committed May 26, 2024
1 parent ce0f538 commit 7706949
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 166 deletions.
180 changes: 57 additions & 123 deletions src/components/AddressAutocomplete/AddressAutocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -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<FormFields>({ defaultValues: { destination: defaultDestination } })
);
render(
<FormProvider {...view.result.current}>
const form = useForm<FormFields>({ defaultValues: { destination: defaultDestination } });

return (
<FormProvider {...form}>
<AddressAutocomplete
allowUnknown={allowUnknown}
contacts={contacts}
Expand All @@ -50,7 +39,7 @@ const fixture = ({

describe("<AddressAutocomplete />", () => {
it("should set the real input when a valid pkh is entered by the user", () => {
fixture({});
render(<TestComponent />);

const rawInput = screen.getByLabelText("destination");
const realInput = screen.getByTestId("real-address-input-destination");
Expand All @@ -61,7 +50,7 @@ describe("<AddressAutocomplete />", () => {
});

test("the input is never shown when keepValid is set to true, but suggestions are available", () => {
fixture({ defaultDestination: mockImplicitContact(0).pkh, keepValid: true });
render(<TestComponent defaultDestination={mockImplicitContact(0).pkh} keepValid />);

const realInput = screen.getByTestId("real-address-input-destination");
expect(realInput).toHaveValue(mockImplicitContact(0).pkh);
Expand All @@ -70,7 +59,7 @@ describe("<AddressAutocomplete />", () => {

// 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);
Expand All @@ -81,12 +70,12 @@ describe("<AddressAutocomplete />", () => {
});

it("hides suggestions by default", () => {
fixture({});
render(<TestComponent />);
expect(screen.queryByTestId("suggestions-list")).not.toBeInTheDocument();
});

it("shows suggestions when the input is focused", () => {
fixture({});
render(<TestComponent />);

const rawInput = screen.getByLabelText("destination");
fireEvent.focus(rawInput);
Expand All @@ -97,7 +86,7 @@ describe("<AddressAutocomplete />", () => {
});

it("hides suggestions if input is an exact suggestion", () => {
fixture({});
render(<TestComponent />);

const rawInput = screen.getByLabelText("destination");

Expand All @@ -109,7 +98,7 @@ describe("<AddressAutocomplete />", () => {
it("displays suggestions if user input has suggestions", () => {
store.dispatch(contactsActions.upsert(mockImplicitContact(0)));
store.dispatch(contactsActions.upsert(mockImplicitContact(1)));
fixture({});
render(<TestComponent />);

const rawInput = screen.getByLabelText("destination");
expect(rawInput).toBeEnabled();
Expand All @@ -120,21 +109,23 @@ describe("<AddressAutocomplete />", () => {
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(
<TestComponent
contacts={[
{ ...mockImplicitContact(1), name: "Abcd" },
mockImplicitContact(2),
mockImplicitContact(3),
]}
/>
);

const rawInput = screen.getByLabelText("destination");
expect(rawInput).toBeEnabled();
Expand All @@ -152,8 +143,23 @@ describe("<AddressAutocomplete />", () => {
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(<TestComponent contacts={contacts} />);

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(<TestComponent defaultDestination={mockImplicitContact(1).pkh} />);

const realInput = screen.getByTestId("real-address-input-destination");

Expand All @@ -164,59 +170,32 @@ describe("<AddressAutocomplete />", () => {
});

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(<TestComponent allowUnknown={false} contacts={[mockImplicitContact(1)]} />);
const rawInput = screen.getByLabelText("destination");
const realInput = screen.getByTestId("real-address-input-destination");
fireEvent.change(rawInput, { target: { value: mockImplicitContact(2).pkh } });

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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
const input = screen.getByLabelText("destination");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "Contact" } });
Expand All @@ -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("<KnownAccountsAutocomplete />", () => {
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<FormFields>({ defaultValues: { destination: "" } }));
render(
<FormProvider {...view.result.current}>
<KnownAccountsAutocomplete allowUnknown={true} inputName="destination" label="" />
</FormProvider>
);

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<FormFields>({ defaultValues: { destination: "" } }));
render(
<FormProvider {...view.result.current}>
<KnownAccountsAutocomplete allowUnknown={true} inputName="destination" label="" />
</FormProvider>
);

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);
});
});
45 changes: 3 additions & 42 deletions src/components/AddressAutocomplete/AddressAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,27 @@ 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";
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,
useImplicitAccounts,
} from "../../utils/hooks/getAccountDataHooks";
import { AddressTile } from "../AddressTile/AddressTile";

// <T extends FieldValues> is needed to be compatible with the useForm's type parameter (FormData)
// <U extends Path<T>> makes sure that we can pass in only valid inputName that exists in FormData
export type BaseProps<T extends FieldValues, U extends Path<T>> = {
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<T, U>["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 = <T extends FieldValues, U extends Path<T>>({
contacts,
isDisabled,
Expand Down Expand Up @@ -223,19 +197,6 @@ const CrossButton = (props: IconProps) => (
/>
);

export const KnownAccountsAutocomplete = <T extends FieldValues, U extends Path<T>>(
props: BaseProps<T, U>
) => {
const contacts = useContactsForSelectedNetwork();

const accounts = useAllAccounts().map(account => ({
name: account.label,
pkh: account.address.pkh,
}));

return <AddressAutocomplete {...props} contacts={contacts.concat(accounts)} />;
};

export const OwnedImplicitAccountsAutocomplete = <T extends FieldValues, U extends Path<T>>(
props: BaseProps<T, U>
) => {
Expand Down
Loading

0 comments on commit 7706949

Please sign in to comment.