feat: notification center toast coordination (PR-4)
- EventToastBridge checks notification_toast_level and notification_mute_categories - toast-rules.ts: event-to-category/severity mapping functions - Settings page: notification preferences section (toast level dropdown, mute checkboxes) - Settings API types extended with notification preference fields - 17 frontend tests (toast-rules + bridge) - Preference hierarchy: mute categories → toast level → show/hide Quality gates: vitest 17 passed, tsc clean, eslint clean
This commit is contained in:
+253
-122
@@ -1,153 +1,284 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import {
|
||||
getUserConfig,
|
||||
updateUserConfig,
|
||||
type UserConfig,
|
||||
type UserConfigUpdate,
|
||||
} from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
const TOAST_LEVEL_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "errors", label: "Errors only" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
config: UserConfig;
|
||||
handleChange: (
|
||||
key: keyof UserConfigUpdate,
|
||||
value: string | string[] | null,
|
||||
) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
last_session_id: null,
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const location = useLocation();
|
||||
const {
|
||||
data: loadedConfig,
|
||||
status,
|
||||
reload,
|
||||
} = useAsyncData<UserConfig>(getUserConfig, []);
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
last_session_id: null,
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: [],
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<
|
||||
"idle" | "saving" | "saved" | "error"
|
||||
>("idle");
|
||||
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
if (loadedConfig) {
|
||||
setConfig(loadedConfig);
|
||||
}
|
||||
}, [loadedConfig]);
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
if (loadedConfig) {
|
||||
setConfig({
|
||||
...loadedConfig,
|
||||
notification_toast_level:
|
||||
loadedConfig.notification_toast_level ?? "all",
|
||||
notification_mute_categories:
|
||||
loadedConfig.notification_mute_categories ?? [],
|
||||
});
|
||||
}
|
||||
}, [loadedConfig]);
|
||||
|
||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
const handleChange = (
|
||||
key: keyof UserConfigUpdate,
|
||||
value: string | string[] | null,
|
||||
) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }) as UserConfig);
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const update: UserConfigUpdate = {
|
||||
theme: config.theme,
|
||||
default_editor: config.default_editor,
|
||||
git_user_name: config.git_user_name,
|
||||
git_user_email: config.git_user_email,
|
||||
};
|
||||
const updated = await updateUserConfig(update);
|
||||
setConfig(updated);
|
||||
setSaveStatus("saved");
|
||||
if (updated.theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||
}
|
||||
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const update: UserConfigUpdate = {
|
||||
theme: config.theme,
|
||||
default_editor: config.default_editor,
|
||||
git_user_name: config.git_user_name,
|
||||
git_user_email: config.git_user_email,
|
||||
notification_toast_level: config.notification_toast_level,
|
||||
notification_mute_categories: config.notification_mute_categories,
|
||||
};
|
||||
const updated = await updateUserConfig(update);
|
||||
setConfig(updated);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("userconfig:updated", { detail: updated }),
|
||||
);
|
||||
setSaveStatus("saved");
|
||||
if (updated.theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||
}
|
||||
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
|
||||
}
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<LoadingState message="Loading settings..." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const activePath = location.pathname.endsWith("/settings")
|
||||
? "general"
|
||||
: (parts[parts.length - 1] ?? "general");
|
||||
|
||||
return (
|
||||
<section className="stack settings-page">
|
||||
<header className="settings-header card stack-sm">
|
||||
<div>
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences, SSH keys, and config profiles.</p>
|
||||
</header>
|
||||
return (
|
||||
<section className="stack settings-page">
|
||||
<header className="settings-header card stack-sm">
|
||||
<div>
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">
|
||||
General preferences, SSH keys, and config profiles.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-panel card">
|
||||
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<div className="settings-panel card">
|
||||
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const GeneralSettingsTab = () => {
|
||||
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||
const { config, handleChange, handleSave, saveStatus } =
|
||||
useOutletContext<SettingsOutletContext>();
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||
</label>
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select
|
||||
value={config.theme}
|
||||
onChange={(e) => handleChange("theme", e.target.value)}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input
|
||||
type="text"
|
||||
value={config.git_user_name ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("git_user_name", e.target.value || null)
|
||||
}
|
||||
placeholder="Your git commit name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input
|
||||
type="email"
|
||||
value={config.git_user_email ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("git_user_email", e.target.value || null)
|
||||
}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input
|
||||
type="text"
|
||||
value={config.default_editor ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("default_editor", e.target.value || null)
|
||||
}
|
||||
placeholder="e.g., vscode, vim, cursor"
|
||||
/>
|
||||
</label>
|
||||
<h3>Notifications</h3>
|
||||
<label className="form-field">
|
||||
Toast level
|
||||
<select
|
||||
value={config.notification_toast_level ?? "all"}
|
||||
onChange={(e) =>
|
||||
handleChange("notification_toast_level", e.target.value)
|
||||
}
|
||||
>
|
||||
{TOAST_LEVEL_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="form-field">
|
||||
<legend>Mute categories</legend>
|
||||
<div className="stack-sm">
|
||||
{MUTE_CATEGORIES.map((cat) => (
|
||||
<label
|
||||
key={cat}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(config.notification_mute_categories ?? []).includes(
|
||||
cat,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const current = config.notification_mute_categories ?? [];
|
||||
const next = e.target.checked
|
||||
? [...current, cat]
|
||||
: current.filter((c) => c !== cat);
|
||||
handleChange("notification_mute_categories", next);
|
||||
}}
|
||||
/>
|
||||
{cat}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{saveStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" /> Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveStatus === "saved" && (
|
||||
<span className="success-text">Settings saved!</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="error-text">Failed to save</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user