mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
chore(ui): wire sonner Toaster; delegate antd managers to sonner
- Rewrite MessageManager (message_manager.tsx) to delegate to toast.* from sonner. Public API preserved (success/error/warning/info/loading/destroy). setMessageInstance() is now a no-op back-compat shim. - Rewrite NotificationManager (notifications_manager.tsx) to delegate to sonner. Same classification logic (titleFor/classifyGeneralMessage) and same public API (error/warning/info/success/fromBackend/clear) preserved. setNotificationInstance() is now a no-op back-compat shim. - antd duration (seconds) \u2192 sonner duration (ms) conversion at each call site. - Gut AntdGlobalProvider to a children passthrough (antd message/notification provider is no longer needed). Will be deleted in the final cleanup task after antd is uninstalled. - Add <Toaster richColors position="top-right" /> to the root layout. - Rewrite message_manager and notifications_manager unit tests to mock sonner's toast.* rather than antd.message/notification. Use vi.unmock in notifications_manager test to bypass the global setupTests.ts mock. - Fix stale antd.message.error assertion in AddFallbacks.test.tsx: mock @/components/molecules/message_manager directly so the assertion is agnostic to the underlying toast library. Build and targeted vitest suites pass. Full vitest suite: 3801/3801 pass after these fixes (2 pre-existing failures were caused by the migration and are now fixed via the updated AddFallbacks test mock). Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
a507e5514a
commit
966d7e0cc2
7 changed files with 241 additions and 237 deletions
|
|
@ -4,6 +4,7 @@ import "./globals.css";
|
|||
|
||||
import AntdGlobalProvider from "@/contexts/AntdGlobalProvider";
|
||||
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export default function RootLayout({
|
|||
<ReactQueryProvider>
|
||||
<AntdGlobalProvider>{children}</AntdGlobalProvider>
|
||||
</ReactQueryProvider>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,15 +8,22 @@ vi.mock("../../../playground/llm_calls/fetch_models", () => ({
|
|||
fetchAvailableModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("antd")>();
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
// MessageManager is the project's global message wrapper (delegates to sonner
|
||||
// post phase-1 migration). Mock it as a first-class object so assertions don't
|
||||
// care which underlying toast library is in use.
|
||||
const mockMessageManager = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/message_manager", () => ({
|
||||
default: mockMessageManager,
|
||||
setMessageInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./FallbackSelectionForm", () => ({
|
||||
FallbackSelectionForm: ({ groups, onGroupsChange }: any) => {
|
||||
|
|
@ -122,7 +129,6 @@ describe("AddFallbacks", () => {
|
|||
|
||||
it("should show error when saving incomplete groups", async () => {
|
||||
const user = userEvent.setup();
|
||||
const antd = await import("antd");
|
||||
render(<AddFallbacks {...defaultProps} />);
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
|
||||
|
|
@ -141,13 +147,12 @@ describe("AddFallbacks", () => {
|
|||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(antd.message.error).toHaveBeenCalled();
|
||||
expect(mockMessageManager.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error message when saving incomplete groups", async () => {
|
||||
const user = userEvent.setup();
|
||||
const antd = await import("antd");
|
||||
render(<AddFallbacks {...defaultProps} />);
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
|
||||
|
|
@ -161,7 +166,7 @@ describe("AddFallbacks", () => {
|
|||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(antd.message.error).toHaveBeenCalled();
|
||||
expect(mockMessageManager.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Use vi.hoisted so the mock object is available when vi.mock is hoisted
|
||||
const mockStaticMessage = vi.hoisted(() => ({
|
||||
// Sonner is a module-level singleton; mock it before importing the manager.
|
||||
const mockToast = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: mockStaticMessage,
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
import MessageManager, { setMessageInstance } from "./message_manager";
|
||||
|
|
@ -21,87 +21,43 @@ describe("MessageManager", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when no instance is set (falls back to static message)", () => {
|
||||
it("delegates success to static message", () => {
|
||||
describe("delegates to sonner", () => {
|
||||
it("delegates success", () => {
|
||||
MessageManager.success("done!");
|
||||
expect(mockStaticMessage.success).toHaveBeenCalledWith("done!", undefined);
|
||||
expect(mockToast.success).toHaveBeenCalledWith("done!", { duration: undefined });
|
||||
});
|
||||
|
||||
it("delegates error to static message", () => {
|
||||
it("delegates error with duration (converts seconds → ms)", () => {
|
||||
MessageManager.error("failed!", 5);
|
||||
expect(mockStaticMessage.error).toHaveBeenCalledWith("failed!", 5);
|
||||
expect(mockToast.error).toHaveBeenCalledWith("failed!", { duration: 5000 });
|
||||
});
|
||||
|
||||
it("delegates warning to static message", () => {
|
||||
it("delegates warning", () => {
|
||||
MessageManager.warning("watch out");
|
||||
expect(mockStaticMessage.warning).toHaveBeenCalledWith("watch out", undefined);
|
||||
expect(mockToast.warning).toHaveBeenCalledWith("watch out", { duration: undefined });
|
||||
});
|
||||
|
||||
it("delegates info to static message", () => {
|
||||
MessageManager.info("fyi");
|
||||
expect(mockStaticMessage.info).toHaveBeenCalledWith("fyi", undefined);
|
||||
it("delegates info with duration", () => {
|
||||
MessageManager.info("fyi", 2);
|
||||
expect(mockToast.info).toHaveBeenCalledWith("fyi", { duration: 2000 });
|
||||
});
|
||||
|
||||
it("delegates loading to static message", () => {
|
||||
MessageManager.loading("loading...", 3);
|
||||
expect(mockStaticMessage.loading).toHaveBeenCalledWith("loading...", 3);
|
||||
it("delegates loading and returns the toast id", () => {
|
||||
mockToast.loading.mockReturnValue("toast-id-42");
|
||||
const result = MessageManager.loading("loading...", 3);
|
||||
expect(mockToast.loading).toHaveBeenCalledWith("loading...", { duration: 3000 });
|
||||
expect(result).toBe("toast-id-42");
|
||||
});
|
||||
|
||||
it("delegates destroy to static message", () => {
|
||||
it("delegates destroy to toast.dismiss()", () => {
|
||||
MessageManager.destroy();
|
||||
expect(mockStaticMessage.destroy).toHaveBeenCalled();
|
||||
expect(mockToast.dismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a custom instance is set", () => {
|
||||
const mockInstance = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
open: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMessageInstance(mockInstance as any);
|
||||
});
|
||||
|
||||
it("delegates success to custom instance", () => {
|
||||
MessageManager.success("done!");
|
||||
expect(mockInstance.success).toHaveBeenCalledWith("done!", undefined);
|
||||
expect(mockStaticMessage.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates error with duration to custom instance", () => {
|
||||
MessageManager.error("failed!", 5);
|
||||
expect(mockInstance.error).toHaveBeenCalledWith("failed!", 5);
|
||||
expect(mockStaticMessage.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates warning to custom instance", () => {
|
||||
MessageManager.warning("watch out");
|
||||
expect(mockInstance.warning).toHaveBeenCalledWith("watch out", undefined);
|
||||
});
|
||||
|
||||
it("delegates info to custom instance", () => {
|
||||
MessageManager.info("fyi", 2);
|
||||
expect(mockInstance.info).toHaveBeenCalledWith("fyi", 2);
|
||||
});
|
||||
|
||||
it("delegates loading to custom instance and returns result", () => {
|
||||
const mockReturn = { then: vi.fn() };
|
||||
mockInstance.loading.mockReturnValue(mockReturn);
|
||||
const result = MessageManager.loading("loading...", 3);
|
||||
expect(mockInstance.loading).toHaveBeenCalledWith("loading...", 3);
|
||||
expect(result).toBe(mockReturn);
|
||||
});
|
||||
|
||||
it("delegates destroy to custom instance", () => {
|
||||
MessageManager.destroy();
|
||||
expect(mockInstance.destroy).toHaveBeenCalled();
|
||||
describe("setMessageInstance is a no-op (back-compat shim)", () => {
|
||||
it("does not throw when called", () => {
|
||||
expect(() => setMessageInstance({} as unknown)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,37 +1,60 @@
|
|||
import { message as staticMessage } from "antd";
|
||||
import type { MessageInstance } from "antd/es/message/interface";
|
||||
/**
|
||||
* Global message manager — thin wrapper around sonner's `toast.*` API.
|
||||
*
|
||||
* Phase-1 shadcn migration: this file previously delegated to antd's
|
||||
* `message.*` API. It now delegates to sonner. The public API (including
|
||||
* `setMessageInstance`) is preserved so existing call sites don't need to
|
||||
* change; the `setMessageInstance` function is now a no-op kept for
|
||||
* backwards-compatibility (sonner is singleton-rendered at the root layout
|
||||
* and does not need per-tree instance registration).
|
||||
*/
|
||||
import { toast } from "sonner";
|
||||
|
||||
let messageInstance: MessageInstance | null = null;
|
||||
|
||||
export const setMessageInstance = (instance: MessageInstance) => {
|
||||
messageInstance = instance;
|
||||
/**
|
||||
* No-op retained for API compatibility with call sites that import
|
||||
* `setMessageInstance` from this module. Sonner is rendered globally via
|
||||
* `<Toaster />` in the root layout; no per-tree registration is required.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const setMessageInstance = (_instance: unknown) => {
|
||||
// no-op — sonner is singleton
|
||||
};
|
||||
|
||||
const getMessageApi = () => messageInstance || staticMessage;
|
||||
/** antd durations are seconds; sonner durations are milliseconds. */
|
||||
function toSonnerDuration(duration?: number): number | undefined {
|
||||
if (duration == null) return undefined;
|
||||
return duration * 1000;
|
||||
}
|
||||
|
||||
const MessageManager = {
|
||||
success(content: string, duration?: number) {
|
||||
getMessageApi().success(content, duration);
|
||||
return toast.success(content, { duration: toSonnerDuration(duration) });
|
||||
},
|
||||
|
||||
error(content: string, duration?: number) {
|
||||
getMessageApi().error(content, duration);
|
||||
return toast.error(content, { duration: toSonnerDuration(duration) });
|
||||
},
|
||||
|
||||
warning(content: string, duration?: number) {
|
||||
getMessageApi().warning(content, duration);
|
||||
return toast.warning(content, { duration: toSonnerDuration(duration) });
|
||||
},
|
||||
|
||||
info(content: string, duration?: number) {
|
||||
getMessageApi().info(content, duration);
|
||||
return toast.info(content, { duration: toSonnerDuration(duration) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a loading toast. Returns the sonner toast id, which can be passed
|
||||
* to `toast.dismiss(id)` (or `MessageManager.destroy()` to clear all).
|
||||
* Behaves like antd's `message.loading` — duration in seconds; omit to
|
||||
* keep the toast open until dismissed.
|
||||
*/
|
||||
loading(content: string, duration?: number) {
|
||||
return getMessageApi().loading(content, duration);
|
||||
return toast.loading(content, { duration: toSonnerDuration(duration) });
|
||||
},
|
||||
|
||||
destroy() {
|
||||
getMessageApi().destroy();
|
||||
toast.dismiss();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,35 @@
|
|||
import { notification } from "antd";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import NotificationManager, { COMMON_NOTIFICATION_PROPS } from "./notifications_manager";
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/components/molecules/notifications_manager")>(
|
||||
"@/components/molecules/notifications_manager",
|
||||
);
|
||||
// The global `setupTests.ts` mocks `@/components/molecules/notifications_manager`
|
||||
// as a safety rail for component tests. Unmock it here so we can exercise the
|
||||
// real manager against sonner.
|
||||
vi.unmock("@/components/molecules/notifications_manager");
|
||||
vi.unmock("./notifications_manager");
|
||||
|
||||
return actual;
|
||||
});
|
||||
|
||||
// Mock the antd notification module
|
||||
vi.mock("antd", () => ({
|
||||
notification: {
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
},
|
||||
// Sonner is a module-level singleton; mock it before importing the manager.
|
||||
const mockToast = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("NotificationManager", () => {
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
// Dynamic import AFTER vi.unmock so the global mock doesn't take effect.
|
||||
const { default: NotificationManager } = await import("./notifications_manager");
|
||||
|
||||
describe("NotificationManager (sonner-backed)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Already Exists case", () => {
|
||||
it("should show error notification for 'already exists' message", () => {
|
||||
it("should show error toast for 'already exists' backend message", () => {
|
||||
const error = {
|
||||
message: "Key with alias 'test10' already exists.",
|
||||
type: "bad_request_error",
|
||||
|
|
@ -36,35 +38,36 @@ describe("NotificationManager", () => {
|
|||
|
||||
NotificationManager.fromBackend(error);
|
||||
|
||||
expect(notification.error).toHaveBeenCalledWith(
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
"Already Exists",
|
||||
expect.objectContaining({
|
||||
message: "Already Exists",
|
||||
description: "Key with alias 'test10' already exists.",
|
||||
duration: 6,
|
||||
placement: "topRight",
|
||||
duration: 6000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("COMMON_NOTIFICATION_PROPS", () => {
|
||||
const notificationTypes = [
|
||||
{ type: "error", method: NotificationManager.error, mockFn: notification.error },
|
||||
{ type: "warning", method: NotificationManager.warning, mockFn: notification.warning },
|
||||
{ type: "info", method: NotificationManager.info, mockFn: notification.info },
|
||||
{ type: "success", method: NotificationManager.success, mockFn: notification.success },
|
||||
describe("direct call routing", () => {
|
||||
const cases = [
|
||||
{ label: "error", call: () => NotificationManager.error("Test error"), mockFn: mockToast.error },
|
||||
{ label: "warning", call: () => NotificationManager.warning("Test warning"), mockFn: mockToast.warning },
|
||||
{ label: "info", call: () => NotificationManager.info("Test info"), mockFn: mockToast.info },
|
||||
{ label: "success", call: () => NotificationManager.success("Test success"), mockFn: mockToast.success },
|
||||
];
|
||||
|
||||
notificationTypes.forEach(({ type, method, mockFn }) => {
|
||||
it(`should pass COMMON_NOTIFICATION_PROPS to ${type} notifications`, () => {
|
||||
method(`Test ${type}`);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...COMMON_NOTIFICATION_PROPS,
|
||||
}),
|
||||
);
|
||||
cases.forEach(({ label, call, mockFn }) => {
|
||||
it(`should route ${label} to toast.${label}`, () => {
|
||||
call();
|
||||
expect(mockFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear()", () => {
|
||||
it("dismisses all toasts", () => {
|
||||
NotificationManager.clear();
|
||||
expect(mockToast.dismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,32 @@
|
|||
import React from "react";
|
||||
import { notification as staticNotification } from "antd";
|
||||
import type { NotificationInstance } from "antd/es/notification/interface";
|
||||
import { toast } from "sonner";
|
||||
import { parseErrorMessage } from "../shared/errorUtils";
|
||||
import { ArgsProps } from "antd/es/notification";
|
||||
|
||||
let notificationInstance: NotificationInstance | null = null;
|
||||
/**
|
||||
* Global notification manager — thin wrapper around sonner's `toast.*` API.
|
||||
*
|
||||
* Phase-1 shadcn migration: this file previously delegated to antd's
|
||||
* `notification.*` API. It now delegates to sonner. The public shape of
|
||||
* the module is preserved (same functions + `setNotificationInstance`)
|
||||
* so existing call sites don't need to change; `setNotificationInstance`
|
||||
* is now a no-op kept for backwards-compatibility.
|
||||
*/
|
||||
|
||||
export const setNotificationInstance = (instance: NotificationInstance) => {
|
||||
notificationInstance = instance;
|
||||
/**
|
||||
* No-op retained for API compatibility with call sites that import
|
||||
* `setNotificationInstance` from this module. Sonner is rendered globally via
|
||||
* `<Toaster />` in the root layout; no per-tree registration is required.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const setNotificationInstance = (_instance: unknown) => {
|
||||
// no-op — sonner is singleton
|
||||
};
|
||||
|
||||
// Helper to get the best available notification instance
|
||||
const getNotification = () => notificationInstance || staticNotification;
|
||||
/** Kept public for callers that used to spread it into an antd notification config. */
|
||||
export const COMMON_NOTIFICATION_PROPS = {
|
||||
// sonner's equivalent toggles are `richColors` (global on <Toaster>) and
|
||||
// `closeButton`; individual toasts don't need these fields.
|
||||
};
|
||||
|
||||
type Placement = "top" | "topLeft" | "topRight" | "bottom" | "bottomLeft" | "bottomRight";
|
||||
|
||||
|
|
@ -25,16 +40,12 @@ type NotificationConfig = {
|
|||
|
||||
type NotificationConfigResolved = Omit<NotificationConfig, "message"> & { message: string | React.ReactNode };
|
||||
|
||||
function defaultPlacement(): Placement {
|
||||
return "topRight";
|
||||
}
|
||||
|
||||
function normalize(input: string | NotificationConfig, fallbackTitle: string): NotificationConfigResolved {
|
||||
if (typeof input === "string") return { message: fallbackTitle, description: input };
|
||||
return { message: input.message ?? fallbackTitle, ...input };
|
||||
}
|
||||
|
||||
function toIntMaybe(val: any): number | undefined {
|
||||
function toIntMaybe(val: unknown): number | undefined {
|
||||
if (typeof val === "number") return val;
|
||||
if (typeof val === "string" && /^\d+$/.test(val)) return parseInt(val, 10);
|
||||
return undefined;
|
||||
|
|
@ -114,7 +125,7 @@ const NOT_FOUND_MATCH = [
|
|||
"team not found",
|
||||
"organization not found",
|
||||
"mcp server with id",
|
||||
"tool '", // will combine with “not found” in message
|
||||
"tool '",
|
||||
];
|
||||
|
||||
const EXISTS_MATCH = ["already exists", "team member is already in team", "user already exists"];
|
||||
|
|
@ -151,10 +162,10 @@ function titleFor(status?: number, desc?: string): string {
|
|||
|
||||
if (AUTH_MATCH.some((s) => d.includes(s))) return "Authentication Error";
|
||||
if (FORBIDDEN_MATCH.some((s) => d.includes(s))) return "Access Denied";
|
||||
if (DB_MATCH?.some?.((s: string) => d.includes(s)) || status === 503) return "Service Unavailable";
|
||||
if (BUDGET_MATCH?.some?.((s: string) => d.includes(s))) return "Budget Exceeded";
|
||||
if (ENTERPRISE_MATCH?.some?.((s: string) => d.includes(s))) return "Feature Unavailable";
|
||||
if (ROUTER_MATCH?.some?.((s: string) => d.includes(s))) return "Routing Error";
|
||||
if (DB_MATCH.some((s) => d.includes(s)) || status === 503) return "Service Unavailable";
|
||||
if (BUDGET_MATCH.some((s) => d.includes(s))) return "Budget Exceeded";
|
||||
if (ENTERPRISE_MATCH.some((s) => d.includes(s))) return "Feature Unavailable";
|
||||
if (ROUTER_MATCH.some((s) => d.includes(s))) return "Routing Error";
|
||||
|
||||
if (EXISTS_MATCH.some((s) => d.includes(s))) return "Already Exists";
|
||||
if (GUARDRAIL_MATCH.some((s) => d.includes(s))) return "Content Blocked";
|
||||
|
|
@ -169,7 +180,7 @@ function titleFor(status?: number, desc?: string): string {
|
|||
d.includes("rate limit") ||
|
||||
d.includes("tpm") ||
|
||||
d.includes("rpm") ||
|
||||
RATE_LIMIT_EXTRA?.some?.((s: string) => d.includes(s))
|
||||
RATE_LIMIT_EXTRA.some((s) => d.includes(s))
|
||||
)
|
||||
return "Rate Limit Exceeded";
|
||||
if (status && status >= 500) return "Server Error";
|
||||
|
|
@ -224,99 +235,115 @@ function classifyGeneralMessage(desc?: string): { kind: "success" | "info" | "wa
|
|||
if (SUCCESS_MATCH.some((s) => d.includes(s))) return { kind: "success", title: "Success" };
|
||||
if (DEPRECATION_FEATURE_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Feature Notice" };
|
||||
if (CONFIG_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Configuration Warning" };
|
||||
if (INFO_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Rate Limit" }; // show as warning for visibility
|
||||
if (INFO_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Rate Limit" };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStatus(input: any): number | undefined {
|
||||
return toIntMaybe(input?.response?.status) ?? toIntMaybe(input?.status_code) ?? toIntMaybe(input?.code);
|
||||
function extractStatus(input: unknown): number | undefined {
|
||||
const obj = (input ?? {}) as Record<string, unknown>;
|
||||
const response = obj?.response as Record<string, unknown> | undefined;
|
||||
return toIntMaybe(response?.status) ?? toIntMaybe(obj?.status_code) ?? toIntMaybe(obj?.code);
|
||||
}
|
||||
|
||||
function extractDescription(input: any): string {
|
||||
if (typeof input === "string") return input; // raw error string
|
||||
function extractDescription(input: unknown): string {
|
||||
if (typeof input === "string") return input;
|
||||
const obj = (input ?? {}) as Record<string, unknown>;
|
||||
const response = obj?.response as Record<string, unknown> | undefined;
|
||||
const data = response?.data as Record<string, unknown> | undefined;
|
||||
const dataError = data?.error as Record<string, unknown> | string | undefined;
|
||||
const backendMsg =
|
||||
input?.response?.data?.error?.message ??
|
||||
input?.response?.data?.message ??
|
||||
input?.response?.data?.error ??
|
||||
input?.detail ??
|
||||
input?.message ??
|
||||
(typeof dataError === "object" ? dataError?.message : dataError) ??
|
||||
data?.message ??
|
||||
obj?.detail ??
|
||||
obj?.message ??
|
||||
input;
|
||||
return parseErrorMessage(backendMsg);
|
||||
}
|
||||
|
||||
export const COMMON_NOTIFICATION_PROPS: Partial<ArgsProps> = {
|
||||
showProgress: true,
|
||||
pauseOnHover: true,
|
||||
};
|
||||
|
||||
function looksErrorPayload(input: any, status?: number): boolean {
|
||||
function looksErrorPayload(input: unknown, status?: number): boolean {
|
||||
if (status !== undefined) return true;
|
||||
if (input instanceof Error) return true;
|
||||
if (typeof input === "string") return true; // treat raw strings passed to fromBackend as errors
|
||||
if (typeof input === "string") return true;
|
||||
if (input && typeof input === "object" && ("error" in input || "detail" in input)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** antd durations are seconds; sonner durations are milliseconds. */
|
||||
function toMs(d?: number): number | undefined {
|
||||
if (d == null) return undefined;
|
||||
return d * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonner accepts a `description` field on every level, so we bundle
|
||||
* the (message, description) pair by using message as the main text and
|
||||
* description as the subtitle — same visual shape as the old antd stack.
|
||||
*/
|
||||
function callToast(
|
||||
level: "error" | "warning" | "info" | "success",
|
||||
cfg: NotificationConfigResolved,
|
||||
defaultDurationSec: number,
|
||||
) {
|
||||
const title =
|
||||
typeof cfg.message === "string" ? cfg.message : String(cfg.message ?? "");
|
||||
const description =
|
||||
typeof cfg.description === "string" || typeof cfg.description === "number"
|
||||
? String(cfg.description)
|
||||
: cfg.description
|
||||
? (cfg.description as React.ReactNode)
|
||||
: undefined;
|
||||
const duration = toMs(cfg.duration ?? defaultDurationSec);
|
||||
const opts: Parameters<typeof toast>[1] = { description, duration };
|
||||
switch (level) {
|
||||
case "error":
|
||||
return toast.error(title, opts);
|
||||
case "warning":
|
||||
return toast.warning(title, opts);
|
||||
case "info":
|
||||
return toast.info(title, opts);
|
||||
case "success":
|
||||
return toast.success(title, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationManager = {
|
||||
error(input: string | NotificationConfig) {
|
||||
const cfg = normalize(input, "Error");
|
||||
getNotification().error({
|
||||
...COMMON_NOTIFICATION_PROPS,
|
||||
...cfg,
|
||||
placement: cfg.placement ?? defaultPlacement(),
|
||||
duration: cfg.duration ?? 6,
|
||||
});
|
||||
callToast("error", cfg, 6);
|
||||
},
|
||||
|
||||
warning(input: string | NotificationConfig) {
|
||||
const cfg = normalize(input, "Warning");
|
||||
getNotification().warning({
|
||||
...COMMON_NOTIFICATION_PROPS,
|
||||
...cfg,
|
||||
placement: cfg.placement ?? defaultPlacement(),
|
||||
duration: cfg.duration ?? 5,
|
||||
});
|
||||
callToast("warning", cfg, 5);
|
||||
},
|
||||
|
||||
info(input: string | NotificationConfig) {
|
||||
const cfg = normalize(input, "Info");
|
||||
getNotification().info({
|
||||
...COMMON_NOTIFICATION_PROPS,
|
||||
...cfg,
|
||||
placement: cfg.placement ?? defaultPlacement(),
|
||||
duration: cfg.duration ?? 4,
|
||||
});
|
||||
callToast("info", cfg, 4);
|
||||
},
|
||||
|
||||
success(input: string | React.ReactNode | NotificationConfig) {
|
||||
if (React.isValidElement(input)) {
|
||||
getNotification().success({
|
||||
...COMMON_NOTIFICATION_PROPS,
|
||||
message: "Success",
|
||||
description: input,
|
||||
placement: defaultPlacement(),
|
||||
duration: 3.5,
|
||||
});
|
||||
toast.success("Success", { description: input, duration: toMs(3.5) });
|
||||
return;
|
||||
}
|
||||
const cfg = normalize(input as string | NotificationConfig, "Success");
|
||||
getNotification().success({
|
||||
...COMMON_NOTIFICATION_PROPS,
|
||||
...cfg,
|
||||
placement: cfg.placement ?? defaultPlacement(),
|
||||
duration: cfg.duration ?? 3.5,
|
||||
});
|
||||
callToast("success", cfg, 3.5);
|
||||
},
|
||||
|
||||
fromBackend(input: any, extra?: Omit<NotificationConfig, "message" | "description">) {
|
||||
fromBackend(input: unknown, extra?: Omit<NotificationConfig, "message" | "description">) {
|
||||
const status = extractStatus(input);
|
||||
const description = extractDescription(input);
|
||||
const base = { ...(extra ?? {}), description, placement: extra?.placement ?? defaultPlacement() };
|
||||
const base: NotificationConfigResolved = {
|
||||
...(extra ?? {}),
|
||||
message: "Info",
|
||||
description,
|
||||
};
|
||||
|
||||
if (looksErrorPayload(input, status)) {
|
||||
const title = titleFor(status, description);
|
||||
const payload = { ...base, message: title };
|
||||
const payload: NotificationConfigResolved = { ...base, message: title };
|
||||
|
||||
if (
|
||||
title === "Rate Limit Exceeded" ||
|
||||
|
|
@ -326,11 +353,11 @@ const NotificationManager = {
|
|||
title === "Content Blocked" ||
|
||||
title === "Integration Error"
|
||||
) {
|
||||
getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 7 });
|
||||
callToast("warning", payload, extra?.duration ?? 7);
|
||||
return;
|
||||
}
|
||||
if (title === "Server Error") {
|
||||
getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 8 });
|
||||
callToast("error", payload, extra?.duration ?? 8);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
|
|
@ -341,30 +368,29 @@ const NotificationManager = {
|
|||
title === "Error" ||
|
||||
title === "Already Exists"
|
||||
) {
|
||||
getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 6 });
|
||||
callToast("error", payload, extra?.duration ?? 6);
|
||||
return;
|
||||
}
|
||||
getNotification().info({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 4 });
|
||||
callToast("info", payload, extra?.duration ?? 4);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-error: success/info/warning classifier
|
||||
const cls = classifyGeneralMessage(description);
|
||||
const payload = { ...base, message: cls?.title ?? "Info" };
|
||||
const payload: NotificationConfigResolved = { ...base, message: cls?.title ?? "Info" };
|
||||
|
||||
if (cls?.kind === "success") {
|
||||
getNotification().success({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 3.5 });
|
||||
callToast("success", payload, extra?.duration ?? 3.5);
|
||||
return;
|
||||
}
|
||||
if (cls?.kind === "warning") {
|
||||
getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 6 });
|
||||
callToast("warning", payload, extra?.duration ?? 6);
|
||||
return;
|
||||
}
|
||||
getNotification().info({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 4 });
|
||||
callToast("info", payload, extra?.duration ?? 4);
|
||||
},
|
||||
|
||||
clear() {
|
||||
getNotification().destroy();
|
||||
toast.dismiss();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { notification, message } from "antd";
|
||||
import { setNotificationInstance } from "@/components/molecules/notifications_manager";
|
||||
import { setMessageInstance } from "@/components/molecules/message_manager";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Legacy wrapper kept for backwards-compatibility during the phase-1
|
||||
* shadcn migration. Previously registered antd notification/message
|
||||
* instances with the global managers. The managers now delegate to
|
||||
* sonner (rendered globally via `<Toaster />` in the root layout), so
|
||||
* this component is a passthrough.
|
||||
*
|
||||
* Will be deleted entirely in the "drop AntdGlobalProvider" cleanup
|
||||
* task after antd has been uninstalled.
|
||||
*/
|
||||
export default function AntdGlobalProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notificationApi, notificationContextHolder] = notification.useNotification();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) {
|
||||
setNotificationInstance(notificationApi);
|
||||
setMessageInstance(messageApi);
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [notificationApi, messageApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{notificationContextHolder}
|
||||
{messageContextHolder}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue