mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
fix: expand contact groups into calendar attendees
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user