c9710d7651
When pasting text containing multiple email addresses into the attendee input, the component now splits by comma, semicolon, newline, or space and adds each valid email as an individual attendee chip. Duplicates are silently skipped. Any invalid chunks remain in the input field with an error message so the user can correct them manually. Co-authored-by: JacobiusMakes <jgalperin98@gmail.com>
413 lines
11 KiB
TypeScript
413 lines
11 KiB
TypeScript
import { PeopleSearch, User } from "@/components/Attendees/PeopleSearch";
|
|
import { searchUsers } from "@/features/User/userAPI";
|
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
|
|
|
jest.mock("@/features/User/userAPI");
|
|
const mockedSearchUsers = searchUsers as jest.MockedFunction<
|
|
typeof searchUsers
|
|
>;
|
|
|
|
describe("PeopleSearch", () => {
|
|
const baseUser: User = {
|
|
email: "test@example.com",
|
|
displayName: "Test User",
|
|
avatarUrl: "https://example.com/avatar.png",
|
|
openpaasId: "1234567890",
|
|
};
|
|
|
|
function setup(
|
|
selectedUsers: User[] = [],
|
|
props?: Partial<React.ComponentProps<typeof PeopleSearch>>
|
|
) {
|
|
const onChange = jest.fn();
|
|
renderWithProviders(
|
|
<PeopleSearch
|
|
objectTypes={["user"]}
|
|
selectedUsers={selectedUsers}
|
|
onChange={onChange}
|
|
{...props}
|
|
/>
|
|
);
|
|
return { onChange };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
mockedSearchUsers.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it("calls searchUsers after debounce when typing", async () => {
|
|
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
|
|
setup();
|
|
|
|
const input = screen.getByRole("combobox");
|
|
await userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
|
|
});
|
|
});
|
|
|
|
it("renders search results and allows selection", async () => {
|
|
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
|
|
const { onChange } = setup();
|
|
|
|
const input = screen.getByRole("combobox");
|
|
await userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
const option = await screen.findByText("Test User");
|
|
await userEvent.click(option);
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("does not show already selected users in options", async () => {
|
|
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
|
|
setup([baseUser]);
|
|
const input = screen.getByRole("combobox");
|
|
await userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("triggers onToggleEventPreview on Enter key press", () => {
|
|
const onToggleEventPreview = jest.fn();
|
|
setup([], { onToggleEventPreview });
|
|
|
|
const input = screen.getByRole("combobox");
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(onToggleEventPreview).toHaveBeenCalled();
|
|
});
|
|
|
|
it("respects disabled state", () => {
|
|
setup([], { disabled: true });
|
|
expect(screen.getByRole("combobox")).toBeDisabled();
|
|
});
|
|
|
|
it("no options doesn't show dropdown when input is empty", async () => {
|
|
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
|
|
setup();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
|
});
|
|
|
|
userEvent.clear(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows 'No results' when search succeeds but returns empty array", async () => {
|
|
mockedSearchUsers.mockResolvedValueOnce([]);
|
|
setup();
|
|
|
|
const input = screen.getByRole("combobox");
|
|
await userEvent.type(input, "Test");
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const noResults = await screen.findByText(
|
|
"peopleSearch.noResults",
|
|
{},
|
|
{ timeout: 5000 }
|
|
);
|
|
expect(noResults).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not clear options when search fails and shows error snackbar", async () => {
|
|
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
|
|
setup();
|
|
|
|
const input = screen.getByRole("combobox");
|
|
await userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Test User")).toBeInTheDocument();
|
|
});
|
|
|
|
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error"));
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, "Error");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const errorMessage = await screen.findByText("peopleSearch.searchError");
|
|
expect(errorMessage).toBeInTheDocument();
|
|
|
|
expect(screen.queryByText("Test User")).not.toBeInTheDocument();
|
|
|
|
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Test User")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows loading text when searching", async () => {
|
|
let resolveSearch: (value: User[]) => void;
|
|
const searchPromise = new Promise<User[]>((resolve) => {
|
|
resolveSearch = resolve;
|
|
});
|
|
mockedSearchUsers.mockReturnValueOnce(searchPromise);
|
|
setup();
|
|
|
|
const input = screen.getByRole("combobox");
|
|
await userEvent.type(input, "Test");
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
const loadingText = await screen.findByText(
|
|
"peopleSearch.loading",
|
|
{},
|
|
{ timeout: 5000 }
|
|
);
|
|
expect(loadingText).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
resolveSearch!([baseUser]);
|
|
await searchPromise;
|
|
});
|
|
});
|
|
|
|
describe("paste multiple attendees", () => {
|
|
function setupFreeSolo(selectedUsers: User[] = []) {
|
|
const onChange = jest.fn();
|
|
renderWithProviders(
|
|
<PeopleSearch
|
|
objectTypes={["user"]}
|
|
selectedUsers={selectedUsers}
|
|
onChange={onChange}
|
|
freeSolo
|
|
/>
|
|
);
|
|
return { onChange };
|
|
}
|
|
|
|
it("splits pasted comma-separated emails into individual attendees", async () => {
|
|
const { onChange } = setupFreeSolo();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com, bob@example.com",
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ email: "alice@example.com" }),
|
|
expect.objectContaining({ email: "bob@example.com" }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
it("splits pasted semicolon-separated emails", async () => {
|
|
const { onChange } = setupFreeSolo();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com;bob@example.com",
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ email: "alice@example.com" }),
|
|
expect.objectContaining({ email: "bob@example.com" }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
it("splits pasted newline-separated emails", async () => {
|
|
const { onChange } = setupFreeSolo();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com\nbob@example.com",
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ email: "alice@example.com" }),
|
|
expect.objectContaining({ email: "bob@example.com" }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
it("splits pasted space-separated emails", async () => {
|
|
const { onChange } = setupFreeSolo();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com bob@example.com",
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ email: "alice@example.com" }),
|
|
expect.objectContaining({ email: "bob@example.com" }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
it("skips duplicate emails already in selectedUsers", async () => {
|
|
const existing: User = {
|
|
email: "alice@example.com",
|
|
displayName: "Alice",
|
|
};
|
|
const { onChange } = setupFreeSolo([existing]);
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com, bob@example.com",
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
existing,
|
|
expect.objectContaining({ email: "bob@example.com" }),
|
|
])
|
|
);
|
|
// Should NOT have alice duplicated
|
|
const call = onChange.mock.calls[0][1];
|
|
const aliceCount = call.filter(
|
|
(u: User) => u.email === "alice@example.com"
|
|
).length;
|
|
expect(aliceCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("leaves invalid text in input when some emails are invalid", async () => {
|
|
const { onChange } = setupFreeSolo();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com, not-an-email, bob@example.com",
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ email: "alice@example.com" }),
|
|
expect.objectContaining({ email: "bob@example.com" }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
it("does not intercept paste of a single email", async () => {
|
|
const { onChange } = setupFreeSolo();
|
|
const input = screen.getByRole("combobox");
|
|
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
getData: () => "alice@example.com",
|
|
},
|
|
});
|
|
|
|
// Single email should NOT trigger multi-paste handler
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("retains input value when field loses focus (blur)", async () => {
|
|
let resolveSearch: (value: User[]) => void;
|
|
const searchPromise = new Promise<User[]>((resolve) => {
|
|
resolveSearch = resolve;
|
|
});
|
|
mockedSearchUsers.mockReturnValueOnce(searchPromise);
|
|
await act(async () => {
|
|
setup();
|
|
});
|
|
|
|
const input = screen.getByRole("combobox");
|
|
|
|
await act(async () => {
|
|
userEvent.type(input, "Test");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
|
|
});
|
|
|
|
expect(input).toHaveValue("Test");
|
|
|
|
await act(async () => {
|
|
input.blur();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(input).toHaveValue("Test");
|
|
});
|
|
});
|
|
});
|