Release govoplan-notifications v0.1.20: batch attempts and unify multi-select filters

This commit is contained in:
2026-09-08 01:32:46 +02:00
parent 713f2d3c63
commit ca22d9e706
10 changed files with 313 additions and 52 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react";
import {
ActionBlockerHint,
@@ -8,7 +8,7 @@ import {
CountBadge,
DismissibleAlert,
DocumentationHelpLink,
SegmentedControl,
MultiSelectFilter,
SelectionList,
SelectionListItem,
StatePanel,
@@ -31,7 +31,6 @@ import {
} from "./interfacePatterns";
type StatusFilter =
| "all"
| "pending"
| "queued"
| "sending"
@@ -43,7 +42,6 @@ type StatusFilter =
| "cancelled";
const statusFilters: StatusFilter[] = [
"all",
"pending",
"queued",
"sending",
@@ -58,9 +56,22 @@ const statusFilters: StatusFilter[] = [
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
const scopeKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id, auth.scopes]);
const currentScope = useRef(scopeKey);
const scopeGeneration = useRef(0);
if (currentScope.current !== scopeKey) {
currentScope.current = scopeKey;
scopeGeneration.current += 1;
}
const generation = scopeGeneration.current;
const isCurrentScope = () => currentScope.current === scopeKey && scopeGeneration.current === generation;
const operationScope = useRef<string | null>(null);
const [loadedScope, setLoadedScope] = useState(scopeKey);
const [loadedNotifications, setNotifications] = useState<NotificationMessage[]>([]);
const notifications = loadedScope === scopeKey ? loadedNotifications : [];
const [selectedId, setSelectedId] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [statusFilter, setStatusFilter] = useState<string[] | null>(null);
const loadRequest = useRef<AbortController | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -73,76 +84,110 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
const commonSelectionReason = busy
? NOTIFICATIONS_I18N.actionActive
: !selected
? NOTIFICATIONS_I18N.selectionRequired
: !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: undefined;
: loading
? "i18n:govoplan-notifications.loading_notifications"
: !selected
? NOTIFICATIONS_I18N.selectionRequired
: !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: undefined;
const markReadDisabledReason = commonSelectionReason ?? (selected?.read_at ? NOTIFICATIONS_I18N.alreadyRead : undefined);
const acknowledgeDisabledReason = commonSelectionReason ?? (selected?.acknowledged_at ? NOTIFICATIONS_I18N.alreadyAcknowledged : undefined);
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
const dispatchDisabledReason = busy
? NOTIFICATIONS_I18N.actionActive
: !canDispatch
? NOTIFICATIONS_I18N.dispatchPermissionRequired
: undefined;
: loading
? "i18n:govoplan-notifications.loading_notifications"
: !canDispatch
? NOTIFICATIONS_I18N.dispatchPermissionRequired
: undefined;
useEffect(() => {
operationScope.current = null;
setBusy(false);
setConfirmingAction(null);
}, [scopeKey]);
useEffect(() => {
if (!canRead) {
loadRequest.current?.abort();
setNotifications([]);
setSelectedId("");
setLoading(false);
return;
}
void load();
}, [canRead, settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusFilter]);
return () => loadRequest.current?.abort();
}, [canRead, scopeKey, statusFilter]);
async function load() {
if (!isCurrentScope() || !canRead) return;
loadRequest.current?.abort();
const request = new AbortController();
loadRequest.current = request;
setLoading(true);
setError("");
try {
const response = await listNotifications(settings, {
status: statusFilter === "all" ? undefined : statusFilter,
status: statusFilter ?? undefined,
limit: 200
});
}, request.signal);
if (request.signal.aborted || !isCurrentScope()) return;
setLoadedScope(scopeKey);
setNotifications(response.notifications);
setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? "");
} catch (err) {
setError(errorMessage(err));
if (!request.signal.aborted && isCurrentScope()) setError(errorMessage(err));
} finally {
setLoading(false);
if (!request.signal.aborted && isCurrentScope()) setLoading(false);
}
}
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
if (!selected || !canWrite) return false;
if (!selected || !canWrite || loading || operationScope.current === scopeKey) return false;
operationScope.current = scopeKey;
loadRequest.current?.abort();
setBusy(true);
setError("");
try {
const next = await updateNotification(settings, selected.id, { status });
setNotifications((items) => items.map((item) => item.id === next.id ? next : item));
if (!isCurrentScope()) return false;
setNotifications((items) => items.map((item) => item.id === next.id ? next : item)
.filter((item) => statusFilter === null || statusFilter.includes(item.status)));
notifyNotificationsChanged();
return true;
} catch (err) {
setError(errorMessage(err));
if (isCurrentScope()) setError(errorMessage(err));
return false;
} finally {
setBusy(false);
if (isCurrentScope()) {
operationScope.current = null;
setBusy(false);
}
}
}
async function runDelivery(): Promise<boolean> {
if (!canDispatch) return false;
if (!canDispatch || loading || operationScope.current === scopeKey) return false;
operationScope.current = scopeKey;
loadRequest.current?.abort();
setBusy(true);
setError("");
try {
await deliverPendingNotifications(settings, 50);
if (!isCurrentScope()) return false;
await load();
if (!isCurrentScope()) return false;
notifyNotificationsChanged();
return true;
} catch (err) {
setError(errorMessage(err));
if (isCurrentScope()) setError(errorMessage(err));
return false;
} finally {
setBusy(false);
if (isCurrentScope()) {
operationScope.current = null;
setBusy(false);
}
}
}
@@ -204,13 +249,13 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
{unreadCount > 0 ? <CountBadge>{unreadCount}</CountBadge> : null}
</div>}
/>
<SegmentedControl
<MultiSelectFilter
className="notifications-status-filter"
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
options={statusFilters.map((status) => ({ value: status, label: statusLabel(status) }))}
value={statusFilter}
onChange={setStatusFilter}
ariaLabel="i18n:govoplan-notifications.notification_status"
width="fill"
label="i18n:govoplan-notifications.notification_status"
disabled={busy}
/>
<div className="notifications-list">
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}