Merge pull request #1507 from Schrobo/fix/calendar-contact-group-attendees

fix: expand contact groups into calendar attendees
This commit is contained in:
Linus Rath
2026-11-03 00:02:05 +01:00
committed by GitHub
3 changed files with 188 additions and 11 deletions
@@ -0,0 +1,60 @@
import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventModal } from '../event-modal';
import { useContactStore } from '@/stores/contact-store';
import { createDemoContacts } from '@/lib/demo/fixtures/contacts';
import { createDemoCalendars } from '@/lib/demo/fixtures/calendars';
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
beforeEach(() => useContactStore.setState({
contacts: createDemoContacts(), contactsLoaded: true, directoryPrincipals: [], recentRecipients: [],
}));
async function saveAfterSelecting(queries: string | string[], maxParticipants?: number) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<EventModal calendars={createDemoCalendars()} onSave={onSave} onClose={vi.fn()} currentUserEmails={['nora@meridianlabs.example']} maxParticipants={maxParticipants} />);
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Issue 1505 synthetic reproduction' } });
for (const query of typeof queries === 'string' ? [queries] : queries) {
fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: query } });
const option = await waitFor(() => within(screen.getByRole('listbox')).getAllByRole('option', { name: new RegExp(query) })[0]);
fireEvent.mouseDown(option);
}
fireEvent.click(screen.getByText('form.save'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
return onSave.mock.calls[0];
}
describe('Issue #1505: actual event editor and save payload', () => {
it('control: individual contact survives selection and reaches saved attendees', async () => {
const [, , metadata] = await saveAfterSelecting('Alice');
expect(metadata.attendees).toHaveLength(1);
});
it('selecting Beacon team includes its five members in the saved attendees', async () => {
const [data, , metadata] = await saveAfterSelecting('Beacon team');
expect(metadata.attendees.map((a: { email: string }) => a.email)).toEqual([
'alice.johnson@meridianlabs.example', 'bob.chen@meridianlabs.example',
'priya.sharma@meridianlabs.example', 'ahmed.hassan@meridianlabs.example', 'lisa.tanaka@meridianlabs.example',
]);
expect(Object.values(data.participants)).toHaveLength(6);
});
it('keeps an existing attendee once when adding their group', async () => {
const [, , metadata] = await saveAfterSelecting(['Alice', 'Beacon team']);
expect(metadata.attendees).toHaveLength(5);
expect(metadata.attendees.filter((a: { email: string }) => a.email === 'alice.johnson@meridianlabs.example')).toHaveLength(1);
});
it('adds two overlapping groups without losing or duplicating attendees', async () => {
const [, , metadata] = await saveAfterSelecting(['Beacon team', 'Leadership']);
expect(metadata.attendees).toHaveLength(7);
expect(new Set(metadata.attendees.map((a: { email: string }) => a.email)).size).toBe(7);
});
it('warns when group members plus the organizer exceed the server participant limit', async () => {
await saveAfterSelecting('Beacon team', 5);
expect(screen.getByRole('status')).toHaveTextContent('participants.limit_warning');
});
});
@@ -0,0 +1,92 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ParticipantInput } from '../participant-input';
import { useContactStore } from '@/stores/contact-store';
import type { ContactCard } from '@/lib/jmap/types';
import { createDemoContacts } from '@/lib/demo/fixtures/contacts';
beforeEach(() => {
vi.useFakeTimers();
useContactStore.setState({ contacts: createDemoContacts(), contactsLoaded: true, directoryPrincipals: [], recentRecipients: [] });
});
afterEach(() => vi.useRealTimers());
function search(query: string, participants: Array<{ name: string; email: string }> = []) {
const onAdd = vi.fn();
render(<ParticipantInput participants={participants} onAdd={onAdd} onRemove={vi.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: query } });
act(() => vi.advanceTimersByTime(200));
return { input, onAdd };
}
describe('Issue #1505: real contact store and real calendar participant input', () => {
it('control: clicking an individual contact adds that attendee', () => {
const { onAdd } = search('Alice');
fireEvent.mouseDown(screen.getAllByRole('option')[0]);
expect(onAdd).toHaveBeenCalledTimes(1);
expect(onAdd.mock.calls[0][0].email).toContain('@');
});
it('clicking Beacon team adds its five members as attendees', () => {
const { onAdd } = search('Beacon team');
fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon team/ }));
expect(onAdd).toHaveBeenCalledTimes(5);
expect(screen.getByRole('combobox')).toHaveValue('');
});
it('selecting Beacon team with the keyboard adds its five members', () => {
const { input, onAdd } = search('Beacon team');
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onAdd).toHaveBeenCalledTimes(5);
});
it('adds the only member of a one-person group', () => {
const contacts = createDemoContacts();
const member = contacts.find(c => c.id === 'demo-contact-alice')!;
const group = contacts.find(c => c.id === 'demo-group-beacon')!;
useContactStore.setState({ contacts: [member, { ...group, members: { [member.id]: true } }] });
const { onAdd } = search('Beacon team');
fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon team/ }));
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('marks group suggestions with a translated group label and member count', () => {
search('Beacon team');
expect(screen.getByTitle('directory.type.group')).toBeInTheDocument();
expect(screen.getByText('groups.member_count')).toBeInTheDocument();
});
it('adds each usable address once, skipping existing attendees and missing or invalid emails', () => {
const contacts: ContactCard[] = [
{ addressBookIds: { book: true }, id: 'group', kind: 'group', name: { full: 'Test group' }, members: { a: true, b: true, c: true, d: true, e: true, f: true } },
{ addressBookIds: { book: true }, id: 'a', kind: 'individual', name: { full: 'Existing' }, emails: { e: { address: 'EXISTING@example.com' } } },
{ addressBookIds: { book: true }, id: 'b', kind: 'individual', name: { full: 'New' }, emails: { e: { address: ' new@example.com ' } } },
{ addressBookIds: { book: true }, id: 'c', kind: 'individual', name: { full: 'Duplicate' }, emails: { e: { address: 'NEW@example.com' } } },
{ addressBookIds: { book: true }, id: 'd', kind: 'individual', name: { full: 'Missing' } },
{ addressBookIds: { book: true }, id: 'e', kind: 'individual', name: { full: 'Invalid' }, emails: { e: { address: 'not-an-email' } } },
{ addressBookIds: { book: true }, id: 'f', kind: 'individual', name: { full: 'Another' }, emails: { e: { address: 'another@example.com' } } },
];
useContactStore.setState({ contacts });
const { onAdd } = search('Test group', [{ name: 'Already invited', email: 'existing@example.com' }]);
fireEvent.mouseDown(screen.getByRole('option', { name: /Test group/ }));
expect(onAdd.mock.calls.map(([person]) => person)).toEqual([
{ name: 'New', email: 'new@example.com' },
{ name: 'Another', email: 'another@example.com' },
]);
});
it('keeps two matching groups selectable as separate suggestions', () => {
const contacts = createDemoContacts();
const group = contacts.find(c => c.id === 'demo-group-beacon')!;
useContactStore.setState({ contacts: [...contacts, { ...group, id: 'other-group', name: { full: 'Beacon other team' } }] });
const { onAdd } = search('Beacon');
expect(screen.getAllByRole('option')).toHaveLength(2);
fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon other team/ }));
expect(onAdd).toHaveBeenCalledTimes(5);
});
});
+36 -11
View File
@@ -3,16 +3,20 @@
import { useState, useRef, useCallback, useEffect, useLayoutEffect, forwardRef, useImperativeHandle } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { X } from "@/components/icons";
import { X, Users } from "@/components/icons";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { useContactStore } from "@/stores/contact-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
interface Participant {
name: string;
email: string;
}
interface ParticipantSuggestion extends Participant {
group?: { id: string; memberCount: number };
}
interface ParticipantInputProps {
participants: Participant[];
onAdd: (participant: Participant) => void;
@@ -30,8 +34,9 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled, id }, ref) {
const t = useTranslations("calendar.participants");
const tContacts = useTranslations("contacts");
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]);
const [suggestions, setSuggestions] = useState<ParticipantSuggestion[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -69,7 +74,7 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
}
const results = useContactStore.getState().getAutocomplete(q);
const existing = new Set(participants.map(p => p.email.toLowerCase()));
const filtered = results.filter(r => !existing.has(r.email.toLowerCase()));
const filtered = results.filter(r => r.group || !existing.has(r.email.toLowerCase()));
setSuggestions(filtered.slice(0, 8));
setShowSuggestions(filtered.length > 0);
setActiveIndex(-1);
@@ -87,10 +92,20 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
};
}, []);
const addParticipant = useCallback((p: Participant) => {
if (!p.email || !EMAIL_REGEX.test(p.email)) return;
if (participants.some(e => e.email.toLowerCase() === p.email.toLowerCase())) return;
onAdd(p);
const addParticipant = useCallback((p: ParticipantSuggestion) => {
if (!p.group && (!EMAIL_REGEX.test(p.email) || participants.some(e => e.email.toLowerCase() === p.email.toLowerCase()))) return;
const candidates = p.group
? useContactStore.getState().getGroupMembers(p.group.id).map(contact => ({
name: getContactDisplayName(contact),
email: getContactPrimaryEmail(contact).trim(),
}))
: [p];
const existing = new Set(participants.map(participant => participant.email.toLowerCase()));
for (const candidate of candidates) {
if (!EMAIL_REGEX.test(candidate.email) || existing.has(candidate.email.toLowerCase())) continue;
existing.add(candidate.email.toLowerCase());
onAdd({ name: candidate.name, email: candidate.email });
}
setQuery("");
setSuggestions([]);
setShowSuggestions(false);
@@ -174,7 +189,7 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
>
{suggestions.map((s, i) => (
<li
key={s.email}
key={s.group ? `group-${s.group.id}` : s.email}
id={`suggestion-${i}`}
role="option"
aria-selected={i === activeIndex}
@@ -183,10 +198,20 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[0.625rem]" />
{s.group ? (
<span title={tContacts("directory.type.group")} className="shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-4 h-4 text-primary" aria-hidden />
</span>
) : (
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[0.625rem]" />
)}
<div className="min-w-0">
<div className="font-medium truncate" title={s.name || s.email}>{s.name || s.email}</div>
{s.name && (
{s.group ? (
<div className="text-xs text-muted-foreground truncate">
{tContacts("groups.member_count", { count: s.group.memberCount })}
</div>
) : s.name && (
<div className="text-xs text-muted-foreground truncate" title={s.email}>{s.email}</div>
)}
</div>