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 <jgalperin98@gmail.com>
This commit is contained in:
Benoit TELLIER
2026-03-30 13:23:07 +02:00
committed by GitHub
parent 7cf3f094a1
commit c9710d7651
3 changed files with 254 additions and 1 deletions
+165
View File
@@ -214,6 +214,171 @@ describe("PeopleSearch", () => {
}); });
}); });
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 () => { it("retains input value when field loses focus (blur)", async () => {
let resolveSearch: (value: User[]) => void; let resolveSearch: (value: User[]) => void;
const searchPromise = new Promise<User[]>((resolve) => { const searchPromise = new Promise<User[]>((resolve) => {
+19 -1
View File
@@ -27,6 +27,7 @@ import {
import { useI18n } from "twake-i18n"; import { useI18n } from "twake-i18n";
import { ResourceIcon } from "./ResourceIcon"; import { ResourceIcon } from "./ResourceIcon";
import { isValidEmail } from "../../utils/isValidEmail"; import { isValidEmail } from "../../utils/isValidEmail";
import { usePasteHandler } from "./usePasteHandler";
export interface User { export interface User {
email: string; email: string;
@@ -124,6 +125,15 @@ export function PeopleSearch({
[query, selectedUsers, onChange, t, setInputError, setQuery] [query, selectedUsers, onChange, t, setInputError, setQuery]
); );
const handlePaste = usePasteHandler({
freeSolo,
selectedUsers,
onChange,
setQuery,
setInputError,
t,
});
const defaultRenderInput = useCallback( const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => { (params: AutocompleteRenderInputParams) => {
const inputProps = { const inputProps = {
@@ -150,6 +160,7 @@ export function PeopleSearch({
inputProps: { inputProps: {
...params.inputProps, ...params.inputProps,
autoComplete: "off", autoComplete: "off",
onPaste: handlePaste,
}, },
}; };
@@ -206,7 +217,14 @@ export function PeopleSearch({
); );
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[inputError, t, onToggleEventPreview, loading, searchPlaceholder] [
inputError,
t,
onToggleEventPreview,
loading,
searchPlaceholder,
handlePaste,
]
); );
return ( return (
@@ -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<HTMLInputElement>) => {
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]
);
}