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:
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { EventToastBridge } from "./event-toast-bridge";
|
||||
import { useEventContext } from "../state/events";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import { handleEventToast } from "./toast-rules";
|
||||
import type { InstanceEventPayload } from "../types/events";
|
||||
|
||||
vi.mock("../state/events", () => ({
|
||||
useEventContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/settings", () => ({
|
||||
getUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./toast-rules", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./toast-rules")>();
|
||||
return {
|
||||
...actual,
|
||||
handleEventToast: vi.fn(),
|
||||
clearToastDedup: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseEventContext = vi.mocked(useEventContext);
|
||||
const mockedGetUserConfig = vi.mocked(getUserConfig);
|
||||
const mockedHandleEventToast = vi.mocked(handleEventToast);
|
||||
|
||||
function makeEvent(
|
||||
eventType: string,
|
||||
overrides?: Partial<InstanceEventPayload>,
|
||||
): InstanceEventPayload {
|
||||
return {
|
||||
event: eventType,
|
||||
instance_id: "i-1",
|
||||
status: undefined,
|
||||
message: undefined,
|
||||
metadata: {},
|
||||
timestamp: "2026-05-29T10:00:00Z",
|
||||
correlation_id: "c1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("EventToastBridge preference checks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
last_session_id: null,
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows toast when level is all and category not muted", async () => {
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("suppresses toast when level is none", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "none",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses info toast when level is errors", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "errors",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error toast when level is errors", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "errors",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.error");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("suppresses toast when category is muted", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: ["instance"],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies preference change immediately via custom event", async () => {
|
||||
const event1 = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event1],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
const { rerender } = render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("userconfig:updated", {
|
||||
detail: { notification_toast_level: "none" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const event2 = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event1, event2],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
rerender(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("muted category overrides all level", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: ["instance"],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.error");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplication still works with preferences", async () => {
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event, event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unmapped event defaults to system/info and shows when level is all", async () => {
|
||||
const event = makeEvent("system.announcement");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,77 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEventContext } from "../state/events";
|
||||
import { handleEventToast } from "./toast-rules";
|
||||
import {
|
||||
handleEventToast,
|
||||
mapEventToCategory,
|
||||
mapEventToSeverity,
|
||||
} from "./toast-rules";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import type { UserConfig } from "../api/settings";
|
||||
|
||||
interface ToastConfig {
|
||||
notification_toast_level: string;
|
||||
notification_mute_categories: string[];
|
||||
}
|
||||
|
||||
export function EventToastBridge(): JSX.Element | null {
|
||||
const { events } = useEventContext();
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
const [config, setConfig] = useState<ToastConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getUserConfig()
|
||||
.then((c) => {
|
||||
setConfig({
|
||||
notification_toast_level: c.notification_toast_level ?? "all",
|
||||
notification_mute_categories: c.notification_mute_categories ?? [],
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setConfig({
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: [],
|
||||
});
|
||||
});
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<Partial<UserConfig>>).detail;
|
||||
if (detail) {
|
||||
setConfig((prev) => ({
|
||||
notification_toast_level:
|
||||
detail.notification_toast_level ??
|
||||
prev?.notification_toast_level ??
|
||||
"all",
|
||||
notification_mute_categories:
|
||||
detail.notification_mute_categories ??
|
||||
prev?.notification_mute_categories ??
|
||||
[],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("userconfig:updated", handler);
|
||||
return () => window.removeEventListener("userconfig:updated", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
for (const event of events) {
|
||||
const key = `${event.correlation_id}:${event.timestamp}`;
|
||||
if (processedRef.current.has(key)) continue;
|
||||
processedRef.current.add(key);
|
||||
|
||||
const category = mapEventToCategory(event);
|
||||
const severity = mapEventToSeverity(event);
|
||||
|
||||
if (config.notification_toast_level === "none") continue;
|
||||
if (config.notification_toast_level === "errors" && severity !== "error")
|
||||
continue;
|
||||
if (config.notification_mute_categories.includes(category)) continue;
|
||||
|
||||
handleEventToast(event);
|
||||
}
|
||||
}, [events]);
|
||||
}, [events, config]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,148 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { handleEventToast, clearToastDedup } from "./toast-rules";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapEventToCategory, mapEventToSeverity } from "./toast-rules";
|
||||
import type { InstanceEventPayload } from "../types/events";
|
||||
|
||||
const mockToastInfo = vi.fn();
|
||||
const mockToastSuccess = vi.fn();
|
||||
const mockToastWarning = vi.fn();
|
||||
const mockToastError = vi.fn();
|
||||
function makeEvent(
|
||||
event: string,
|
||||
overrides?: Partial<InstanceEventPayload>,
|
||||
): InstanceEventPayload {
|
||||
return {
|
||||
event,
|
||||
instance_id: "i-1",
|
||||
status: undefined,
|
||||
message: undefined,
|
||||
metadata: {},
|
||||
timestamp: "2026-05-29T10:00:00Z",
|
||||
correlation_id: "c1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../state/toast", () => ({
|
||||
toast: {
|
||||
info: (...args: unknown[]) => mockToastInfo(...args),
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
warning: (...args: unknown[]) => mockToastWarning(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("toast-rules", () => {
|
||||
beforeEach(() => {
|
||||
clearToastDedup();
|
||||
mockToastInfo.mockClear();
|
||||
mockToastSuccess.mockClear();
|
||||
mockToastWarning.mockClear();
|
||||
mockToastError.mockClear();
|
||||
describe("mapEventToCategory", () => {
|
||||
it('returns "instance" for instance.* events', () => {
|
||||
expect(mapEventToCategory(makeEvent("instance.started"))).toBe("instance");
|
||||
expect(mapEventToCategory(makeEvent("instance.error"))).toBe("instance");
|
||||
});
|
||||
|
||||
it("maps instance.started to info toast", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.started",
|
||||
instance_id: "inst-1",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastInfo).toHaveBeenCalledWith("Container starting...", {
|
||||
duration: 3000,
|
||||
});
|
||||
it('returns "health" for health.* events', () => {
|
||||
expect(mapEventToCategory(makeEvent("health.error"))).toBe("health");
|
||||
});
|
||||
|
||||
it("maps health_changed to running to success toast", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.health_changed",
|
||||
instance_id: "inst-1",
|
||||
status: "running",
|
||||
message: "Container is running",
|
||||
metadata: { previous_status: "starting" },
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("Container running", {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps health_changed to unhealthy to warning toast", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.health_changed",
|
||||
instance_id: "inst-1",
|
||||
status: "unhealthy",
|
||||
message: "Container is unhealthy",
|
||||
metadata: { previous_status: "running" },
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastWarning).toHaveBeenCalledWith("Container unhealthy", {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps instance.error to error toast with exit code", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.error",
|
||||
instance_id: "inst-1",
|
||||
status: "error",
|
||||
message: "Container crashed",
|
||||
metadata: { exit_code: 137 },
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"Container crashed (exit code: 137)",
|
||||
{ duration: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("maps instance.error to error toast without exit code", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.error",
|
||||
instance_id: "inst-1",
|
||||
status: "error",
|
||||
message: "Build failed",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastError).toHaveBeenCalledWith("Build failed", {
|
||||
duration: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates within one second", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.started",
|
||||
instance_id: "inst-1",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
handleEventToast(event);
|
||||
expect(mockToastInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows duplicate after one second", () => {
|
||||
vi.useFakeTimers();
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.started",
|
||||
instance_id: "inst-1",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
vi.advanceTimersByTime(1100);
|
||||
handleEventToast(event);
|
||||
expect(mockToastInfo).toHaveBeenCalledTimes(2);
|
||||
vi.useRealTimers();
|
||||
it('returns "system" for unknown events', () => {
|
||||
expect(mapEventToCategory(makeEvent("system.announcement"))).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapEventToSeverity", () => {
|
||||
it("returns error for instance.error and health.error", () => {
|
||||
expect(mapEventToSeverity(makeEvent("instance.error"))).toBe("error");
|
||||
expect(mapEventToSeverity(makeEvent("health.error"))).toBe("error");
|
||||
});
|
||||
|
||||
it("returns warning for unhealthy health changes", () => {
|
||||
expect(
|
||||
mapEventToSeverity(
|
||||
makeEvent("instance.health_changed", { status: "unhealthy" }),
|
||||
),
|
||||
).toBe("warning");
|
||||
});
|
||||
|
||||
it("returns success for recovery to running", () => {
|
||||
expect(
|
||||
mapEventToSeverity(
|
||||
makeEvent("instance.health_changed", { status: "running" }),
|
||||
),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("returns info for lifecycle events", () => {
|
||||
expect(mapEventToSeverity(makeEvent("instance.created"))).toBe("info");
|
||||
expect(mapEventToSeverity(makeEvent("instance.started"))).toBe("info");
|
||||
expect(mapEventToSeverity(makeEvent("instance.stopped"))).toBe("info");
|
||||
expect(mapEventToSeverity(makeEvent("instance.restarted"))).toBe("info");
|
||||
expect(mapEventToSeverity(makeEvent("instance.deleted"))).toBe("info");
|
||||
});
|
||||
|
||||
it("returns info for unmapped events", () => {
|
||||
expect(mapEventToSeverity(makeEvent("unknown.event"))).toBe("info");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,32 @@ function shouldShowToast(instanceId: string, eventType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mapEventToCategory(event: InstanceEventPayload): string {
|
||||
if (event.event.startsWith("instance.")) return "instance";
|
||||
if (event.event.startsWith("health.")) return "health";
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function mapEventToSeverity(
|
||||
event: InstanceEventPayload,
|
||||
): "info" | "warning" | "error" | "success" {
|
||||
switch (event.event) {
|
||||
case "instance.error":
|
||||
case "health.error":
|
||||
return "error";
|
||||
case "instance.health_changed":
|
||||
return event.status === "unhealthy" ? "warning" : "success";
|
||||
case "instance.created":
|
||||
case "instance.started":
|
||||
case "instance.stopped":
|
||||
case "instance.restarted":
|
||||
case "instance.deleted":
|
||||
return "info";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
export function handleEventToast(event: InstanceEventPayload): void {
|
||||
const { event: eventType, instance_id, status, message, metadata } = event;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user