feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import type { MapNoticeTone } from "./notice-types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { custom: vi.fn(), dismiss: vi.fn() } }));
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
it.each<MapNoticeTone>(["info", "success", "warning", "error", "loading"])(
|
||||
"keeps actionable %s notices until manual dismissal even with an explicit timeout", async (tone) => {
|
||||
const onAction = vi.fn();
|
||||
const id = showMapNotice({ tone, title: "报告已更新", message: "查看分析结果", duration: 10_000,
|
||||
dismissible: false, actionLabel: "查看报告", onAction });
|
||||
await Promise.resolve();
|
||||
const [content, options] = vi.mocked(toast.custom).mock.calls[0];
|
||||
expect(options).toMatchObject({ duration: Infinity, dismissible: true });
|
||||
render(content(id));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "查看报告" }));
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
expect(toast.dismiss).not.toHaveBeenCalled();
|
||||
expect(options?.cancel).toMatchObject({ label: expect.anything(), onClick: expect.any(Function) });
|
||||
}
|
||||
);
|
||||
|
||||
it("preserves transient feedback and the explicit lifecycle of non-actionable notices", async () => {
|
||||
showMapNotice({ tone: "success", message: "已保存" });
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 3200 });
|
||||
showMapNotice({ tone: "warning", message: "稍后重试", duration: 8000, actionLabel: "缺少回调" });
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 8000 });
|
||||
showMapNotice({ tone: "loading", message: "正在加载" });
|
||||
await Promise.resolve();
|
||||
const [content, options] = vi.mocked(toast.custom).mock.lastCall!;
|
||||
expect(options).toMatchObject({ duration: Infinity, dismissible: false });
|
||||
render(content("loading"));
|
||||
expect(screen.queryByRole("button", { name: "关闭通知" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("publishes after effects and cancels notices dismissed before publication", async () => {
|
||||
showMapNotice({ id: "source", tone: "loading", message: "加载中" });
|
||||
showMapNotice({ id: "source", tone: "success", message: "已完成" });
|
||||
const cancelled = showMapNotice({ tone: "info", message: "已取消" });
|
||||
dismissMapNotice(cancelled);
|
||||
expect(toast.custom).not.toHaveBeenCalled();
|
||||
await Promise.resolve();
|
||||
expect(toast.custom).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ id: "source", duration: 3200 });
|
||||
});
|
||||
@@ -1,46 +1,30 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
|
||||
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
|
||||
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
|
||||
|
||||
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||
|
||||
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||
|
||||
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
|
||||
"top-right": "top-[84px] right-[72px]",
|
||||
"bottom-left": "bottom-12 left-4",
|
||||
"bottom-right": "bottom-12 right-4"
|
||||
};
|
||||
|
||||
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||
"top-center": "top-center",
|
||||
"top-right": "top-right",
|
||||
"bottom-left": "bottom-left",
|
||||
"bottom-right": "bottom-right"
|
||||
};
|
||||
const pendingNotices = new Map<string | number, () => void>();
|
||||
let nextNoticeId = 0;
|
||||
|
||||
export function showMapNotice(options: MapNoticeOptions) {
|
||||
const id = options.id ?? `map-notice:${++nextNoticeId}`;
|
||||
const tone = options.tone ?? "info";
|
||||
const position = options.position ?? DEFAULT_POSITION;
|
||||
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||
const dismissible = options.dismissible ?? tone !== "loading";
|
||||
const hasAction = Boolean(options.actionLabel && options.onAction);
|
||||
// Actionable notices must remain available even when a caller supplies a timeout.
|
||||
const duration = hasAction
|
||||
? Infinity
|
||||
: options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||
const dismissible = hasAction || (options.dismissible ?? tone !== "loading");
|
||||
|
||||
return toast.custom(
|
||||
const publish = () => toast.custom(
|
||||
() => <MapNoticeContent {...options} tone={tone} />,
|
||||
{
|
||||
id: options.id,
|
||||
duration,
|
||||
position: sonnerPositions[position],
|
||||
dismissible,
|
||||
id, duration, position, dismissible,
|
||||
cancel: dismissible
|
||||
? {
|
||||
label: <MapNoticeCloseIcon />,
|
||||
onClick: (event) => event.stopPropagation()
|
||||
}
|
||||
? { label: <MapNoticeCloseIcon />, onClick: (event) => event.stopPropagation() }
|
||||
: undefined,
|
||||
classNames: {
|
||||
cancelButton: cn(
|
||||
@@ -48,11 +32,22 @@ export function showMapNotice(options: MapNoticeOptions) {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)
|
||||
},
|
||||
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
|
||||
className: "w-full"
|
||||
}
|
||||
);
|
||||
// Publish after React finishes effects: Sonner renews its subscription during
|
||||
// those effects and can otherwise miss an alert while map sources update.
|
||||
pendingNotices.set(id, publish);
|
||||
queueMicrotask(() => {
|
||||
if (pendingNotices.get(id) !== publish) return;
|
||||
pendingNotices.delete(id);
|
||||
publish();
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissMapNotice(id?: string | number) {
|
||||
if (id === undefined) pendingNotices.clear();
|
||||
else pendingNotices.delete(id);
|
||||
toast.dismiss(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, ArrowUpRight, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -32,16 +32,16 @@ export function MapNoticeContent({
|
||||
|
||||
return (
|
||||
<MapNoticeFrame tone={tone}>
|
||||
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={
|
||||
actionLabel && onAction ? (
|
||||
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div data-slot="map-notice-summary" className="flex items-start gap-3 py-3.5 pl-4 pr-12">
|
||||
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||
<MapNoticeBody title={title} message={message} />
|
||||
</div>
|
||||
{actionLabel && onAction ? (
|
||||
<div data-slot="map-notice-actions" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pb-3 pl-14 pr-3">
|
||||
<span className="text-xs leading-5 text-[var(--ink-muted)]">保留至手动关闭</span>
|
||||
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||
</div>
|
||||
) : null}
|
||||
</MapNoticeFrame>
|
||||
);
|
||||
}
|
||||
@@ -59,8 +59,11 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
|
||||
return (
|
||||
<div
|
||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||
// Sonner restores its saved focus on blur. Persistent action buttons must
|
||||
// let the user move directly to another workbench control instead.
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
className={cn(
|
||||
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
|
||||
"glass-transient relative w-full overflow-hidden border text-sm text-slate-900",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
)}
|
||||
>
|
||||
@@ -90,23 +93,20 @@ function MapNoticeIcon({
|
||||
|
||||
function MapNoticeBody({
|
||||
title,
|
||||
message,
|
||||
action
|
||||
message
|
||||
}: {
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title ? (
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||
<span className="block break-words text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||
) : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
|
||||
<span className={cn("block break-words text-sm leading-6 text-slate-600", title && "mt-1")}>
|
||||
{message}
|
||||
</span>
|
||||
{action}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,11 +118,12 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
|
||||
"pointer-events-auto h-9 max-w-full gap-1.5 whitespace-normal border-[var(--surface-border)] bg-[var(--action-soft)] px-2.5 text-xs text-[var(--action-blue-hover)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MapNoticeOptions = {
|
||||
title?: string;
|
||||
message: string;
|
||||
tone?: MapNoticeTone;
|
||||
/** Actionable notices always remain until manually dismissed. */
|
||||
duration?: number;
|
||||
position?: MapNoticePosition;
|
||||
actionLabel?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, type CSSProperties } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import type { MapNoticeTone } from "./notice-types";
|
||||
@@ -8,10 +8,15 @@ export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notic
|
||||
export function MapToaster() {
|
||||
return (
|
||||
<Toaster
|
||||
className="map-notice-stack"
|
||||
style={{ fontFamily: "inherit", "--width": "min(420px, calc(100vw - 24px))" } as CSSProperties}
|
||||
containerAriaLabel="系统通知"
|
||||
closeButton={false}
|
||||
expand
|
||||
visibleToasts={4}
|
||||
position="top-center"
|
||||
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
|
||||
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
|
||||
Reference in New Issue
Block a user