diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx index 79463fa..c3c9f1a 100644 --- a/__test__/components/PeopleSearch.test.tsx +++ b/__test__/components/PeopleSearch.test.tsx @@ -214,6 +214,171 @@ describe("PeopleSearch", () => { }); }); + describe("paste multiple attendees", () => { + function setupFreeSolo(selectedUsers: User[] = []) { + const onChange = jest.fn(); + renderWithProviders( + + ); + 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((resolve) => { diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index 63a8d93..1eeb45c 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -27,6 +27,7 @@ import { import { useI18n } from "twake-i18n"; import { ResourceIcon } from "./ResourceIcon"; import { isValidEmail } from "../../utils/isValidEmail"; +import { usePasteHandler } from "./usePasteHandler"; export interface User { email: string; @@ -124,6 +125,15 @@ export function PeopleSearch({ [query, selectedUsers, onChange, t, setInputError, setQuery] ); + const handlePaste = usePasteHandler({ + freeSolo, + selectedUsers, + onChange, + setQuery, + setInputError, + t, + }); + const defaultRenderInput = useCallback( (params: AutocompleteRenderInputParams) => { const inputProps = { @@ -150,6 +160,7 @@ export function PeopleSearch({ inputProps: { ...params.inputProps, autoComplete: "off", + onPaste: handlePaste, }, }; @@ -206,7 +217,14 @@ export function PeopleSearch({ ); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [inputError, t, onToggleEventPreview, loading, searchPlaceholder] + [ + inputError, + t, + onToggleEventPreview, + loading, + searchPlaceholder, + handlePaste, + ] ); return ( diff --git a/src/components/Attendees/usePasteHandler.ts b/src/components/Attendees/usePasteHandler.ts new file mode 100644 index 0000000..c4139d6 --- /dev/null +++ b/src/components/Attendees/usePasteHandler.ts @@ -0,0 +1,70 @@ +import { useCallback } from "react"; +import type { SyntheticEvent } from "react"; +import { isValidEmail } from "../../utils/isValidEmail"; +import type { User } from "./PeopleSearch"; + +export function usePasteHandler({ + freeSolo, + selectedUsers, + onChange, + setQuery, + setInputError, + t, +}: { + freeSolo?: boolean; + selectedUsers: User[]; + onChange: (event: SyntheticEvent, users: User[]) => void; + setQuery: (value: string) => void; + setInputError: (error: string | null) => void; + t: (key: string) => string; +}) { + return useCallback( + (event: React.ClipboardEvent) => { + if (!freeSolo) return; + + const pasted = event.clipboardData.getData("text/plain"); + if (!pasted) return; + + // Split by comma, semicolon, newline, or whitespace + const chunks = pasted + .split(/[,;\n\r\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + + // If there is only one chunk, let the default Autocomplete behaviour handle it + if (chunks.length <= 1) return; + + event.preventDefault(); + + const existingEmails = new Set(selectedUsers.map((u) => u.email)); + const validUsers: User[] = []; + const invalid: string[] = []; + + for (const chunk of chunks) { + if (!isValidEmail(chunk)) { + invalid.push(chunk); + } else if (!existingEmails.has(chunk)) { + existingEmails.add(chunk); + validUsers.push({ email: chunk, displayName: chunk }); + } + // silently skip duplicates + } + + if (validUsers.length > 0) { + onChange(event, [...selectedUsers, ...validUsers]); + } + + if (invalid.length > 0) { + // Leave the invalid text in the input for manual correction + setQuery(invalid.join(", ")); + setInputError( + t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", ")) + ); + } else { + setQuery(""); + setInputError(null); + } + }, + [freeSolo, selectedUsers, onChange, setQuery, setInputError, t] + ); +}