diff --git a/pyproject.toml b/pyproject.toml index d720bff..a357cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-notifications" -version = "0.1.19" +version = "0.1.20" description = "GovOPlaN notification inbox and delivery module." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.45", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_notifications/backend/manifest.py b/src/govoplan_notifications/backend/manifest.py index c0a6fc0..d99eec8 100644 --- a/src/govoplan_notifications/backend/manifest.py +++ b/src/govoplan_notifications/backend/manifest.py @@ -37,7 +37,7 @@ from govoplan_notifications.backend.dsar_provider import ( MODULE_ID = "notifications" MODULE_NAME = "Notifications" -MODULE_VERSION = "0.1.19" +MODULE_VERSION = "0.1.20" READ_SCOPE = "notifications:notification:read" WRITE_SCOPE = "notifications:notification:write" DISPATCH_SCOPE = "notifications:delivery:dispatch" @@ -250,7 +250,7 @@ manifest = ModuleManifest( id="notifications.center-and-preferences", title="Use the notification center", summary="The title-bar badge and notification center collect durable notices that require attention outside an immediate request.", - body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.", + body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. The status dropdown uses the same checkbox filter as tables: select multiple states to include any of them, select all to remove the restriction, or deselect all to show no notifications. Status filtering happens before the latest 200 matching notifications are loaded; this list is not a complete archive. Changing the filter does not change delivery or read state. Reload refreshes the current selection without a cached response. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.", documentation_types=("user",), audience=("user",), conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),), @@ -281,7 +281,12 @@ manifest = ModuleManifest( ), "body": ( "Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder " - "Weiterverfolgen. Persönlich stummgeschaltete Quellmodule entfernen passende Einträge aus der " + "Weiterverfolgen. Der Statusfilter verwendet dieselben Kontrollkästchen wie Tabellen: mehrere " + "Zustände einschließen, mit Alle auswählen die Einschränkung aufheben oder mit Alle abwählen " + "keine Benachrichtigungen anzeigen. Die Filterung erfolgt vor dem Laden der neuesten 200 passenden " + "Benachrichtigungen; die Liste ist kein vollständiges Archiv. Filtern ändert weder Zustellung " + "noch Lesestatus. Neu laden aktualisiert die Auswahl ohne zwischengespeicherte Antwort. " + "Persönlich stummgeschaltete Quellmodule entfernen passende Einträge aus der " "persönlichen Liste und der Kennzahl, auch wenn ein Erzeugermodul die Person über Konto, " "Mitgliedschaft oder Identität adressiert hat; Nachweisansichten für Mandantenadministratoren " "bleiben vollständig. Die Einstellungen steuern außerdem zulässige Zustellkanäle und Kategorien. " @@ -310,7 +315,7 @@ manifest = ModuleManifest( id="notifications.delivery-operations", title="Operate notification delivery", summary="Notifications persists message intent and bounded per-channel attempts before workers dispatch optional delivery channels.", - body="Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them.", + body="Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them. Notification lists batch-load delivery attempts for the already tenant- and recipient-filtered page, avoiding one additional database query per message. Attempt order and full evidence are preserved. Attempts whose tenant or notification reference does not match the parent are never projected, including already-loaded relationships; inconsistent stored evidence requires an authorized operator investigation rather than broader visibility. This is read optimization, not dispatch, and it does not truncate an individual notification's history.", documentation_types=("admin",), audience=("tenant_admin", "operator", "module_admin"), related_modules=("mail", "audit", "ops"), @@ -340,7 +345,11 @@ manifest = ModuleManifest( "Ergebnisse erneut versuchen, deren Wiederholung sicher ist. Die Modulberechtigung des Mandanten " "wird vor dem Einreihen und erneut vor der Worker-Zustellung geprüft. Das Deaktivieren von " "Notifications bewahrt angenommene Nachrichten und bietet eine Betreiberaktion an, statt sie " - "unbemerkt zu verarbeiten." + "unbemerkt zu verarbeiten. Benachrichtigungslisten laden Zustellversuche gemeinsam für die bereits nach Mandant und Empfänger gefilterte Seite; " + "eine zusätzliche Datenbankabfrage je Nachricht entfällt. Reihenfolge und vollständige Nachweise bleiben erhalten. " + "Versuche mit abweichendem Mandanten oder Nachrichtenverweis werden auch bei bereits geladenen Beziehungen niemals ausgegeben. " + "Widersprüchliche gespeicherte Nachweise erfordern eine berechtigte Betreiberprüfung statt erweiterter Sichtbarkeit. " + "Dies optimiert das Lesen, löst keine Zustellung aus und kürzt nicht die Historie einer einzelnen Benachrichtigung." ), } }, diff --git a/src/govoplan_notifications/backend/router.py b/src/govoplan_notifications/backend/router.py index 804b819..a4605fe 100644 --- a/src/govoplan_notifications/backend/router.py +++ b/src/govoplan_notifications/backend/router.py @@ -82,7 +82,7 @@ def _recipient_ids_for_view(principal: ApiPrincipal, view: Literal["personal", " @router.get("", response_model=NotificationListResponse) def api_list_notifications( - status_filter: str | None = Query(default=None, alias="status"), + status_filter: list[str] | None = Query(default=None, alias="status", max_length=20), channel: str | None = None, source_module: str | None = None, recipient_id: str | None = None, diff --git a/src/govoplan_notifications/backend/service.py b/src/govoplan_notifications/backend/service.py index d96ab21..8f7c16b 100644 --- a/src/govoplan_notifications/backend/service.py +++ b/src/govoplan_notifications/backend/service.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any from sqlalchemy import and_, case, event, func, or_ -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from govoplan_core.core.mail import ( NotificationMailDeliveryRequest, @@ -175,7 +175,7 @@ def list_notifications( session: Session, *, tenant_id: str, - status: str | None = None, + status: str | Sequence[str] | None = None, channel: str | None = None, source_module: str | None = None, recipient_id: str | None = None, @@ -187,8 +187,10 @@ def list_notifications( NotificationMessage.tenant_id == tenant_id, NotificationMessage.deleted_at.is_(None), ) - if status: - query = query.filter(NotificationMessage.status == status) + if status is not None: + # Repeated status parameters form an OR filter, before ordering/limit. + # Keep single-status service callers compatible; [] matches nothing. + query = query.filter(NotificationMessage.status.in_([status] if isinstance(status, str) else status)) if channel: query = query.filter(NotificationMessage.channel == _clean_channel(channel)) if source_module: @@ -200,7 +202,18 @@ def list_notifications( muted_sources = _clean_source_modules(list(muted_source_modules)) if muted_sources: query = query.filter(NotificationMessage.source_module.notin_(muted_sources)) - return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all() + return ( + query.options( + selectinload( + NotificationMessage.attempts.and_( + NotificationDeliveryAttempt.tenant_id == tenant_id, + ) + ) + ) + .order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()) + .limit(limit) + .all() + ) def notification_summary( @@ -694,7 +707,11 @@ def notification_response(notification: NotificationMessage) -> dict[str, Any]: "metadata": notification.metadata_ or {}, "created_at": response_datetime(notification.created_at), "updated_at": response_datetime(notification.updated_at), - "attempts": [notification_attempt_response(attempt) for attempt in notification.attempts], + "attempts": [ + notification_attempt_response(attempt) + for attempt in notification.attempts + if attempt.tenant_id == notification.tenant_id and attempt.notification_id == notification.id + ], } diff --git a/tests/test_notification_list_efficiency.py b/tests/test_notification_list_efficiency.py new file mode 100644 index 0000000..0ced648 --- /dev/null +++ b/tests/test_notification_list_efficiency.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session + +from govoplan_core.db.base import Base +from govoplan_notifications.backend.db.models import ( + NotificationDeliveryAttempt, + NotificationMessage, +) +from govoplan_notifications.backend.schemas import NotificationCreateRequest +from govoplan_notifications.backend.service import ( + create_notification, + list_notifications, + notification_response, +) + + +class NotificationListEfficiencyTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + NotificationMessage.__table__, + NotificationDeliveryAttempt.__table__, + ], + ) + + def tearDown(self) -> None: + self.engine.dispose() + + def seed(self, count: int) -> str: + with Session(self.engine) as session: + first_id = "" + for index in range(count + 2): + row = create_notification( + session, + tenant_id="tenant-other" if index == count else "tenant-one", + payload=NotificationCreateRequest( + source_module="test", + source_resource_type="record", + event_kind="changed", + enqueue_delivery=False, + recipient_id="other-recipient" + if index == count + 1 + else "reader", + subject=f"Fixture {index}", + ), + ) + if not first_id: + first_id = row.id + session.add( + NotificationDeliveryAttempt( + notification_id=row.id, + tenant_id=row.tenant_id, + attempt_no=1, + channel="inbox", + status="failed", + ) + ) + session.commit() + return first_id + + def test_list_and_full_attempt_projection_use_two_queries_independent_of_page_size( + self, + ) -> None: + for count in (1, 40): + with self.subTest(count=count): + first_id = self.seed(count) + statements: list[str] = [] + + def count_selects( + _connection, _cursor, statement, _parameters, _context, _many + ): + if statement.lstrip().upper().startswith("SELECT"): + statements.append(statement) + + event.listen(self.engine, "before_cursor_execute", count_selects) + try: + with Session(self.engine) as session: + rows = list_notifications( + session, + tenant_id="tenant-one", + recipient_ids=("reader",), + limit=count, + ) + payloads = [notification_response(row) for row in rows] + self.assertEqual(count, len(payloads)) + self.assertTrue( + all(len(item["attempts"]) == 1 for item in payloads) + ) + self.assertTrue( + all( + item["tenant_id"] == "tenant-one" + and item["recipient_id"] == "reader" + for item in payloads + ) + ) + self.assertEqual( + 2, + len(statements), + "The list and attempt projection must not add a query per message.", + ) + finally: + event.remove(self.engine, "before_cursor_execute", count_selects) + self.assertTrue(first_id) + + def test_attempts_with_inconsistent_tenant_are_never_projected_even_from_a_loaded_relationship( + self, + ) -> None: + first_id = self.seed(1) + with Session(self.engine) as session: + session.add( + NotificationDeliveryAttempt( + id="foreign-attempt", + notification_id=first_id, + tenant_id="tenant-other", + attempt_no=2, + channel="mail", + status="failed", + error="Foreign tenant evidence", + ) + ) + session.commit() + with Session(self.engine) as session: + row = session.get(NotificationMessage, first_id) + self.assertEqual(2, len(row.attempts)) + self.assertEqual(1, len(notification_response(row)["attempts"])) + with Session(self.engine) as session: + rows = list_notifications( + session, tenant_id="tenant-one", recipient_ids=("reader",) + ) + self.assertEqual([1], [len(row.attempts) for row in rows]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 7b44ad4..9a9b3f6 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import patch from pathlib import Path from types import SimpleNamespace +from datetime import datetime, timedelta, timezone from fastapi import HTTPException from sqlalchemy import create_engine @@ -24,6 +25,7 @@ from govoplan_notifications.backend.service import ( NotificationError, create_notification, deliver_pending, + list_notifications, notification_preferences_response, notification_response, notification_summary, @@ -174,6 +176,51 @@ class NotificationServiceTests(unittest.TestCase): api_get_notification(other_tenant.id, view="personal", session=session, principal=principal) self.assertEqual(cross_tenant.exception.status_code, 404) + def test_status_multiselection_filters_before_limit_and_preserves_visibility(self) -> None: + with self.Session() as session: + for index, (state, recipient, tenant) in enumerate([ + ("pending", "user-1", "tenant-1"), ("failed", "user-1", "tenant-1"), + ("sent", "user-1", "tenant-1"), ("sent", "user-1", "tenant-1"), + ("failed", "user-2", "tenant-1"), ("failed", "user-1", "tenant-2"), + ]): + item = create_notification(session, tenant_id=tenant, + payload=self._inbox_payload(recipient_id=recipient, subject=f"Message {index}")) + item.status = state + item.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=index) + session.flush() + principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1", + scopes={"notifications:notification:read"}) + result = api_list_notifications(status_filter=["pending", "failed"], channel=None, + source_module=None, recipient_id=None, view="personal", limit=2, session=session, principal=principal) + self.assertEqual([item.status for item in result.notifications], ["failed", "pending"]) + self.assertEqual(list_notifications(session, tenant_id="tenant-1", status=[]), []) + single = list_notifications(session, tenant_id="tenant-1", status="pending") + self.assertEqual(len(single), 1) + + def test_http_repeated_status_parameters_use_or_filter(self) -> None: + from fastapi import FastAPI + from fastapi.testclient import TestClient + from govoplan_core.auth import get_api_principal + from govoplan_core.db.session import get_session + from govoplan_notifications.backend.router import router + + app = FastAPI() + app.include_router(router) + principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1", + scopes={"notifications:notification:read"}) + app.dependency_overrides[get_api_principal] = lambda: principal + app.dependency_overrides[get_session] = lambda: object() + with patch("govoplan_notifications.backend.router.get_notification_preferences", return_value=SimpleNamespace(muted_source_modules=[])), \ + patch("govoplan_notifications.backend.router.list_notifications", return_value=[]) as listing, TestClient(app) as client: + response = client.get("/notifications?status=pending&status=failed") + self.assertEqual(response.status_code, 200) + self.assertEqual(listing.call_args.kwargs["status"], ["pending", "failed"]) + self.assertIn("user-1", listing.call_args.kwargs["recipient_ids"]) + self.assertEqual(client.get("/notifications?status=sent").status_code, 200) + self.assertEqual(listing.call_args.kwargs["status"], ["sent"]) + self.assertEqual(client.get("/notifications").status_code, 200) + self.assertIsNone(listing.call_args.kwargs["status"]) + def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None: with self.Session() as session: another_user = create_notification( diff --git a/webui/package.json b/webui/package.json index 3e257e6..481e5c9 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/api/notifications.ts b/webui/src/api/notifications.ts index 47e13e9..b73b017 100644 --- a/webui/src/api/notifications.ts +++ b/webui/src/api/notifications.ts @@ -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 { +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 { 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(settings, `/api/v1/notifications${query ? `?${query}` : ""}`); + return apiFetch(settings, `/api/v1/notifications${query ? `?${query}` : ""}`, { signal, cache: "no-store" }); } export function notificationSummary(settings: ApiSettings): Promise { diff --git a/webui/src/features/notifications/NotificationCenterPage.tsx b/webui/src/features/notifications/NotificationCenterPage.tsx index d583a99..9c363e0 100644 --- a/webui/src/features/notifications/NotificationCenterPage.tsx +++ b/webui/src/features/notifications/NotificationCenterPage.tsx @@ -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([]); + 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(null); + const [loadedScope, setLoadedScope] = useState(scopeKey); + const [loadedNotifications, setNotifications] = useState([]); + const notifications = loadedScope === scopeKey ? loadedNotifications : []; const [selectedId, setSelectedId] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState(null); + const loadRequest = useRef(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 { - 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 { - 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 ? {unreadCount} : null} } /> - ({ 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} />
{loading ?
i18n:govoplan-notifications.loading_notifications
: null} diff --git a/webui/src/styles/notifications.css b/webui/src/styles/notifications.css index 1b0d0c1..96b3797 100644 --- a/webui/src/styles/notifications.css +++ b/webui/src/styles/notifications.css @@ -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 {