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
@@ -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]
+14 -5
View File
@@ -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."
),
}
},
+1 -1
View File
@@ -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,
+23 -6
View File
@@ -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
],
}
+140
View File
@@ -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()
+47
View File
@@ -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(
+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 {