From a8106d9617efbbf5aee88cf90c8b9406bc12ed3d Mon Sep 17 00:00:00 2001 From: Szymon Chroboczek Date: Mon, 2 Nov 2026 23:10:47 +0100 Subject: [PATCH] fix: expand contact groups into calendar attendees --- .../event-modal-contact-groups.test.tsx | 60 ++++++++++++ .../participant-input-groups.test.tsx | 92 +++++++++++++++++++ components/calendar/participant-input.tsx | 47 +++++++--- 3 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-contact-groups.test.tsx create mode 100644 components/calendar/__tests__/participant-input-groups.test.tsx diff --git a/components/calendar/__tests__/event-modal-contact-groups.test.tsx b/components/calendar/__tests__/event-modal-contact-groups.test.tsx new file mode 100644 index 000000000..35b17b039 --- /dev/null +++ b/components/calendar/__tests__/event-modal-contact-groups.test.tsx @@ -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(); + 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'); + }); +}); diff --git a/components/calendar/__tests__/participant-input-groups.test.tsx b/components/calendar/__tests__/participant-input-groups.test.tsx new file mode 100644 index 000000000..aa30b14de --- /dev/null +++ b/components/calendar/__tests__/participant-input-groups.test.tsx @@ -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(); + 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); + }); + +}); diff --git a/components/calendar/participant-input.tsx b/components/calendar/participant-input.tsx index 37edd7b56..abfe832b5 100644 --- a/components/calendar/participant-input.tsx +++ b/components/calendar/participant-input.tsx @@ -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(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([]); + const [suggestions, setSuggestions] = useState([]); const [activeIndex, setActiveIndex] = useState(-1); const [showSuggestions, setShowSuggestions] = useState(false); const inputRef = useRef(null); @@ -69,7 +74,7 @@ export const ParticipantInput = forwardRef 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 { - 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 {suggestions.map((s, i) => (
  • - + {s.group ? ( + + + + ) : ( + + )}
    {s.name || s.email}
    - {s.name && ( + {s.group ? ( +
    + {tContacts("groups.member_count", { count: s.group.memberCount })} +
    + ) : s.name && (
    {s.email}
    )}