fix(notification): clarify query and progress feedback

This commit is contained in:
2026-07-17 10:48:58 +08:00
parent acf13639ef
commit 202f18332f
8 changed files with 256 additions and 2 deletions
+2 -2
View File
@@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
RefineSnackbarProvider,
useNotificationProvider,
} from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
@@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext";
import { useAuthStore } from "@/store/authStore";
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
@@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
resources={[
{
@@ -184,6 +184,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
? "没有找到任何方案"
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
});
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
}
} catch (error) {
console.error("查询请求失败:", error);
@@ -250,6 +250,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
? "没有找到任何方案"
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
});
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
}
} catch (error) {
console.error("查询请求失败:", error);
@@ -88,6 +88,21 @@ const SchemeQuery: React.FC<Props> = ({
});
const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes);
if (nextSchemes.length === 0) {
open?.({
type: "error",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
});
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${nextSchemes.length} 条方案记录`,
});
}
} catch (error: any) {
open?.({
type: "error",
@@ -296,6 +296,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
message: "未找到相关方案",
description: "请尝试更改查询条件",
});
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
}
} catch (error) {
console.error("查询请求失败:", error);
@@ -214,6 +214,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
? "没有找到任何方案"
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
});
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
}
} catch (error) {
console.error("查询请求失败:", error);
@@ -0,0 +1,78 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useSnackbar } from "@refinedev/mui";
import { useAppNotificationProvider } from "./useAppNotificationProvider";
jest.mock("@refinedev/mui", () => ({
useSnackbar: jest.fn(),
}));
const mockedUseSnackbar = useSnackbar as jest.MockedFunction<typeof useSnackbar>;
const TestComponent = ({
cancelMutation,
}: {
cancelMutation?: () => void;
}) => {
const notificationProvider = useAppNotificationProvider();
return (
<button
type="button"
onClick={() =>
notificationProvider.open({
key: "analysis-progress",
type: "progress",
message: "分析中",
undoableTimeout: 3,
cancelMutation,
})
}
>
Open progress
</button>
);
};
describe("useAppNotificationProvider", () => {
const enqueueSnackbar = jest.fn();
const closeSnackbar = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockedUseSnackbar.mockReturnValue({
enqueueSnackbar,
closeSnackbar,
} as unknown as ReturnType<typeof useSnackbar>);
});
it("does not render an undo action for progress notifications without cancellation", async () => {
render(<TestComponent />);
fireEvent.click(screen.getByRole("button", { name: "Open progress" }));
expect(enqueueSnackbar).toHaveBeenCalledTimes(1);
expect(enqueueSnackbar.mock.calls[0][1]).toMatchObject({
key: "analysis-progress",
preventDuplicate: true,
autoHideDuration: 3000,
});
expect(enqueueSnackbar.mock.calls[0][1]).not.toHaveProperty("action");
});
it("keeps undo action for progress notifications with cancellation", async () => {
const cancelMutation = jest.fn();
render(<TestComponent cancelMutation={cancelMutation} />);
fireEvent.click(screen.getByRole("button", { name: "Open progress" }));
const options = enqueueSnackbar.mock.calls[0][1];
expect(options.action).toEqual(expect.any(Function));
const undoAction = render(options.action("progress-key"));
fireEvent.click(within(undoAction.container).getByRole("button"));
expect(cancelMutation).toHaveBeenCalledTimes(1);
expect(closeSnackbar).toHaveBeenCalledWith("progress-key");
});
});
@@ -0,0 +1,137 @@
"use client";
import React, { useEffect, useState } from "react";
import type { NotificationProvider } from "@refinedev/core";
import { useSnackbar } from "@refinedev/mui";
import UndoOutlined from "@mui/icons-material/UndoOutlined";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
type ProgressNotificationProps = {
undoableTimeout: number;
message: string;
};
const ProgressNotification = ({
undoableTimeout,
message,
}: ProgressNotificationProps) => {
const [progress, setProgress] = useState(100);
const [timeCount, setTimeCount] = useState(undoableTimeout);
useEffect(() => {
if (undoableTimeout <= 0 || timeCount <= 0) {
return;
}
const progressStep = 100 / undoableTimeout;
const timer = window.setInterval(() => {
setTimeCount((previous) => Math.max(previous - 1, 0));
setProgress((previous) => Math.max(previous - progressStep, 0));
}, 1000);
return () => {
window.clearInterval(timer);
};
}, [timeCount, undoableTimeout]);
return (
<>
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
color="inherit"
variant="determinate"
value={progress}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography component="div">{timeCount}</Typography>
</Box>
</Box>
<Box
sx={{
marginLeft: "10px",
maxWidth: { xs: "150px", md: "100%" },
}}
>
<Typography variant="subtitle2">{message}</Typography>
</Box>
</>
);
};
export const useAppNotificationProvider = (): NotificationProvider => {
const { closeSnackbar, enqueueSnackbar } = useSnackbar();
return {
open: ({
message,
type,
undoableTimeout,
key,
cancelMutation,
description,
}) => {
if (type === "progress") {
const options: NonNullable<Parameters<typeof enqueueSnackbar>[1]> = {
preventDuplicate: true,
key,
autoHideDuration: (undoableTimeout ?? 0) * 1000,
};
if (cancelMutation) {
options.action = (snackbarKey) => (
<IconButton
onClick={() => {
cancelMutation();
closeSnackbar(snackbarKey);
}}
color="inherit"
>
<UndoOutlined />
</IconButton>
);
}
enqueueSnackbar(
<ProgressNotification
undoableTimeout={undoableTimeout ?? 0}
message={message}
/>,
options,
);
return;
}
enqueueSnackbar(
<Box>
<Typography variant="subtitle2" component="h6">
{description}
</Typography>
<Typography variant="caption" component="p">
{message}
</Typography>
</Box>,
{
key,
variant: type,
},
);
},
close: (key) => {
closeSnackbar(key);
},
};
};