feat: turn out of office off from the banner in the mail sidebar

This commit is contained in:
Linus Rath
2026-11-04 12:04:03 +01:00
parent 39e43de51e
commit 760a400074
32 changed files with 99 additions and 11 deletions
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
// "Vacation responder is active" opens Settings > Out of Office, not
@@ -21,10 +21,13 @@ vi.mock('@/hooks/use-mailbox-drop', () => ({
}));
vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) }));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
const { Sidebar } = await import('../sidebar');
const { useVacationStore } = await import('@/stores/vacation-store');
const { buildSettingsPath } = await import('@/lib/deep-links');
const { useAuthStore } = await import('@/stores/auth-store');
describe('vacation banner', () => {
it('opens the Out of Office settings', () => {
@@ -34,4 +37,25 @@ describe('vacation banner', () => {
expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation'));
expect(buildSettingsPath('vacation')).not.toBe('/settings');
});
// settings-p2-12: the banner says "Out of office is on", as the setting is
// called, and turns it off without a trip to Settings, like Gmail's "End now".
it('turns out of office off on the server', async () => {
const update = vi.fn(async () => true);
useAuthStore.setState({ client: {} as never });
useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never);
render(<Sidebar mailboxes={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.anything(), { isEnabled: false }, undefined));
expect(toast.error).not.toHaveBeenCalled();
});
it('says so when turning it off fails', async () => {
const update = vi.fn(async () => { throw new Error('down'); });
useAuthStore.setState({ client: {} as never });
useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never);
render(<Sidebar mailboxes={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('vacation_save_failed'));
});
});
+44 -10
View File
@@ -76,6 +76,7 @@ import { sidebarFolded, useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { unifiedLoadProgress } from "@/lib/unified-mailbox";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store";
@@ -1070,26 +1071,59 @@ function DemoBanner({ ruled }: { ruled: boolean }) {
);
}
function VacationBanner() {
export function VacationBanner() {
const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
const tFilters = useTranslations('settings.filters');
const router = useRouter();
const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported })));
const { isEnabled, isSupported, isSaving } = useVacationStore(useShallow((state) => ({
isEnabled: state.isEnabled, isSupported: state.isSupported, isSaving: state.isSaving,
})));
// The store holds the reply of the account Settings last loaded it for.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const client = useAuthStore((s) => s.client);
const [turningOff, setTurningOff] = useState(false);
if (!isSupported || !isEnabled) return null;
// Like Gmail's "End now": off on the server, without opening Settings.
const turnOff = async () => {
if (!client || turningOff) return;
setTurningOff(true);
try {
await useVacationStore.getState().updateVacationResponse(client, { isEnabled: false }, managedAccountId ?? undefined);
} catch {
toast.error(tNotifications('vacation_save_failed'));
} finally {
setTurningOff(false);
}
};
return (
<button
onClick={() => router.push(buildSettingsPath('vacation'))}
<div
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs",
"flex items-center gap-2 w-full px-3 py-1.5 text-xs",
"bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-400",
"hover:bg-amber-500/15 dark:hover:bg-amber-400/15 transition-colors"
)}
>
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{t("vacation_active")}</span>
<Settings className="w-3 h-3 ms-auto flex-shrink-0 opacity-60" />
</button>
<button
type="button"
onClick={() => router.push(buildSettingsPath('vacation'))}
className="flex items-center gap-2 min-w-0 flex-1 py-0.5 text-start hover:underline"
>
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{tFilters("vacation_active")}</span>
</button>
<button
type="button"
onClick={() => void turnOff()}
disabled={turningOff || isSaving}
className="inline-flex flex-shrink-0 items-center gap-1 px-2 py-0.5 rounded-sm font-medium hover:bg-amber-500/15 dark:hover:bg-amber-400/15 disabled:opacity-60 transition-colors"
>
{turningOff ? <Loader2 className="w-3 h-3 animate-spin" aria-hidden /> : null}
{t("vacation_turn_off")}
</button>
</div>
);
}
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "مسح البحث",
"vacation_active": "الرد التلقائي أثناء الغياب مفعّل",
"vacation_turn_off": "إيقاف",
"demo_banner": "وضع تجريبي",
"demo_reset": "إعادة تعيين",
"demo_tour": "جولة",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Neteja la cerca",
"vacation_active": "La resposta automàtica està activa",
"vacation_turn_off": "Desactiva",
"demo_banner": "Mode de demostració",
"demo_reset": "Reinicia",
"demo_tour": "Visita guiada",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Vymazat hledání",
"vacation_active": "Automatická odpověď je aktivní",
"vacation_turn_off": "Vypnout",
"demo_banner": "Demo režim",
"demo_reset": "Resetovat",
"demo_tour": "Průvodce",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Ryd søgning",
"vacation_active": "Fraværsbeskeder er aktive",
"vacation_turn_off": "Slå fra",
"demo_banner": "Demo-tilstand",
"demo_reset": "Nulstil",
"demo_tour": "Rundvisning",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv",
"vacation_turn_off": "Ausschalten",
"demo_banner": "Demo-Modus",
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active",
"vacation_turn_off": "Turn off",
"demo_banner": "Demo Mode",
"demo_reset": "Reset",
"demo_tour": "Tour",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa",
"vacation_turn_off": "Desactivar",
"demo_banner": "Modo demo",
"demo_reset": "Restablecer",
"demo_tour": "Tour",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Tühjenda otsing",
"vacation_active": "Puhkuse vastaja on aktiivne",
"vacation_turn_off": "Lülita välja",
"demo_banner": "Demorežiim",
"demo_reset": "Lähtesta",
"demo_tour": "Tutvustus",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "پاک کردن جستجو",
"vacation_active": "پاسخ‌گوی خودکار فعال است",
"vacation_turn_off": "خاموش کردن",
"demo_banner": "حالت آزمایشی",
"demo_reset": "بازنشانی",
"demo_tour": "تور",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé",
"vacation_turn_off": "Désactiver",
"demo_banner": "Mode démo",
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
+1
View File
@@ -165,6 +165,7 @@
},
"clear_search": "נקה חיפוש",
"vacation_active": "משיב חופשה פעיל",
"vacation_turn_off": "כיבוי",
"demo_banner": "מצב הדגמה",
"demo_reset": "אִתחוּל",
"demo_tour": "סִיוּר",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Keresés törlése",
"vacation_active": "Automatikus válasz aktív",
"vacation_turn_off": "Kikapcsolás",
"demo_banner": "Demó mód",
"demo_reset": "Visszaállítás",
"demo_tour": "Bemutató",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo",
"vacation_turn_off": "Disattiva",
"demo_banner": "Modalità demo",
"demo_reset": "Reimposta",
"demo_tour": "Tour",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です",
"vacation_turn_off": "オフにする",
"demo_banner": "デモモード",
"demo_reset": "リセット",
"demo_tour": "ツアー",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "검색어 지우기",
"vacation_active": "부재중 자동 응답이 켜져 있어요",
"vacation_turn_off": "끄기",
"demo_banner": "데모 모드",
"demo_reset": "초기화",
"demo_tour": "둘러보기",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Notīrīt meklēšanu",
"vacation_active": "Atvaļinājuma atbilde ir aktīva",
"vacation_turn_off": "Izslēgt",
"demo_banner": "Demo režīms",
"demo_reset": "Atiestatīt",
"demo_tour": "Ekskursija",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Хайлтыг арилгах",
"vacation_active": "Амралтын хариулагч идэвхтэй байна",
"vacation_turn_off": "Унтраах",
"demo_banner": "Демо горим",
"demo_reset": "Дахин тохируулах",
"demo_tour": "Аялал",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Tøm søket",
"vacation_active": "Feriesvar er aktivt",
"vacation_turn_off": "Slå av",
"demo_banner": "Demomodus",
"demo_reset": "Tilbakestill",
"demo_tour": "Omvisning",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Zoekopdracht wissen",
"vacation_active": "Afwezigheidsmelder is actief",
"vacation_turn_off": "Uitzetten",
"demo_banner": "Demomodus",
"demo_reset": "Resetten",
"demo_tour": "Rondleiding",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Wyczyść wyszukiwanie",
"vacation_active": "Autoresponder wakacyjny jest aktywny",
"vacation_turn_off": "Wyłącz",
"demo_banner": "Tryb demo",
"demo_reset": "Resetuj",
"demo_tour": "Przewodnik",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Limpar busca",
"vacation_active": "Resposta automática ativa",
"vacation_turn_off": "Desativar",
"demo_banner": "Modo de demonstração",
"demo_reset": "Repor",
"demo_tour": "Tour",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Șterge căutarea",
"vacation_active": "Răspunsul automat de vacanță este activ",
"vacation_turn_off": "Dezactivați",
"demo_banner": "Modul demo",
"demo_reset": "Resetare",
"demo_tour": "Tur de prezentare",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Очистить поиск",
"vacation_active": "Автоответ на отпуск активен",
"vacation_turn_off": "Выключить",
"demo_banner": "Режим демо",
"demo_reset": "Сбросить",
"demo_tour": "Тур",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Vymazať hľadanie",
"vacation_active": "Automatická odpoveď je aktívna",
"vacation_turn_off": "Vypnúť",
"demo_banner": "Demo režim",
"demo_reset": "Resetovať",
"demo_tour": "Sprievodca",
+1
View File
@@ -320,6 +320,7 @@
},
"clear_search": "ล้างการค้นหา",
"vacation_active": "เปิดใช้งานการตอบกลับอัตโนมัติอยู่",
"vacation_turn_off": "ปิด",
"demo_banner": "โหมดทดลองใช้งาน (Demo)",
"demo_reset": "รีเซ็ตข้อมูล",
"demo_tour": "แนะนำการใช้งาน",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Aramayı temizle",
"vacation_active": "Tatil otomatik yanıtı aktif",
"vacation_turn_off": "Kapat",
"demo_banner": "Demo Modu",
"demo_reset": "Sıfırla",
"demo_tour": "Tur",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Очистити пошук",
"vacation_active": "Автовідповідач активний",
"vacation_turn_off": "Вимкнути",
"demo_banner": "Демонстраційний режим",
"demo_reset": "Скинути",
"demo_tour": "Тур",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Xóa tìm kiếm",
"vacation_active": "Đang bật trả lời tự động khi vắng mặt",
"vacation_turn_off": "Tắt",
"demo_banner": "Chế độ demo",
"demo_reset": "Đặt lại",
"demo_tour": "Tham quan",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "清除搜尋",
"vacation_active": "休假自動回覆已開啟",
"vacation_turn_off": "關閉",
"demo_banner": "示範模式",
"demo_reset": "重設",
"demo_tour": "功能導覽",
+1
View File
@@ -174,6 +174,7 @@
},
"clear_search": "清除搜索",
"vacation_active": "假期自动回复已开启",
"vacation_turn_off": "关闭",
"demo_banner": "演示模式",
"demo_reset": "重置",
"demo_tour": "引导",