From d70bf4ae745d376eb92b4c2d98f35dc284ec9e5f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:32:48 +0100 Subject: [PATCH] fix: delete tags and filter rules at once with an Undo toast --- .../__tests__/settings-delete-undo.test.tsx | 90 +++++++++++++++++++ components/settings/filter-settings.tsx | 76 ++++++++-------- components/settings/keyword-settings.tsx | 18 ++++ 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 + 32 files changed, 173 insertions(+), 40 deletions(-) create mode 100644 components/settings/__tests__/settings-delete-undo.test.tsx diff --git a/components/settings/__tests__/settings-delete-undo.test.tsx b/components/settings/__tests__/settings-delete-undo.test.tsx new file mode 100644 index 000000000..63b633221 --- /dev/null +++ b/components/settings/__tests__/settings-delete-undo.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { FilterSettings } from '../filter-settings'; +import { KeywordSettings } from '../keyword-settings'; + +// R3: a tag or a filter rule can be put back exactly as it was, so Delete +// removes it at once, without a question or an inline Delete/Cancel strip, +// and the toast offers Undo. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +type UndoOptions = { action: { label: string; onClick: () => void } }; +const undo = () => (toast.success.mock.calls.at(-1)?.[1] as UndoOptions).action; + +const initial = { + auth: useAuthStore.getState(), + email: useEmailStore.getState(), + filter: useFilterStore.getState(), + settings: useSettingsStore.getState(), +}; +beforeEach(() => { + toast.success.mockClear(); + toast.error.mockClear(); +}); +afterEach(() => { + useAuthStore.setState(initial.auth, true); + useEmailStore.setState(initial.email, true); + useFilterStore.setState(initial.filter, true); + useSettingsStore.setState(initial.settings, true); +}); + +describe('deleting a tag', () => { + it('goes at once and Undo puts it back in its place', () => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false }); + render(); + const second = DEFAULT_KEYWORDS[1]; + + fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[1]); + + expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).not.toContain(second.id); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('deleted', expect.objectContaining({ action: expect.anything() })); + expect(undo().label).toBe('rule_undo'); + + undo().onClick(); + expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).toEqual(DEFAULT_KEYWORDS.map((k) => k.id)); + }); +}); + +describe('deleting a filter rule', () => { + const rule = (id: string): FilterRule => ({ + id, name: `Rule ${id}`, enabled: true, matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'star' }], stopProcessing: false, + }); + + it('goes at once, with Undo that saves it back in its place', async () => { + const updateSieveScript = vi.fn(async () => {}); + useAuthStore.setState({ client: { updateSieveScript, getSieveAccountId: () => 'primary' } as never }); + useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never }); + useFilterStore.setState({ + rules: [rule('a'), rule('b'), rule('c')], + isSupported: true, isLoading: false, isOpaque: false, activeScriptId: 'script-1', + selectedAccountId: 'primary', sieveCapabilities: null, selectAccount: vi.fn(async () => {}), + }); + render(); + + fireEvent.click(screen.getAllByRole('button', { name: 'delete_rule' })[1]); + + // No inline Delete/Cancel strip: the rule is gone and saved without it. + expect(screen.queryByText('confirm_delete')).toBeNull(); + await waitFor(() => expect(updateSieveScript).toHaveBeenCalledTimes(1)); + expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'c']); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('filters_deleted', expect.anything())); + + undo().onClick(); + expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b', 'c']); + await waitFor(() => expect(updateSieveScript).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx index 0be9c38d0..902dcd514 100644 --- a/components/settings/filter-settings.tsx +++ b/components/settings/filter-settings.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { SettingsSection, ToggleSwitch } from "./settings-section"; import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { FilterRuleModal } from "@/components/filters/filter-rule-modal"; import { SieveEditorModal } from "@/components/filters/sieve-editor-modal"; import { useFilterStore } from "@/stores/filter-store"; @@ -21,7 +22,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store"; import { Plus, GripVertical, - X, + Trash2, Code, AlertTriangle, Loader2, @@ -288,7 +289,6 @@ export function FilterSettings() { const [editingRule, setEditingRule] = useState(); const [showRuleModal, setShowRuleModal] = useState(false); const [showSieveEditor, setShowSieveEditor] = useState(false); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const [dragOverIndex, setDragOverIndex] = useState(null); const draggedIndexRef = useRef(null); @@ -336,22 +336,38 @@ export function FilterSettings() { [client, toggleRule, saveFilters, tNotifications] ); + // A rule can be put back as it was, so it goes at once with an Undo (R3). const handleDelete = useCallback( async (ruleId: string) => { - const deletedRule = rules.find((r) => r.id === ruleId); + const before = useFilterStore.getState().rules; + const deletedRule = before.find((r) => r.id === ruleId); + if (!deletedRule) return; deleteRule(ruleId); - setDeleteConfirmId(null); - if (client) { - try { - await saveFilters(client); - toast.success(tNotifications("filters_deleted")); - } catch { - if (deletedRule) addRule(deletedRule); - toast.error(tNotifications("filters_save_failed")); - } + if (!client) return; + const putBack = () => useFilterStore.setState({ rules: before }); + try { + await saveFilters(client); + } catch { + putBack(); + toast.error(tNotifications("filters_save_failed")); + return; } + toast.success(tNotifications("filters_deleted"), { + action: { + label: tNotifications("rule_undo"), + onClick: () => { + if (useFilterStore.getState().rules.some((r) => r.id === ruleId)) return; + const current = useFilterStore.getState().rules; + putBack(); + saveFilters(client).catch(() => { + useFilterStore.setState({ rules: current }); + toast.error(tNotifications("filters_save_failed")); + }); + }, + }, + }); }, - [client, rules, deleteRule, addRule, saveFilters, tNotifications] + [client, deleteRule, saveFilters, tNotifications] ); const handleSaveRule = useCallback( @@ -700,33 +716,13 @@ export function FilterSettings() { )} - {deleteConfirmId === rule.id ? ( -
- - -
- ) : ( - - )} + void handleDelete(rule.id)} + className="text-muted-foreground hover:text-destructive" + > + + ); })} diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 76dfb5a93..623b13b77 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -523,6 +523,7 @@ function UnrecognizedKeywords() { export function KeywordSettings() { const t = useTranslations("settings.keywords"); + const tNotifications = useTranslations("notifications"); const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } = useSettingsStore(); const { client } = useAuthStore(); @@ -634,8 +635,25 @@ export function KeywordSettings() { setEditingId(null); }; + // A tag can be put back as it was, so it goes at once with an Undo (R3). const handleDelete = (id: string) => { + const before = useSettingsStore.getState().emailKeywords; + const index = before.findIndex((k) => k.id === id); + const removed = before[index]; + if (!removed) return; removeKeyword(id); + toast.success(t("deleted"), { + action: { + label: tNotifications("rule_undo"), + onClick: () => { + const now = useSettingsStore.getState().emailKeywords; + if (now.some((k) => k.id === id)) return; + const restored = [...now]; + restored.splice(Math.min(index, restored.length), 0, removed); + reorderKeywords(restored); + }, + }, + }); }; const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => { diff --git a/locales/ar/common.json b/locales/ar/common.json index 6e3c8141f..2741f3f87 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1287,6 +1287,7 @@ "id_exists": "معرّف الوسم هذا موجود بالفعل", "edit": "تعديل الوسم", "delete": "حذف الوسم", + "deleted": "تم حذف الوسم", "save": "حفظ", "add": "إضافة", "cancel": "إلغاء", diff --git a/locales/ca/common.json b/locales/ca/common.json index 919f91c66..b3cfc550b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Aquest ID d'etiqueta ja existeix", "edit": "Edita l'etiqueta", "delete": "Suprimeix l'etiqueta", + "deleted": "Etiqueta suprimida", "save": "Desa", "add": "Afegeix", "cancel": "Cancel·la", diff --git a/locales/cs/common.json b/locales/cs/common.json index d1943e7d5..985a19e39 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Toto ID štítku již existuje", "edit": "Upravit štítek", "delete": "Odstranit štítek", + "deleted": "Štítek byl odstraněn", "save": "Uložit", "add": "Přidat", "cancel": "Zrušit", diff --git a/locales/da/common.json b/locales/da/common.json index 91882fd4f..97fc8ec71 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Dette tag-ID findes allerede", "edit": "Redigér tag", "delete": "Slet tag", + "deleted": "Tag slettet", "save": "Gem", "add": "Tilføj", "cancel": "Annuller", diff --git a/locales/de/common.json b/locales/de/common.json index a4cf13cb0..5b2c295cb 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Diese Label-ID existiert bereits", "edit": "Label bearbeiten", "delete": "Label löschen", + "deleted": "Label gelöscht", "save": "Speichern", "add": "Hinzufügen", "cancel": "Abbrechen", diff --git a/locales/en/common.json b/locales/en/common.json index 758e6fb1a..25bf64104 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1287,6 +1287,7 @@ "id_exists": "This tag ID already exists", "edit": "Edit tag", "delete": "Delete tag", + "deleted": "Tag deleted", "save": "Save", "add": "Add", "cancel": "Cancel", diff --git a/locales/es/common.json b/locales/es/common.json index b04b2d76e..18158ec71 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Este ID de etiqueta ya existe", "edit": "Editar etiqueta", "delete": "Eliminar etiqueta", + "deleted": "Etiqueta eliminada", "save": "Guardar", "add": "Añadir", "cancel": "Cancelar", diff --git a/locales/et/common.json b/locales/et/common.json index bb578edfe..5d4dd429f 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1287,6 +1287,7 @@ "id_exists": "See sildi ID on juba kasutusel", "edit": "Muuda silti", "delete": "Kustuta silt", + "deleted": "Silt kustutatud", "save": "Salvesta", "add": "Lisa", "cancel": "Tühista", diff --git a/locales/fa/common.json b/locales/fa/common.json index 63f08279a..b63b18391 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1287,6 +1287,7 @@ "id_exists": "این شناسه قبلاً وجود دارد", "edit": "ویرایش برچسب", "delete": "حذف برچسب", + "deleted": "برچسب حذف شد", "save": "ذخیره", "add": "افزودن", "cancel": "انصراف", diff --git a/locales/fr/common.json b/locales/fr/common.json index 357ab45c2..553069342 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Cet ID d'étiquette existe déjà", "edit": "Éditer l'étiquette", "delete": "Supprimer l'étiquette", + "deleted": "Étiquette supprimée", "save": "Enregistrer", "add": "Ajouter", "cancel": "Annuler", diff --git a/locales/he/common.json b/locales/he/common.json index ae3070be0..2545a435e 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1249,6 +1249,7 @@ "id_exists": "מזהה מילת מפתח זה כבר קיים", "edit": "ערוך מילת מפתח", "delete": "מחק מילת מפתח", + "deleted": "מילת המפתח נמחקה", "save": "שמור", "add": "לְהוֹסִיף", "cancel": "לְבַטֵל", diff --git a/locales/hu/common.json b/locales/hu/common.json index d1539bb71..7b7914cbc 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Ez a címke azonosító már létezik", "edit": "Címke szerkesztése", "delete": "Címke törlése", + "deleted": "Címke törölve", "save": "Mentés", "add": "Hozzáadás", "cancel": "Mégse", diff --git a/locales/it/common.json b/locales/it/common.json index b23a795fd..432e30035 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Questo ID etichetta esiste già", "edit": "Modifica etichetta", "delete": "Elimina etichetta", + "deleted": "Etichetta eliminata", "save": "Salva", "add": "Aggiungi", "cancel": "Annulla", diff --git a/locales/ja/common.json b/locales/ja/common.json index 73df9d3bc..0b129b192 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1284,6 +1284,7 @@ "id_exists": "このラベルIDは既に存在します", "edit": "ラベルを編集", "delete": "ラベルを削除", + "deleted": "ラベルを削除しました", "save": "保存", "add": "追加", "cancel": "キャンセル", diff --git a/locales/ko/common.json b/locales/ko/common.json index b7a2d2188..64ed138ea 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1284,6 +1284,7 @@ "id_exists": "이 태그 ID는 이미 존재합니다", "edit": "태그 편집", "delete": "태그 삭제", + "deleted": "태그가 삭제되었어요", "save": "저장", "add": "추가", "cancel": "취소", diff --git a/locales/lv/common.json b/locales/lv/common.json index 214c2eb31..ca57a2623 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Šāds taga identifikators jau pastāv", "edit": "Rediģēt tagu", "delete": "Dzēst tagu", + "deleted": "Tags izdzēsts", "save": "Saglabāt", "add": "Pievienot", "cancel": "Atcelt", diff --git a/locales/mn/common.json b/locales/mn/common.json index d65aa0fc8..c3ffc3e89 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Энэ хаягийн ID аль хэдийн байна", "edit": "Таг засах", "delete": "Тагыг устгах", + "deleted": "Таг устгасан", "save": "Хадгалах", "add": "Нэмэх", "cancel": "Цуцлах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 43d5addfe..5390f0e7f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Denne etikett-ID-en finnes allerede", "edit": "Rediger etikett", "delete": "Slett etikett", + "deleted": "Etikett slettet", "save": "Lagre", "add": "Legg til", "cancel": "Avbryt", diff --git a/locales/nl/common.json b/locales/nl/common.json index aa41f2667..1f6d1337a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Deze label-ID bestaat al", "edit": "Label bewerken", "delete": "Label verwijderen", + "deleted": "Label verwijderd", "save": "Opslaan", "add": "Toevoegen", "cancel": "Annuleren", diff --git a/locales/pl/common.json b/locales/pl/common.json index ae4ee7b09..1897737fa 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Ten ID etykiety już istnieje", "edit": "Edytuj etykietę", "delete": "Usuń etykietę", + "deleted": "Etykieta usunięta", "save": "Zapisz", "add": "Dodaj", "cancel": "Anuluj", diff --git a/locales/pt/common.json b/locales/pt/common.json index 02d5947d6..5c2d44af8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Este ID de etiqueta já existe", "edit": "Editar etiqueta", "delete": "Eliminar etiqueta", + "deleted": "Etiqueta eliminada", "save": "Salvar", "add": "Adicionar", "cancel": "Cancelar", diff --git a/locales/ro/common.json b/locales/ro/common.json index 0418cb3b8..77f665533 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Această etichetă ID există deja", "edit": "Editați eticheta", "delete": "Șterge eticheta", + "deleted": "Eticheta a fost ștearsă", "save": "Salvare", "add": "Adăugați", "cancel": "Anulează", diff --git a/locales/ru/common.json b/locales/ru/common.json index 84656ee28..d1b5ce229 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Этот идентификатор тега уже существует", "edit": "Редактировать тег", "delete": "Удалить тег", + "deleted": "Тег удалён", "save": "Сохранить", "add": "Добавить", "cancel": "Отмена", diff --git a/locales/sk/common.json b/locales/sk/common.json index 1c3becf6e..4d8477789 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1287,6 +1287,7 @@ "id_exists": "Toto ID štítku už existuje", "edit": "Upraviť štítok", "delete": "Odstrániť štítok", + "deleted": "Štítok bol odstránený", "save": "Uložiť", "add": "Pridať", "cancel": "Zrušiť", diff --git a/locales/th/common.json b/locales/th/common.json index 05991d3be..07851e3d5 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3297,6 +3297,7 @@ "id_exists": "รหัสแท็กนี้มีอยู่แล้ว", "edit": "แก้ไขแท็ก", "delete": "ลบแท็ก", + "deleted": "ลบแท็กแล้ว", "save": "บันทึก", "add": "เพิ่ม", "cancel": "ยกเลิก", diff --git a/locales/tr/common.json b/locales/tr/common.json index e8402f677..54d6017e8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Bu etiket kimliği zaten mevcut", "edit": "Etiketi düzenle", "delete": "Etiketi sil", + "deleted": "Etiket silindi", "save": "Kaydet", "add": "Ekle", "cancel": "İptal", diff --git a/locales/uk/common.json b/locales/uk/common.json index a17fc2e73..d54af636c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1284,6 +1284,7 @@ "id_exists": "Цей ідентифікатор ключового слова вже існує", "edit": "Редагувати ключове слово", "delete": "Видалити ключове слово", + "deleted": "Ключове слово видалено", "save": "зберегти", "add": "додати", "cancel": "Скасувати", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 732a3ec03..c16795923 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1287,6 +1287,7 @@ "id_exists": "此標籤 ID 已存在", "edit": "編輯標籤", "delete": "刪除標籤", + "deleted": "標籤已刪除", "save": "儲存", "add": "新增", "cancel": "取消", diff --git a/locales/zh/common.json b/locales/zh/common.json index f7beb41de..2acac04ab 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1284,6 +1284,7 @@ "id_exists": "此标签ID已存在", "edit": "编辑标签", "delete": "删除标签", + "deleted": "标签已删除", "save": "保存", "add": "添加", "cancel": "取消",