fix: leave read-only calendars out of the task editor's calendar list

This commit is contained in:
Linus Rath
2026-10-28 10:29:43 +01:00
parent 4dc13c229b
commit b96dc85ab9
3 changed files with 49 additions and 3 deletions
@@ -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(<TaskModal calendars={calendars} isSubscriptionCalendar={isSubscriptionCalendar} onSave={vi.fn()} onClose={vi.fn()} />);
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(<TaskModal task={task} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement;
expect(select.value).toBe('birthdays');
});
});
+2
View File
@@ -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}
+11 -3
View File
@@ -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<AlertOption>(() => {
if (!task?.alerts) return "none";
const first = Object.values(task.alerts)[0];
@@ -313,7 +321,7 @@ export function TaskModal({
</div>
{/* Calendar */}
{writableCalendars.length > 1 && (
{calendarOptions.length > 1 && (
<div className="space-y-2">
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
{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) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</select>