mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
fix: delete tags and filter rules at once with an Undo toast
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1287,6 +1287,7 @@
|
||||
"id_exists": "معرّف الوسم هذا موجود بالفعل",
|
||||
"edit": "تعديل الوسم",
|
||||
"delete": "حذف الوسم",
|
||||
"deleted": "تم حذف الوسم",
|
||||
"save": "حفظ",
|
||||
"add": "إضافة",
|
||||
"cancel": "إلغاء",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1287,6 +1287,7 @@
|
||||
"id_exists": "این شناسه قبلاً وجود دارد",
|
||||
"edit": "ویرایش برچسب",
|
||||
"delete": "حذف برچسب",
|
||||
"deleted": "برچسب حذف شد",
|
||||
"save": "ذخیره",
|
||||
"add": "افزودن",
|
||||
"cancel": "انصراف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1249,6 +1249,7 @@
|
||||
"id_exists": "מזהה מילת מפתח זה כבר קיים",
|
||||
"edit": "ערוך מילת מפתח",
|
||||
"delete": "מחק מילת מפתח",
|
||||
"deleted": "מילת המפתח נמחקה",
|
||||
"save": "שמור",
|
||||
"add": "לְהוֹסִיף",
|
||||
"cancel": "לְבַטֵל",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1284,6 +1284,7 @@
|
||||
"id_exists": "このラベルIDは既に存在します",
|
||||
"edit": "ラベルを編集",
|
||||
"delete": "ラベルを削除",
|
||||
"deleted": "ラベルを削除しました",
|
||||
"save": "保存",
|
||||
"add": "追加",
|
||||
"cancel": "キャンセル",
|
||||
|
||||
@@ -1284,6 +1284,7 @@
|
||||
"id_exists": "이 태그 ID는 이미 존재합니다",
|
||||
"edit": "태그 편집",
|
||||
"delete": "태그 삭제",
|
||||
"deleted": "태그가 삭제되었어요",
|
||||
"save": "저장",
|
||||
"add": "추가",
|
||||
"cancel": "취소",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1287,6 +1287,7 @@
|
||||
"id_exists": "Энэ хаягийн ID аль хэдийн байна",
|
||||
"edit": "Таг засах",
|
||||
"delete": "Тагыг устгах",
|
||||
"deleted": "Таг устгасан",
|
||||
"save": "Хадгалах",
|
||||
"add": "Нэмэх",
|
||||
"cancel": "Цуцлах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -1284,6 +1284,7 @@
|
||||
"id_exists": "Этот идентификатор тега уже существует",
|
||||
"edit": "Редактировать тег",
|
||||
"delete": "Удалить тег",
|
||||
"deleted": "Тег удалён",
|
||||
"save": "Сохранить",
|
||||
"add": "Добавить",
|
||||
"cancel": "Отмена",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -3297,6 +3297,7 @@
|
||||
"id_exists": "รหัสแท็กนี้มีอยู่แล้ว",
|
||||
"edit": "แก้ไขแท็ก",
|
||||
"delete": "ลบแท็ก",
|
||||
"deleted": "ลบแท็กแล้ว",
|
||||
"save": "บันทึก",
|
||||
"add": "เพิ่ม",
|
||||
"cancel": "ยกเลิก",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1284,6 +1284,7 @@
|
||||
"id_exists": "Цей ідентифікатор ключового слова вже існує",
|
||||
"edit": "Редагувати ключове слово",
|
||||
"delete": "Видалити ключове слово",
|
||||
"deleted": "Ключове слово видалено",
|
||||
"save": "зберегти",
|
||||
"add": "додати",
|
||||
"cancel": "Скасувати",
|
||||
|
||||
@@ -1287,6 +1287,7 @@
|
||||
"id_exists": "此標籤 ID 已存在",
|
||||
"edit": "編輯標籤",
|
||||
"delete": "刪除標籤",
|
||||
"deleted": "標籤已刪除",
|
||||
"save": "儲存",
|
||||
"add": "新增",
|
||||
"cancel": "取消",
|
||||
|
||||
@@ -1284,6 +1284,7 @@
|
||||
"id_exists": "此标签ID已存在",
|
||||
"edit": "编辑标签",
|
||||
"delete": "删除标签",
|
||||
"deleted": "标签已删除",
|
||||
"save": "保存",
|
||||
"add": "添加",
|
||||
"cancel": "取消",
|
||||
|
||||
Reference in New Issue
Block a user