revert: drop the alert feed widget, the notification center replaces it

This commit is contained in:
LukeGus committed 2026-09-30 16:19:40 -05:00
1 parent 84c94bf9a6
commit 0d94ad5e5f
11 files changed
+5 -319

No files matched your search

-8
View File
@@ -160,13 +160,5 @@
"save": "Save",
"saving": "Saving...",
"delete": "Delete"
},
"widget": {
"name": "Alert Feed",
"description": "Your latest alerts, with a quick way to mark them read",
"maxItems": "Max items",
"showRead": "Show read alerts",
"allClear": "All clear",
"loading": "Loading..."
}
}
+1 -2
View File
@@ -141,8 +141,7 @@
]
}
]
},
"uses": ["homepage.widgets"]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -1,197 +0,0 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
AlertTriangle,
Bell,
Check,
CheckCircle,
Info,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@termix/plugin-sdk/ui";
import type { AlertItem } from "../types";
import type { AlertsApi } from "./api";
import { timeAgo } from "./format";
import { useAlertsVersion, type AlertsStore } from "./store";
export interface AlertFeedConfig {
maxItems: number;
showRead: boolean;
/** 2.8 name for showRead, still found in saved homepages. */
showAcknowledged?: boolean;
}
export const DEFAULT_ALERT_FEED_CONFIG: AlertFeedConfig = {
maxItems: 10,
showRead: false,
};
function SeverityIcon({ severity }: { severity: AlertItem["severity"] }) {
if (severity === "critical")
return <AlertCircle size={11} className="shrink-0 text-red-500" />;
if (severity === "warning")
return <AlertTriangle size={11} className="shrink-0 text-amber-500" />;
if (severity === "success")
return <CheckCircle size={11} className="shrink-0 text-green-500" />;
return <Info size={11} className="shrink-0 text-blue-400" />;
}
export function showsRead(config: Partial<AlertFeedConfig>): boolean {
return config.showRead ?? config.showAcknowledged ?? false;
}
export function createAlertFeedWidget(api: AlertsApi, store: AlertsStore) {
function AlertFeedWidget({
widget,
config,
}: {
widget: { title?: string | null };
config: AlertFeedConfig;
}) {
const { t, language } = useTranslation();
const version = useAlertsVersion(store);
const maxItems = Math.max(1, config.maxItems || 10);
const showRead = showsRead(config);
const [items, setItems] = useState<AlertItem[] | null>(null);
useEffect(() => {
let cancelled = false;
api
.items({ unread: showRead ? undefined : true, limit: maxItems })
.then((result) => {
if (!cancelled) setItems(result.items.slice(0, maxItems));
})
.catch(() => {
if (!cancelled) setItems([]);
});
return () => {
cancelled = true;
};
}, [maxItems, showRead, version]);
const markRead = async (id: number) => {
try {
await api.setRead([id]);
setItems((prev) =>
(prev ?? []).flatMap((item) =>
item.id !== id
? [item]
: showRead
? [{ ...item, readAt: new Date().toISOString() }]
: [],
),
);
} catch {
// the next reload shows the real state
}
};
if (items === null) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("widget.loading")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 text-[11px] font-semibold text-muted-foreground">
<Bell size={11} />
<span className="truncate">{widget.title || t("widget.name")}</span>
</div>
{items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<CheckCircle size={20} className="text-green-500" />
<span className="text-xs text-muted-foreground">
{t("widget.allClear")}
</span>
</div>
) : (
<div className="flex-1 overflow-auto">
{items.map((item) => (
<div
key={item.id}
className={`flex items-start gap-2 px-2 py-1.5 border-b border-border/30 ${item.readAt ? "opacity-50" : ""}`}
>
<SeverityIcon severity={item.severity} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[10px] font-semibold text-foreground truncate">
{item.title}
</span>
<span className="text-[9px] text-muted-foreground truncate">
{timeAgo(item.createdAt, language)}
</span>
</div>
{!item.readAt && (
<button
type="button"
title={t("inbox.markRead")}
onClick={() => void markRead(item.id)}
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground"
>
<Check size={11} />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
return AlertFeedWidget;
}
export function AlertFeedEditForm({
config,
onChange,
}: {
config: AlertFeedConfig;
onChange: (config: AlertFeedConfig) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("widget.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.min(50, Math.max(1, Number(e.target.value))),
})
}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={showsRead(config)}
onChange={(e) =>
onChange({
...config,
showRead: e.target.checked,
showAcknowledged: undefined,
})
}
className="accent-accent-brand"
/>
{t("widget.showRead")}
</label>
</div>
);
}
/** The widget's name in the homepage add menu, in this plugin's locale. */
export function AlertFeedLabel({ part }: { part: "name" | "description" }) {
const { t } = useTranslation();
return <>{t(part === "name" ? "widget.name" : "widget.description")}</>;
}
-26
View File
@@ -10,16 +10,6 @@ import { AlertsView } from "./AlertsView";
import { createSectionRequests } from "./sections";
import { InboxView } from "./InboxView";
import { createAlertsStore, useUnreadCount } from "./store";
import { createAlertsApi } from "./api";
import {
AlertFeedEditForm,
AlertFeedLabel,
DEFAULT_ALERT_FEED_CONFIG,
createAlertFeedWidget,
} from "./AlertFeedWidget";
// The homepage grid step, in pixels.
const GRID_SIZE = 30;
const VIEW_ID = "alerts";
@@ -81,22 +71,6 @@ export function activate(app: TermixApp): void {
component: Toaster as ComponentType<Record<string, unknown>>,
});
app.registerExtension("homepage.widgets", {
id: "alert_feed",
name: "Alert Feed",
description: "Your latest alerts",
category: "monitoring",
icon: <Bell size={14} />,
defaultConfig: DEFAULT_ALERT_FEED_CONFIG,
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 5 },
components: {
view: createAlertFeedWidget(createAlertsApi(app.api), store) as never,
editForm: AlertFeedEditForm as never,
label: AlertFeedLabel as never,
},
});
app.registerAction("alerts.open", () => openTab());
app.registerAction("alerts.openChannels", () => {
sections.request("channels");
@@ -148,50 +148,3 @@ describe("the alerts panel", () => {
);
});
});
describe("the alert feed homepage widget", () => {
it("registers under the 2.8 widget id", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.extensions("homepage.widgets")).toEqual([
"alert_feed",
]);
});
it("shows unread alerts and marks one read", async () => {
const { api, calls } = stubApi();
rendered = await renderWithApp(plugin, { manifest, locales, api });
const view = rendered.renderExtension(
"homepage.widgets",
"alert_feed",
"view",
{
widget: { title: null },
config: { maxItems: 5, showRead: false },
},
);
await waitFor(() =>
expect(view.textContent).toContain("Certificate renewal failed"),
);
expect(calls).toContainEqual(["GET", "/items?unread=true&limit=5"]);
fireEvent.click(view.querySelector('[title="Mark as read"]')!);
await waitFor(() =>
expect(calls).toContainEqual([
"POST",
"/items/read",
{ ids: [2], read: true },
]),
);
});
it("reads the 2.8 showAcknowledged option", async () => {
const { api, calls } = stubApi();
rendered = await renderWithApp(plugin, { manifest, locales, api });
rendered.renderExtension("homepage.widgets", "alert_feed", "view", {
widget: {},
config: { maxItems: 3, showAcknowledged: true },
});
await waitFor(() =>
expect(calls).toContainEqual(["GET", "/items?limit=3"]),
);
});
});
+2 -2
View File
@@ -18,7 +18,6 @@
"homepage": {
"title": "Homepage",
"addWidget": "Add Widget",
"noWidgetsInCategory": "No widgets in this category",
"editWidget": "Edit Widget",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
@@ -169,7 +168,8 @@
"noServices": "No services set up",
"configureLocation": "Set a location in the widget settings",
"bannerTextPlaceholder": "Banner text",
"serviceNamePlaceholder": "My service"
"serviceNamePlaceholder": "My service",
"noWidgetsInCategory": "No widgets in this category"
},
"permissions": {
"use": {
@@ -136,19 +136,9 @@ export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
>
<span className="text-accent-brand shrink-0">{type.icon}</span>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">
{type.labelComponent ? (
<type.labelComponent part="name" />
) : (
type.name
)}
</span>
<span className="font-medium truncate">{type.name}</span>
<span className="text-[10px] text-muted-foreground truncate">
{type.labelComponent ? (
<type.labelComponent part="description" />
) : (
type.description
)}
{type.description}
</span>
</div>
</button>
-2
View File
@@ -365,8 +365,6 @@ export interface WidgetTypeDefinition<C = Record<string, unknown>> {
minSize: { w: number; h: number };
component: React.ComponentType<WidgetComponentProps<C>>;
editFormComponent?: React.ComponentType<WidgetEditFormProps<C>>;
/** Renders the translated name or description in the add menu. */
labelComponent?: React.ComponentType<{ part: "name" | "description" }>;
}
export interface DragState {
@@ -49,7 +49,6 @@ function toDefinition(
...(rest as unknown as WidgetTypeDefinition),
component: view,
editFormComponent: components?.editForm,
labelComponent: components?.label,
};
}