Release govoplan-notifications v0.1.20: batch attempts and unify multi-select filters
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user