Add navigation and smooth scrolling for alert toasts
This update enables opening specific alerts via toast buttons, utilizing `useNavigate` to redirect and auto-expand the corresponding alert on the Alerts page. Includes enhancements for toast dismissal with animations and adds new styles for smooth transitions and better user interaction.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "./state";
|
import { useAuth } from "./state";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
@@ -18,6 +18,7 @@ function Protected({ children }) {
|
|||||||
|
|
||||||
function Layout({ children }) {
|
function Layout({ children }) {
|
||||||
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast } = useAuth();
|
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,12 +94,32 @@ function Layout({ children }) {
|
|||||||
{children}
|
{children}
|
||||||
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
||||||
{alertToasts.map((toast) => (
|
{alertToasts.map((toast) => (
|
||||||
<div key={toast.id} className={`alert-toast ${toast.severity || "warning"}`}>
|
<div key={toast.id} className={`alert-toast ${toast.severity || "warning"}${toast.closing ? " closing" : ""}`}>
|
||||||
<div className="alert-toast-head">
|
<div className="alert-toast-head">
|
||||||
<strong>{toast.severity === "alert" ? "New Alert" : "New Warning"}</strong>
|
<strong>{toast.severity === "alert" ? "New Alert" : "New Warning"}</strong>
|
||||||
<button type="button" className="toast-close" onClick={() => dismissAlertToast(toast.id)}>
|
<div className="toast-actions">
|
||||||
x
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="toast-view"
|
||||||
|
title="Open in Alerts"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/alerts?open=${encodeURIComponent(toast.alertKey || "")}`);
|
||||||
|
dismissAlertToast(toast.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path
|
||||||
|
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="toast-close" onClick={() => dismissAlertToast(toast.id)}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alert-toast-title">{toast.title}</div>
|
<div className="alert-toast-title">{toast.title}</div>
|
||||||
<div className="alert-toast-target">{toast.target}</div>
|
<div className="alert-toast-target">{toast.target}</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ function buildAlertSuggestions(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AlertsPage() {
|
export function AlertsPage() {
|
||||||
|
const location = useLocation();
|
||||||
const { tokens, refresh, me, alertStatus } = useAuth();
|
const { tokens, refresh, me, alertStatus } = useAuth();
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
const [definitions, setDefinitions] = useState([]);
|
const [definitions, setDefinitions] = useState([]);
|
||||||
@@ -206,6 +208,18 @@ export function AlertsPage() {
|
|||||||
setExpandedKey((prev) => (prev === key ? "" : key));
|
setExpandedKey((prev) => (prev === key ? "" : key));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const openKey = params.get("open");
|
||||||
|
if (!openKey) return;
|
||||||
|
setExpandedKey(openKey);
|
||||||
|
const id = `alert-item-${openKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 120);
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
if (loading) return <div className="card">Loading alerts...</div>;
|
if (loading) return <div className="card">Loading alerts...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -237,6 +251,7 @@ export function AlertsPage() {
|
|||||||
<article
|
<article
|
||||||
className={`alert-item warning ${isOpen ? "is-open" : ""}`}
|
className={`alert-item warning ${isOpen ? "is-open" : ""}`}
|
||||||
key={item.alert_key}
|
key={item.alert_key}
|
||||||
|
id={`alert-item-${item.alert_key.replace(/[^a-zA-Z0-9_-]/g, "-")}`}
|
||||||
onClick={() => toggleExpanded(item.alert_key)}
|
onClick={() => toggleExpanded(item.alert_key)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -287,6 +302,7 @@ export function AlertsPage() {
|
|||||||
<article
|
<article
|
||||||
className={`alert-item alert ${isOpen ? "is-open" : ""}`}
|
className={`alert-item alert ${isOpen ? "is-open" : ""}`}
|
||||||
key={item.alert_key}
|
key={item.alert_key}
|
||||||
|
id={`alert-item-${item.alert_key.replace(/[^a-zA-Z0-9_-]/g, "-")}`}
|
||||||
onClick={() => toggleExpanded(item.alert_key)}
|
onClick={() => toggleExpanded(item.alert_key)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ export function AuthProvider({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dismissAlertToast = (toastId) => {
|
const dismissAlertToast = (toastId) => {
|
||||||
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
|
setAlertToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, closing: true } : t)));
|
||||||
|
setTimeout(() => {
|
||||||
|
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
|
||||||
|
}, 220);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,10 +117,12 @@ export function AuthProvider({ children }) {
|
|||||||
const createdAt = Date.now();
|
const createdAt = Date.now();
|
||||||
const nextToasts = items.slice(0, 4).map((item, idx) => ({
|
const nextToasts = items.slice(0, 4).map((item, idx) => ({
|
||||||
id: `${createdAt}-${idx}-${item.alert_key}`,
|
id: `${createdAt}-${idx}-${item.alert_key}`,
|
||||||
|
alertKey: item.alert_key,
|
||||||
severity: item.severity,
|
severity: item.severity,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
target: item.target_name,
|
target: item.target_name,
|
||||||
message: item.message,
|
message: item.message,
|
||||||
|
closing: false,
|
||||||
}));
|
}));
|
||||||
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
|
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
|
||||||
for (const toast of nextToasts) {
|
for (const toast of nextToasts) {
|
||||||
|
|||||||
@@ -1000,6 +1000,10 @@ td {
|
|||||||
animation: toastIn 0.22s ease;
|
animation: toastIn 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-toast.closing {
|
||||||
|
animation: toastOut 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-toast.warning {
|
.alert-toast.warning {
|
||||||
border-color: #db9125;
|
border-color: #db9125;
|
||||||
background: linear-gradient(180deg, #4a2d0f, #35210d);
|
background: linear-gradient(180deg, #4a2d0f, #35210d);
|
||||||
@@ -1023,6 +1027,24 @@ td {
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-view {
|
||||||
|
border: 1px solid #5d80b1;
|
||||||
|
background: #0f274b;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 7px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-close {
|
.toast-close {
|
||||||
border: 1px solid #5d80b1;
|
border: 1px solid #5d80b1;
|
||||||
background: #0f274b;
|
background: #0f274b;
|
||||||
@@ -1181,6 +1203,17 @@ select:-webkit-autofill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes toastOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.query {
|
.query {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user