feat(workbench): refine alert actions and removal transitions
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
|||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRef, useState, type ReactNode } from "react";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import { AnimatePresence } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
||||||
import {
|
import {
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
@@ -138,6 +138,9 @@ export function AlertMenu({
|
|||||||
onToggleConditionFeed: () => void;
|
onToggleConditionFeed: () => void;
|
||||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const keyboardInteraction = useRef(false);
|
||||||
|
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current;
|
||||||
const hasAlerts = alerts.length > 0;
|
const hasAlerts = alerts.length > 0;
|
||||||
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
|
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
|
||||||
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
|
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
|
||||||
@@ -190,7 +193,10 @@ export function AlertMenu({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
|
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}
|
||||||
|
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
|
||||||
|
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
|
||||||
|
>
|
||||||
<AlertQueueSummary
|
<AlertQueueSummary
|
||||||
count={alerts.length}
|
count={alerts.length}
|
||||||
latestAlertTime={latestAlertTime}
|
latestAlertTime={latestAlertTime}
|
||||||
@@ -199,33 +205,42 @@ export function AlertMenu({
|
|||||||
tone={hasAlerts ? alertSummaryTone : "success"}
|
tone={hasAlerts ? alertSummaryTone : "success"}
|
||||||
/>
|
/>
|
||||||
<MenuSectionLabel>待处理事项</MenuSectionLabel>
|
<MenuSectionLabel>待处理事项</MenuSectionLabel>
|
||||||
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
|
<div className="scheduled-feed-scroll relative max-h-[min(52dvh,420px)] overflow-x-hidden overflow-y-auto pr-1">
|
||||||
{hasAlerts ? alerts.map((alert) => {
|
<AnimatePresence initial={false} mode="popLayout" custom={skipRemovalMotion}>
|
||||||
const tone = alert.severity === "warning" ? "warning" : "danger";
|
{hasAlerts ? alerts.map((alert) => {
|
||||||
return (
|
const tone = alert.severity === "warning" ? "warning" : "danger";
|
||||||
<div
|
return (
|
||||||
key={alert.id}
|
<motion.div
|
||||||
className={cn("surface-well my-1 px-3 pb-3 pt-1", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
key={alert.id}
|
||||||
>
|
data-alert-id={alert.id}
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
layout="position"
|
||||||
|
initial={false}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit="removed"
|
||||||
|
variants={{ removed: (skip: boolean) => ({
|
||||||
|
opacity: 0, x: skip ? 0 : 12, pointerEvents: "none",
|
||||||
|
transition: { duration: skip ? 0 : 0.18, ease: [0.4, 0, 1, 1] }
|
||||||
|
}) }}
|
||||||
|
transition={{ duration: skipRemovalMotion ? 0 : 0.18, ease: [0.4, 0, 1, 1] }}
|
||||||
|
className={cn(
|
||||||
|
tone === "warning" ? "status-tone-warning" : "status-tone-danger",
|
||||||
|
"relative my-1 min-h-[88px] border border-transparent py-3 pl-3 pr-[76px] transition-colors hover:border-[var(--status-border)] hover:bg-[var(--action-soft)] focus-within:border-[var(--status-border)] focus-within:bg-[var(--action-soft)]",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
menuReadableRowClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2 text-xs text-[var(--ink-muted)]">
|
<span className="flex min-w-0 items-center gap-2 text-xs text-[var(--ink-muted)]">
|
||||||
<StatusDot tone={tone} size="md" />
|
<StatusDot tone={tone} size="md" />
|
||||||
<span className="font-mono tabular-nums">{alert.time}</span>
|
<span className="font-mono tabular-nums">{alert.time}</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<p className="mt-2 break-words text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
|
||||||
<DropdownMenuItem
|
<p className="mt-1 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
|
||||||
aria-label={`打开通知:${alert.title}`}
|
<div className="absolute bottom-1 right-1 top-1 flex w-16 flex-col items-center justify-between">
|
||||||
className={cn("h-10 justify-center gap-1 px-2 text-xs font-medium text-[var(--action-blue)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
|
|
||||||
onSelect={() => onSelectAlert(alert)}
|
|
||||||
>
|
|
||||||
打开
|
|
||||||
<ChevronRight size={14} aria-hidden="true" />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onRemoveAlert ? (
|
{onRemoveAlert ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
aria-label={`移除通知:${alert.title}`}
|
aria-label={`移除通知:${alert.title}`}
|
||||||
title="移除通知"
|
title="移除通知"
|
||||||
className={cn("h-10 w-10 justify-center p-0 text-[var(--ink-muted)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
|
className={cn("h-10 w-10 shrink-0 justify-center p-0 text-[var(--ink-muted)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onRemoveAlert(alert);
|
onRemoveAlert(alert);
|
||||||
@@ -234,23 +249,29 @@ export function AlertMenu({
|
|||||||
<X size={14} aria-hidden="true" />
|
<X size={14} aria-hidden="true" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
aria-label={`打开通知:${alert.title}`}
|
||||||
|
title="打开通知"
|
||||||
|
className={cn("h-10 w-10 shrink-0 justify-center p-0 text-[var(--action-blue)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
|
||||||
|
onSelect={() => onSelectAlert(alert)}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
</DropdownMenuItem>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div key="empty-alert-queue" className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
</SemanticStatusIcon>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="break-words text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
|
|
||||||
<p className="mt-1 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}) : (
|
</AnimatePresence>
|
||||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
</SemanticStatusIcon>
|
|
||||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
|
|
||||||
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2">
|
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2">
|
||||||
<MenuPlainButton
|
<MenuPlainButton
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||||
import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client";
|
import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client";
|
||||||
|
|
||||||
for (const width of [1280, 375, 320]) {
|
for (const { width, reducedMotion } of [{ width: 1280, reducedMotion: false }, { width: 375, reducedMotion: false }, { width: 320, reducedMotion: false }, { width: 320, reducedMotion: true }]) {
|
||||||
test(`待办和告警可独立移除且刷新后保持 ${width}`, async ({ page }, testInfo) => {
|
test(`待办和告警可独立移除且刷新后保持 ${width}${reducedMotion ? " 减少动态效果" : ""}`, async ({ page }, testInfo) => {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
||||||
const task = investigationFixture(1, "通知移除测试调查");
|
const task = investigationFixture(1, "通知移除测试调查");
|
||||||
const notification: OperationalTaskNotification = {
|
const notification: OperationalTaskNotification = {
|
||||||
notification_id: "96000000-0000-4000-8000-000000000011", task_id: task.task_id,
|
notification_id: "96000000-0000-4000-8000-000000000011", task_id: task.task_id,
|
||||||
@@ -32,25 +33,52 @@ for (const width of [1280, 375, 320]) {
|
|||||||
});
|
});
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
const trigger = page.getByRole("button", { name: /查看待办与告警/ });
|
const trigger = page.getByRole("button", { name: /查看待办与告警/ });
|
||||||
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 2 条待处理事项");
|
// Runtime alerts and task notifications finish loading independently on a cold page.
|
||||||
|
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 2 条待处理事项", { timeout: 15_000 });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const menu = page.getByRole("menu");
|
const menu = page.getByRole("menu");
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
const removeTask = page.getByRole("menuitem", { name: `移除通知:${notification.title}` });
|
const removeTask = page.getByRole("menuitem", { name: `移除通知:${notification.title}` });
|
||||||
await expect(removeTask).toBeVisible();
|
await expect(removeTask).toBeVisible();
|
||||||
const openTask = page.getByRole("menuitem", { name: `打开通知:${notification.title}` });
|
const openTask = page.getByRole("menuitem", { name: `打开通知:${notification.title}` });
|
||||||
|
await expect(openTask).toHaveText("");
|
||||||
const actions = await openTask.evaluate((element) => {
|
const actions = await openTask.evaluate((element) => {
|
||||||
const open = element.getBoundingClientRect();
|
const open = element.getBoundingClientRect();
|
||||||
const remove = element.nextElementSibling!.getBoundingClientRect();
|
const remove = element.previousElementSibling!.getBoundingClientRect();
|
||||||
return { topDifference: Math.abs(open.top - remove.top), gap: remove.left - open.right };
|
return { centerDifference: Math.abs((open.left + open.width / 2) - (remove.left + remove.width / 2)), gap: open.top - remove.bottom };
|
||||||
});
|
});
|
||||||
expect(actions.topDifference).toBeLessThan(1);
|
expect(actions.centerDifference).toBeLessThan(1);
|
||||||
expect(actions.gap).toBeGreaterThanOrEqual(0);
|
expect(actions.gap).toBeGreaterThanOrEqual(0);
|
||||||
const box = await menu.boundingBox();
|
const box = await menu.boundingBox();
|
||||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||||
await testInfo.attach(`alert-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath("alert-menu.png"), style: "[data-sonner-toaster] { visibility: hidden !important; }" }), contentType: "image/png" });
|
await testInfo.attach(`alert-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath("alert-menu.png"), style: "[data-sonner-toaster] { visibility: hidden !important; }" }), contentType: "image/png" });
|
||||||
await removeTask.click();
|
const row = page.locator(`[data-alert-id="operational-task-notification:${notification.notification_id}"]`);
|
||||||
|
if (!reducedMotion) {
|
||||||
|
const exitState = await removeTask.evaluate(async (element) => {
|
||||||
|
const row = element.closest("[data-alert-id]")!;
|
||||||
|
(element as HTMLElement).click();
|
||||||
|
const list = row.parentElement!;
|
||||||
|
let opacity = 1;
|
||||||
|
for (let frame = 0; frame < 30 && opacity === 1; frame++) {
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
|
opacity = Number(getComputedStyle(row).opacity);
|
||||||
|
}
|
||||||
|
const style = getComputedStyle(row);
|
||||||
|
return { retained: row.isConnected, opacity, pointerEvents: style.pointerEvents,
|
||||||
|
overflowX: getComputedStyle(list).overflowX,
|
||||||
|
pageOverflows: document.documentElement.scrollWidth > window.innerWidth };
|
||||||
|
});
|
||||||
|
expect(exitState.retained).toBe(true);
|
||||||
|
expect(exitState.opacity).toBeGreaterThan(0);
|
||||||
|
expect(exitState.opacity).toBeLessThan(1);
|
||||||
|
expect(exitState.pointerEvents).toBe("none");
|
||||||
|
expect(exitState.overflowX).toBe("hidden");
|
||||||
|
expect(exitState.pageOverflows).toBe(false);
|
||||||
|
} else {
|
||||||
|
await removeTask.click();
|
||||||
|
}
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项");
|
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项");
|
||||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user