diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx new file mode 100644 index 000000000..1bd0abf30 --- /dev/null +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { TaskModal } from '../task-modal'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// The task editor's Calendar select offered the read-only Birthdays +// calendar, which the event editor leaves out. + +const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }; +const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false }; +const calendars = [ + { id: 'work', name: 'Work', isShared: false, myRights: writable }, + { id: 'personal', name: 'Personal', isShared: false, myRights: writable }, + { id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly }, + { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable }, +] as unknown as Calendar[]; +const isSubscriptionCalendar = (id: string) => id === 'feed'; + +describe('task editor calendar select', () => { + it('lists only the calendars a task can go into', () => { + render(); + const select = screen.getByLabelText('tasks.calendar'); + const names = within(select).getAllByRole('option').map((o) => o.textContent); + expect(names).toEqual(['Work', 'Personal']); + }); + + it('keeps the calendar of a task that is already in a read-only one', () => { + const task = { + id: 't1', '@type': 'Task', title: 'Card for Ann', calendarIds: { birthdays: true }, + progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null, + } as unknown as CalendarTask; + render(); + const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement; + expect(select.value).toBe('birthdays'); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b9d63985b..b56e29ceb 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2219,6 +2219,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} @@ -2380,6 +2381,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 6d3069d4a..26507e54d 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -14,6 +14,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { canCreateEventsIn } from "@/lib/calendar-editability"; interface TaskModalProps { task?: CalendarTask | null; @@ -25,6 +26,8 @@ interface TaskModalProps { /** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */ onDirtyChange?: (dirty: boolean) => void; isMobile?: boolean; + /** Subscribed feeds are read-only: no tasks go there. */ + isSubscriptionCalendar?: (calendarId: string) => boolean; } type PriorityLevel = "none" | "high" | "medium" | "low"; @@ -54,6 +57,7 @@ export function TaskModal({ onClose, onDirtyChange, isMobile: _isMobile, + isSubscriptionCalendar, }: TaskModalProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -63,7 +67,9 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn); + // The calendars a task can go into: not read-only ones (Birthdays, + // feeds), the same rule as the event editor. + const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); @@ -80,6 +86,8 @@ export function TaskModal({ } return defaultCalendarId; }); + // A task already in a read-only calendar keeps it listed. + const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -313,7 +321,7 @@ export function TaskModal({ {/* Calendar */} - {writableCalendars.length > 1 && ( + {calendarOptions.length > 1 && ( {t("tasks.calendar")} @@ -324,7 +332,7 @@ export function TaskModal({ onChange={(e) => setCalendarId(e.target.value)} className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full" > - {writableCalendars.map((cal) => ( + {calendarOptions.map((cal) => ( {cal.name} ))}