feat: ask which occurrences a dragged or resized series event moves, and offer Undo after a move

This commit is contained in:
Linus Rath
2026-10-28 03:35:37 +01:00
parent 526f7a584d
commit 6b7d6bdb5d
35 changed files with 191 additions and 11 deletions
@@ -339,3 +339,39 @@ describe('CalendarApp read-only events', () => {
expect(screen.getAllByRole('button', { name: /Standup/ })[0]).toHaveAttribute('draggable', 'true');
});
});
function dropOnMonthDay(day: RegExp, event: CalendarEvent) {
const cell = screen.getAllByRole('gridcell').find((c) => day.test(c.getAttribute('aria-label') ?? ''));
if (!cell) throw new Error(`no cell ${day}`);
const json = JSON.stringify({ type: 'calendar-event', eventId: event.id, originalStart: event.start, duration: event.duration });
fireEvent.drop(cell, { dataTransfer: { types: ['application/x-calendar-event'], getData: () => json } });
}
describe('CalendarApp dragging', () => {
it('asks which occurrences a drag of a series moves', async () => {
setUp([weekly], 'month');
renderApp();
dropOnMonthDay(/October 14, 2026/, weekly);
const scope = await screen.findByRole('dialog', { name: 'edit_title' });
expect(store.updateEvent).not.toHaveBeenCalled();
fireEvent.click(within(scope).getByRole('button', { name: 'cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'edit_title' })).toBeNull());
expect(store.updateEvent).not.toHaveBeenCalled();
});
it('moves one event at once and can move it back', async () => {
setUp([makeEvent()], 'month');
renderApp();
dropOnMonthDay(/October 14, 2026/, makeEvent());
await waitFor(() => expect(store.updateEvent).toHaveBeenCalledTimes(1));
expect(store.updateEvent.mock.calls[0][2]).toMatchObject({ start: '2026-10-14T10:00:00' });
const moved = await waitFor(() => {
const found = useToastStore.getState().toasts.find((t) => t.title === 'notifications.event_moved');
expect(found).toBeTruthy();
return found!;
});
moved.action!.onClick();
await waitFor(() => expect(store.updateEvent).toHaveBeenCalledTimes(2));
expect(store.updateEvent.mock.calls[1][2]).toMatchObject({ start: '2026-10-12T10:00:00', duration: 'PT1H' });
});
});
+53 -2
View File
@@ -59,7 +59,7 @@ import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/l
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventStartDate } from "@/lib/calendar-utils";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
import { canCreateEventsIn, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover";
@@ -73,7 +73,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header";
import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes } from "@/lib/calendar-participants";
import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes, getParticipantList } from "@/lib/calendar-participants";
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
@@ -1303,6 +1304,53 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]);
// A drag or resize. A series asks which occurrences it covers, as the
// editor does; one event moves at once and can be moved back.
const handleMoveEvent = useCallback<MoveEventHandler>(async (event, updates, sendScheduling, kind) => {
if (!client) { toast.error(t("notifications.event_move_error")); return; }
if (isRecurringEvent(event)) {
const moved = { ...event, ...updates };
const allDay = !!event.showWithoutTime;
// The attendees stay as they are; the save works out the rest from
// where the occurrence went, as for a time changed in the editor.
const attendees = getParticipantList(event, { resolveName: resolveContactName })
.filter((p) => !p.isOrganizer && p.email)
.map((p) => ({ name: p.name, email: p.email.trim() }));
setPendingScopeAction({
type: "edit",
event,
updates,
sendScheduling,
editor: {
initialAttendees: attendees,
attendees,
when: { allDay, start: getEventStartDate(moved), end: allDay ? getEventDisplayEndDate(moved) : getEventEndDate(moved) },
ruleChanged: false,
},
});
return;
}
try {
// false: a plugin stopped it and said why.
if (!(await updateEvent(client, event.id, updates, sendScheduling))) return;
} catch (error) {
toast.error(describeError(error, t(kind === "resize" ? "notifications.event_resize_error" : "notifications.event_move_error")));
return;
}
const before: Partial<CalendarEvent> = { start: event.start, duration: event.duration };
if (updates.timeZone !== undefined) before.timeZone = event.timeZone;
toast.success(t(kind === "resize" ? "notifications.event_updated" : "notifications.event_moved"), {
action: {
label: t("notifications.undo"),
onClick: () => {
void updateEvent(client, event.id, before, sendScheduling).catch((error: unknown) => {
toast.error(describeError(error, t("notifications.event_move_error")));
});
},
},
});
}, [client, updateEvent, resolveContactName, describeError, t]);
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
if (!client) return;
// An answer on one occurrence of a series asks whether it covers just
@@ -1665,6 +1713,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
canEditEvent={canEditEvent}
onMoveEvent={handleMoveEvent}
/>
);
case "week":
@@ -1691,6 +1740,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
canEditEvent={canEditEvent}
onMoveEvent={handleMoveEvent}
/>
);
case "day":
@@ -1714,6 +1764,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
canEditEvent={canEditEvent}
onMoveEvent={handleMoveEvent}
/>
);
case "agenda":
+8 -1
View File
@@ -15,7 +15,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
@@ -46,6 +46,11 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
currentUserEmails?: string[];
/** Whether the user may change an event: only those can be dragged or resized. */
canEditEvent?: (event: CalendarEvent) => boolean;
/**
* Save a drag or resize. The page asks which occurrences a change to a
* series covers; without it the change goes straight to the store.
*/
onMoveEvent?: MoveEventHandler;
}
const HOUR_HEIGHT = 64;
@@ -78,6 +83,7 @@ export function CalendarDayView({
onSelectTask,
currentUserEmails,
canEditEvent = () => true,
onMoveEvent,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -280,6 +286,7 @@ export function CalendarDayView({
describeError,
currentUserEmails,
isMobile,
onMoveEvent,
});
const formatHour = (h: number): string => {
+10 -2
View File
@@ -21,6 +21,7 @@ import { useScrollWindow } from "@/hooks/use-scroll-window";
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { PluginDayLabel } from "./plugin-day-label";
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
interface CalendarMonthViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -48,6 +49,11 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
currentUserEmails?: string[];
/** Whether the user may change an event: only those can be dragged or resized. */
canEditEvent?: (event: CalendarEvent) => boolean;
/**
* Save a drag or resize. The page asks which occurrences a change to a
* series covers; without it the change goes straight to the store.
*/
onMoveEvent?: MoveEventHandler;
}
/** Fraction of the viewport height at which the "current month" is sampled. */
@@ -80,6 +86,7 @@ export function CalendarMonthView({
onSelectTask,
currentUserEmails,
canEditEvent = () => true,
onMoveEvent,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -290,11 +297,12 @@ export function CalendarMonthView({
const client = useAuthStore.getState().client;
if (!client) return;
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move");
else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
} catch (error) {
toast.error(describeError(error, t("notifications.event_move_error")));
}
}, [t, describeError, currentUserEmails]);
}, [t, describeError, currentUserEmails, onMoveEvent]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
+8 -1
View File
@@ -18,7 +18,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
@@ -53,6 +53,11 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
currentUserEmails?: string[];
/** Whether the user may change an event: only those can be dragged or resized. */
canEditEvent?: (event: CalendarEvent) => boolean;
/**
* Save a drag or resize. The page asks which occurrences a change to a
* series covers; without it the change goes straight to the store.
*/
onMoveEvent?: MoveEventHandler;
}
const HOUR_HEIGHT = 60;
@@ -89,6 +94,7 @@ export function CalendarWeekView({
onSelectTask,
currentUserEmails,
canEditEvent = () => true,
onMoveEvent,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -373,6 +379,7 @@ export function CalendarWeekView({
describeError,
currentUserEmails,
isMobile,
onMoveEvent,
});
const formatHour = (h: number): string => {
+18 -5
View File
@@ -7,7 +7,15 @@ import { debug } from "@/lib/debug";
import { formatIsoInTimeZone, getEventStartDate } from "@/lib/calendar-utils";
import { fromDisplayDate, getEffectiveTimeZone } from "@/lib/timezone";
import { movedEventUpdates } from "@/lib/calendar-participants";
import type { Calendar } from "@/lib/jmap/types";
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
/** Saves a dragged or resized event; `kind` says which, for the message. */
export type MoveEventHandler = (
event: CalendarEvent,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
kind: "move" | "resize",
) => Promise<unknown> | void;
interface DragCreateState {
dayKey: string;
@@ -59,6 +67,8 @@ interface UseTimeGridInteractionsOptions {
*/
gridStartMinutes?: number;
gridEndMinutes?: number;
/** Save a drop or resize (the page asks the scope of a series); else the store directly. */
onMoveEvent?: MoveEventHandler;
}
/** `date` moved by `minutes` on the local clock, not by elapsed time. */
@@ -78,6 +88,7 @@ export function useTimeGridInteractions({
isMobile,
gridStartMinutes = 0,
gridEndMinutes = 1440,
onMoveEvent,
}: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = gridStartMinutes + ((clientY - containerTop) / hourHeight) * 60;
@@ -275,14 +286,15 @@ export function useTimeGridInteractions({
const updates: { start?: string; duration: string } = { duration: dur };
if (start) updates.start = start;
const moved = movedEventUpdates(event, updates, currentUserEmails ?? []);
await useCalendarStore.getState().updateEvent(client, resize.eventId, moved.updates, moved.sendSchedulingMessages);
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "resize");
else await useCalendarStore.getState().updateEvent(client, resize.eventId, moved.updates, moved.sendSchedulingMessages);
} catch (error) {
debug.error("Failed to resize event:", resize.eventId, error);
toast.error(describeError ? describeError(error, errorMessages.resize) : errorMessages.resize);
} finally {
setResizeVisual(null);
}
}, [computeResize, errorMessages.resize, describeError, currentUserEmails]);
}, [computeResize, errorMessages.resize, describeError, currentUserEmails, onMoveEvent]);
// --- Click / Double-click / Quick-create ---
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -410,11 +422,12 @@ export function useTimeGridInteractions({
return;
}
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move");
else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
} catch (error) {
toast.error(describeError ? describeError(error, errorMessages.move) : errorMessages.move);
}
}, [snapDragMinutes, errorMessages.move, describeError, currentUserEmails]);
}, [snapDragMinutes, errorMessages.move, describeError, currentUserEmails, onMoveEvent]);
return {
dragCreate,
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "تم إنشاء الحدث",
"event_updated": "تم تحديث الحدث",
"event_moved": "تم نقل الحدث",
"undo": "تراجع",
"event_deleted": "تم حذف الحدث",
"event_delete_error": "تعذّر حذف الحدث",
"task_deleted": "تم حذف المهمة",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Esdeveniment creat",
"event_updated": "Esdeveniment actualitzat",
"event_moved": "Esdeveniment mogut",
"undo": "Desfés",
"event_deleted": "Esdeveniment suprimit",
"event_delete_error": "No s'ha pogut suprimir l'esdeveniment",
"task_deleted": "Tasca suprimida",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Událost byla vytvořena",
"event_updated": "Událost byla aktualizována",
"event_moved": "Událost byla přesunuta",
"undo": "Zpět",
"event_deleted": "Událost byla odstraněna",
"event_delete_error": "Událost se nepodařilo odstranit",
"task_deleted": "Úkol byl odstraněn",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Begivenhed oprettet",
"event_updated": "Begivenhed opdateret",
"event_moved": "Begivenhed flyttet",
"undo": "Fortryd",
"event_deleted": "Begivenhed slettet",
"event_delete_error": "Begivenheden kunne ikke slettes",
"task_deleted": "Opgave slettet",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Termin erstellt",
"event_updated": "Termin aktualisiert",
"event_moved": "Termin verschoben",
"undo": "Rückgängig",
"event_deleted": "Termin gelöscht",
"event_delete_error": "Termin konnte nicht gelöscht werden",
"task_deleted": "Aufgabe gelöscht",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Event created",
"event_updated": "Event updated",
"event_moved": "Event moved",
"undo": "Undo",
"event_deleted": "Event deleted",
"event_delete_error": "Couldn't delete event",
"task_deleted": "Task deleted",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Evento creado",
"event_updated": "Evento actualizado",
"event_moved": "Evento movido",
"undo": "Deshacer",
"event_deleted": "Evento eliminado",
"event_delete_error": "No se pudo eliminar el evento",
"task_deleted": "Tarea eliminada",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Sündmus loodud",
"event_updated": "Sündmus uuendatud",
"event_moved": "Sündmus liigutatud",
"undo": "Võta tagasi",
"event_deleted": "Sündmus kustutatud",
"event_delete_error": "Sündmust ei saanud kustutada",
"task_deleted": "Ülesanne kustutatud",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "رویداد ایجاد شد",
"event_updated": "رویداد به‌روزرسانی شد",
"event_moved": "رویداد منتقل شد",
"undo": "بازگردانی",
"event_deleted": "رویداد حذف شد",
"event_delete_error": "حذف رویداد ممکن نشد",
"task_deleted": "وظیفه حذف شد",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Événement créé",
"event_updated": "Événement mis à jour",
"event_moved": "Événement déplacé",
"undo": "Annuler",
"event_deleted": "Événement supprimé",
"event_delete_error": "Impossible de supprimer l'événement",
"task_deleted": "Tâche supprimée",
+2
View File
@@ -3436,6 +3436,8 @@
"notifications": {
"event_created": "האירוע נוצר",
"event_updated": "האירוע עודכן",
"event_moved": "האירוע הועבר",
"undo": "בטל",
"event_deleted": "האירוע נמחק",
"event_delete_error": "לא ניתן היה למחוק את האירוע",
"task_deleted": "המשימה נמחקה",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Esemény létrehozva",
"event_updated": "Esemény frissítve",
"event_moved": "Esemény áthelyezve",
"undo": "Visszavonás",
"event_deleted": "Esemény törölve",
"event_delete_error": "Nem sikerült törölni az eseményt",
"task_deleted": "Feladat törölve",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Evento creato",
"event_updated": "Evento aggiornato",
"event_moved": "Evento spostato",
"undo": "Annulla",
"event_deleted": "Evento eliminato",
"event_delete_error": "Impossibile eliminare l'evento",
"task_deleted": "Attività eliminata",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "予定を作成しました",
"event_updated": "予定を更新しました",
"event_moved": "予定を移動しました",
"undo": "元に戻す",
"event_deleted": "予定を削除しました",
"event_delete_error": "予定を削除できませんでした",
"task_deleted": "タスクを削除しました",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "일정이 만들어졌어요",
"event_updated": "일정이 업데이트되었어요",
"event_moved": "일정을 이동했어요",
"undo": "실행 취소",
"event_deleted": "일정이 삭제되었어요",
"event_delete_error": "일정을 삭제하지 못했습니다",
"task_deleted": "할 일이 삭제되었습니다",
+2
View File
@@ -3517,6 +3517,8 @@
"notifications": {
"event_created": "Pasākums izveidots",
"event_updated": "Pasākums atjaunināts",
"event_moved": "Pasākums pārvietots",
"undo": "Atsaukt",
"event_deleted": "Pasākums izdzēsts",
"event_delete_error": "Neizdevās dzēst notikumu",
"task_deleted": "Uzdevums izdzēsts",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Үйл явдал үүсгэсэн",
"event_updated": "Үйл явдал шинэчлэгдсэн",
"event_moved": "Үйл явдлыг зөөсөн",
"undo": "Цуцлах",
"event_deleted": "Үйл явдал устгагдсан",
"event_delete_error": "Арга хэмжээг устгаж чадсангүй",
"task_deleted": "Даалгавар устгагдсан",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Hendelsen er opprettet",
"event_updated": "Hendelsen er oppdatert",
"event_moved": "Hendelsen er flyttet",
"undo": "Angre",
"event_deleted": "Hendelsen er slettet",
"event_delete_error": "Kunne ikke slette hendelsen",
"task_deleted": "Oppgaven er slettet",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Evenement aangemaakt",
"event_updated": "Evenement bijgewerkt",
"event_moved": "Evenement verplaatst",
"undo": "Ongedaan maken",
"event_deleted": "Evenement verwijderd",
"event_delete_error": "Kan afspraak niet verwijderen",
"task_deleted": "Taak verwijderd",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Wydarzenie utworzono",
"event_updated": "Wydarzenie zaktualizowano",
"event_moved": "Wydarzenie przeniesiono",
"undo": "Cofnij",
"event_deleted": "Wydarzenie usunięto",
"event_delete_error": "Nie udało się usunąć wydarzenia",
"task_deleted": "Zadanie usunięto",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Evento criado",
"event_updated": "Evento atualizado",
"event_moved": "Evento movido",
"undo": "Desfazer",
"event_deleted": "Evento excluído",
"event_delete_error": "Não foi possível excluir o evento",
"task_deleted": "Tarefa excluída",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Eveniment creat",
"event_updated": "Eveniment actualizat",
"event_moved": "Eveniment mutat",
"undo": "Anulează",
"event_deleted": "Eveniment șters",
"event_delete_error": "Evenimentul nu a putut fi șters",
"task_deleted": "Sarcină ștearsă",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Событие создано",
"event_updated": "Событие обновлено",
"event_moved": "Событие перемещено",
"undo": "Отменить",
"event_deleted": "Событие удалено",
"event_delete_error": "Не удалось удалить событие",
"task_deleted": "Задача удалена",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Udalosť bola vytvorená",
"event_updated": "Udalosť bola aktualizovaná",
"event_moved": "Udalosť bola presunutá",
"undo": "Späť",
"event_deleted": "Udalosť bola odstránená",
"event_delete_error": "Udalosť sa nepodarilo odstrániť",
"task_deleted": "Úloha bola odstránená",
+2
View File
@@ -2858,6 +2858,8 @@
"notifications": {
"event_created": "สร้างกิจกรรมแล้ว",
"event_updated": "อัปเดตกิจกรรมแล้ว",
"event_moved": "ย้ายกิจกรรมแล้ว",
"undo": "เลิกทำ",
"event_deleted": "ลบกิจกรรมแล้ว",
"event_delete_error": "ไม่สามารถลบกิจกรรมได้",
"task_deleted": "ลบงานแล้ว",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "Etkinlik oluşturuldu",
"event_updated": "Etkinlik güncellendi",
"event_moved": "Etkinlik taşındı",
"undo": "Geri al",
"event_deleted": "Etkinlik silindi",
"event_delete_error": "Etkinlik silinemedi",
"task_deleted": "Görev silindi",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "Подія створена",
"event_updated": "Подію оновлено",
"event_moved": "Подію переміщено",
"undo": "Скасувати",
"event_deleted": "Подію видалено",
"event_delete_error": "Не вдалося видалити подію",
"task_deleted": "Завдання видалено",
+2
View File
@@ -3544,6 +3544,8 @@
"notifications": {
"event_created": "活動已建立",
"event_updated": "活動已更新",
"event_moved": "活動已移動",
"undo": "復原",
"event_deleted": "活動已刪除",
"event_delete_error": "無法刪除活動",
"task_deleted": "任務已刪除",
+2
View File
@@ -3518,6 +3518,8 @@
"notifications": {
"event_created": "活动已创建",
"event_updated": "活动已更新",
"event_moved": "活动已移动",
"undo": "撤销",
"event_deleted": "活动已删除",
"event_delete_error": "无法删除事件",
"task_deleted": "任务已删除",