feat: toast for settings save

This commit is contained in:
Leon
2025-07-16 18:20:45 +02:00
parent 0e19af170d
commit d47e975574
2 changed files with 137 additions and 27 deletions

View File

@@ -11,9 +11,21 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Loader2, CheckCircle, XCircle } from "lucide-react" import { Loader2, CheckCircle, XCircle } from "lucide-react"
import { Settings as AppSettings, SettingsCreate, updateSettings, testImapConnection } from "@/lib/api" import {
Settings as AppSettings,
SettingsCreate,
updateSettings,
testImapConnection,
} from "@/lib/api"
import { toast } from "sonner"
interface SettingsDialogProps { interface SettingsDialogProps {
settings: AppSettings settings: AppSettings
@@ -23,9 +35,19 @@ interface SettingsDialogProps {
onSuccess: () => void onSuccess: () => void
} }
export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange, onSuccess }: SettingsDialogProps) { export function SettingsDialog({
const [currentSettings, setCurrentSettings] = useState<SettingsCreate | null>(null) settings,
const [testConnectionStatus, setTestConnectionStatus] = useState<"idle" | "loading" | "success" | "error">("idle") folderOptions,
isOpen,
onOpenChange,
onSuccess,
}: SettingsDialogProps) {
const [currentSettings, setCurrentSettings] = useState<SettingsCreate | null>(
null
)
const [testConnectionStatus, setTestConnectionStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle")
const [testConnectionMessage, setTestConnectionMessage] = useState("") const [testConnectionMessage, setTestConnectionMessage] = useState("")
useEffect(() => { useEffect(() => {
@@ -36,7 +58,10 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
if (!currentSettings) return null if (!currentSettings) return null
const handleSettingsChange = <K extends keyof SettingsCreate>(key: K, value: SettingsCreate[K]) => { const handleSettingsChange = <K extends keyof SettingsCreate>(
key: K,
value: SettingsCreate[K]
) => {
setCurrentSettings((prev) => (prev ? { ...prev, [key]: value } : null)) setCurrentSettings((prev) => (prev ? { ...prev, [key]: value } : null))
} }
@@ -44,10 +69,12 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
if (!currentSettings) return if (!currentSettings) return
try { try {
await updateSettings(currentSettings) await updateSettings(currentSettings)
toast.success("Settings saved successfully!")
onOpenChange(false) onOpenChange(false)
onSuccess() onSuccess()
} catch (error) { } catch (error) {
console.error("Failed to save settings:", error) console.error("Failed to save settings:", error)
toast.error("Failed to save settings.")
} }
} }
@@ -74,18 +101,24 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Settings</DialogTitle> <DialogTitle>Settings</DialogTitle>
<DialogDescription>Fields are locked if they are set by environment variables.</DialogDescription> <DialogDescription>
Fields are locked if they are set by environment variables.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 py-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 py-4">
{/* IMAP Configuration */} {/* IMAP Configuration */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium col-span-1 md:col-span-2">IMAP Configuration</h3> <h3 className="text-lg font-medium col-span-1 md:col-span-2">
IMAP Configuration
</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="imap-server">IMAP Server</Label> <Label htmlFor="imap-server">IMAP Server</Label>
<Input <Input
id="imap-server" id="imap-server"
value={currentSettings.imap_server} value={currentSettings.imap_server}
onChange={(e) => handleSettingsChange("imap_server", e.target.value)} onChange={(e) =>
handleSettingsChange("imap_server", e.target.value)
}
placeholder="imap.gmail.com" placeholder="imap.gmail.com"
disabled={settings.locked_fields.includes("imap_server")} disabled={settings.locked_fields.includes("imap_server")}
/> />
@@ -95,7 +128,9 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
<Input <Input
id="imap-username" id="imap-username"
value={currentSettings.imap_username} value={currentSettings.imap_username}
onChange={(e) => handleSettingsChange("imap_username", e.target.value)} onChange={(e) =>
handleSettingsChange("imap_username", e.target.value)
}
placeholder="your-email@gmail.com" placeholder="your-email@gmail.com"
disabled={settings.locked_fields.includes("imap_username")} disabled={settings.locked_fields.includes("imap_username")}
/> />
@@ -106,7 +141,9 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
id="imap-password" id="imap-password"
type="password" type="password"
value={currentSettings.imap_password} value={currentSettings.imap_password}
onChange={(e) => handleSettingsChange("imap_password", e.target.value)} onChange={(e) =>
handleSettingsChange("imap_password", e.target.value)
}
placeholder="Your password or app password" placeholder="Your password or app password"
disabled={settings.locked_fields.includes("imap_password")} disabled={settings.locked_fields.includes("imap_password")}
/> />
@@ -118,18 +155,26 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
variant="outline" variant="outline"
size="sm" size="sm"
> >
{testConnectionStatus === "loading" && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} {testConnectionStatus === "loading" && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
Test Connection Test Connection
</Button> </Button>
{testConnectionStatus !== "idle" && ( {testConnectionStatus !== "idle" && (
<div <div
data-testid="connection-status" data-testid="connection-status"
className={`mt-2 flex items-center text-sm ${ className={`mt-2 flex items-center text-sm ${
testConnectionStatus === "success" ? "text-green-600" : "text-red-600" testConnectionStatus === "success"
? "text-green-600"
: "text-red-600"
}`} }`}
> >
{testConnectionStatus === "success" && <CheckCircle className="w-4 h-4 mr-2" />} {testConnectionStatus === "success" && (
{testConnectionStatus === "error" && <XCircle className="w-4 h-4 mr-2" />} <CheckCircle className="w-4 h-4 mr-2" />
)}
{testConnectionStatus === "error" && (
<XCircle className="w-4 h-4 mr-2" />
)}
{testConnectionMessage} {testConnectionMessage}
</div> </div>
)} )}
@@ -138,12 +183,16 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
{/* Email Processing */} {/* Email Processing */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium col-span-1 md:col-span-2">Email Processing</h3> <h3 className="text-lg font-medium col-span-1 md:col-span-2">
Email Processing
</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search-folder">Folder to Search</Label> <Label htmlFor="search-folder">Folder to Search</Label>
<Select <Select
value={currentSettings.search_folder} value={currentSettings.search_folder}
onValueChange={(value) => handleSettingsChange("search_folder", value)} onValueChange={(value) =>
handleSettingsChange("search_folder", value)
}
disabled={settings.locked_fields.includes("search_folder")} disabled={settings.locked_fields.includes("search_folder")}
> >
<SelectTrigger> <SelectTrigger>
@@ -162,7 +211,12 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
<Label htmlFor="move-folder">Move to Folder</Label> <Label htmlFor="move-folder">Move to Folder</Label>
<Select <Select
value={currentSettings.move_to_folder || "None"} value={currentSettings.move_to_folder || "None"}
onValueChange={(value) => handleSettingsChange("move_to_folder", value === "None" ? null : value)} onValueChange={(value) =>
handleSettingsChange(
"move_to_folder",
value === "None" ? null : value
)
}
disabled={settings.locked_fields.includes("move_to_folder")} disabled={settings.locked_fields.includes("move_to_folder")}
> >
<SelectTrigger> <SelectTrigger>
@@ -186,7 +240,12 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
min="1" min="1"
max="1440" max="1440"
value={currentSettings.email_check_interval} value={currentSettings.email_check_interval}
onChange={(e) => handleSettingsChange("email_check_interval", Number.parseInt(e.target.value) || 15)} onChange={(e) =>
handleSettingsChange(
"email_check_interval",
Number.parseInt(e.target.value) || 15
)
}
placeholder="15" placeholder="15"
disabled={settings.locked_fields.includes("email_check_interval")} disabled={settings.locked_fields.includes("email_check_interval")}
/> />
@@ -195,7 +254,9 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
<Checkbox <Checkbox
id="mark-read" id="mark-read"
checked={currentSettings.mark_as_read} checked={currentSettings.mark_as_read}
onCheckedChange={(checked) => handleSettingsChange("mark_as_read", !!checked)} onCheckedChange={(checked) =>
handleSettingsChange("mark_as_read", !!checked)
}
disabled={settings.locked_fields.includes("mark_as_read")} disabled={settings.locked_fields.includes("mark_as_read")}
/> />
<Label htmlFor="mark-read" className="text-sm font-normal"> <Label htmlFor="mark-read" className="text-sm font-normal">
@@ -206,7 +267,9 @@ export function SettingsDialog({ settings, folderOptions, isOpen, onOpenChange,
<Checkbox <Checkbox
id="auto-add" id="auto-add"
checked={currentSettings.auto_add_new_senders} checked={currentSettings.auto_add_new_senders}
onCheckedChange={(checked) => handleSettingsChange("auto_add_new_senders", !!checked)} onCheckedChange={(checked) =>
handleSettingsChange("auto_add_new_senders", !!checked)
}
disabled={settings.locked_fields.includes("auto_add_new_senders")} disabled={settings.locked_fields.includes("auto_add_new_senders")}
/> />
<Label htmlFor="auto-add" className="text-sm font-normal"> <Label htmlFor="auto-add" className="text-sm font-normal">

View File

@@ -4,6 +4,7 @@ import "@testing-library/jest-dom"
import { SettingsDialog } from "../SettingsDialog" import { SettingsDialog } from "../SettingsDialog"
import { Settings } from "@/lib/api" import { Settings } from "@/lib/api"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { toast } from "sonner"
// Mock the API module // Mock the API module
jest.mock("@/lib/api", () => ({ jest.mock("@/lib/api", () => ({
@@ -12,6 +13,14 @@ jest.mock("@/lib/api", () => ({
testImapConnection: jest.fn(), testImapConnection: jest.fn(),
})) }))
// Mock the toast module
jest.mock("sonner", () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}))
const mockedApi = api as jest.Mocked<typeof api> const mockedApi = api as jest.Mocked<typeof api>
const mockSettings: Settings = { const mockSettings: Settings = {
@@ -31,9 +40,15 @@ const mockFolderOptions = ["INBOX", "Sent", "Archive", "Spam"]
describe("SettingsDialog", () => { describe("SettingsDialog", () => {
const handleOpenChange = jest.fn() const handleOpenChange = jest.fn()
const handleSuccess = jest.fn() const handleSuccess = jest.fn()
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
consoleError.mockClear()
})
afterAll(() => {
consoleError.mockRestore()
}) })
it("renders settings and respects locked fields", () => { it("renders settings and respects locked fields", () => {
@@ -53,11 +68,13 @@ describe("SettingsDialog", () => {
expect(screen.getByLabelText(/IMAP Username/i)).toBeEnabled() expect(screen.getByLabelText(/IMAP Username/i)).toBeEnabled()
// Check that values are set correctly // Check that values are set correctly
expect(screen.getByLabelText(/IMAP Username/i)).toHaveValue(mockSettings.imap_username) expect(screen.getByLabelText(/IMAP Username/i)).toHaveValue(
mockSettings.imap_username
)
expect(screen.getByLabelText(/Mark emails as read/i)).toBeChecked() expect(screen.getByLabelText(/Mark emails as read/i)).toBeChecked()
}) })
it("allows updating and saving settings", async () => { it("allows updating and saving settings, showing success toast", async () => {
mockedApi.updateSettings.mockResolvedValueOnce(mockSettings) mockedApi.updateSettings.mockResolvedValueOnce(mockSettings)
render( render(
@@ -71,7 +88,9 @@ describe("SettingsDialog", () => {
) )
// Change a setting // Change a setting
fireEvent.change(screen.getByLabelText(/IMAP Username/i), { target: { value: "new.user@example.com" } }) fireEvent.change(screen.getByLabelText(/IMAP Username/i), {
target: { value: "new.user@example.com" },
})
// Save // Save
fireEvent.click(screen.getByRole("button", { name: /Save Settings/i })) fireEvent.click(screen.getByRole("button", { name: /Save Settings/i }))
@@ -82,13 +101,37 @@ describe("SettingsDialog", () => {
imap_username: "new.user@example.com", imap_username: "new.user@example.com",
}) })
) )
expect(toast.success).toHaveBeenCalledWith("Settings saved successfully!")
expect(handleSuccess).toHaveBeenCalledTimes(1) expect(handleSuccess).toHaveBeenCalledTimes(1)
expect(handleOpenChange).toHaveBeenCalledWith(false) expect(handleOpenChange).toHaveBeenCalledWith(false)
}) })
}) })
it("shows an error toast if saving settings fails", async () => {
mockedApi.updateSettings.mockRejectedValueOnce(new Error("Failed to save"))
render(
<SettingsDialog
settings={mockSettings}
folderOptions={mockFolderOptions}
isOpen={true}
onOpenChange={handleOpenChange}
onSuccess={handleSuccess}
/>
)
fireEvent.click(screen.getByRole("button", { name: /Save Settings/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to save settings.")
expect(handleSuccess).not.toHaveBeenCalled()
})
})
it("handles successful connection test", async () => { it("handles successful connection test", async () => {
mockedApi.testImapConnection.mockResolvedValueOnce({ message: "Connection successful!" }) mockedApi.testImapConnection.mockResolvedValueOnce({
message: "Connection successful!",
})
render( render(
<SettingsDialog <SettingsDialog
@@ -104,12 +147,16 @@ describe("SettingsDialog", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Connection successful!")).toBeInTheDocument() expect(screen.getByText("Connection successful!")).toBeInTheDocument()
expect(screen.getByTestId("connection-status")).toHaveClass("text-green-600") expect(screen.getByTestId("connection-status")).toHaveClass(
"text-green-600"
)
}) })
}) })
it("handles failed connection test", async () => { it("handles failed connection test", async () => {
mockedApi.testImapConnection.mockRejectedValueOnce(new Error("Authentication failed")) mockedApi.testImapConnection.mockRejectedValueOnce(
new Error("Authentication failed")
)
render( render(
<SettingsDialog <SettingsDialog