From 15d93aaa254aa3513e6910d544aaa565775fbaa0 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 19:49:00 +0200 Subject: [PATCH] feat(webui): complete postbox quick access --- src/govoplan_postbox/backend/manifest.py | 18 +- .../test_interface_documentation_contract.py | 25 +++ webui/src/features/postbox/PostboxPage.tsx | 50 +++++- .../features/postbox/PostboxQuickAccess.tsx | 160 ++++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 16 ++ webui/src/module.ts | 7 +- webui/src/styles/postbox.css | 22 +++ 7 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 webui/src/features/postbox/PostboxQuickAccess.tsx diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index b1ca349..0a64cee 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -400,6 +400,8 @@ manifest = ModuleManifest( required_any=(READ_SCOPE,), order=20, modes=("browse", "author"), + returned_reference_kinds=("postbox.message",), + help_context_id="postbox.quick_access.messages", ), ), ), @@ -478,9 +480,12 @@ manifest = ModuleManifest( title="Postbox in Communication and Messages", summary="Use function-bound Postboxes in Communication and the shared Messages Quick Access drawer.", body=( - "Postbox contributes its inbox to Communication. With Quick Access enabled, its owner-rendered unread summary " - "appears beside Mail and future chat providers inside Messages while retaining function assignment, classification, " - "read-receipt, retention, encryption, and evidence semantics in Postbox." + "Postbox contributes its inbox to Communication. With Quick Access enabled, its owner-rendered surface lists at most " + "seven currently readable unread messages beside independent Mail and future chat providers. Selecting or opening a " + "message returns a typed Postbox reference; accounts with message-write permission can launch the full owner-rendered " + "composer for a currently accessible function Postbox. Directory, message, and submission calls recheck the active " + "assignment or acting context. Function assignment, classification, read-receipt, retention, encryption, and evidence " + "semantics remain in Postbox." ), layer="configured", documentation_types=("user", "admin"), @@ -492,8 +497,11 @@ manifest = ModuleManifest( "summary": "Funktionsgebundene Postfächer in Kommunikation und der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.", "body": ( "Postbox ordnet seinen Eingang Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die vom Modul gerenderte " - "Zusammenfassung ungelesener Nachrichten neben Mail und künftigen Chat-Anbietern unter Nachrichten. " - "Funktionszuordnung, Klassifikation, Lesestatus, Aufbewahrung, Verschlüsselung und Nachweise verbleiben bei Postbox." + "Oberfläche mit höchstens sieben aktuell lesbaren ungelesenen Nachrichten neben unabhängigen Beiträgen aus Mail. " + "Auswahl oder Öffnen liefert eine typisierte Postbox-Referenz; mit Schreibberechtigung lässt sich der vollständige " + "Editor für ein aktuell zugängliches Funktionspostfach öffnen. Verzeichnis, Nachricht und Versand prüfen die aktive " + "Zuweisung beziehungsweise den Handlungskontext erneut. Klassifikation, Lesestatus, Aufbewahrung, Verschlüsselung " + "und Nachweise verbleiben bei Postbox." ), } }, diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index ad73575..7e46e43 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -1,10 +1,14 @@ from __future__ import annotations import unittest +from pathlib import Path from govoplan_postbox.backend.manifest import manifest +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + class PostboxInterfaceDocumentationContractTests(unittest.TestCase): def test_route_and_contributed_surfaces_remain_declared(self) -> None: frontend = manifest.frontend @@ -41,6 +45,27 @@ class PostboxInterfaceDocumentationContractTests(unittest.TestCase): self.assertIn("archive_postbox", reference.metadata["consequence_classes"]) self.assertIn("withdraw_or_expire", reference.metadata["consequence_classes"]) + quick_tool = manifest.frontend.quick_access_tools[0] + self.assertEqual(("postbox.message",), quick_tool.returned_reference_kinds) + self.assertEqual("postbox.quick_access.messages", quick_tool.help_context_id) + self.assertEqual("/postbox", quick_tool.full_page_path) + + def test_quick_access_is_bounded_and_owner_launched(self) -> None: + quick_access = ( + REPOSITORY_ROOT / "webui/src/features/postbox/PostboxQuickAccess.tsx" + ).read_text(encoding="utf-8") + page = ( + REPOSITORY_ROOT / "webui/src/features/postbox/PostboxPage.tsx" + ).read_text(encoding="utf-8") + + self.assertIn("const MESSAGE_LIMIT = 7", quick_access) + self.assertIn('"unread"', quick_access) + self.assertIn('kind: "message"', quick_access) + self.assertIn("launchContext.actingContext", quick_access) + self.assertIn("quickAccessLaunchState(launchContext)", quick_access) + self.assertIn('parameters.get("quickAction") !== "compose"', page) + self.assertIn("openComposeFor(postbox)", page) + if __name__ == "__main__": unittest.main() diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx index 1d7771d..26590ef 100644 --- a/webui/src/features/postbox/PostboxPage.tsx +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -45,6 +45,7 @@ import { FormGrid, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; +import { useLocation, useNavigate } from "react-router"; import { createPostboxGrouping, createPostboxMessage, @@ -110,13 +111,16 @@ export default function PostboxPage({ settings: ApiSettings; auth: AuthInfo; }) { + const location = useLocation(); + const navigate = useNavigate(); const requestedMessageId = useRef( - new URLSearchParams(window.location.search).get("message") ?? "" + new URLSearchParams(location.search).get("message") ?? "" ); const requestedPostboxId = useRef( - new URLSearchParams(window.location.search).get("postbox") ?? "" + new URLSearchParams(location.search).get("postbox") ?? "" ); const requestedMessageLoaded = useRef(false); + const requestedComposeLoaded = useRef(false); const [postboxes, setPostboxes] = useState([]); const [groupings, setGroupings] = useState([]); const [selectedScope, setSelectedScope] = useState("all"); @@ -269,6 +273,44 @@ export default function PostboxPage({ void loadDirectory(); }, [loadDirectory]); + useEffect(() => { + const parameters = new URLSearchParams(location.search); + if ( + requestedComposeLoaded.current || + parameters.get("quickAction") !== "compose" || + loadingDirectory + ) { + return; + } + requestedComposeLoaded.current = true; + const requestedId = parameters.get("postbox"); + const postbox = + postboxes.find((item) => item.id === requestedId) ?? postboxes[0] ?? null; + if (canSend && postbox) { + setSelectedPostboxId(postbox.id); + openComposeFor(postbox); + } else if (!canSend) { + setError(POSTBOX_INTERFACE_I18N.noSendReason); + } else { + setError(POSTBOX_INTERFACE_I18N.noPostbox); + } + + parameters.delete("quickAction"); + const search = parameters.toString(); + navigate( + { pathname: location.pathname, search: search ? `?${search}` : "" }, + { replace: true, state: location.state } + ); + }, [ + canSend, + loadingDirectory, + location.pathname, + location.search, + location.state, + navigate, + postboxes + ]); + useEffect(() => { void loadMessages(); }, [loadMessages]); @@ -469,6 +511,10 @@ export default function PostboxPage({ function openCompose() { const postbox = selectedPostbox ?? postboxes[0] ?? null; if (!postbox) return; + openComposeFor(postbox); + } + + function openComposeFor(postbox: PostboxDirectoryItem) { setReplyParent(null); const next = { ...emptyMessageDraft(), diff --git a/webui/src/features/postbox/PostboxQuickAccess.tsx b/webui/src/features/postbox/PostboxQuickAccess.tsx new file mode 100644 index 0000000..eb691f6 --- /dev/null +++ b/webui/src/features/postbox/PostboxQuickAccess.tsx @@ -0,0 +1,160 @@ +import { ExternalLink, Inbox, Pencil, Send } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router"; +import { + Button, + DismissibleAlert, + LoadingFrame, + SelectionList, + SelectionListItem, + SelectionListItemContent, + hasScope, + quickAccessLaunchState, + useDashboardWidgetData, + usePlatformLanguage, + type QuickAccessToolRenderContext +} from "@govoplan/core-webui"; +import { + listPostboxMessages, + listPostboxes, + type PostboxMessage +} from "../../api/postbox"; + +const MESSAGE_LIMIT = 7; + +type Props = Pick< + QuickAccessToolRenderContext, + "settings" | "auth" | "launchContext" | "complete" | "close" +>; + +/** Function-bound projection; Postbox re-evaluates the current acting context. */ +export default function PostboxQuickAccess({ + settings, + auth, + launchContext, + complete, + close +}: Props) { + const { language } = usePlatformLanguage(); + const [selectedId, setSelectedId] = useState(""); + const load = useCallback(async () => { + const postboxes = await listPostboxes(settings); + if (!postboxes.length) return { postboxes, messages: [], total: 0 }; + const response = await listPostboxMessages( + settings, + postboxes.map((postbox) => postbox.id), + MESSAGE_LIMIT, + 0, + "", + "unread" + ); + return { postboxes, ...response }; + }, [settings]); + const { data, loading, error } = useDashboardWidgetData(load, 0); + const messages = data?.messages ?? []; + const selected = useMemo( + () => messages.find((message) => message.id === selectedId) ?? messages[0] ?? null, + [messages, selectedId] + ); + const selectedPostbox = data?.postboxes.find( + (postbox) => postbox.id === selected?.postbox_id + ) ?? data?.postboxes[0] ?? null; + const canCompose = hasScope(auth, "postbox:message:write"); + + useEffect(() => { + if (!selectedId && messages[0]) setSelectedId(messages[0].id); + if (selectedId && !messages.some((message) => message.id === selectedId)) { + setSelectedId(messages[0]?.id ?? ""); + } + }, [messages, selectedId]); + + function selectForHost(message: PostboxMessage) { + complete({ + contractVersion: "1", + outcome: "completed", + action: "selected", + reference: { + ownerModule: "postbox", + kind: "message", + objectId: message.id, + tenantId: message.tenant_id, + label: message.subject, + version: `${message.status}:${message.delivered_at}`, + path: `/postbox?message=${encodeURIComponent(message.id)}` + } + }); + } + + return ( + + {launchContext.actingContext?.assignmentId ? ( +

+ i18n:govoplan-postbox.quick_acting_context +

+ ) : null} + {error ? {error} : null} + + {messages.length ? ( + + {messages.map((message) => ( + setSelectedId(message.id)} + > + + ))} + + ) : !loading && !error ? ( +

i18n:govoplan-postbox.quick_no_unread

+ ) : null} + + {selected ? ( +
+ {selected.subject} + {selectedPostbox?.function_name || selectedPostbox?.name} + {selected.body_text ?

{selected.body_text}

: null} +
+ + selectForHost(selected)} + > +
+
+ ) : null} + +
+ {canCompose && selectedPostbox ? ( + +
+
+ ); +} + +function messageDate(message: PostboxMessage, language: string): string { + return new Intl.DateTimeFormat(language, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(message.delivered_at)); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index c58b522..f5d0b2a 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -3,6 +3,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; const en = { "i18n:govoplan-postbox.postbox": "Postbox", "i18n:govoplan-postbox.quick_access_description": "Institutional messages addressed to your functions.", + "i18n:govoplan-postbox.quick_loading_unread": "Loading unread Postbox messages", + "i18n:govoplan-postbox.quick_acting_context": "Showing messages for the current acting assignment.", + "i18n:govoplan-postbox.quick_unread_messages": "Unread Postbox messages", + "i18n:govoplan-postbox.quick_no_unread": "No unread Postbox messages.", + "i18n:govoplan-postbox.quick_message_details": "Postbox message details", + "i18n:govoplan-postbox.quick_select_message": "Select message", + "i18n:govoplan-postbox.quick_open_message": "Open message", + "i18n:govoplan-postbox.quick_compose": "Compose message", "i18n:govoplan-postbox.postboxes": "Postboxes", "i18n:govoplan-postbox.postbox_inbox": "Postbox inbox", "i18n:govoplan-postbox.postbox_inbox_description": "Unread messages across accessible Postboxes.", @@ -237,6 +245,14 @@ const de = { ...en, "i18n:govoplan-postbox.postbox": "Postfach", "i18n:govoplan-postbox.quick_access_description": "Institutionelle Nachrichten an Ihre Funktionen.", + "i18n:govoplan-postbox.quick_loading_unread": "Ungelesene Postfachnachrichten werden geladen", + "i18n:govoplan-postbox.quick_acting_context": "Nachrichten der aktuellen Handlungszuweisung werden angezeigt.", + "i18n:govoplan-postbox.quick_unread_messages": "Ungelesene Postfachnachrichten", + "i18n:govoplan-postbox.quick_no_unread": "Keine ungelesenen Postfachnachrichten.", + "i18n:govoplan-postbox.quick_message_details": "Details der Postfachnachricht", + "i18n:govoplan-postbox.quick_select_message": "Nachricht auswählen", + "i18n:govoplan-postbox.quick_open_message": "Nachricht öffnen", + "i18n:govoplan-postbox.quick_compose": "Nachricht verfassen", "i18n:govoplan-postbox.postboxes": "Postfächer", "i18n:govoplan-postbox.postbox_inbox": "Postfach-Eingang", "i18n:govoplan-postbox.postbox_inbox_description": "Ungelesene Nachrichten aus zugänglichen Postfächern.", diff --git a/webui/src/module.ts b/webui/src/module.ts index bdd012b..468ca01 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -8,6 +8,7 @@ import { } from "@govoplan/core-webui"; import { generatedTranslations } from "./i18n/generatedTranslations"; import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget"; +import PostboxQuickAccess from "./features/postbox/PostboxQuickAccess"; import "./styles/postbox.css"; @@ -64,11 +65,7 @@ const postboxQuickAccessTools: QuickAccessToolsUiCapability = { tools: [ { id: "postbox.messages", - render: ({ settings }) => createElement(PostboxInboxWidget, { - settings, - refreshKey: 0, - configuration: { maxItems: 7 } - }) + render: (context) => createElement(PostboxQuickAccess, context) } ] }; diff --git a/webui/src/styles/postbox.css b/webui/src/styles/postbox.css index 790ce5f..b7f24ff 100644 --- a/webui/src/styles/postbox.css +++ b/webui/src/styles/postbox.css @@ -554,6 +554,28 @@ resize: vertical; } +.postbox-quick-detail { + display: grid; + gap: 7px; + margin-top: 12px; + border-top: var(--border-line); + padding-top: 12px; +} + +.postbox-quick-detail > span, +.postbox-quick-detail > p { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.postbox-quick-detail > p { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + .postbox-dialog-actions.end { justify-content: flex-end; gap: 8px;