mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -72,11 +72,6 @@ const SplitScreenPanel = lazy(() =>
|
||||
default: m.SplitScreenPanel,
|
||||
})),
|
||||
);
|
||||
const AlertManager = lazy(() =>
|
||||
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
|
||||
default: m.AlertManager,
|
||||
})),
|
||||
);
|
||||
|
||||
// Secondary rail panels — load on first open, not with the shell critical path.
|
||||
const SshToolsPanel = lazy(() =>
|
||||
@@ -259,7 +254,6 @@ export function AppShell({
|
||||
// The standalone desktop backend still owns system settings such as the
|
||||
// Tailscale API key, even though it has only one implicit user.
|
||||
const showAdminUI = isAdmin;
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
@@ -396,7 +390,6 @@ export function AppShell({
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
setIsAdmin(info.is_admin);
|
||||
setUserId(info.userId);
|
||||
setShowDonationModal(!!info.show_donation_modal);
|
||||
})
|
||||
.catch(() => setIsAdmin(false));
|
||||
@@ -2913,9 +2906,6 @@ export function AppShell({
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
<AlertManager userId={userId} loggedIn={!!username} />
|
||||
</Suspense>
|
||||
<OnboardingDialog
|
||||
open={showOnboarding}
|
||||
context={{}}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { rbacApi } from "@/main-axios";
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: "webhook" | "ntfy" | "discord";
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getNotificationChannels(): Promise<
|
||||
NotificationChannel[]
|
||||
> {
|
||||
const res = await rbacApi.get("/notification-channels");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export type NotificationChannelPayload = Partial<
|
||||
Omit<NotificationChannel, "config">
|
||||
> & {
|
||||
// When creating/updating the channel the UI may pass a parsed object
|
||||
// for `config` (e.g., { url, username }) — the backend stores it as
|
||||
// a JSON string. Accept either a string or any structured object here.
|
||||
// Use `unknown` to allow the component-local config types to be passed
|
||||
// without importing them into this module.
|
||||
config: string | unknown;
|
||||
};
|
||||
|
||||
export async function createNotificationChannel(
|
||||
data: NotificationChannelPayload,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.post("/notification-channels", data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(
|
||||
id: number,
|
||||
data: NotificationChannelPayload,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.put(`/notification-channels/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteNotificationChannel(id: number): Promise<void> {
|
||||
await rbacApi.delete(`/notification-channels/${id}`);
|
||||
}
|
||||
|
||||
export async function testNotificationChannel(id: number): Promise<void> {
|
||||
const res = await rbacApi.post(`/notification-channels/${id}/test`);
|
||||
const data = res.data as { success?: boolean; error?: string };
|
||||
if (data && data.success === false) {
|
||||
throw new Error(data.error || "Test notification failed");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,5 @@
|
||||
import type { TermixAlert } from "@/types";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// ALERTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getUserAlerts(): Promise<{
|
||||
alerts: TermixAlert[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get(`/alerts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user alerts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAlert(
|
||||
alertId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "dismiss alert");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATES & RELEASES
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import {
|
||||
X,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "@/types";
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getAlertIcon = (type?: string) => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-accent-brand" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-destructive" />;
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-400" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAccentBorderClass = (priority?: string, type?: string): string => {
|
||||
if (priority === "critical" || type === "error")
|
||||
return "border-t-2 border-t-destructive";
|
||||
if (priority === "high" || type === "warning")
|
||||
return "border-t-2 border-t-accent-brand";
|
||||
if (priority === "medium" || type === "success")
|
||||
return "border-t-2 border-t-green-400";
|
||||
return "";
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (
|
||||
priority?: string,
|
||||
): "destructive" | "secondary" | "default" => {
|
||||
switch (priority) {
|
||||
case "critical":
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "secondary";
|
||||
case "low":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadgeVariant = (
|
||||
type?: string,
|
||||
): "destructive" | "secondary" | "default" => {
|
||||
switch (type) {
|
||||
case "error":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "secondary";
|
||||
case "success":
|
||||
case "info":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
export function AlertCard({
|
||||
alert,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: AlertCardProps): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDismiss(alert.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border border-foreground/10 rounded-none bg-card overflow-hidden ${getAccentBorderClass(alert.priority, alert.type)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between px-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<span className="text-sm font-semibold">{alert.title}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(alert.priority || alert.type) && (
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
{alert.priority && (
|
||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||
{alert.priority.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.type && (
|
||||
<Badge
|
||||
variant={getTypeBadgeVariant(alert.type)}
|
||||
className={
|
||||
alert.type === "success" ? "text-green-400" : undefined
|
||||
}
|
||||
>
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-3 mb-3 border border-foreground/10 px-3 py-3 bg-background">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{alert.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
className="gap-1.5 text-accent-brand border-accent-brand/30 hover:border-accent-brand/60"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AlertCard } from "./AlertCard.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AlertManagerProps {
|
||||
userId: string | null;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AlertManager({
|
||||
userId,
|
||||
loggedIn,
|
||||
}: AlertManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && userId) {
|
||||
fetchUserAlerts();
|
||||
}
|
||||
}, [loggedIn, userId]);
|
||||
|
||||
const fetchUserAlerts = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getUserAlerts();
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
const aPriority =
|
||||
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||
const bPriority =
|
||||
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
|
||||
return (
|
||||
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch {
|
||||
toast.error(t("alerts.failedToLoad"));
|
||||
setError(t("alerts.failedToLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
try {
|
||||
await dismissAlert(alertId);
|
||||
|
||||
setAlerts((prev) => {
|
||||
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||
return newAlerts;
|
||||
});
|
||||
|
||||
setCurrentAlertIndex((prevIndex) => {
|
||||
const newAlertsLength = alerts.length - 1;
|
||||
if (newAlertsLength === 0) return 0;
|
||||
if (prevIndex >= newAlertsLength)
|
||||
return Math.max(0, newAlertsLength - 1);
|
||||
return prevIndex;
|
||||
});
|
||||
} catch {
|
||||
setError(t("alerts.failedToDismiss"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCurrentAlert = () => {
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
} else {
|
||||
setAlerts([]);
|
||||
setCurrentAlertIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousAlert = () => {
|
||||
if (currentAlertIndex > 0) {
|
||||
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextAlert = () => {
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loggedIn || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentAlert = alerts[currentAlertIndex];
|
||||
|
||||
if (!currentAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 z-[99999]">
|
||||
<div className="w-full max-w-2xl mx-4 flex flex-col gap-2">
|
||||
{hasMultipleAlerts && (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="border border-destructive/50 rounded-md px-3 py-2 text-destructive text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
-23
@@ -2271,27 +2271,6 @@
|
||||
}
|
||||
},
|
||||
"terminalToolbar": {},
|
||||
"alerts": {
|
||||
"addChannel": "Add Channel",
|
||||
"editChannel": "Edit Channel",
|
||||
"channelName": "Name",
|
||||
"channelType": "Type",
|
||||
"channelNameRequired": "Name is required",
|
||||
"webhookUrl": "URL",
|
||||
"webhookUrlRequired": "Webhook URL is required",
|
||||
"webhookDesc": "POST JSON payload to this URL on each alert firing",
|
||||
"ntfyServer": "Server URL",
|
||||
"ntfyTopic": "Topic",
|
||||
"ntfyTopicRequired": "Topic is required",
|
||||
"ntfyToken": "Access Token (optional)",
|
||||
"channelSaveFailed": "Failed to save channel",
|
||||
"discordUrlInvalid": "Discord URL must be a valid Discord webhook URL",
|
||||
"discordWebhookDesc": "POST a Discord embed to this webhook URL on each alert firing",
|
||||
"discordUsername": "Display name (optional)",
|
||||
"discordAvatar": "Avatar URL (optional)",
|
||||
"failedToLoad": "Failed to load alerts",
|
||||
"failedToDismiss": "Failed to dismiss alert"
|
||||
},
|
||||
"macros": {
|
||||
"title": "Macros",
|
||||
"description": "Run a saved series of commands in a terminal, and react to what the server sends back.",
|
||||
@@ -2450,8 +2429,12 @@
|
||||
"consequence": "Including activity from other users, not just yours."
|
||||
},
|
||||
"notify:send": {
|
||||
"title": "Send notifications",
|
||||
"consequence": "Through the channels you have already set up."
|
||||
"title": "Send alerts",
|
||||
"consequence": "To you or other users, and through the channels they set up."
|
||||
},
|
||||
"notify:hub": {
|
||||
"title": "Receive every alert",
|
||||
"consequence": "Stores and delivers the alerts all plugins send, including their contents."
|
||||
},
|
||||
"audit:read": {
|
||||
"title": "Read the audit log",
|
||||
|
||||
@@ -1297,8 +1297,6 @@ export {
|
||||
} from "@/api/admin-user-data-api";
|
||||
|
||||
export {
|
||||
getUserAlerts,
|
||||
dismissAlert,
|
||||
getReleasesRSS,
|
||||
getVersionInfo,
|
||||
releaseUrlFrom,
|
||||
|
||||
@@ -130,6 +130,8 @@ export function createPluginApp(
|
||||
hidden: item.hidden,
|
||||
after: item.after,
|
||||
order: item.order,
|
||||
placement: item.placement,
|
||||
useBadge: item.useBadge,
|
||||
pluginId,
|
||||
permission: item.permission
|
||||
? resolvePluginPermission(pluginId, item.permission)
|
||||
|
||||
@@ -150,16 +150,6 @@ export {
|
||||
type VersionInfo,
|
||||
} from "@/api/system-status-api";
|
||||
|
||||
// Notification channels are core (ctx.notify on the backend). A plugin that
|
||||
// lets users pick or manage them shows core's own dialog.
|
||||
export { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
|
||||
export {
|
||||
getNotificationChannels,
|
||||
deleteNotificationChannel,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
|
||||
// Keyboard hints, the connection log panel and a named console logger.
|
||||
export { Kbd, KbdKey, KbdSeparator } from "@/components/kbd";
|
||||
export { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel";
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { RailView } from "@/sidebar/AppRail";
|
||||
import { useRailItems } from "@/sidebar/rail-items";
|
||||
import type { SplitMode } from "@/types/ui-types";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
import { RailBadge } from "@/sidebar/RailBadge";
|
||||
|
||||
export function MobileBottomBar({
|
||||
railView,
|
||||
@@ -75,7 +76,15 @@ export function MobileBottomBar({
|
||||
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
|
||||
${active ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<span className="relative">
|
||||
<Icon className="size-5" />
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2.5"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="max-w-full truncate px-0.5">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
@@ -92,7 +101,19 @@ export function MobileBottomBar({
|
||||
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
|
||||
${moreActive ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
<span className="relative">
|
||||
<MoreHorizontal className="size-5" />
|
||||
{moreItems.map((item) =>
|
||||
item.useBadge ? (
|
||||
<RailBadge
|
||||
key={item.id}
|
||||
useBadge={item.useBadge}
|
||||
variant="dot"
|
||||
className="-top-0.5 -right-1"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</span>
|
||||
<span>{t("common.more")}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -115,6 +136,11 @@ export function MobileBottomBar({
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{t(item.labelKey)}
|
||||
{item.useBadge && (
|
||||
<span className="relative ml-auto w-5 h-3.5">
|
||||
<RailBadge useBadge={item.useBadge} className="right-0" />
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -775,7 +775,6 @@ export function AdminSettingsPanel({
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.pluginItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("admin.importCompleted", { total, skipped: s.skippedItems || 0 }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { Skeleton } from "@/components/skeleton";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -36,8 +37,15 @@ type RailItem =
|
||||
dot?: boolean;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| {
|
||||
kind: "tab";
|
||||
tabType: TabType;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(
|
||||
@@ -55,6 +63,7 @@ function buildRailButtons(
|
||||
tabType: item.id as TabType,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
} else {
|
||||
all.push({
|
||||
@@ -64,6 +73,7 @@ function buildRailButtons(
|
||||
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
}
|
||||
if (item.separatorAfter) all.push({ kind: "separator" });
|
||||
@@ -202,7 +212,15 @@ export function AppRail({
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const railButtons = buildRailButtons(railItems, splitMode, t, hiddenTabs);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems.filter((item) => item.placement !== "footer"),
|
||||
splitMode,
|
||||
t,
|
||||
hiddenTabs,
|
||||
);
|
||||
const footerItems = railItems.filter(
|
||||
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
@@ -265,10 +283,16 @@ export function AppRail({
|
||||
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
@@ -318,10 +342,16 @@ export function AppRail({
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
@@ -373,6 +403,70 @@ export function AppRail({
|
||||
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
||||
/>
|
||||
)}
|
||||
{footerItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={(e) => {
|
||||
if (item.kind === "tab") {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.id as RailView);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.id as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable || item.rightDockable)
|
||||
setMenuTarget({
|
||||
view: item.id as RailView,
|
||||
title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={
|
||||
item.promotable || item.rightDockable ? "" : undefined
|
||||
}
|
||||
title={
|
||||
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.id
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(
|
||||
[
|
||||
{
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNotificationChannel,
|
||||
updateNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel: NotificationChannel | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ChannelType = "webhook" | "ntfy" | "discord";
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface DiscordConfig {
|
||||
url: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
onSaved,
|
||||
}: NotificationChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<ChannelType>("webhook");
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
const [ntfyUrl, setNtfyUrl] = useState("https://ntfy.sh");
|
||||
const [ntfyTopic, setNtfyTopic] = useState("");
|
||||
const [ntfyToken, setNtfyToken] = useState("");
|
||||
const [discordUsername, setDiscordUsername] = useState("");
|
||||
const [discordAvatarUrl, setDiscordAvatarUrl] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (channel) {
|
||||
setName(channel.name);
|
||||
setType(channel.type);
|
||||
try {
|
||||
const cfg = JSON.parse(channel.config);
|
||||
if (channel.type === "webhook") {
|
||||
setWebhookUrl((cfg as WebhookConfig).url ?? "");
|
||||
} else if (channel.type === "discord") {
|
||||
const dc = cfg as DiscordConfig;
|
||||
setWebhookUrl(dc.url ?? "");
|
||||
setDiscordUsername(dc.username ?? "");
|
||||
setDiscordAvatarUrl(dc.avatar_url ?? "");
|
||||
} else {
|
||||
const nc = cfg as NtfyConfig;
|
||||
setNtfyUrl(nc.url ?? "https://ntfy.sh");
|
||||
setNtfyTopic(nc.topic ?? "");
|
||||
setNtfyToken(nc.token ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed config
|
||||
}
|
||||
} else {
|
||||
setName("");
|
||||
setType("webhook");
|
||||
setWebhookUrl("");
|
||||
setNtfyUrl("https://ntfy.sh");
|
||||
setNtfyTopic("");
|
||||
setNtfyToken("");
|
||||
}
|
||||
}, [open, channel]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("alerts.channelNameRequired", "Name is required"));
|
||||
return;
|
||||
}
|
||||
let config: WebhookConfig | NtfyConfig | DiscordConfig;
|
||||
if (type === "webhook") {
|
||||
if (!webhookUrl.trim()) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
return;
|
||||
}
|
||||
config = {
|
||||
url: webhookUrl.trim(),
|
||||
} satisfies WebhookConfig;
|
||||
} else if (type === "discord") {
|
||||
const trimmedUrl = webhookUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
|
||||
trimmedUrl,
|
||||
)
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"alerts.discordUrlInvalid",
|
||||
"Discord URL must be a valid Discord webhook URL",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const dc: DiscordConfig = { url: trimmedUrl };
|
||||
if (discordUsername.trim()) dc.username = discordUsername.trim();
|
||||
if (discordAvatarUrl.trim()) dc.avatar_url = discordAvatarUrl.trim();
|
||||
config = dc;
|
||||
} else {
|
||||
if (!ntfyTopic.trim()) {
|
||||
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
|
||||
return;
|
||||
}
|
||||
const cfg: NtfyConfig = { url: ntfyUrl.trim(), topic: ntfyTopic.trim() };
|
||||
if (ntfyToken.trim()) cfg.token = ntfyToken.trim();
|
||||
config = cfg;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (channel) {
|
||||
await updateNotificationChannel(channel.id, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
await createNotificationChannel({ name: name.trim(), type, config });
|
||||
}
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error(t("alerts.channelSaveFailed", "Failed to save channel"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{channel
|
||||
? t("alerts.editChannel", "Edit Channel")
|
||||
: t("alerts.addChannel", "Add Channel")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channelName", "Name")}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channelType", "Type")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["webhook", "ntfy", "discord"] as ChannelType[]).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
onClick={() => setType(ct)}
|
||||
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30"}`}
|
||||
>
|
||||
{ct === "webhook"
|
||||
? "Webhook"
|
||||
: ct === "ntfy"
|
||||
? "ntfy"
|
||||
: "Discord"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "webhook" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.webhookUrl", "URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"alerts.webhookDesc",
|
||||
"POST JSON payload to this URL on each alert firing",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "discord" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.webhookUrl", "Discord webhook URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"alerts.discordWebhookDesc",
|
||||
"POST a Discord embed to this webhook URL on each alert firing",
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.discordUsername", "Display name (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
value={discordUsername}
|
||||
onChange={(e) => setDiscordUsername(e.target.value)}
|
||||
placeholder="Termix"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.discordAvatar", "Avatar URL (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
value={discordAvatarUrl}
|
||||
onChange={(e) => setDiscordAvatarUrl(e.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "ntfy" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyServer", "Server URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={ntfyUrl}
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
placeholder="https://ntfy.sh"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyTopic", "Topic")}
|
||||
</label>
|
||||
<Input
|
||||
value={ntfyTopic}
|
||||
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||
placeholder="my-alerts"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyToken", "Access Token (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={ntfyToken}
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
placeholder="tk_..."
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
{saving
|
||||
? t("common.saving", "Saving...")
|
||||
: t("common.save", "Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The count a rail item shows on its icon. Each badge is its own component so
|
||||
* the item's useBadge hook runs once per render of a stable element.
|
||||
*/
|
||||
export function RailBadge({
|
||||
useBadge,
|
||||
variant = "count",
|
||||
className = "",
|
||||
}: {
|
||||
useBadge: () => number | null | undefined;
|
||||
variant?: "count" | "dot";
|
||||
className?: string;
|
||||
}) {
|
||||
const count = useBadge();
|
||||
if (!count || count <= 0) return null;
|
||||
if (variant === "dot") {
|
||||
return (
|
||||
<span
|
||||
className={`absolute size-1.5 rounded-full bg-accent-brand ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`absolute min-w-[14px] h-[14px] px-[3px] rounded-full bg-accent-brand text-[9px] font-bold leading-[14px] text-center text-white tabular-nums ${className}`}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1270,7 +1270,6 @@ export function UserProfilePanel({
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.pluginItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("newUi.sidebar.userProfile.importCompleted", {
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface RailItemDef {
|
||||
pluginId?: string;
|
||||
/** Role permission the user needs to see it at all, as a full id. */
|
||||
permission?: string;
|
||||
/** "footer" renders it at the bottom of the rail, above the profile. */
|
||||
placement?: "main" | "footer";
|
||||
/** Called as a hook by the rail; a positive number shows as a badge. */
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Bell, Boxes } from "lucide-react";
|
||||
import { AppRail } from "../../sidebar/AppRail";
|
||||
import { RailBadge } from "../../sidebar/RailBadge";
|
||||
import {
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
} from "../../sidebar/rail-items";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({ has: () => true, loaded: true }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderRail(onRailClick = vi.fn()) {
|
||||
render(
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
splitMode="none"
|
||||
username="alice"
|
||||
isAdmin={false}
|
||||
onRailClick={onRailClick}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return onRailClick;
|
||||
}
|
||||
|
||||
describe("AppRail", () => {
|
||||
it("puts a footer item above the profile with its badge", async () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
icon: Bell,
|
||||
labelKey: "test.inbox",
|
||||
placement: "footer",
|
||||
useBadge: () => 3,
|
||||
pluginId: "demo",
|
||||
});
|
||||
registerRailItem({
|
||||
id: "boxes",
|
||||
icon: Boxes,
|
||||
labelKey: "test.boxes",
|
||||
pluginId: "demo",
|
||||
});
|
||||
const onRailClick = renderRail();
|
||||
|
||||
const inbox = screen.getByTitle("test.inbox");
|
||||
const profile = screen.getAllByTitle("nav.userProfile")[0];
|
||||
const boxes = screen.getByTitle("test.boxes");
|
||||
// Footer items render after the main list and before the profile.
|
||||
expect(
|
||||
boxes.compareDocumentPosition(inbox) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
inbox.compareDocumentPosition(profile) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(inbox.textContent).toContain("3");
|
||||
|
||||
await userEvent.click(inbox);
|
||||
expect(onRailClick).toHaveBeenCalledWith("inbox");
|
||||
});
|
||||
|
||||
it("hides a footer item the user hid from Navigation", () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["inbox"]));
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
icon: Bell,
|
||||
labelKey: "test.inbox",
|
||||
placement: "footer",
|
||||
pluginId: "demo",
|
||||
});
|
||||
renderRail();
|
||||
expect(screen.queryByTitle("test.inbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RailBadge", () => {
|
||||
it("shows nothing for zero", () => {
|
||||
const { container } = render(<RailBadge useBadge={() => 0} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("caps a large count", () => {
|
||||
render(<RailBadge useBadge={() => 150} />);
|
||||
expect(screen.getByText("99+")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user