Kyle Pope f64e181fbe Fix template form: location picker auto-open and event title wording
- LocationPicker: skip initial mount effect so dropdown doesn't auto-open
  when form loads with an existing location value
- EventForm: separate templateData/templateName props from event prop so
  template-based creation shows "Create Event from X Template" title
  instead of "Edit Event", and correctly uses Create button + no Delete
- CalendarPage: pass templateName through to EventForm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:10:31 +08:00

466 lines
17 KiB
TypeScript

import { useState, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location, RecurrenceRule } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
SheetClose,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import LocationPicker from '@/components/ui/location-picker';
interface EventFormProps {
event: CalendarEvent | null;
templateData?: Partial<CalendarEvent> | null;
templateName?: string | null;
initialStart?: string | null;
initialEnd?: string | null;
initialAllDay?: boolean;
editScope?: 'this' | 'this_and_future' | null;
onClose: () => void;
}
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16);
return `${dt}T${fallbackTime}`;
}
function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
}
/** FullCalendar uses exclusive end dates for all-day events. Subtract 1 day for display. */
function adjustAllDayEndForDisplay(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
d.setDate(d.getDate() - 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Add 1 day to form end date before sending to API for all-day events. */
function adjustAllDayEndForSave(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// Python weekday: 0=Monday, 6=Sunday
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function nowLocal(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function plusOneHour(dt: string): string {
const d = new Date(dt);
d.setHours(d.getHours() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function EventForm({ event, templateData, templateName, initialStart, initialEnd, initialAllDay, editScope, onClose }: EventFormProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
// Merge template data as defaults for new events
const source = event || templateData;
const isAllDay = source?.all_day ?? initialAllDay ?? false;
// Default to current time / +1 hour when creating a new event with no selection
const defaultStart = nowLocal();
const defaultEnd = plusOneHour(defaultStart);
const rawStart = event?.start_datetime || initialStart || defaultStart;
const rawEnd = event?.end_datetime || initialEnd || defaultEnd;
const defaultCalendar = calendars.find((c) => c.is_default);
const initialCalendarId = source?.calendar_id?.toString() || defaultCalendar?.id?.toString() || '';
const isEditing = !!event?.id;
// For all-day events, adjust end date for display (FullCalendar exclusive end)
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
const [formData, setFormData] = useState({
title: source?.title || '',
description: source?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: source?.location_id?.toString() || '',
calendar_id: initialCalendarId,
is_starred: source?.is_starred || false,
});
const existingRule = parseRecurrenceRule(source?.recurrence_rule);
const [recurrenceType, setRecurrenceType] = useState<string>(existingRule?.type || '');
const [recurrenceInterval, setRecurrenceInterval] = useState(existingRule?.interval || 2);
const [recurrenceWeekday, setRecurrenceWeekday] = useState(existingRule?.weekday ?? 1);
const [recurrenceWeek, setRecurrenceWeek] = useState(existingRule?.week || 1);
const [recurrenceDay, setRecurrenceDay] = useState(existingRule?.day || 1);
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<Location[]>('/locations');
return data;
},
});
// Location picker state
const existingLocation = locations.find((l) => l.id === source?.location_id);
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
const selectableCalendars = calendars.filter((c) => !c.is_system);
const buildRecurrenceRule = (): RecurrenceRule | null => {
if (!recurrenceType) return null;
switch (recurrenceType) {
case 'every_n_days':
return { type: 'every_n_days', interval: recurrenceInterval };
case 'weekly':
// No weekday needed — backend derives it from the event's start date
return { type: 'weekly' };
case 'monthly_nth_weekday':
return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday };
case 'monthly_date':
return { type: 'monthly_date', day: recurrenceDay };
default:
return null;
}
};
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const rule = buildRecurrenceRule();
// Adjust end date for all-day events before save
let endDt = data.end_datetime;
if (data.all_day && endDt) {
endDt = adjustAllDayEndForSave(endDt);
}
const payload: Record<string, any> = {
title: data.title,
description: data.description,
start_datetime: data.start_datetime,
end_datetime: endDt,
all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
};
if (isEditing) {
if (editScope) {
payload.edit_scope = editScope;
}
const response = await api.put(`/events/${event!.id}`, payload);
return response.data;
} else {
const response = await api.post('/events', payload);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(isEditing ? 'Event updated' : 'Event created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, isEditing ? 'Failed to update event' : 'Failed to create event'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
const params = editScope ? `?scope=${editScope}` : '';
await api.delete(`/events/${event?.id}${params}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event deleted');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader>
<SheetTitle>
{isEditing
? 'Edit Event'
: templateName
? `Create Event from ${templateName} Template`
: 'New Event'}
</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="title" required>Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="min-h-[80px] flex-1"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="all_day"
checked={formData.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
setFormData({
...formData,
all_day: checked,
start_datetime: formatForInput(formData.start_datetime, checked, '09:00'),
end_datetime: formatForInput(formData.end_datetime, checked, '10:00'),
});
}}
/>
<Label htmlFor="all_day">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start" required>Start</Label>
<Input
id="start"
type={formData.all_day ? 'date' : 'datetime-local'}
value={formData.start_datetime}
onChange={(e) => setFormData({ ...formData, start_datetime: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="end">End</Label>
<Input
id="end"
type={formData.all_day ? 'date' : 'datetime-local'}
value={formData.end_datetime}
onChange={(e) => setFormData({ ...formData, end_datetime: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="calendar">Calendar</Label>
<Select
id="calendar"
value={formData.calendar_id}
onChange={(e) => setFormData({ ...formData, calendar_id: e.target.value })}
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<LocationPicker
id="location"
value={locationSearch}
onChange={(val) => {
setLocationSearch(val);
if (!val) setFormData({ ...formData, location_id: '' });
}}
onSelect={async (result) => {
if (result.source === 'local' && result.location_id) {
setFormData({ ...formData, location_id: result.location_id.toString() });
} else if (result.source === 'nominatim') {
try {
const { data: newLoc } = await api.post('/locations', {
name: result.name,
address: result.address,
category: 'other',
});
queryClient.invalidateQueries({ queryKey: ['locations'] });
setFormData({ ...formData, location_id: newLoc.id.toString() });
toast.success(`Location "${result.name}" created`);
} catch {
toast.error('Failed to create location');
}
}
}}
placeholder="Search for a location..."
/>
</div>
</div>
{/* Recurrence picker */}
<div className="space-y-2">
<Label htmlFor="recurrence_type">Recurrence</Label>
<Select
id="recurrence_type"
value={recurrenceType}
onChange={(e) => setRecurrenceType(e.target.value)}
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
{recurrenceType === 'every_n_days' && (
<div className="space-y-2">
<Label htmlFor="interval">Every how many days?</Label>
<Input
id="interval"
type="number"
min={1}
max={365}
value={recurrenceInterval}
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
/>
</div>
)}
{recurrenceType === 'weekly' && (
<p className="text-xs text-muted-foreground">
Repeats every week on the same day as the start date.
</p>
)}
{recurrenceType === 'monthly_nth_weekday' && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="week">Week of month</Label>
<Select
id="week"
value={recurrenceWeek.toString()}
onChange={(e) => setRecurrenceWeek(parseInt(e.target.value))}
>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="weekday_nth">Day of week</Label>
<Select
id="weekday_nth"
value={recurrenceWeekday.toString()}
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
</div>
)}
{recurrenceType === 'monthly_date' && (
<div className="space-y-2">
<Label htmlFor="day">Day of month</Label>
<Input
id="day"
type="number"
min={1}
max={31}
value={recurrenceDay}
onChange={(e) => setRecurrenceDay(parseInt(e.target.value) || 1)}
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="is_starred"
checked={formData.is_starred}
onChange={(e) => setFormData({ ...formData, is_starred: (e.target as HTMLInputElement).checked })}
/>
<Label htmlFor="is_starred">Star this event</Label>
</div>
</div>
<SheetFooter>
{isEditing && (
<Button
type="button"
variant="destructive"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="mr-auto"
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}