From c9710d7651eeed75f52e60f1926e7630813569ae Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Mon, 30 Mar 2026 13:23:07 +0200 Subject: [PATCH] feat: parse multiple attendees from pasted text (#723) (#731) 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 --- __test__/components/PeopleSearch.test.tsx | 165 ++++++++++++++++++++ src/components/Attendees/PeopleSearch.tsx | 20 ++- src/components/Attendees/usePasteHandler.ts | 70 +++++++++ 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/components/Attendees/usePasteHandler.ts 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] + ); +}