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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/notifications-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:ui-structure": "node scripts/test-notification-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+6 -3
View File
@@ -84,16 +84,19 @@ export type NotificationDeliveryResult = {
errors: string[];
};
export function listNotifications(settings: ApiSettings, filters: { status?: string; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}): Promise<NotificationListResponse> {
export function listNotifications(settings: ApiSettings, filters: { status?: string | string[]; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}, signal?: AbortSignal): Promise<NotificationListResponse> {
const params = new URLSearchParams();
if (filters.status) params.set("status", filters.status);
if (Array.isArray(filters.status)) {
if (filters.status.length === 0) return Promise.resolve({ notifications: [] });
for (const status of filters.status) params.append("status", status);
} else if (filters.status) params.set("status", filters.status);
if (filters.channel) params.set("channel", filters.channel);
if (filters.source_module) params.set("source_module", filters.source_module);
if (filters.recipient_id) params.set("recipient_id", filters.recipient_id);
if (filters.view) params.set("view", filters.view);
if (filters.limit) params.set("limit", String(filters.limit));
const query = params.toString();
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`);
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`, { signal, cache: "no-store" });
}
export function notificationSummary(settings: ApiSettings): Promise<NotificationSummary> {
@@ -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}
+3 -3
View File
@@ -26,11 +26,11 @@
.notifications-status-filter {
width: calc(100% - 16px);
margin: 8px;
overflow-x: auto;
}
.notifications-status-filter .segmented-control-option {
flex: 0 0 auto;
.notifications-status-filter .multi-select-filter-trigger {
width: 100%;
justify-content: space-between;
}
.notifications-list {