UMBRA/frontend/src/components/calendar/CalendarForm.tsx
Kyle Pope f45b7a2115 Fix 4 reported bugs from Phase 4 testing
1. Invite auto-sends at read_only: now stages connection with permission
   selector (Read Only / Create Modify / Full Access) before sending
2. Shared calendars missing from event create dropdown: members with
   create_modify+ permission now see shared calendars in calendar picker
3. Shared calendar category not showing for owner: owner's shared calendars
   now appear under SHARED CALENDARS section with "Owner" badge
4. Event creation not updating calendar: handlePanelClose now invalidates
   calendar-events query to ensure FullCalendar refreshes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:23:45 +08:00

270 lines
9.7 KiB
TypeScript

import { useState, FormEvent, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Select } from '@/components/ui/select';
import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import CalendarMemberSearch from './CalendarMemberSearch';
import CalendarMemberList from './CalendarMemberList';
interface CalendarFormProps {
calendar: Calendar | null;
onClose: () => void;
}
const colorSwatches = [
'#3b82f6', '#ef4444', '#f97316', '#eab308',
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
];
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
const queryClient = useQueryClient();
const [name, setName] = useState(calendar?.name || '');
const [color, setColor] = useState(calendar?.color || '#3b82f6');
const [isShared, setIsShared] = useState(calendar?.is_shared ?? false);
const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null);
const { connections } = useConnections();
const { invite, isInviting, updateMember, removeMember } = useSharedCalendars();
const membersQuery = useQuery({
queryKey: ['calendar-members', calendar?.id],
queryFn: async () => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/shared-calendars/${calendar!.id}/members`
);
return data;
},
enabled: !!calendar?.is_shared,
});
const members = membersQuery.data ?? [];
const mutation = useMutation({
mutationFn: async () => {
if (calendar) {
const { data } = await api.put(`/calendars/${calendar.id}`, { name, color });
return data;
} else {
const { data } = await api.post('/calendars', { name, color });
return data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success(calendar ? 'Calendar updated' : 'Calendar created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to save calendar'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/calendars/${calendar?.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Calendar deleted');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete calendar'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
mutation.mutate();
};
const handleSelectConnection = useCallback((conn: Connection) => {
setPendingInvite({ conn, permission: 'read_only' });
}, []);
const handleSendInvite = async () => {
if (!calendar || !pendingInvite) return;
await invite({
calendarId: calendar.id,
connectionId: pendingInvite.conn.id,
permission: pendingInvite.permission,
canAddOthers: false,
});
setPendingInvite(null);
};
const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => {
if (!calendar) return;
await updateMember({ calendarId: calendar.id, memberId, permission });
};
const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => {
if (!calendar) return;
await updateMember({ calendarId: calendar.id, memberId, canAddOthers });
};
const handleRemoveMember = async (memberId: number) => {
if (!calendar) return;
await removeMember({ calendarId: calendar.id, memberId });
};
const canDelete = calendar && !calendar.is_default && !calendar.is_system;
const showSharing = calendar && !calendar.is_system;
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className={isShared && showSharing ? 'sm:max-w-lg' : undefined}>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cal-name" required>Name</Label>
<Input
id="cal-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Calendar name"
required
disabled={calendar?.is_system}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-2">
{colorSwatches.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
boxShadow: color === c ? `0 0 0 2px ${c}40` : 'none',
}}
/>
))}
</div>
</div>
{showSharing && (
<>
<div className="flex items-center justify-between py-3 border-t border-border">
<Label className="mb-0">Share this calendar</Label>
<Switch
checked={isShared}
onCheckedChange={setIsShared}
/>
</div>
{isShared && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="mb-0">Members</Label>
<span className="text-[11px] text-muted-foreground">
You (Owner)
</span>
</div>
{pendingInvite ? (
<div className="rounded-md border border-border bg-card-elevated p-3 space-y-2 animate-fade-in">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
</span>
<button
type="button"
onClick={() => setPendingInvite(null)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</div>
<div className="flex items-center gap-2">
<Select
value={pendingInvite.permission}
onChange={(e) => setPendingInvite((prev) => prev ? { ...prev, permission: e.target.value as CalendarPermission } : null)}
className="text-xs flex-1"
>
<option value="read_only">Read Only</option>
<option value="create_modify">Create / Modify</option>
<option value="full_access">Full Access</option>
</Select>
<Button
type="button"
size="sm"
onClick={handleSendInvite}
disabled={isInviting}
>
{isInviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</div>
) : (
<CalendarMemberSearch
connections={connections}
existingMembers={members}
onSelect={handleSelectConnection}
isLoading={isInviting}
/>
)}
<CalendarMemberList
members={members}
isLoading={membersQuery.isLoading}
isOwner={true}
onUpdatePermission={handleUpdatePermission}
onUpdateCanAddOthers={handleUpdateCanAddOthers}
onRemove={handleRemoveMember}
/>
</div>
)}
</>
)}
<DialogFooter>
{canDelete && (
<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...' : calendar ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}