+ // The desktop side panel is a dialog named by its title, like the event
+ // editor; on a phone the full-screen wrapper around it is.
+
{/* Header */}
-
+
{isEdit ? t("tasks.edit") : t("tasks.create")}
From 5d95dc0830b15ec95e297cca31a0ecfc541ab868 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:37:33 +0100
Subject: [PATCH 42/54] fix: name the missing folder, tag or address under a
rule's actions, mark the empty condition and focus the first field to fix
---
.../__tests__/filter-rule-modal.test.tsx | 43 ++++++++++-
components/filters/filter-rule-modal.tsx | 75 +++++++++++++++----
locales/ar/common.json | 5 ++
locales/ca/common.json | 5 ++
locales/cs/common.json | 5 ++
locales/da/common.json | 5 ++
locales/de/common.json | 5 ++
locales/en/common.json | 5 ++
locales/es/common.json | 5 ++
locales/et/common.json | 5 ++
locales/fa/common.json | 5 ++
locales/fr/common.json | 5 ++
locales/he/common.json | 5 ++
locales/hu/common.json | 5 ++
locales/it/common.json | 5 ++
locales/ja/common.json | 5 ++
locales/ko/common.json | 5 ++
locales/lv/common.json | 5 ++
locales/mn/common.json | 5 ++
locales/nb/common.json | 5 ++
locales/nl/common.json | 5 ++
locales/pl/common.json | 5 ++
locales/pt/common.json | 5 ++
locales/ro/common.json | 5 ++
locales/ru/common.json | 5 ++
locales/sk/common.json | 5 ++
locales/th/common.json | 5 ++
locales/tr/common.json | 5 ++
locales/uk/common.json | 5 ++
locales/zh-TW/common.json | 5 ++
locales/zh/common.json | 5 ++
31 files changed, 247 insertions(+), 16 deletions(-)
diff --git a/components/filters/__tests__/filter-rule-modal.test.tsx b/components/filters/__tests__/filter-rule-modal.test.tsx
index 6e9062919..b0fd0937b 100644
--- a/components/filters/__tests__/filter-rule-modal.test.tsx
+++ b/components/filters/__tests__/filter-rule-modal.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { toast } from '@/stores/toast-store';
@@ -82,7 +82,8 @@ describe('FilterRuleModal incomplete action', () => {
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
- expect(screen.getByRole('alert').textContent).toBe('validation_empty_actions');
+ // The row is there; what it misses is the folder, not "an action".
+ expect(screen.getByRole('alert').textContent).toBe('validation_action_folder');
const folder = screen.getByRole('combobox', { name: 'move_to_folder' });
expect(folder.getAttribute('aria-invalid')).toBe('true');
@@ -92,6 +93,44 @@ describe('FilterRuleModal incomplete action', () => {
expect(onSave.mock.calls[0][0].actions).toEqual([{ type: 'move', mailboxId: 'news', value: 'News' }]);
});
+ it('marks the empty condition too, and focuses the first field to fix', async () => {
+ const onSave = vi.fn();
+ render( {}} />);
+ fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } });
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
+
+ const value = screen.getByRole('textbox', { name: 'condition_value' });
+ const folder = screen.getByRole('combobox', { name: 'move_to_folder' });
+ expect(value.getAttribute('aria-invalid')).toBe('true');
+ expect(folder.getAttribute('aria-invalid')).toBe('true');
+ expect(screen.getAllByRole('alert').map((a) => a.textContent)).toEqual([
+ 'validation_empty_conditions',
+ 'validation_action_folder',
+ ]);
+ // The condition comes first on the form, so focus goes there, not to
+ // the folder.
+ await waitFor(() => expect(document.activeElement).toBe(value));
+
+ fireEvent.change(value, { target: { value: 'news@example.com' } });
+ expect(value.getAttribute('aria-invalid')).toBe('false');
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('does not save a second action left without its folder', () => {
+ const onSave = vi.fn();
+ render( {}} />);
+ fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } });
+ fireEvent.change(screen.getByPlaceholderText('value_placeholder_multi'), { target: { value: 'news@example.com' } });
+ fireEvent.change(screen.getByRole('combobox', { name: 'move_to_folder' }), { target: { value: 'news' } });
+ fireEvent.click(screen.getByText('add_action'));
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
+
+ expect(onSave).not.toHaveBeenCalled();
+ const folders = screen.getAllByRole('combobox', { name: 'move_to_folder' });
+ expect(folders.map((f) => f.getAttribute('aria-invalid'))).toEqual(['false', 'true']);
+ expect(screen.getByRole('alert').textContent).toBe('validation_action_folder');
+ });
+
it('starts a changed action without a folder too, like a new row', () => {
render( {}} />);
fireEvent.change(screen.getByRole('combobox', { name: 'actions' }), { target: { value: 'copy' } });
diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx
index e813d2331..9f4dabf21 100644
--- a/components/filters/filter-rule-modal.tsx
+++ b/components/filters/filter-rule-modal.tsx
@@ -260,7 +260,7 @@ export function FilterRuleModal({
})
.map((c) => (c.field === "header" ? { ...c, headerName: (c.headerName ?? "").trim() } : c)), [conditions]);
- const validActions = useMemo(() => actions
+ const resolvedActions = useMemo(() => actions
.map((a) => {
if (!ACTIONS_WITH_MAILBOX.has(a.type)) return a;
// Store the folder id next to the path and refresh the path from it,
@@ -268,8 +268,17 @@ export function FilterRuleModal({
const mailboxId = mailboxIdFor(a);
const path = mailboxId ? mailboxPathMap.get(mailboxId) : undefined;
return mailboxId ? { ...a, mailboxId, value: path ?? a.value } : a;
- })
- .filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim()), [actions, mailboxIdFor, mailboxPathMap]);
+ }), [actions, mailboxIdFor, mailboxPathMap]);
+ // A row still missing its folder, tag, address or message.
+ const actionIncomplete = useMemo(
+ () => resolvedActions.map((a) => ACTIONS_WITH_VALUE.has(a.type) && !a.value?.trim()),
+ [resolvedActions],
+ );
+ const validActions = useMemo(
+ () => resolvedActions.filter((_, i) => !actionIncomplete[i]),
+ [resolvedActions, actionIncomplete],
+ );
+ const firstIncompleteAction = resolvedActions.find((_, i) => actionIncomplete[i]);
// Old mail can only be sorted by what the client can check the way Sieve
// does. Until an action is complete (a Move still without its folder), only
@@ -298,7 +307,17 @@ export function FilterRuleModal({
: validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))
? t("validation_header_name")
: null,
- actions: validActions.length === 0 ? t("validation_empty_actions") : null,
+ // A row that is there but unfinished says what it misses; "at least one
+ // action" is left for when no row has a type at all.
+ actions: firstIncompleteAction
+ ? t(ACTIONS_WITH_MAILBOX.has(firstIncompleteAction.type)
+ ? "validation_action_folder"
+ : firstIncompleteAction.type === "add_label"
+ ? "validation_action_tag"
+ : firstIncompleteAction.type === "forward"
+ ? "validation_action_forward"
+ : "validation_action_reject")
+ : validActions.length === 0 ? t("validation_empty_actions") : null,
period: !periodOn
? null
// A field the browser holds half filled in (a date without its time)
@@ -313,6 +332,14 @@ export function FilterRuleModal({
};
const hasErrors = Object.values(errors).some(Boolean);
const shown = (error: string | null) => (showErrors ? error : null);
+ // The fields the shown errors are about: every empty value while no
+ // condition has one, a header name Sieve refuses, an unfinished action.
+ const valueMissing = (c: FilterCondition) =>
+ !!shown(errors.conditions) && validConditions.length === 0 && !takesNoValue(c) && isConditionValueEmpty(c.value);
+ const headerInvalid = (c: FilterCondition) =>
+ !!shown(errors.conditions) && c.field === "header" && !isConditionValueEmpty(c.value) &&
+ !isValidSieveHeaderName((c.headerName ?? "").trim());
+ const actionInvalid = (index: number) => !!shown(errors.actions) && actionIncomplete[index];
const handleSave = useCallback(() => {
const halfFilled = periodOn &&
@@ -535,7 +562,10 @@ export function FilterRuleModal({
updateCondition(index, { headerName: e.target.value })
}
placeholder={t("header_name")}
- className="w-28"
+ aria-label={t("header_name")}
+ aria-invalid={headerInvalid(condition)}
+ data-rule-error={headerInvalid(condition)}
+ className={cn("w-28", headerInvalid(condition) && "border-destructive")}
/>
)}
@@ -598,7 +628,11 @@ export function FilterRuleModal({
? t("attachment_type_placeholder")
: t("value_placeholder_multi")
}
- className="flex-1 min-w-[120px]"
+ aria-label={t("condition_value")}
+ aria-invalid={valueMissing(condition)}
+ aria-describedby={valueMissing(condition) ? "rule-conditions-error" : undefined}
+ data-rule-error={valueMissing(condition)}
+ className={cn("flex-1 min-w-[120px]", valueMissing(condition) && "border-destructive")}
type={condition.field === "size" ? "number" : "text"}
/>
)}
@@ -622,7 +656,7 @@ export function FilterRuleModal({
{t("add_condition")}
-
+
@@ -656,6 +690,7 @@ export function FilterRuleModal({
{ACTIONS_WITH_MAILBOX.has(action.type) && (() => {
const selectedId = mailboxIdFor(action);
+ const invalid = actionInvalid(index);
const missing = !!selectedId && !mailboxPathMap.has(selectedId);
return (
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 2a6ea30ed..1d986128a 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -2487,6 +2487,7 @@
"label_name": "اسم الوسم",
"label_placeholder": "اختر وسمًا",
"header_name": "اسم الترويسة",
+ "condition_value": "قيمة الشرط",
"header_placeholder": "مثال: X-Mailing-List",
"size_bytes": "الحجم بالبايت",
"size_placeholder": "مثال: 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "اسم القاعدة مطلوب",
"validation_empty_conditions": "مطلوب شرط واحد على الأقل بقيمة",
"validation_empty_actions": "مطلوب إجراء واحد على الأقل",
+ "validation_action_folder": "اختر مجلدًا لهذا الإجراء",
+ "validation_action_tag": "اختر وسمًا لهذا الإجراء",
+ "validation_action_forward": "أدخل عنوانًا لإعادة التوجيه إليه",
+ "validation_action_reject": "أدخل رسالة الرفض",
"validation_header_name": "يمكن أن يحتوي اسم الترويسة على الأحرف A-Z والأرقام والشرطات والشرطات السفلية فقط",
"templates_section": "البدء من قالب",
"template_newsletters": "نقل النشرات البريدية إلى مجلد",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index f74732c16..88fca6816 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Nom de l'etiqueta",
"label_placeholder": "Selecciona una etiqueta",
"header_name": "Nom de la capçalera",
+ "condition_value": "Valor de la condició",
"header_placeholder": "p. ex., X-Mailing-List",
"size_bytes": "Mida en bytes",
"size_placeholder": "p. ex., 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "El nom de la regla és obligatori",
"validation_empty_conditions": "Cal com a mínim una condició amb un valor",
"validation_empty_actions": "Cal com a mínim una acció",
+ "validation_action_folder": "Trieu una carpeta per a aquesta acció",
+ "validation_action_tag": "Trieu una etiqueta per a aquesta acció",
+ "validation_action_forward": "Introduïu una adreça on reenviar",
+ "validation_action_reject": "Introduïu el missatge de rebuig",
"validation_header_name": "Els noms de capçalera només poden contenir les lletres A-Z, xifres, guionets i guions baixos",
"templates_section": "Comença des d'una plantilla",
"template_newsletters": "Mou els butlletins a una carpeta",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 96b58539f..e3041d047 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Název štítku",
"label_placeholder": "Vyberte štítek",
"header_name": "Název hlavičky",
+ "condition_value": "Hodnota podmínky",
"header_placeholder": "např. X-Mailing-List",
"size_bytes": "Velikost v bajtech",
"size_placeholder": "např. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Název pravidla je vyžadován",
"validation_empty_conditions": "Je vyžadována alespoň jedna podmínka s hodnotou",
"validation_empty_actions": "Je vyžadována alespoň jedna akce",
+ "validation_action_folder": "Vyberte složku pro tuto akci",
+ "validation_action_tag": "Vyberte štítek pro tuto akci",
+ "validation_action_forward": "Zadejte adresu, na kterou se má přeposílat",
+ "validation_action_reject": "Zadejte zprávu o odmítnutí",
"validation_header_name": "Názvy hlaviček smí obsahovat jen písmena A-Z, číslice, pomlčky a podtržítka",
"templates_section": "Začít ze šablony",
"template_newsletters": "Přesunout newslettery do složky",
diff --git a/locales/da/common.json b/locales/da/common.json
index c97ed0092..b9eb1ec39 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2483,6 +2483,7 @@
"label_name": "Tag-navn",
"label_placeholder": "Vælg tag",
"header_name": "Headernavn",
+ "condition_value": "Betingelsens værdi",
"header_placeholder": "f.eks. X-Mailing-List",
"size_bytes": "Størrelse i bytes",
"size_placeholder": "f.eks. 1000000",
@@ -2510,6 +2511,10 @@
"validation_empty_name": "Regelnavn er påkrævet",
"validation_empty_conditions": "Mindst én betingelse med en værdi er påkrævet",
"validation_empty_actions": "Mindst én handling er påkrævet",
+ "validation_action_folder": "Vælg en mappe til denne handling",
+ "validation_action_tag": "Vælg et tag til denne handling",
+ "validation_action_forward": "Angiv en adresse, der skal videresendes til",
+ "validation_action_reject": "Angiv afvisningsbeskeden",
"validation_header_name": "Headernavne må kun indeholde bogstaverne A-Z, cifre, bindestreger og understregninger",
"templates_section": "Start fra skabelon",
"template_newsletters": "Flyt nyhedsbreve til mappe",
diff --git a/locales/de/common.json b/locales/de/common.json
index 266e1dd2a..2aca5ca4a 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Label-Name",
"label_placeholder": "Label auswählen",
"header_name": "Header-Name",
+ "condition_value": "Wert der Bedingung",
"header_placeholder": "z.B. X-Mailing-List",
"size_bytes": "Größe in Bytes",
"size_placeholder": "z.B. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Regelname ist erforderlich",
"validation_empty_conditions": "Mindestens eine Bedingung mit einem Wert ist erforderlich",
"validation_empty_actions": "Mindestens eine Aktion ist erforderlich",
+ "validation_action_folder": "Wählen Sie einen Ordner für diese Aktion",
+ "validation_action_tag": "Wählen Sie ein Label für diese Aktion",
+ "validation_action_forward": "Geben Sie eine Adresse zum Weiterleiten ein",
+ "validation_action_reject": "Geben Sie die Ablehnungsnachricht ein",
"validation_header_name": "Header-Namen dürfen nur die Buchstaben A-Z, Ziffern, Bindestriche und Unterstriche enthalten",
"templates_section": "Von Vorlage starten",
"template_newsletters": "Newsletter in Ordner verschieben",
diff --git a/locales/en/common.json b/locales/en/common.json
index 86aa6c500..48d33cc96 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Tag name",
"label_placeholder": "Select tag",
"header_name": "Header name",
+ "condition_value": "Condition value",
"header_placeholder": "e.g., X-Mailing-List",
"size_bytes": "Size in bytes",
"size_placeholder": "e.g., 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "Rule name is required",
"validation_empty_conditions": "At least one condition with a value is required",
"validation_empty_actions": "At least one action is required",
+ "validation_action_folder": "Select a folder for this action",
+ "validation_action_tag": "Select a tag for this action",
+ "validation_action_forward": "Enter an address to forward to",
+ "validation_action_reject": "Enter the rejection message",
"validation_header_name": "Header names may only contain the letters A-Z, digits, hyphens and underscores",
"templates_section": "Start from template",
"template_newsletters": "Move newsletters to folder",
diff --git a/locales/es/common.json b/locales/es/common.json
index 5ce2d373d..fdce84a88 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Nombre de la etiqueta",
"label_placeholder": "Seleccionar etiqueta",
"header_name": "Nombre del encabezado",
+ "condition_value": "Valor de la condición",
"header_placeholder": "ej. X-Mailing-List",
"size_bytes": "Tamaño en bytes",
"size_placeholder": "ej. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "El nombre de la regla es obligatorio",
"validation_empty_conditions": "Se requiere al menos una condición con un valor",
"validation_empty_actions": "Se requiere al menos una acción",
+ "validation_action_folder": "Selecciona una carpeta para esta acción",
+ "validation_action_tag": "Selecciona una etiqueta para esta acción",
+ "validation_action_forward": "Introduce una dirección a la que reenviar",
+ "validation_action_reject": "Introduce el mensaje de rechazo",
"validation_header_name": "Los nombres de encabezado solo pueden contener las letras A-Z, dígitos, guiones y guiones bajos",
"templates_section": "Usar una plantilla",
"template_newsletters": "Mover newsletters a carpeta",
diff --git a/locales/et/common.json b/locales/et/common.json
index 346b882c7..405c4ef57 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Sildi nimi",
"label_placeholder": "Vali silt",
"header_name": "Päise nimi",
+ "condition_value": "Tingimuse väärtus",
"header_placeholder": "nt X-Mailing-List",
"size_bytes": "Suurus baitides",
"size_placeholder": "nt 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "Reegli nimi on kohustuslik",
"validation_empty_conditions": "Vajalik on vähemalt üks väärtusega tingimus",
"validation_empty_actions": "Vajalik on vähemalt üks tegevus",
+ "validation_action_folder": "Vali selle toimingu jaoks kaust",
+ "validation_action_tag": "Vali selle toimingu jaoks silt",
+ "validation_action_forward": "Sisesta aadress, kuhu edasi saata",
+ "validation_action_reject": "Sisesta tagasilükkamise sõnum",
"validation_header_name": "Päise nimes võivad olla ainult tähed A-Z, numbrid, sidekriipsud ja allkriipsud",
"templates_section": "Alusta mallist",
"template_newsletters": "Liiguta uudiskirjad kausta",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index ac07764a4..25e1c4bc8 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -2487,6 +2487,7 @@
"label_name": "نام برچسب",
"label_placeholder": "انتخاب برچسب",
"header_name": "نام هدر",
+ "condition_value": "مقدار شرط",
"header_placeholder": "مثال: X-Mailing-List",
"size_bytes": "حجم به بایت",
"size_placeholder": "مثال: 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "نام قانون الزامی است",
"validation_empty_conditions": "حداقل یک شرط با مقدار الزامی است",
"validation_empty_actions": "حداقل یک عملیات الزامی است",
+ "validation_action_folder": "یک پوشه برای این عمل انتخاب کنید",
+ "validation_action_tag": "یک برچسب برای این عمل انتخاب کنید",
+ "validation_action_forward": "نشانیای برای هدایت وارد کنید",
+ "validation_action_reject": "پیام رد را وارد کنید",
"validation_header_name": "نام هدر فقط میتواند شامل حروف A-Z، ارقام، خط تیره و زیرخط باشد",
"templates_section": "شروع از قالب",
"template_newsletters": "انتقال خبرنامهها به پوشه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 253c6d2a3..37470e214 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Nom de l'étiquette",
"label_placeholder": "Sélectionner une étiquette",
"header_name": "Nom de l'en-tête",
+ "condition_value": "Valeur de la condition",
"header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Taille en octets",
"size_placeholder": "ex. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Le nom de la règle est requis",
"validation_empty_conditions": "Au moins une condition avec une valeur est requise",
"validation_empty_actions": "Au moins une action est requise",
+ "validation_action_folder": "Sélectionnez un dossier pour cette action",
+ "validation_action_tag": "Sélectionnez une étiquette pour cette action",
+ "validation_action_forward": "Saisissez une adresse de transfert",
+ "validation_action_reject": "Saisissez le message de refus",
"validation_header_name": "Les noms d'en-tête ne peuvent contenir que les lettres A-Z, des chiffres, des traits d'union et des tirets bas",
"templates_section": "Partir d'un modèle",
"template_newsletters": "Déplacer les newsletters dans un dossier",
diff --git a/locales/he/common.json b/locales/he/common.json
index 070245561..8ee7c0388 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -2400,6 +2400,7 @@
"label_name": "שם התווית",
"label_placeholder": "למשל, חשוב",
"header_name": "שם הכותרת",
+ "condition_value": "ערך התנאי",
"header_placeholder": "למשל, X-Mailing-list",
"size_bytes": "גודל בבתים",
"size_placeholder": "למשל, 1000000",
@@ -2427,6 +2428,10 @@
"validation_empty_name": "נדרש שם כלל",
"validation_empty_conditions": "נדרש לפחות תנאי אחד עם ערך",
"validation_empty_actions": "נדרשת לפחות פעולה אחת",
+ "validation_action_folder": "בחר תיקייה לפעולה הזו",
+ "validation_action_tag": "בחר תגית לפעולה הזו",
+ "validation_action_forward": "הזן כתובת להעברה",
+ "validation_action_reject": "הזן את הודעת הדחייה",
"validation_header_name": "שמות כותרות יכולים להכיל רק את האותיות A-Z, ספרות, מקפים וקווים תחתונים",
"templates_section": "התחל מהתבנית",
"template_newsletters": "העבר ניוזלטרים לתיקיה",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index b206c9994..5a6180aae 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -2483,6 +2483,7 @@
"label_name": "Címke neve",
"label_placeholder": "Címke kiválasztása",
"header_name": "Fejléc neve",
+ "condition_value": "Feltétel értéke",
"header_placeholder": "pl. X-Mailing-List",
"size_bytes": "Méret bájtokban",
"size_placeholder": "pl. 1000000",
@@ -2510,6 +2511,10 @@
"validation_empty_name": "A szabály neve kötelező",
"validation_empty_conditions": "Legalább egy feltétel szükséges értékkel",
"validation_empty_actions": "Legalább egy művelet szükséges",
+ "validation_action_folder": "Válasszon mappát ehhez a művelethez",
+ "validation_action_tag": "Válasszon címkét ehhez a művelethez",
+ "validation_action_forward": "Adja meg a továbbítási címet",
+ "validation_action_reject": "Adja meg az elutasító üzenetet",
"validation_header_name": "A fejlécnevek csak az A-Z betűket, számjegyeket, kötőjeleket és aláhúzásjeleket tartalmazhatják",
"templates_section": "Kezdés sablonból",
"template_newsletters": "Hírlevelek áthelyezése mappába",
diff --git a/locales/it/common.json b/locales/it/common.json
index 19b919309..859d4b827 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Nome dell'etichetta",
"label_placeholder": "Seleziona etichetta",
"header_name": "Nome dell'intestazione",
+ "condition_value": "Valore della condizione",
"header_placeholder": "es. X-Mailing-List",
"size_bytes": "Dimensione in byte",
"size_placeholder": "es. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Il nome della regola è obbligatorio",
"validation_empty_conditions": "È necessaria almeno una condizione con un valore",
"validation_empty_actions": "È necessaria almeno un'azione",
+ "validation_action_folder": "Seleziona una cartella per questa azione",
+ "validation_action_tag": "Seleziona un'etichetta per questa azione",
+ "validation_action_forward": "Inserisci un indirizzo a cui inoltrare",
+ "validation_action_reject": "Inserisci il messaggio di rifiuto",
"validation_header_name": "I nomi delle intestazioni possono contenere solo le lettere A-Z, cifre, trattini e trattini bassi",
"templates_section": "Parti da un modello",
"template_newsletters": "Sposta newsletter in cartella",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 6fb759ee4..900588922 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2480,6 +2480,7 @@
"label_name": "ラベル名",
"label_placeholder": "ラベルを選択",
"header_name": "ヘッダー名",
+ "condition_value": "条件の値",
"header_placeholder": "例:X-Mailing-List",
"size_bytes": "サイズ(バイト)",
"size_placeholder": "例:1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "ルール名は必須です",
"validation_empty_conditions": "値を持つ条件が少なくとも1つ必要です",
"validation_empty_actions": "少なくとも1つのアクションが必要です",
+ "validation_action_folder": "この操作のフォルダーを選択してください",
+ "validation_action_tag": "この操作のタグを選択してください",
+ "validation_action_forward": "転送先のアドレスを入力してください",
+ "validation_action_reject": "拒否メッセージを入力してください",
"validation_header_name": "ヘッダー名に使用できるのは英字(A-Z)、数字、ハイフン、アンダースコアのみです",
"templates_section": "テンプレートから作成",
"template_newsletters": "ニュースレターをフォルダに移動",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 18ec15f75..ca7c59f30 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2480,6 +2480,7 @@
"label_name": "태그 이름",
"label_placeholder": "태그 선택",
"header_name": "헤더 이름",
+ "condition_value": "조건 값",
"header_placeholder": "예: X-Mailing-List",
"size_bytes": "크기 (바이트)",
"size_placeholder": "예: 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "규칙 이름을 입력해 주세요",
"validation_empty_conditions": "값을 포함한 조건이 하나 이상 필요해요",
"validation_empty_actions": "동작이 하나 이상 필요해요",
+ "validation_action_folder": "이 작업에 사용할 폴더를 선택하세요",
+ "validation_action_tag": "이 작업에 사용할 태그를 선택하세요",
+ "validation_action_forward": "전달할 주소를 입력하세요",
+ "validation_action_reject": "거부 메시지를 입력하세요",
"validation_header_name": "헤더 이름에는 영문자(A-Z), 숫자, 하이픈, 밑줄만 쓸 수 있어요",
"templates_section": "템플릿으로 시작하기",
"template_newsletters": "뉴스레터를 특정 폴더로 이동",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 3e6b0d64c..ed466f232 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Taga nosaukums",
"label_placeholder": "Izvēlēties tagu",
"header_name": "Galvenes nosaukums",
+ "condition_value": "Nosacījuma vērtība",
"header_placeholder": "piem., X-Mailing-List",
"size_bytes": "Izmērs baitos",
"size_placeholder": "piem., 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Nepieciešams noteikuma nosaukums",
"validation_empty_conditions": "Nepieciešams vismaz viens nosacījums ar vērtību",
"validation_empty_actions": "Nepieciešama vismaz viena darbība",
+ "validation_action_folder": "Atlasiet mapi šai darbībai",
+ "validation_action_tag": "Atlasiet birku šai darbībai",
+ "validation_action_forward": "Ievadiet adresi, uz kuru pārsūtīt",
+ "validation_action_reject": "Ievadiet noraidīšanas ziņojumu",
"validation_header_name": "Galvenes nosaukumā drīkst būt tikai burti A-Z, cipari, defises un pasvītras",
"templates_section": "Sākt no veidnes",
"template_newsletters": "Pārvietot ziņu izdevumus uz mapi",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index b20b0f117..b9cec81c3 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Тагийн нэр",
"label_placeholder": "Таг сонгох",
"header_name": "Толгойн нэр",
+ "condition_value": "Нөхцлийн утга",
"header_placeholder": "жишээ нь, X-мэйл хаягийн жагсаалт",
"size_bytes": "Хэмжээ байтаар",
"size_placeholder": "жишээ нь, 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "Дүрмийн нэр шаардлагатай",
"validation_empty_conditions": "Дор хаяж нэг утга бүхий нөхцөл шаардлагатай",
"validation_empty_actions": "Дор хаяж нэг үйлдэл хийх шаардлагатай",
+ "validation_action_folder": "Энэ үйлдэлд хавтас сонгоно уу",
+ "validation_action_tag": "Энэ үйлдэлд шошго сонгоно уу",
+ "validation_action_forward": "Дамжуулах хаягаа оруулна уу",
+ "validation_action_reject": "Татгалзах мессежээ оруулна уу",
"validation_header_name": "Толгойн нэрэнд зөвхөн A-Z үсэг, тоо, зураас, доогуур зураас ашиглаж болно",
"templates_section": "Загвар",
"template_newsletters": "Мэдээллийн хуудсыг хавтас руу зөөх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index d125f9c35..750cf6251 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Etikettnavn",
"label_placeholder": "Velg etikett",
"header_name": "Navn på meldingshode",
+ "condition_value": "Verdi for betingelsen",
"header_placeholder": "f.eks. X-Mailing-List",
"size_bytes": "Størrelse i byte",
"size_placeholder": "f.eks. 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "Regelnavn må fylles ut",
"validation_empty_conditions": "Minst ett vilkår må ha en verdi",
"validation_empty_actions": "Du må legge til minst én handling",
+ "validation_action_folder": "Velg en mappe for denne handlingen",
+ "validation_action_tag": "Velg en etikett for denne handlingen",
+ "validation_action_forward": "Skriv inn en adresse det skal videresendes til",
+ "validation_action_reject": "Skriv inn avvisningsmeldingen",
"validation_header_name": "Navn på meldingshoder kan bare inneholde bokstavene A-Z, sifre, bindestreker og understreker",
"templates_section": "Start med en mal",
"template_newsletters": "Flytt nyhetsbrev til mappe",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index ebada4d1f..4267e8ac0 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Labelnaam",
"label_placeholder": "Label kiezen",
"header_name": "Headernaam",
+ "condition_value": "Waarde van de voorwaarde",
"header_placeholder": "bijv. X-Mailing-List",
"size_bytes": "Grootte in bytes",
"size_placeholder": "bijv. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Regelnaam is verplicht",
"validation_empty_conditions": "Minstens één voorwaarde met een waarde is vereist",
"validation_empty_actions": "Minstens één actie is vereist",
+ "validation_action_folder": "Kies een map voor deze actie",
+ "validation_action_tag": "Kies een label voor deze actie",
+ "validation_action_forward": "Vul een adres in om naar door te sturen",
+ "validation_action_reject": "Vul het afwijzingsbericht in",
"validation_header_name": "Headernamen mogen alleen de letters A-Z, cijfers, koppeltekens en underscores bevatten",
"templates_section": "Starten vanuit sjabloon",
"template_newsletters": "Nieuwsbrieven naar map verplaatsen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 096758d2d..ba19e9651 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Nazwa etykiety",
"label_placeholder": "Wybierz etykietę",
"header_name": "Nazwa nagłówka",
+ "condition_value": "Wartość warunku",
"header_placeholder": "np. X-Mailing-List",
"size_bytes": "Rozmiar w bajtach",
"size_placeholder": "np. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Nazwa reguły jest wymagana",
"validation_empty_conditions": "Wymagany jest co najmniej jeden warunek z wartością",
"validation_empty_actions": "Wymagana jest co najmniej jedna akcja",
+ "validation_action_folder": "Wybierz folder dla tej akcji",
+ "validation_action_tag": "Wybierz etykietę dla tej akcji",
+ "validation_action_forward": "Wpisz adres, na który przekazywać",
+ "validation_action_reject": "Wpisz wiadomość odrzucenia",
"validation_header_name": "Nazwy nagłówków mogą zawierać tylko litery A-Z, cyfry, łączniki i podkreślenia",
"templates_section": "Rozpocznij od szablonu",
"template_newsletters": "Przenieś newslettery do folderu",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a21a34292..448848187 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Nome da etiqueta",
"label_placeholder": "Selecionar etiqueta",
"header_name": "Nome do cabeçalho",
+ "condition_value": "Valor da condição",
"header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Tamanho em bytes",
"size_placeholder": "ex. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "O nome da regra é obrigatório",
"validation_empty_conditions": "É necessária pelo menos uma condição com um valor",
"validation_empty_actions": "É necessária pelo menos uma ação",
+ "validation_action_folder": "Selecione uma pasta para esta ação",
+ "validation_action_tag": "Selecione uma etiqueta para esta ação",
+ "validation_action_forward": "Digite um endereço para encaminhar",
+ "validation_action_reject": "Digite a mensagem de rejeição",
"validation_header_name": "Os nomes de cabeçalho só podem conter as letras A-Z, algarismos, hífenes e sublinhados",
"templates_section": "Começar com modelo",
"template_newsletters": "Mover newsletters para pasta",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index a1afacfdd..1c8477576 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Numele etichetei",
"label_placeholder": "Selectați eticheta",
"header_name": "Numele antetului",
+ "condition_value": "Valoarea condiției",
"header_placeholder": "de ex., X-Mailing-List",
"size_bytes": "Dimensiune în octeți",
"size_placeholder": "de exemplu, 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "Numele regulii este obligatoriu",
"validation_empty_conditions": "Este necesară cel puțin o condiție cu o valoare",
"validation_empty_actions": "Este necesară cel puțin o acțiune",
+ "validation_action_folder": "Selectați un dosar pentru această acțiune",
+ "validation_action_tag": "Selectați o etichetă pentru această acțiune",
+ "validation_action_forward": "Introduceți o adresă către care să se redirecționeze",
+ "validation_action_reject": "Introduceți mesajul de respingere",
"validation_header_name": "Numele antetelor pot conține doar literele A-Z, cifre, cratime și liniuțe de subliniere",
"templates_section": "Începeți de la șablon",
"template_newsletters": "Mutați buletinele informative în dosar",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 83e4f8075..17a0b29dd 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Название тега",
"label_placeholder": "Выбрать тег",
"header_name": "Имя заголовка",
+ "condition_value": "Значение условия",
"header_placeholder": "напр., X-Mailing-List",
"size_bytes": "Размер в байтах",
"size_placeholder": "напр., 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Требуется название правила",
"validation_empty_conditions": "Требуется хотя бы одно условие со значением",
"validation_empty_actions": "Требуется хотя бы одно действие",
+ "validation_action_folder": "Выберите папку для этого действия",
+ "validation_action_tag": "Выберите метку для этого действия",
+ "validation_action_forward": "Введите адрес для пересылки",
+ "validation_action_reject": "Введите сообщение об отклонении",
"validation_header_name": "Имя заголовка может содержать только буквы A-Z, цифры, дефисы и подчёркивания",
"templates_section": "Начать с шаблона",
"template_newsletters": "Перемещать рассылки в папку",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index ef0666e3f..4d1eac135 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -2487,6 +2487,7 @@
"label_name": "Názov štítku",
"label_placeholder": "Vybrať štítok",
"header_name": "Názov hlavičky",
+ "condition_value": "Hodnota podmienky",
"header_placeholder": "napr. X-Mailing-List",
"size_bytes": "Veľkosť v bajtoch",
"size_placeholder": "napr. 1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "Názov pravidla je povinný",
"validation_empty_conditions": "Je potrebná aspoň jedna podmienka s hodnotou",
"validation_empty_actions": "Je potrebná aspoň jedna akcia",
+ "validation_action_folder": "Vyberte priečinok pre túto akciu",
+ "validation_action_tag": "Vyberte štítok pre túto akciu",
+ "validation_action_forward": "Zadajte adresu, na ktorú sa má preposielať",
+ "validation_action_reject": "Zadajte správu o odmietnutí",
"validation_header_name": "Názvy hlavičiek môžu obsahovať iba písmená A-Z, číslice, spojovníky a podčiarkovníky",
"templates_section": "Začať zo šablóny",
"template_newsletters": "Presunúť newslettery do priečinka",
diff --git a/locales/th/common.json b/locales/th/common.json
index 35ce3acba..08b874a4f 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -4667,6 +4667,7 @@
"label_name": "ชื่อแท็ก",
"label_placeholder": "เลือกแท็ก",
"header_name": "ชื่อส่วนหัว",
+ "condition_value": "ค่าของเงื่อนไข",
"header_placeholder": "เช่น X-Mailing-List",
"size_bytes": "ขนาดเป็นไบต์",
"size_placeholder": "เช่น 1000000",
@@ -4694,6 +4695,10 @@
"validation_empty_name": "จำเป็นต้องระบุชื่อกฎ",
"validation_empty_conditions": "จำเป็นต้องมีเงื่อนไขพร้อมค่าอย่างน้อยหนึ่งข้อ",
"validation_empty_actions": "จำเป็นต้องมีการดำเนินการอย่างน้อยหนึ่งอย่าง",
+ "validation_action_folder": "เลือกโฟลเดอร์สำหรับการดำเนินการนี้",
+ "validation_action_tag": "เลือกแท็กสำหรับการดำเนินการนี้",
+ "validation_action_forward": "ป้อนที่อยู่ที่จะส่งต่อไป",
+ "validation_action_reject": "ป้อนข้อความปฏิเสธ",
"validation_header_name": "ชื่อส่วนหัวต้องประกอบด้วยตัวอักษร A-Z, ตัวเลข, เครื่องหมายยัติภังค์ และขีดล่างเท่านั้น",
"templates_section": "เริ่มต้นจากเทมเพลต",
"template_newsletters": "ย้ายจดหมายข่าวไปยังโฟลเดอร์",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 04f453524..ed803829a 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Etiket adı",
"label_placeholder": "Etiket seçin",
"header_name": "Başlık adı",
+ "condition_value": "Koşul değeri",
"header_placeholder": "ör. X-Mailing-List",
"size_bytes": "Bayt cinsinden boyut",
"size_placeholder": "ör. 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Kural adı gerekli",
"validation_empty_conditions": "En az bir değeri olan koşul gerekli",
"validation_empty_actions": "En az bir işlem gerekli",
+ "validation_action_folder": "Bu işlem için bir klasör seçin",
+ "validation_action_tag": "Bu işlem için bir etiket seçin",
+ "validation_action_forward": "İletilecek bir adres girin",
+ "validation_action_reject": "Ret mesajını girin",
"validation_header_name": "Başlık adları yalnızca A-Z harflerini, rakamları, kısa çizgileri ve alt çizgileri içerebilir",
"templates_section": "Şablondan başla",
"template_newsletters": "Bültenleri klasöre taşı",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 106321b8a..db08d90c8 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -2480,6 +2480,7 @@
"label_name": "Назва мітки",
"label_placeholder": "наприклад, важливий",
"header_name": "Назва заголовка",
+ "condition_value": "Значення умови",
"header_placeholder": "напр., X-Mailing-List",
"size_bytes": "Розмір у байтах",
"size_placeholder": "напр., 1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "Потрібно вказати назву правила",
"validation_empty_conditions": "Потрібна принаймні одна умова зі значенням",
"validation_empty_actions": "Необхідна принаймні одна дія",
+ "validation_action_folder": "Виберіть теку для цієї дії",
+ "validation_action_tag": "Виберіть мітку для цієї дії",
+ "validation_action_forward": "Введіть адресу для пересилання",
+ "validation_action_reject": "Введіть повідомлення про відхилення",
"validation_header_name": "Назва заголовка може містити лише літери A-Z, цифри, дефіси та підкреслення",
"templates_section": "Почніть із шаблону",
"template_newsletters": "Перемістити інформаційні бюлетені в папку",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 1ded5e530..365cdda67 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -2487,6 +2487,7 @@
"label_name": "標籤名稱",
"label_placeholder": "選取標籤",
"header_name": "標頭名稱",
+ "condition_value": "條件值",
"header_placeholder": "例如,X-Mailing-List",
"size_bytes": "大小(以位元組為單位)",
"size_placeholder": "例如,1000000",
@@ -2514,6 +2515,10 @@
"validation_empty_name": "規則名稱為必填欄位",
"validation_empty_conditions": "至少需要一個具有值的條件",
"validation_empty_actions": "至少需要一個動作",
+ "validation_action_folder": "請為此動作選擇資料夾",
+ "validation_action_tag": "請為此動作選擇標籤",
+ "validation_action_forward": "請輸入要轉寄到的地址",
+ "validation_action_reject": "請輸入退信訊息",
"validation_header_name": "標頭名稱只能包含字母 A-Z、數字、連字號和底線",
"templates_section": "從範本開始",
"template_newsletters": "將訂閱郵件移至信件匣",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index d7f55ca5c..22c7731c9 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2480,6 +2480,7 @@
"label_name": "标签名称",
"label_placeholder": "选择标签",
"header_name": "标头名称",
+ "condition_value": "条件值",
"header_placeholder": "例如,X-Mailing-List",
"size_bytes": "大小(以字节为单位)",
"size_placeholder": "例如,1000000",
@@ -2507,6 +2508,10 @@
"validation_empty_name": "规则名称为必填项",
"validation_empty_conditions": "至少需要一个具有值的条件",
"validation_empty_actions": "至少需要执行一项操作",
+ "validation_action_folder": "请为此操作选择文件夹",
+ "validation_action_tag": "请为此操作选择标签",
+ "validation_action_forward": "请输入要转发到的地址",
+ "validation_action_reject": "请输入拒收消息",
"validation_header_name": "标头名称只能包含字母 A-Z、数字、连字符和下划线",
"templates_section": "从模板开始",
"template_newsletters": "将订阅邮件移至文件夹",
From 83886633af2c5f499675053b03c26946233dcba7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:39:28 +0100
Subject: [PATCH 43/54] fix: ask "delete recurring event?" and say which of its
events go when deleting a series
---
.../calendar/__tests__/calendar-app.test.tsx | 2 +-
.../recurrence-scope-dialog.test.tsx | 35 +++++++++++++++++++
components/calendar/calendar-app.tsx | 1 +
.../calendar/recurrence-scope-dialog.tsx | 18 ++++++++--
locales/ar/common.json | 4 +++
locales/ca/common.json | 4 +++
locales/cs/common.json | 4 +++
locales/da/common.json | 4 +++
locales/de/common.json | 4 +++
locales/en/common.json | 4 +++
locales/es/common.json | 4 +++
locales/et/common.json | 4 +++
locales/fa/common.json | 4 +++
locales/fr/common.json | 4 +++
locales/he/common.json | 4 +++
locales/hu/common.json | 4 +++
locales/it/common.json | 4 +++
locales/ja/common.json | 4 +++
locales/ko/common.json | 4 +++
locales/lv/common.json | 4 +++
locales/mn/common.json | 4 +++
locales/nb/common.json | 4 +++
locales/nl/common.json | 4 +++
locales/pl/common.json | 4 +++
locales/pt/common.json | 4 +++
locales/ro/common.json | 4 +++
locales/ru/common.json | 4 +++
locales/sk/common.json | 4 +++
locales/th/common.json | 4 +++
locales/tr/common.json | 4 +++
locales/uk/common.json | 4 +++
locales/zh-TW/common.json | 4 +++
locales/zh/common.json | 4 +++
33 files changed, 168 insertions(+), 4 deletions(-)
create mode 100644 components/calendar/__tests__/recurrence-scope-dialog.test.tsx
diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx
index 24ce2f577..4b36fb2f2 100644
--- a/components/calendar/__tests__/calendar-app.test.tsx
+++ b/components/calendar/__tests__/calendar-app.test.tsx
@@ -402,7 +402,7 @@ describe('CalendarApp deleting', () => {
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' }));
- expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument();
+ expect(await screen.findByRole('dialog', { name: 'delete_question' })).toBeInTheDocument();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
diff --git a/components/calendar/__tests__/recurrence-scope-dialog.test.tsx b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx
new file mode 100644
index 000000000..7f93c7c81
--- /dev/null
+++ b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { RecurrenceScopeDialog } from '../recurrence-scope-dialog';
+
+// The delete scope dialog asked "Which events would you like to modify?":
+// no word of what is lost (R3), and a title without a question mark.
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, values?: Record) =>
+ values ? `${key}(${Object.values(values).join(',')})` : key,
+}));
+
+describe('RecurrenceScopeDialog', () => {
+ it('asks to delete with a question and says what goes for the chosen scope', () => {
+ render(
+ ,
+ );
+ const dialog = screen.getByRole('dialog', { name: 'delete_question' });
+ expect(dialog).toHaveAccessibleDescription('delete_this_message(Yoga)');
+ fireEvent.click(screen.getByLabelText('all_events'));
+ expect(dialog).toHaveAccessibleDescription('delete_all_message(Yoga)');
+ fireEvent.click(screen.getByLabelText('this_and_future'));
+ expect(dialog).toHaveAccessibleDescription('delete_following_message(Yoga)');
+ // The cancellation note for an organizer's event with participants
+ expect(screen.getByText('cancel_notification')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'delete' }).className).toMatch(/destructive/);
+ });
+
+ it('keeps the edit wording for an edit', () => {
+ render();
+ const dialog = screen.getByRole('dialog', { name: 'edit_title' });
+ expect(dialog).toHaveAccessibleDescription('description');
+ expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
+ });
+});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index b56e29ceb..c5bfdb825 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -2437,6 +2437,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
&& userOrganizes(pendingScopeAction.event, currentUserEmails)
? t("participants.cancel_notification")
: undefined}
+ eventTitle={pendingScopeAction?.event.title || t("events.no_title")}
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
diff --git a/components/calendar/recurrence-scope-dialog.tsx b/components/calendar/recurrence-scope-dialog.tsx
index 8f79bf84b..dc7e4422f 100644
--- a/components/calendar/recurrence-scope-dialog.tsx
+++ b/components/calendar/recurrence-scope-dialog.tsx
@@ -15,14 +15,23 @@ interface RecurrenceScopeDialogProps {
onClose: () => void;
/** A line under the question, e.g. that the participants get a cancellation. */
note?: string;
+ /** The series' title: a delete says which of its events go (R3). */
+ eventTitle?: string;
}
+const DELETE_MESSAGES: Record = {
+ this: "delete_this_message",
+ this_and_future: "delete_following_message",
+ all: "delete_all_message",
+};
+
export function RecurrenceScopeDialog({
isOpen,
actionType,
onSelect,
onClose,
note,
+ eventTitle = "",
}: RecurrenceScopeDialogProps) {
const t = useTranslations("calendar.recurrence_scope");
const id = useId();
@@ -72,10 +81,13 @@ export function RecurrenceScopeDialog({
- {isDelete ? t("delete_title") : isRsvp ? t("rsvp_title") : t("edit_title")}
+ {isDelete ? t("delete_question") : isRsvp ? t("rsvp_title") : t("edit_title")}
-
- {isRsvp ? t("rsvp_description") : t("description")}
+ {/* A delete says what goes with the chosen scope, and that it is for good. */}
+
+ {isDelete
+ ? t(DELETE_MESSAGES[choice], { title: eventTitle })
+ : isRsvp ? t("rsvp_description") : t("description")}
{note &&
{note}
}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 8362d7178..bd7610dae 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "تعديل حدث متكرر",
"delete_title": "حذف حدث متكرر",
+ "delete_question": "حذف الحدث المتكرر؟",
+ "delete_this_message": "سيتم حذف هذا التكرار فقط من \"{title}\". لا يمكن التراجع عن ذلك.",
+ "delete_following_message": "سيتم حذف هذا التكرار وكل التكرارات التالية من \"{title}\". لا يمكن التراجع عن ذلك.",
+ "delete_all_message": "سيتم حذف كل تكرارات \"{title}\". لا يمكن التراجع عن ذلك.",
"description": "هذا حدث متكرر. ما الأحداث التي تريد تعديلها؟",
"this_event": "هذا الحدث فقط",
"this_and_future": "هذا الحدث والأحداث التالية",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 93443e066..37275c320 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Edita l'esdeveniment periòdic",
"delete_title": "Suprimeix l'esdeveniment periòdic",
+ "delete_question": "Voleu suprimir l'esdeveniment periòdic?",
+ "delete_this_message": "Només se suprimirà aquesta repetició de «{title}». No es pot desfer.",
+ "delete_following_message": "Se suprimiran aquesta repetició i totes les següents de «{title}». No es pot desfer.",
+ "delete_all_message": "Se suprimiran totes les repeticions de «{title}». No es pot desfer.",
"description": "Aquest és un esdeveniment periòdic. Quins esdeveniments voleu modificar?",
"this_event": "Només aquest esdeveniment",
"this_and_future": "Aquest i els següents esdeveniments",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 9a36130c2..0c6ff7da4 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Upravit opakující se událost",
"delete_title": "Odstranit opakující se událost",
+ "delete_question": "Odstranit opakující se událost?",
+ "delete_this_message": "Odstraní se pouze tento výskyt události \"{title}\". Tuto akci nelze vrátit.",
+ "delete_following_message": "Odstraní se tento a všechny další výskyty události \"{title}\". Tuto akci nelze vrátit.",
+ "delete_all_message": "Odstraní se všechny výskyty události \"{title}\". Tuto akci nelze vrátit.",
"description": "Toto je opakující se událost. Které události chcete upravit?",
"this_event": "Pouze tuto událost",
"this_and_future": "Tuto a všechny následující události",
diff --git a/locales/da/common.json b/locales/da/common.json
index 3a02cdc78..06a3cbef9 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Redigér tilbagevendende begivenhed",
"delete_title": "Slet tilbagevendende begivenhed",
+ "delete_question": "Slet tilbagevendende begivenhed?",
+ "delete_this_message": "Kun denne forekomst af \"{title}\" bliver slettet. Det kan ikke fortrydes.",
+ "delete_following_message": "Denne og alle senere forekomster af \"{title}\" bliver slettet. Det kan ikke fortrydes.",
+ "delete_all_message": "Alle forekomster af \"{title}\" bliver slettet. Det kan ikke fortrydes.",
"description": "Dette er en tilbagevendende begivenhed. Hvilke begivenheder vil du ændre?",
"this_event": "Kun denne begivenhed",
"this_and_future": "Denne og fremtidige begivenheder",
diff --git a/locales/de/common.json b/locales/de/common.json
index c48ca51e2..5d3fa2185 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Wiederkehrendes Ereignis bearbeiten",
"delete_title": "Wiederkehrendes Ereignis löschen",
+ "delete_question": "Wiederkehrenden Termin löschen?",
+ "delete_this_message": "Nur dieser Termin von „{title}“ wird gelöscht. Das kann nicht rückgängig gemacht werden.",
+ "delete_following_message": "Dieser und alle folgenden Termine von „{title}“ werden gelöscht. Das kann nicht rückgängig gemacht werden.",
+ "delete_all_message": "Alle Termine von „{title}“ werden gelöscht. Das kann nicht rückgängig gemacht werden.",
"description": "Dies ist ein wiederkehrendes Ereignis. Welche Ereignisse möchten Sie ändern?",
"this_event": "Nur dieses Ereignis",
"this_and_future": "Dieses und folgende Ereignisse",
diff --git a/locales/en/common.json b/locales/en/common.json
index 4329039cf..973a445cf 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Edit recurring event",
"delete_title": "Delete recurring event",
+ "delete_question": "Delete recurring event?",
+ "delete_this_message": "Only this occurrence of \"{title}\" will be deleted. This can't be undone.",
+ "delete_following_message": "This and every later occurrence of \"{title}\" will be deleted. This can't be undone.",
+ "delete_all_message": "Every occurrence of \"{title}\" will be deleted. This can't be undone.",
"description": "This is a recurring event. Which events would you like to modify?",
"this_event": "This event only",
"this_and_future": "This and following events",
diff --git a/locales/es/common.json b/locales/es/common.json
index 681722895..0446248c1 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Editar evento recurrente",
"delete_title": "Eliminar evento recurrente",
+ "delete_question": "¿Eliminar evento recurrente?",
+ "delete_this_message": "Solo se eliminará esta repetición de \"{title}\". No se puede deshacer.",
+ "delete_following_message": "Se eliminarán esta repetición y todas las siguientes de \"{title}\". No se puede deshacer.",
+ "delete_all_message": "Se eliminarán todas las repeticiones de \"{title}\". No se puede deshacer.",
"description": "Este es un evento recurrente. ¿Qué eventos desea modificar?",
"this_event": "Solo este evento",
"this_and_future": "Este evento y los siguientes",
diff --git a/locales/et/common.json b/locales/et/common.json
index 6677299c0..ae3379813 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Korduva sündmuse muutmine",
"delete_title": "Korduva sündmuse kustutamine",
+ "delete_question": "Kas kustutada korduv sündmus?",
+ "delete_this_message": "Kustutatakse ainult see \"{title}\" kordus. Seda ei saa tagasi võtta.",
+ "delete_following_message": "Kustutatakse see ja kõik järgmised \"{title}\" kordused. Seda ei saa tagasi võtta.",
+ "delete_all_message": "Kustutatakse kõik \"{title}\" kordused. Seda ei saa tagasi võtta.",
"description": "See on korduv sündmus. Milliseid sündmusi soovid muuta?",
"this_event": "Ainult see sündmus",
"this_and_future": "See ja järgnevad sündmused",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 32557ba10..c43917681 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "ویرایش رویداد تکراری",
"delete_title": "حذف رویداد تکراری",
+ "delete_question": "رویداد تکراری حذف شود؟",
+ "delete_this_message": "فقط همین تکرارِ \"{title}\" حذف خواهد شد. این کار قابل بازگشت نیست.",
+ "delete_following_message": "این تکرار و همه تکرارهای بعدیِ \"{title}\" حذف خواهند شد. این کار قابل بازگشت نیست.",
+ "delete_all_message": "همه تکرارهای \"{title}\" حذف خواهند شد. این کار قابل بازگشت نیست.",
"description": "این یک رویداد تکراری است. کدام رویدادها را میخواهید تغییر دهید؟",
"this_event": "فقط این رویداد",
"this_and_future": "این رویداد و رویدادهای بعدی",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 45c46b169..2384f1255 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Modifier l'événement récurrent",
"delete_title": "Supprimer l'événement récurrent",
+ "delete_question": "Supprimer l'événement récurrent ?",
+ "delete_this_message": "Seule cette occurrence de « {title} » sera supprimée. Cette action est irréversible.",
+ "delete_following_message": "Cette occurrence et toutes les suivantes de « {title} » seront supprimées. Cette action est irréversible.",
+ "delete_all_message": "Toutes les occurrences de « {title} » seront supprimées. Cette action est irréversible.",
"description": "Ceci est un événement récurrent. Quels événements souhaitez-vous modifier ?",
"this_event": "Cet événement uniquement",
"this_and_future": "Cet événement et les suivants",
diff --git a/locales/he/common.json b/locales/he/common.json
index af15c6fe9..8b626f13f 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3692,6 +3692,10 @@
"recurrence_scope": {
"edit_title": "ערוך אירוע חוזר",
"delete_title": "מחק אירוע חוזר",
+ "delete_question": "למחוק את האירוע החוזר?",
+ "delete_this_message": "רק המופע הזה של \"{title}\" יימחק. לא ניתן לבטל פעולה זו.",
+ "delete_following_message": "המופע הזה וכל המופעים הבאים של \"{title}\" יימחקו. לא ניתן לבטל פעולה זו.",
+ "delete_all_message": "כל המופעים של \"{title}\" יימחקו. לא ניתן לבטל פעולה זו.",
"description": "זהו אירוע שחוזר על עצמו. אילו אירועים תרצה לשנות?",
"this_event": "אירוע זה בלבד",
"this_and_future": "זה והאירועים הבאים",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 4aa36c35c..49d858db6 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Ismétlődő esemény szerkesztése",
"delete_title": "Ismétlődő esemény törlése",
+ "delete_question": "Törli az ismétlődő eseményt?",
+ "delete_this_message": "Csak a(z) \"{title}\" esemény ezen alkalma törlődik. Ez nem vonható vissza.",
+ "delete_following_message": "A(z) \"{title}\" esemény ezen és minden későbbi alkalma törlődik. Ez nem vonható vissza.",
+ "delete_all_message": "A(z) \"{title}\" esemény minden alkalma törlődik. Ez nem vonható vissza.",
"description": "Ez egy ismétlődő esemény. Mely eseményeket szeretnéd módosítani?",
"this_event": "Csak ez az esemény",
"this_and_future": "Ez és a következő események",
diff --git a/locales/it/common.json b/locales/it/common.json
index 4d5ec0030..118458e91 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Modifica evento ricorrente",
"delete_title": "Elimina evento ricorrente",
+ "delete_question": "Eliminare l'evento ricorrente?",
+ "delete_this_message": "Verrà eliminata solo questa occorrenza di \"{title}\". L'operazione non può essere annullata.",
+ "delete_following_message": "Verranno eliminate questa occorrenza e tutte le successive di \"{title}\". L'operazione non può essere annullata.",
+ "delete_all_message": "Verranno eliminate tutte le occorrenze di \"{title}\". L'operazione non può essere annullata.",
"description": "Questo è un evento ricorrente. Quali eventi desideri modificare?",
"this_event": "Solo questo evento",
"this_and_future": "Questo evento e i seguenti",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 5d6d4771c..4624ab95a 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "繰り返しイベントを編集",
"delete_title": "繰り返しイベントを削除",
+ "delete_question": "繰り返しの予定を削除しますか?",
+ "delete_this_message": "「{title}」のこの回だけを削除します。元に戻すことはできません。",
+ "delete_following_message": "「{title}」のこの回以降をすべて削除します。元に戻すことはできません。",
+ "delete_all_message": "「{title}」のすべての回を削除します。元に戻すことはできません。",
"description": "これは繰り返しイベントです。どのイベントを変更しますか?",
"this_event": "このイベントのみ",
"this_and_future": "これ以降のすべてのイベント",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 5f5e78516..200af04b8 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "반복 일정 수정",
"delete_title": "반복 일정 삭제",
+ "delete_question": "반복 일정을 삭제할까요?",
+ "delete_this_message": "\"{title}\"의 이번 일정만 삭제됩니다. 되돌릴 수 없습니다.",
+ "delete_following_message": "\"{title}\"의 이번 일정과 이후 모든 일정이 삭제됩니다. 되돌릴 수 없습니다.",
+ "delete_all_message": "\"{title}\"의 모든 일정이 삭제됩니다. 되돌릴 수 없습니다.",
"description": "반복되는 일정이에요. 어떤 일정을 수정할까요?",
"this_event": "이 일정만",
"this_and_future": "이 일정 및 향후 일정 모두",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 4abab7cdc..11185479f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3773,6 +3773,10 @@
"recurrence_scope": {
"edit_title": "Rediģēt atkārtotu pasākumu",
"delete_title": "Dzēst atkārtotu pasākumu",
+ "delete_question": "Dzēst atkārtotu notikumu?",
+ "delete_this_message": "Tiks dzēsta tikai šī «{title}» reize. To nevar atsaukt.",
+ "delete_following_message": "Tiks dzēsta šī un visas nākamās «{title}» reizes. To nevar atsaukt.",
+ "delete_all_message": "Tiks dzēstas visas «{title}» reizes. To nevar atsaukt.",
"description": "Šis ir atkārtots pasākums. Kuras instances vēlaties mainīt?",
"this_event": "Tikai šo pasākumu",
"this_and_future": "Šo un turpmākos",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 6e29449b6..0b72f4a33 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Давтах үйл явдлыг засах",
"delete_title": "Дахин давтагдах үйл явдлыг устгах",
+ "delete_question": "Давтагдах арга хэмжээг устгах уу?",
+ "delete_this_message": "\"{title}\"-ийн зөвхөн энэ удаагийнх устгагдана. Үүнийг буцаах боломжгүй.",
+ "delete_following_message": "\"{title}\"-ийн энэ болон дараагийн бүх удаагийнх устгагдана. Үүнийг буцаах боломжгүй.",
+ "delete_all_message": "\"{title}\"-ийн бүх удаагийнх устгагдана. Үүнийг буцаах боломжгүй.",
"description": "Энэ бол давтагдах үйл явдал. Та ямар үйл явдлыг өөрчлөхийг хүсэж байна вэ?",
"this_event": "Зөвхөн энэ арга хэмжээ",
"this_and_future": "Энэ болон дараах үйл явдлууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 316709528..5a82da2de 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Rediger gjentakende hendelse",
"delete_title": "Slett gjentakende hendelse",
+ "delete_question": "Vil du slette den gjentakende hendelsen?",
+ "delete_this_message": "Bare denne forekomsten av «{title}» blir slettet. Dette kan ikke angres.",
+ "delete_following_message": "Denne og alle senere forekomster av «{title}» blir slettet. Dette kan ikke angres.",
+ "delete_all_message": "Alle forekomster av «{title}» blir slettet. Dette kan ikke angres.",
"description": "Dette er en gjentakende hendelse. Hvilke hendelser vil du endre?",
"this_event": "Bare denne hendelsen",
"this_and_future": "Dette og følgende hendelser",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index a9d8f202d..945dbd2ef 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Terugkerend evenement bewerken",
"delete_title": "Terugkerend evenement verwijderen",
+ "delete_question": "Terugkerende afspraak verwijderen?",
+ "delete_this_message": "Alleen deze keer van \"{title}\" wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
+ "delete_following_message": "Deze en alle volgende keren van \"{title}\" worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
+ "delete_all_message": "Alle keren van \"{title}\" worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
"description": "Dit is een terugkerend evenement. Welke evenementen wilt u wijzigen?",
"this_event": "Alleen dit evenement",
"this_and_future": "Dit en volgende evenementen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 69c043ef9..8d007bb53 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Edytuj wydarzenie cykliczne",
"delete_title": "Usuń wydarzenie cykliczne",
+ "delete_question": "Usunąć wydarzenie cykliczne?",
+ "delete_this_message": "Zostanie usunięte tylko to wystąpienie \"{title}\". Tej operacji nie można cofnąć.",
+ "delete_following_message": "Zostaną usunięte to i wszystkie następne wystąpienia \"{title}\". Tej operacji nie można cofnąć.",
+ "delete_all_message": "Zostaną usunięte wszystkie wystąpienia \"{title}\". Tej operacji nie można cofnąć.",
"description": "To jest wydarzenie cykliczne. Które wydarzenia chcesz zmodyfikować?",
"this_event": "Tylko to wydarzenie",
"this_and_future": "To i następne wydarzenia",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 34febc6cd..92259d40f 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Editar evento recorrente",
"delete_title": "Excluir evento recorrente",
+ "delete_question": "Excluir evento recorrente?",
+ "delete_this_message": "Somente esta ocorrência de \"{title}\" será excluída. Isso não pode ser desfeito.",
+ "delete_following_message": "Esta e todas as ocorrências seguintes de \"{title}\" serão excluídas. Isso não pode ser desfeito.",
+ "delete_all_message": "Todas as ocorrências de \"{title}\" serão excluídas. Isso não pode ser desfeito.",
"description": "Este é um evento recorrente. Quais eventos você deseja modificar?",
"this_event": "Apenas este evento",
"this_and_future": "Este evento e os seguintes",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index c888e1c34..638904294 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Editați evenimentul recurent",
"delete_title": "Șterge evenimentul recurent",
+ "delete_question": "Ștergeți evenimentul recurent?",
+ "delete_this_message": "Va fi ștearsă doar această apariție a „{title}”. Acțiunea nu poate fi anulată.",
+ "delete_following_message": "Vor fi șterse această apariție și toate cele următoare ale „{title}”. Acțiunea nu poate fi anulată.",
+ "delete_all_message": "Vor fi șterse toate aparițiile „{title}”. Acțiunea nu poate fi anulată.",
"description": "Acesta este un eveniment recurent. Ce evenimente doriți să modificați?",
"this_event": "Numai acest eveniment",
"this_and_future": "Acest eveniment și evenimentele următoare",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 26248fc12..1b348bd51 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Редактировать повторяющееся событие",
"delete_title": "Удалить повторяющееся событие",
+ "delete_question": "Удалить повторяющееся событие?",
+ "delete_this_message": "Будет удалено только это повторение «{title}». Это действие нельзя отменить.",
+ "delete_following_message": "Будут удалены это и все следующие повторения «{title}». Это действие нельзя отменить.",
+ "delete_all_message": "Будут удалены все повторения «{title}». Это действие нельзя отменить.",
"description": "Это повторяющееся событие. Какие события вы хотите изменить?",
"this_event": "Только это событие",
"this_and_future": "Это и последующие события",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 5f78df7b6..cafdc77ad 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Upraviť opakujúcu sa udalosť",
"delete_title": "Odstrániť opakujúcu sa udalosť",
+ "delete_question": "Odstrániť opakujúcu sa udalosť?",
+ "delete_this_message": "Odstráni sa iba tento výskyt udalosti \"{title}\". Túto akciu nemožno vrátiť.",
+ "delete_following_message": "Odstráni sa tento a všetky ďalšie výskyty udalosti \"{title}\". Túto akciu nemožno vrátiť.",
+ "delete_all_message": "Odstránia sa všetky výskyty udalosti \"{title}\". Túto akciu nemožno vrátiť.",
"description": "Toto je opakujúca sa udalosť. Ktoré udalosti chcete upraviť?",
"this_event": "Iba túto udalosť",
"this_and_future": "Túto a všetky nasledujúce udalosti",
diff --git a/locales/th/common.json b/locales/th/common.json
index 191606baa..62defd1b8 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -2898,6 +2898,10 @@
"recurrence_scope": {
"edit_title": "แก้ไขกิจกรรมที่เกิดซ้ำ",
"delete_title": "ลบกิจกรรมที่เกิดซ้ำ",
+ "delete_question": "ลบกิจกรรมที่เกิดซ้ำหรือไม่?",
+ "delete_this_message": "จะลบเฉพาะครั้งนี้ของ “{title}” และไม่สามารถเรียกคืนได้",
+ "delete_following_message": "จะลบครั้งนี้และทุกครั้งถัดไปของ “{title}” และไม่สามารถเรียกคืนได้",
+ "delete_all_message": "จะลบทุกครั้งของ “{title}” และไม่สามารถเรียกคืนได้",
"description": "นี่คือกิจกรรมที่เกิดซ้ำ คุณต้องการปรับเปลี่ยนกิจกรรมใดบ้าง?",
"this_event": "เฉพาะกิจกรรมนี้",
"this_and_future": "กิจกรรมนี้และกิจกรรมที่ตามมา",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index ccb1b0544..71edc44bc 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Yinelenen etkinliği düzenle",
"delete_title": "Yinelenen etkinliği sil",
+ "delete_question": "Yinelenen etkinlik silinsin mi?",
+ "delete_this_message": "\"{title}\" etkinliğinin yalnızca bu tekrarı silinecek. Bu işlem geri alınamaz.",
+ "delete_following_message": "\"{title}\" etkinliğinin bu ve sonraki tüm tekrarları silinecek. Bu işlem geri alınamaz.",
+ "delete_all_message": "\"{title}\" etkinliğinin tüm tekrarları silinecek. Bu işlem geri alınamaz.",
"description": "Bu yinelenen bir etkinlik. Hangi etkinlikleri değiştirmek istersiniz?",
"this_event": "Yalnızca bu etkinlik",
"this_and_future": "Bu ve sonraki etkinlikler",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 735d06df1..7398275dd 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Редагувати повторювану подію",
"delete_title": "Видалити повторювану подію",
+ "delete_question": "Видалити повторювану подію?",
+ "delete_this_message": "Буде видалено лише це повторення «{title}». Цю дію не можна скасувати.",
+ "delete_following_message": "Буде видалено це й усі наступні повторення «{title}». Цю дію не можна скасувати.",
+ "delete_all_message": "Буде видалено всі повторення «{title}». Цю дію не можна скасувати.",
"description": "Це повторювана подія. Які події ви б хотіли змінити?",
"this_event": "Тільки ця подія",
"this_and_future": "Ця та наступні події",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 904bc88de..3bea7b652 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "編輯重複活動",
"delete_title": "刪除重複活動",
+ "delete_question": "要刪除重複活動嗎?",
+ "delete_this_message": "僅刪除「{title}」的這一次,且無法復原。",
+ "delete_following_message": "將刪除「{title}」的這一次及之後的所有重複,且無法復原。",
+ "delete_all_message": "將刪除「{title}」的所有重複,且無法復原。",
"description": "這是重複活動。您要修改哪些活動?",
"this_event": "僅限本次活動",
"this_and_future": "本次及後續活動",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index c0d17e304..4be939f45 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "编辑重复事件",
"delete_title": "删除重复事件",
+ "delete_question": "删除重复事件?",
+ "delete_this_message": "仅删除“{title}”的这一次,且无法撤销。",
+ "delete_following_message": "将删除“{title}”的这一次及之后的所有重复,且无法撤销。",
+ "delete_all_message": "将删除“{title}”的所有重复,且无法撤销。",
"description": "这是一个重复事件。您想修改哪些事件?",
"this_event": "仅限本次活动",
"this_and_future": "本次及后续活动",
From 8b2b77c349739e099126a97930b0ddfc285bedae Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:41:08 +0100
Subject: [PATCH 44/54] =?UTF-8?q?fix:=20put=20the=20calendar=20row=20actio?=
=?UTF-8?q?ns=20behind=20a=20=E2=8B=AE=20menu=20on=20the=20phone=20so=20na?=
=?UTF-8?q?mes=20and=20the=20default=20badge=20fit,=20and=20make=20the=20r?=
=?UTF-8?q?ow=20icons=20full-size=20named=20buttons?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../calendar-management-settings.test.tsx | 44 ++++
.../settings/calendar-management-settings.tsx | 219 ++++++++++--------
locales/ar/common.json | 1 +
locales/ca/common.json | 1 +
locales/cs/common.json | 1 +
locales/da/common.json | 1 +
locales/de/common.json | 1 +
locales/en/common.json | 1 +
locales/es/common.json | 1 +
locales/et/common.json | 1 +
locales/fa/common.json | 1 +
locales/fr/common.json | 1 +
locales/he/common.json | 1 +
locales/hu/common.json | 1 +
locales/it/common.json | 1 +
locales/ja/common.json | 1 +
locales/ko/common.json | 1 +
locales/lv/common.json | 1 +
locales/mn/common.json | 1 +
locales/nb/common.json | 1 +
locales/nl/common.json | 1 +
locales/pl/common.json | 1 +
locales/pt/common.json | 1 +
locales/ro/common.json | 1 +
locales/ru/common.json | 1 +
locales/sk/common.json | 1 +
locales/th/common.json | 1 +
locales/tr/common.json | 1 +
locales/uk/common.json | 1 +
locales/zh-TW/common.json | 1 +
locales/zh/common.json | 1 +
31 files changed, 191 insertions(+), 101 deletions(-)
diff --git a/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx
index af1f4d79d..439eb43d6 100644
--- a/components/settings/__tests__/calendar-management-settings.test.tsx
+++ b/components/settings/__tests__/calendar-management-settings.test.tsx
@@ -72,3 +72,47 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
vi.restoreAllMocks();
});
});
+
+// Phone: seven icons in a row hid the calendar names. The row actions are
+// also in a ⋮ menu (right-click too), the same list as the icons, and every
+// icon is a named button with the touch hit area.
+describe('CalendarManagementSettings row actions', () => {
+ const work = {
+ id: 'work', name: 'Work', color: '#ff0000', isDefault: false, isShared: false, shareWith: null, sortOrder: 1,
+ myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
+ } as unknown as Calendar;
+ const home = { ...appleColored, color: '#00ff00', sortOrder: 0 } as Calendar;
+
+ beforeEach(() => {
+ useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
+ useCalendarStore.setState({ calendars: [home, work], icalSubscriptions: [], updateCalendar: vi.fn() } as never);
+ });
+
+ it('offers the row actions from a named ⋮ menu, Delete last and only where it applies', async () => {
+ render(
);
+ const more = screen.getAllByRole('button', { name: 'row_actions' });
+ expect(more).toHaveLength(2);
+
+ fireEvent.click(more[1]); // Work, not the default calendar
+ const menu = await screen.findByRole('menu');
+ const items = Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent);
+ expect(items).toEqual(['edit', 'default_alerts', 'share', 'clear_events', 'move_up', 'move_down', 'delete']);
+
+ fireEvent.click(screen.getByRole('menuitem', { name: 'delete' }));
+ expect(await screen.findByRole('alertdialog')).toBeTruthy();
+ });
+
+ it('leaves Delete out of the default calendar menu', async () => {
+ render(
);
+ fireEvent.click(screen.getAllByRole('button', { name: 'row_actions' })[0]);
+ const menu = await screen.findByRole('menu');
+ expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent)).not.toContain('delete');
+ });
+
+ it('names every row icon', () => {
+ render(
);
+ for (const name of ['move_up', 'move_down', 'edit', 'default_alerts', 'share', 'clear_events', 'delete', 'change_color']) {
+ expect(screen.getAllByRole('button', { name }).length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx
index 4c56f0e69..f8841ba76 100644
--- a/components/settings/calendar-management-settings.tsx
+++ b/components/settings/calendar-management-settings.tsx
@@ -9,7 +9,10 @@ import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
-import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical } from '@/components/icons';
+import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical, MoreVertical } from '@/components/icons';
+import { IconButton } from '@/components/ui/icon-button';
+import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
+import { useContextMenu } from '@/hooks/use-context-menu';
import { AlertListEditor } from '@/components/calendar/alert-list-editor';
import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts';
import { ShareCollectionDialog } from './share-collection-dialog';
@@ -287,6 +290,7 @@ export function CalendarManagementSettings() {
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef
(null);
const [draggingId, setDraggingId] = useState(null);
+ const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu();
const listedCalendars = calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
@@ -560,8 +564,64 @@ export function CalendarManagementSettings() {
}
};
+ // One action list per calendar (R6): the row's icons on wider screens, the
+ // ⋮ and right-click menu everywhere.
+ const calendarActions = (cal: Calendar) => {
+ const actions: Array<{ id: string; label: string; icon: typeof Pencil; run: () => void; destructive?: boolean }> = [
+ { id: 'edit', label: t('edit'), icon: Pencil, run: () => setEditingId(cal.id) },
+ ];
+ // The owner's defaults; a sharee can't write the calendar.
+ if (!cal.isShared) {
+ actions.push({ id: 'alerts', label: t('default_alerts'), icon: Bell, run: () => setAlertsEditingId(cal.id) });
+ }
+ if (cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id)) {
+ actions.push({ id: 'share', label: t('share'), icon: Users, run: () => setSharingId(cal.id) });
+ }
+ actions.push({ id: 'clear', label: t('clear_events'), icon: Eraser, run: () => setClearingId(cal.id) });
+ if (!cal.isDefault) {
+ actions.push({ id: 'delete', label: t('delete'), icon: Trash2, run: () => setDeletingId(cal.id), destructive: true });
+ }
+ return actions;
+ };
+
+ const renderRowMenu = () => {
+ const cal = contextMenu.data;
+ if (!cal) return null;
+ const run = (fn: () => void) => () => { closeContextMenu(); fn(); };
+ const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal));
+ const orderIndex = siblings.indexOf(cal);
+ const previousId = siblings[orderIndex - 1]?.id;
+ const nextId = siblings[orderIndex + 1]?.id;
+ const buildUrl = buildCalDavUrl(cal.id);
+ const actions = calendarActions(cal);
+ const plain = actions.filter((a) => !a.destructive);
+ const destructive = actions.filter((a) => a.destructive);
+ return (
+
+ {plain.map((a) => (
+
+ ))}
+ {buildUrl && (
+ handleCopyUrl(buildUrl))} />
+ )}
+ {siblings.length > 1 && (
+ <>
+
+ void handleMove(cal.id, previousId))} />
+ void handleMove(cal.id, nextId))} />
+ >
+ )}
+ {destructive.length > 0 && }
+ {destructive.map((a) => (
+
+ ))}
+
+ );
+ };
+
return (
+ {renderRowMenu()}
{listedCalendars.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
@@ -611,8 +671,9 @@ export function CalendarManagementSettings() {
onDragOver={isOrderable && draggingId ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } : undefined}
onDrop={isOrderable && draggingId ? (e) => { e.preventDefault(); void handleMove(draggingId, cal.id); setDraggingId(null); } : undefined}
onDragEnd={() => setDraggingId(null)}
+ onContextMenu={(e) => openContextMenu(e, cal)}
className={cn(
- "flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group",
+ "flex items-center gap-2 sm:gap-3 py-2.5 px-3 rounded-md border border-border bg-background group",
draggingId === cal.id && "opacity-50",
)}
>
@@ -628,6 +689,7 @@ export function CalendarManagementSettings() {
className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ backgroundColor: color }}
title={t('change_color')}
+ aria-label={t('change_color')}
/>
{/* Inline color picker popover */}
@@ -648,7 +710,7 @@ export function CalendarManagementSettings() {
- {cal.name}
+ {cal.name}
{(() => {
const caldavUrl = buildCalDavUrl(cal.id);
if (!caldavUrl) return null;
@@ -658,24 +720,23 @@ export function CalendarManagementSettings() {
{caldavUrl}
-
+
+
);
})()}
{cal.isDefault && (
-
+
{t('default')}
)}
@@ -689,7 +750,7 @@ export function CalendarManagementSettings() {
: t('shared_with_count', { count: shareCount });
return (
@@ -700,81 +761,44 @@ export function CalendarManagementSettings() {
);
})()}
+ {/* Phone: seven icons next to the name left it a letter or two
+ and pushed the Default badge over the copy button, so the
+ row actions sit behind a ⋮ there, as for folders. */}
+ openContextMenuFrom(e.currentTarget, cal, 'end')}
+ >
+
+
+
{/* Shown for touch too: there is no hover, and no HTML drag and drop. */}
{isOrderable && (
-
-
-
+
+
handleMove(cal.id, previousId)} disabled={!previousId} className="text-muted-foreground">
+
+
+
handleMove(cal.id, nextId)} disabled={!nextId} className="text-muted-foreground">
+
+
)}
-
-
- {/* The owner's defaults; a sharee can't write the calendar. */}
- {!cal.isShared && (
-
);
@@ -855,32 +879,25 @@ export function CalendarManagementSettings() {
)}
-
-
setEditingSubscription(sub)}
- className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
- title={tSub('edit')}
- >
-
-
-
+ setEditingSubscription(sub)} className="text-muted-foreground">
+
+
+ handleRefreshSubscription(sub.id)}
disabled={refreshingSubId === sub.id}
- className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
- title={tSub('refresh')}
+ className="text-muted-foreground"
>
-
-
-
+
+
setDeletingSubId(sub.id)}
- className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
- title={tSub('unsubscribe')}
+ className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
-
-
+
+
);
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 1d986128a..ed53371f8 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -4025,6 +4025,7 @@
"create": "إنشاء",
"cancel": "إلغاء",
"default": "افتراضي",
+ "row_actions": "إجراءات {name}",
"confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع الأحداث في هذا التقويم.",
"confirm_clear": "مسح جميع الأحداث من \"{name}\"؟ لا يمكن التراجع عن هذا.",
"set_default": "تعيين كافتراضي",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 88fca6816..d1140cb30 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -4025,6 +4025,7 @@
"create": "Crea",
"cancel": "Cancel·la",
"default": "Predeterminat",
+ "row_actions": "Accions per a {name}",
"confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els esdeveniments d'aquest calendari.",
"confirm_clear": "Voleu esborrar tots els esdeveniments de «{name}»? Això no es pot desfer.",
"set_default": "Estableix com a predeterminat",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index e3041d047..a4de56156 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3996,6 +3996,7 @@
"create": "Vytvořit",
"cancel": "Zrušit",
"default": "Výchozí",
+ "row_actions": "Akce pro {name}",
"confirm_delete": "Odstranit \"{name}\"? Všechny události v tomto kalendáři budou odstraněny.",
"confirm_clear": "Vymazat všechny události z \"{name}\"? Tuto akci nelze vrátit zpět.",
"set_default": "Nastavit jako výchozí",
diff --git a/locales/da/common.json b/locales/da/common.json
index b9eb1ec39..d9b47bc99 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -4022,6 +4022,7 @@
"create": "Opret",
"cancel": "Annuller",
"default": "Standard",
+ "row_actions": "Handlinger for {name}",
"confirm_delete": "Slet \"{name}\"? Alle begivenheder i denne kalender fjernes.",
"confirm_clear": "Ryd alle begivenheder fra \"{name}\"? Dette kan ikke fortrydes.",
"set_default": "Angiv som standard",
diff --git a/locales/de/common.json b/locales/de/common.json
index 2aca5ca4a..7a3c452b4 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3996,6 +3996,7 @@
"create": "Erstellen",
"cancel": "Abbrechen",
"default": "Standard",
+ "row_actions": "Aktionen für {name}",
"confirm_delete": "\"{name}\" löschen? Alle Termine in diesem Kalender werden entfernt.",
"calendar_created": "Kalender erstellt",
"calendar_updated": "Kalender aktualisiert",
diff --git a/locales/en/common.json b/locales/en/common.json
index 48d33cc96..91582f0e1 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -4025,6 +4025,7 @@
"create": "Create",
"cancel": "Cancel",
"default": "Default",
+ "row_actions": "Actions for {name}",
"confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.",
"confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.",
"set_default": "Set as default",
diff --git a/locales/es/common.json b/locales/es/common.json
index fdce84a88..e199b0670 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3996,6 +3996,7 @@
"create": "Crear",
"cancel": "Cancelar",
"default": "Predeterminado",
+ "row_actions": "Acciones para {name}",
"confirm_delete": "¿Eliminar \"{name}\"? Se eliminarán todos los eventos de este calendario.",
"calendar_created": "Calendario creado",
"calendar_updated": "Calendario actualizado",
diff --git a/locales/et/common.json b/locales/et/common.json
index 405c4ef57..85055ddce 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -4025,6 +4025,7 @@
"create": "Loo",
"cancel": "Tühista",
"default": "Vaikimisi",
+ "row_actions": "Toimingud: {name}",
"confirm_delete": "Kustutada \"{name}\"? Kõik selle kalendri sündmused eemaldatakse.",
"confirm_clear": "Tühjendada kõik sündmused kalendrist \"{name}\"? Seda ei saa tagasi võtta.",
"set_default": "Määra vaikimisi kalendriks",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 25e1c4bc8..6e6bbf648 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -4025,6 +4025,7 @@
"create": "ایجاد",
"cancel": "انصراف",
"default": "پیشفرض",
+ "row_actions": "کارها برای {name}",
"confirm_delete": "حذف \"{name}\"؟ همه رویدادهای این تقویم حذف خواهند شد.",
"confirm_clear": "پاک کردن همه رویدادهای \"{name}\"؟ این عملیات قابل بازگشت نیست.",
"set_default": "تنظیم به عنوان پیشفرض",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 37470e214..3bcef07e3 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -4022,6 +4022,7 @@
"create": "Créer",
"cancel": "Annuler",
"default": "Par défaut",
+ "row_actions": "Actions pour {name}",
"confirm_delete": "Supprimer \"{name}\" ? Tous les événements de ce calendrier seront supprimés.",
"calendar_created": "Calendrier créé",
"calendar_updated": "Calendrier mis à jour",
diff --git a/locales/he/common.json b/locales/he/common.json
index 8ee7c0388..9cb89dd7f 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3904,6 +3904,7 @@
"create": "צור",
"cancel": "לְבַטֵל",
"default": "ברירת מחדל",
+ "row_actions": "פעולות עבור {name}",
"confirm_delete": "למחוק את \"{name}\"? כל האירועים בלוח השנה הזה יוסרו.",
"confirm_clear": "לנקות את כל האירועים מ-\"{name}\"? לא ניתן לבטל זאת.",
"clear_events": "נקה אירועים",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 5a6180aae..94f8ad664 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -4025,6 +4025,7 @@
"create": "Létrehozás",
"cancel": "Mégse",
"default": "Alapértelmezett",
+ "row_actions": "{name} – műveletek",
"confirm_delete": "Törlöd a \"{name}\" naptárat? Az összes esemény eltávolításra kerül.",
"confirm_clear": "Törlöd az összes eseményt a \"{name}\" naptárból? Ez nem vonható vissza.",
"set_default": "Beállítás alapértelmezettként",
diff --git a/locales/it/common.json b/locales/it/common.json
index 859d4b827..2de6a01ff 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3996,6 +3996,7 @@
"create": "Crea",
"cancel": "Annulla",
"default": "Predefinito",
+ "row_actions": "Azioni per {name}",
"confirm_delete": "Eliminare \"{name}\"? Tutti gli eventi in questo calendario verranno rimossi.",
"calendar_created": "Calendario creato",
"calendar_updated": "Calendario aggiornato",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 900588922..7ce5da10f 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3996,6 +3996,7 @@
"create": "作成",
"cancel": "キャンセル",
"default": "デフォルト",
+ "row_actions": "{name} の操作",
"confirm_delete": "\"{name}\"を削除しますか?このカレンダーのすべてのイベントが削除されます。",
"calendar_created": "カレンダーを作成しました",
"calendar_updated": "カレンダーを更新しました",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index ca7c59f30..29f52e38d 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3996,6 +3996,7 @@
"create": "만들기",
"cancel": "취소",
"default": "기본",
+ "row_actions": "{name} 작업",
"confirm_delete": "\"{name}\" 캘린더를 삭제할까요? 캘린더 안의 모든 일정이 함께 지워져요.",
"confirm_clear": "\"{name}\" 캘린더의 모든 일정을 지울까요? 이 작업은 되돌릴 수 없어요.",
"set_default": "기본으로 설정",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index ed466f232..5e6a711ec 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3995,6 +3995,7 @@
"create": "Izveidot",
"cancel": "Atcelt",
"default": "Noklusējuma",
+ "row_actions": "Darbības: {name}",
"confirm_delete": "Dzēst «{name}»? Visi pasākumi šajā kalendārā tiks dzēsti.",
"confirm_clear": "Iztīrīt visus pasākumus no «{name}»? Šo darbību nevar atcelt.",
"set_default": "Iestatīt kā noklusējumu",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index b9cec81c3..2bc750466 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -4025,6 +4025,7 @@
"create": "Бүтээх",
"cancel": "Цуцлах",
"default": "Өгөгдмөл",
+ "row_actions": "{name}-ийн үйлдлүүд",
"confirm_delete": "\"{name}\"-г устгах уу? Энэ хуанли дахь бүх үйл явдлууд устах болно.",
"confirm_clear": "\"{name}\"-с бүх үйл явдлыг арилгах уу? Үүнийг буцаах боломжгүй.",
"set_default": "Өгөгдмөлөөр тохируулах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 750cf6251..66f39372a 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -4025,6 +4025,7 @@
"create": "Opprett",
"cancel": "Avbryt",
"default": "Standard",
+ "row_actions": "Handlinger for {name}",
"confirm_delete": "Vil du slette «{name}»? Alle hendelsene i kalenderen fjernes.",
"confirm_clear": "Vil du fjerne alle hendelser fra «{name}»? Dette kan ikke angres.",
"set_default": "Sett som standard",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 4267e8ac0..fd595555b 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3996,6 +3996,7 @@
"create": "Aanmaken",
"cancel": "Annuleren",
"default": "Standaard",
+ "row_actions": "Acties voor {name}",
"confirm_delete": "\"{name}\" verwijderen? Alle afspraken in deze agenda worden verwijderd.",
"calendar_created": "Agenda aangemaakt",
"calendar_updated": "Agenda bijgewerkt",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index ba19e9651..568bd8911 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3996,6 +3996,7 @@
"create": "Utwórz",
"cancel": "Anuluj",
"default": "Domyślny",
+ "row_actions": "Działania dla {name}",
"confirm_delete": "Usunąć \"{name}\"? Wszystkie wydarzenia w tym kalendarzu zostaną usunięte.",
"confirm_clear": "Wyczyścić wszystkie wydarzenia z \"{name}\"? Tej operacji nie można cofnąć.",
"set_default": "Ustaw jako domyślny",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 448848187..b4f28e0fd 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -4022,6 +4022,7 @@
"create": "Criar",
"cancel": "Cancelar",
"default": "Padrão",
+ "row_actions": "Ações para {name}",
"confirm_delete": "Excluir \"{name}\"? Todos os eventos neste calendário serão removidos.",
"calendar_created": "Calendário criado",
"calendar_updated": "Calendário atualizado",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 1c8477576..de504c70a 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -4025,6 +4025,7 @@
"create": "Creați",
"cancel": "Anulează",
"default": "Implicit",
+ "row_actions": "Acțiuni pentru {name}",
"confirm_delete": "Doriți să ștergeți „{name}”? Toate evenimentele din acest calendar vor fi eliminate.",
"confirm_clear": "Ștergeți toate evenimentele din „{name}”? Această acțiune nu poate fi anulată.",
"set_default": "Setați ca implicită",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 17a0b29dd..eeba5d2d3 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3996,6 +3996,7 @@
"create": "Создать",
"cancel": "Отмена",
"default": "По умолчанию",
+ "row_actions": "Действия для «{name}»",
"confirm_delete": "Удалить «{name}»? Все события в этом календаре будут удалены.",
"confirm_clear": "Очистить все события из «{name}»? Это действие нельзя отменить.",
"set_default": "Сделать по умолчанию",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 4d1eac135..66d1d0b58 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -4025,6 +4025,7 @@
"create": "Vytvoriť",
"cancel": "Zrušiť",
"default": "Predvolený",
+ "row_actions": "Akcie pre {name}",
"confirm_delete": "Odstrániť \"{name}\"? Všetky udalosti v tomto kalendári budú odstránené.",
"confirm_clear": "Vymazať všetky udalosti z \"{name}\"? Túto akciu nie je možné vrátiť.",
"set_default": "Nastaviť ako predvolený",
diff --git a/locales/th/common.json b/locales/th/common.json
index 08b874a4f..cd740b2da 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -3141,6 +3141,7 @@
"create": "สร้าง",
"cancel": "ยกเลิก",
"default": "ค่าเริ่มต้น",
+ "row_actions": "การทำงานสำหรับ {name}",
"confirm_delete": "ลบ “{name}” หรือไม่? กิจกรรมทั้งหมดในปฏิทินนี้จะถูกลบ",
"confirm_clear": "ล้างกิจกรรมทั้งหมดจาก “{name}” หรือไม่? การดำเนินการนี้ไม่สามารถยกเลิกได้",
"set_default": "ตั้งเป็นค่าเริ่มต้น",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index ed803829a..dd9fdf7a3 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -4022,6 +4022,7 @@
"create": "Oluştur",
"cancel": "İptal",
"default": "Varsayılan",
+ "row_actions": "{name} için işlemler",
"confirm_delete": "\"{name}\" silinsin mi? Bu takvimdeki tüm etkinlikler kaldırılacak.",
"confirm_clear": "\"{name}\" takvimdeki tüm etkinlikler temizlensin mi? Bu geri alınamaz.",
"set_default": "Varsayılan olarak ayarla",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index db08d90c8..0594999a3 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3996,6 +3996,7 @@
"create": "Створити",
"cancel": "Скасувати",
"default": "За замовчуванням",
+ "row_actions": "Дії для «{name}»",
"confirm_delete": "Видалити \"{name}\"? Усі події в цьому календарі буде видалено.",
"confirm_clear": "Очистити всі події з \"{name}\"? Це неможливо скасувати.",
"set_default": "Зробити типовим",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 365cdda67..d40cab6f6 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -4025,6 +4025,7 @@
"create": "建立",
"cancel": "取消",
"default": "預設",
+ "row_actions": "{name} 的動作",
"confirm_delete": "要刪除「{name}」嗎?此行事曆中的所有活動都會移除。",
"confirm_clear": "要清除「{name}」中的所有活動嗎?此動作無法復原。",
"set_default": "設為預設",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 22c7731c9..9265219ce 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3996,6 +3996,7 @@
"create": "创建",
"cancel": "取消",
"default": "默认",
+ "row_actions": "{name} 的操作",
"confirm_delete": "删除\"{name}\"?此日历中的所有活动都将被删除。",
"confirm_clear": "清除\"{name}\"中的所有活动吗?此操作无法撤销。",
"set_default": "设为默认",
From 94029e5aa236d4c676637afb1d8c170b053338ac Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:28:07 +0100
Subject: [PATCH 45/54] fix: ask before the nav rail or the phone tab bar
leaves a contact or group form with changes
---
.../contacts/__tests__/contacts-app.test.tsx | 24 ++++++
components/contacts/contacts-app.tsx | 22 +++--
.../navigation-rail-leave-guard.test.tsx | 52 +++++++++++
components/layout/navigation-rail.tsx | 6 +-
hooks/use-close-on-back.ts | 8 ++
lib/__tests__/leave-guard.test.ts | 86 +++++++++++++++++++
lib/leave-guard.ts | 78 +++++++++++++++++
7 files changed, 266 insertions(+), 10 deletions(-)
create mode 100644 components/layout/__tests__/navigation-rail-leave-guard.test.tsx
create mode 100644 lib/__tests__/leave-guard.test.ts
create mode 100644 lib/leave-guard.ts
diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx
index a69a0e1d4..95d65033a 100644
--- a/components/contacts/__tests__/contacts-app.test.tsx
+++ b/components/contacts/__tests__/contacts-app.test.tsx
@@ -42,6 +42,7 @@ function getRegisteredGroups(): CommandGroup[] {
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
import { useToastStore } from '@/stores/toast-store';
+import { interceptLeave } from '@/lib/leave-guard';
export const alice: ContactCard = {
id: 'c-alice',
@@ -146,6 +147,29 @@ describe('ContactsApp', () => {
expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-bob', 'c-carol']);
});
+ it('has the rail and tab bar ask before they leave a contact form with changes', async () => {
+ renderContacts();
+ fireEvent.click(screen.getByText('Alice Smith'));
+ fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
+ fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
+
+ // A rail link wired as the real one is: interceptLeave first.
+ const navigated = vi.fn();
+ const link = document.createElement('a');
+ link.addEventListener('click', (e) => { if (!interceptLeave(e)) navigated(); });
+ document.body.appendChild(link);
+ try {
+ act(() => { link.click(); });
+ const dialog = await screen.findByRole('alertdialog');
+ expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument();
+ expect(navigated).not.toHaveBeenCalled();
+ fireEvent.click(within(dialog).getByRole('button', { name: 'form.discard' }));
+ await waitFor(() => expect(navigated).toHaveBeenCalledOnce());
+ } finally {
+ link.remove();
+ }
+ });
+
it('leaves an untouched contact form without asking', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index 3e88086d2..e4ed9c6b5 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -52,6 +52,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useRegisterCommands } from "@/lib/command-registry";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
+import { useLeaveGuard } from "@/lib/leave-guard";
import { useDeleteCategory } from "@/hooks/use-delete-category";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
@@ -172,18 +173,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
useEffect(() => {
if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false);
}, [view, setEditorDirty]);
+ const askDiscard = useCallback(() => confirmDialog({
+ title: t("form.discard_title"),
+ message: t("form.discard_message"),
+ confirmText: t("form.discard"),
+ cancelText: t("form.keep_editing"),
+ variant: "destructive",
+ }), [confirmDialog, t]);
const confirmDiscard = useCallback(async () => {
if (!editorDirtyRef.current) return true;
- const discard = await confirmDialog({
- title: t("form.discard_title"),
- message: t("form.discard_message"),
- confirmText: t("form.discard"),
- cancelText: t("form.keep_editing"),
- variant: "destructive",
- });
+ const discard = await askDiscard();
if (discard) setEditorDirty(false);
return discard;
- }, [confirmDialog, t, setEditorDirty]);
+ }, [askDiscard, setEditorDirty]);
+ // The rail and the phone tab bar ask too, before they leave Contacts. Only
+ // ask there: leaving unmounts the form, and clearing it first would let the
+ // Back guard below take its history step back over the navigation.
+ useLeaveGuard(() => editorDirtyRef.current, askDiscard);
const guard =
,
+}));
+
+const { NavigationRail } = await import('../navigation-rail');
+const { useUpdateStore } = await import('@/stores/update-store');
+const { registerLeaveGuard } = await import('@/lib/leave-guard');
+
+let off: (() => void) | null = null;
+beforeEach(() => {
+ useUpdateStore.setState({ startPolling: vi.fn() } as never);
+});
+afterEach(() => { off?.(); off = null; });
+
+describe.each([
+ ['desktop rail', { orientation: 'vertical', collapsed: true }],
+ ['phone tab bar', { orientation: 'horizontal', collapsed: false }],
+] as const)('%s', (_name, props) => {
+ it('asks before leaving an editor with unsaved changes, and stays on "Keep editing"', async () => {
+ const confirm = vi.fn(async () => false);
+ off = registerLeaveGuard({ isDirty: () => true, confirm });
+ render(
);
+ const mail = screen.getAllByRole('link', { name: 'mail' })[0];
+ const click = fireEvent.click(mail);
+ // fireEvent returns false when the default (navigating) was prevented.
+ expect(click).toBe(false);
+ expect(confirm).toHaveBeenCalledOnce();
+ });
+
+ it('leaves without asking when nothing is unsaved', () => {
+ const confirm = vi.fn(async () => false);
+ off = registerLeaveGuard({ isDirty: () => false, confirm });
+ render(
);
+ fireEvent.click(screen.getAllByRole('link', { name: 'mail' })[0]);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index c3c0af96b..8a0c854e9 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -27,6 +27,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
+import { interceptLeave } from "@/lib/leave-guard";
import { Avatar } from "@/components/ui/avatar";
import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
@@ -351,6 +352,7 @@ export function NavigationRail({
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
(e: React.MouseEvent) => {
+ if (interceptLeave(e)) return;
if (onNavigate) {
const intercepted = onNavigate(itemId);
if (intercepted !== false) {
@@ -421,7 +423,7 @@ export function NavigationRail({
{ if (activeAppId) onCloseInlineApp?.(); }}
+ onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
title={page.pluginName}
className={cn(
@@ -633,7 +635,7 @@ export function NavigationRail({
{ if (activeAppId) onCloseInlineApp?.(); }}
+ onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
diff --git a/hooks/use-close-on-back.ts b/hooks/use-close-on-back.ts
index ba8079478..01c02f7ba 100644
--- a/hooks/use-close-on-back.ts
+++ b/hooks/use-close-on-back.ts
@@ -10,6 +10,14 @@ const STATE_KEY = "__closeOnBack";
// would pop it and close the layer at once.
let pendingHistoryBack: Promise