fix: persist typed calendar participant on save without requiring Enter #206
This commit is contained in:
@@ -9,7 +9,7 @@ import { format, parseISO, addHours, addDays } from "date-fns";
|
|||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -233,6 +233,7 @@ export function EventModal({
|
|||||||
.map(p => ({ name: p.name, email: p.email }));
|
.map(p => ({ name: p.name, email: p.email }));
|
||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
|
||||||
// Report live preview to parent for grid outline
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -264,6 +265,9 @@ export function EventModal({
|
|||||||
if (!trimmedTitle || isSaving) return;
|
if (!trimmedTitle || isSaving) return;
|
||||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||||
|
|
||||||
|
const pendingAttendee = participantInputRef.current?.flush() ?? null;
|
||||||
|
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
|
||||||
|
|
||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
? `${startDate}T00:00:00`
|
? `${startDate}T00:00:00`
|
||||||
: `${startDate}T${startTime}:00`;
|
: `${startDate}T${startTime}:00`;
|
||||||
@@ -377,20 +381,20 @@ export function EventModal({
|
|||||||
data.alerts = null;
|
data.alerts = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||||
const organizerEmail = currentUserEmails[0];
|
const organizerEmail = currentUserEmails[0];
|
||||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||||
data.participants = buildParticipantMap(
|
data.participants = buildParticipantMap(
|
||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
attendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||||
} else if (attendees.length === 0 && event?.participants) {
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
data.replyTo = null;
|
data.replyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(data, shouldSendScheduling);
|
await onSave(data, shouldSendScheduling);
|
||||||
@@ -843,6 +847,7 @@ export function EventModal({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
|
ref={participantInputRef}
|
||||||
participants={attendees}
|
participants={attendees}
|
||||||
onAdd={handleAddAttendee}
|
onAdd={handleAddAttendee}
|
||||||
onRemove={handleRemoveAttendee}
|
onRemove={handleRemoveAttendee}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParticipantInputHandle {
|
||||||
|
flush: () => Participant | null;
|
||||||
|
}
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
|
||||||
const t = useTranslations("calendar.participants");
|
const t = useTranslations("calendar.participants");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||||
@@ -86,8 +90,28 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setTimeout(() => setShowSuggestions(false), 200);
|
setTimeout(() => {
|
||||||
}, []);
|
setShowSuggestions(false);
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||||
|
addParticipant({ name: "", email: trimmed });
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}, [query, addParticipant]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
flush: () => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
|
||||||
|
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
|
||||||
|
const p = { name: "", email: trimmed };
|
||||||
|
onAdd(p);
|
||||||
|
setQuery("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
}), [query, participants, onAdd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -160,4 +184,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user