fix(notification): clarify query and progress feedback
This commit is contained in:
@@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core";
|
|||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||||
import {
|
import {
|
||||||
RefineSnackbarProvider,
|
RefineSnackbarProvider,
|
||||||
useNotificationProvider,
|
|
||||||
} from "@refinedev/mui";
|
} from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||||
@@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
<Refine
|
<Refine
|
||||||
routerProvider={routerProvider}
|
routerProvider={routerProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
notificationProvider={useNotificationProvider}
|
notificationProvider={useAppNotificationProvider}
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
resources={[
|
resources={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
|
|||||||
@@ -250,6 +250,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
const nextSchemes = response.data as LeakageSchemeRecord[];
|
||||||
setSchemes(nextSchemes);
|
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) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
message: "未找到相关方案",
|
message: "未找到相关方案",
|
||||||
description: "请尝试更改查询条件",
|
description: "请尝试更改查询条件",
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
|
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user