fix: delete tags and filter rules at once with an Undo toast

This commit is contained in:
Linus Rath
2026-10-28 03:32:48 +01:00
parent deb171022d
commit d70bf4ae74
32 changed files with 173 additions and 40 deletions
@@ -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<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(<KeywordSettings />);
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(<FilterSettings />);
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));
});
});
+36 -40
View File
@@ -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<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
const [showSieveEditor, setShowSieveEditor] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(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() {
)}
</div>
{deleteConfirmId === rule.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirmId(rule.id)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={t("delete_rule")}
>
<X className="w-4 h-4" />
</button>
)}
<IconButton
label={t("delete_rule")}
onClick={() => void handleDelete(rule.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</IconButton>
</div>
);
})}
+18
View File
@@ -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) => {
+1
View File
@@ -1287,6 +1287,7 @@
"id_exists": "معرّف الوسم هذا موجود بالفعل",
"edit": "تعديل الوسم",
"delete": "حذف الوسم",
"deleted": "تم حذف الوسم",
"save": "حفظ",
"add": "إضافة",
"cancel": "إلغاء",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1287,6 +1287,7 @@
"id_exists": "این شناسه قبلاً وجود دارد",
"edit": "ویرایش برچسب",
"delete": "حذف برچسب",
"deleted": "برچسب حذف شد",
"save": "ذخیره",
"add": "افزودن",
"cancel": "انصراف",
+1
View File
@@ -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",
+1
View File
@@ -1249,6 +1249,7 @@
"id_exists": "מזהה מילת מפתח זה כבר קיים",
"edit": "ערוך מילת מפתח",
"delete": "מחק מילת מפתח",
"deleted": "מילת המפתח נמחקה",
"save": "שמור",
"add": "לְהוֹסִיף",
"cancel": "לְבַטֵל",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1284,6 +1284,7 @@
"id_exists": "このラベルIDは既に存在します",
"edit": "ラベルを編集",
"delete": "ラベルを削除",
"deleted": "ラベルを削除しました",
"save": "保存",
"add": "追加",
"cancel": "キャンセル",
+1
View File
@@ -1284,6 +1284,7 @@
"id_exists": "이 태그 ID는 이미 존재합니다",
"edit": "태그 편집",
"delete": "태그 삭제",
"deleted": "태그가 삭제되었어요",
"save": "저장",
"add": "추가",
"cancel": "취소",
+1
View File
@@ -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",
+1
View File
@@ -1287,6 +1287,7 @@
"id_exists": "Энэ хаягийн ID аль хэдийн байна",
"edit": "Таг засах",
"delete": "Тагыг устгах",
"deleted": "Таг устгасан",
"save": "Хадгалах",
"add": "Нэмэх",
"cancel": "Цуцлах",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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ă",
+1
View File
@@ -1284,6 +1284,7 @@
"id_exists": "Этот идентификатор тега уже существует",
"edit": "Редактировать тег",
"delete": "Удалить тег",
"deleted": "Тег удалён",
"save": "Сохранить",
"add": "Добавить",
"cancel": "Отмена",
+1
View File
@@ -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ť",
+1
View File
@@ -3297,6 +3297,7 @@
"id_exists": "รหัสแท็กนี้มีอยู่แล้ว",
"edit": "แก้ไขแท็ก",
"delete": "ลบแท็ก",
"deleted": "ลบแท็กแล้ว",
"save": "บันทึก",
"add": "เพิ่ม",
"cancel": "ยกเลิก",
+1
View File
@@ -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",
+1
View File
@@ -1284,6 +1284,7 @@
"id_exists": "Цей ідентифікатор ключового слова вже існує",
"edit": "Редагувати ключове слово",
"delete": "Видалити ключове слово",
"deleted": "Ключове слово видалено",
"save": "зберегти",
"add": "додати",
"cancel": "Скасувати",
+1
View File
@@ -1287,6 +1287,7 @@
"id_exists": "此標籤 ID 已存在",
"edit": "編輯標籤",
"delete": "刪除標籤",
"deleted": "標籤已刪除",
"save": "儲存",
"add": "新增",
"cancel": "取消",
+1
View File
@@ -1284,6 +1284,7 @@
"id_exists": "此标签ID已存在",
"edit": "编辑标签",
"delete": "删除标签",
"deleted": "标签已删除",
"save": "保存",
"add": "添加",
"cancel": "取消",