feat(webui): complete postbox quick access

This commit is contained in:
2026-08-19 19:49:00 +02:00
parent d005065e50
commit 15d93aaa25
7 changed files with 286 additions and 12 deletions
+13 -5
View File
@@ -400,6 +400,8 @@ manifest = ModuleManifest(
required_any=(READ_SCOPE,), required_any=(READ_SCOPE,),
order=20, order=20,
modes=("browse", "author"), 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", title="Postbox in Communication and Messages",
summary="Use function-bound Postboxes in Communication and the shared Messages Quick Access drawer.", summary="Use function-bound Postboxes in Communication and the shared Messages Quick Access drawer.",
body=( body=(
"Postbox contributes its inbox to Communication. With Quick Access enabled, its owner-rendered unread summary " "Postbox contributes its inbox to Communication. With Quick Access enabled, its owner-rendered surface lists at most "
"appears beside Mail and future chat providers inside Messages while retaining function assignment, classification, " "seven currently readable unread messages beside independent Mail and future chat providers. Selecting or opening a "
"read-receipt, retention, encryption, and evidence semantics in Postbox." "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", layer="configured",
documentation_types=("user", "admin"), documentation_types=("user", "admin"),
@@ -492,8 +497,11 @@ manifest = ModuleManifest(
"summary": "Funktionsgebundene Postfächer in Kommunikation und der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.", "summary": "Funktionsgebundene Postfächer in Kommunikation und der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.",
"body": ( "body": (
"Postbox ordnet seinen Eingang Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die vom Modul gerenderte " "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. " "Oberfläche mit höchstens sieben aktuell lesbaren ungelesenen Nachrichten neben unabhängigen Beiträgen aus Mail. "
"Funktionszuordnung, Klassifikation, Lesestatus, Aufbewahrung, Verschlüsselung und Nachweise verbleiben bei Postbox." "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."
), ),
} }
}, },
@@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from pathlib import Path
from govoplan_postbox.backend.manifest import manifest from govoplan_postbox.backend.manifest import manifest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
class PostboxInterfaceDocumentationContractTests(unittest.TestCase): class PostboxInterfaceDocumentationContractTests(unittest.TestCase):
def test_route_and_contributed_surfaces_remain_declared(self) -> None: def test_route_and_contributed_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend frontend = manifest.frontend
@@ -41,6 +45,27 @@ class PostboxInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("archive_postbox", reference.metadata["consequence_classes"]) self.assertIn("archive_postbox", reference.metadata["consequence_classes"])
self.assertIn("withdraw_or_expire", 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+48 -2
View File
@@ -45,6 +45,7 @@ import { FormGrid,
type ApiSettings, type ApiSettings,
type AuthInfo type AuthInfo
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { useLocation, useNavigate } from "react-router";
import { import {
createPostboxGrouping, createPostboxGrouping,
createPostboxMessage, createPostboxMessage,
@@ -110,13 +111,16 @@ export default function PostboxPage({
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo; auth: AuthInfo;
}) { }) {
const location = useLocation();
const navigate = useNavigate();
const requestedMessageId = useRef( const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? "" new URLSearchParams(location.search).get("message") ?? ""
); );
const requestedPostboxId = useRef( const requestedPostboxId = useRef(
new URLSearchParams(window.location.search).get("postbox") ?? "" new URLSearchParams(location.search).get("postbox") ?? ""
); );
const requestedMessageLoaded = useRef(false); const requestedMessageLoaded = useRef(false);
const requestedComposeLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]); const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]); const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
const [selectedScope, setSelectedScope] = useState("all"); const [selectedScope, setSelectedScope] = useState("all");
@@ -269,6 +273,44 @@ export default function PostboxPage({
void loadDirectory(); void loadDirectory();
}, [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(() => { useEffect(() => {
void loadMessages(); void loadMessages();
}, [loadMessages]); }, [loadMessages]);
@@ -469,6 +511,10 @@ export default function PostboxPage({
function openCompose() { function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null; const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return; if (!postbox) return;
openComposeFor(postbox);
}
function openComposeFor(postbox: PostboxDirectoryItem) {
setReplyParent(null); setReplyParent(null);
const next = { const next = {
...emptyMessageDraft(), ...emptyMessageDraft(),
@@ -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 (
<LoadingFrame loading={loading} label="i18n:govoplan-postbox.quick_loading_unread">
{launchContext.actingContext?.assignmentId ? (
<p className="muted small-note">
i18n:govoplan-postbox.quick_acting_context
</p>
) : null}
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{messages.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-postbox.quick_unread_messages">
{messages.map((message) => (
<SelectionListItem
key={message.id}
selected={selected?.id === message.id}
onClick={() => setSelectedId(message.id)}
>
<SelectionListItemContent
leading={<Inbox size={16} aria-hidden="true" />}
title={message.subject}
description={`${message.sender_label || message.producer_module || "Postbox"} · ${messageDate(message, language)}`}
/>
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-postbox.quick_no_unread</p>
) : null}
{selected ? (
<section className="postbox-quick-detail" aria-label="i18n:govoplan-postbox.quick_message_details">
<strong>{selected.subject}</strong>
<span>{selectedPostbox?.function_name || selectedPostbox?.name}</span>
{selected.body_text ? <p>{selected.body_text}</p> : null}
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => selectForHost(selected)}>
<Send size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_select_message
</Button>
<Link
className="btn btn-secondary"
to={`/postbox?message=${encodeURIComponent(selected.id)}`}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_open_message
</Link>
</div>
</section>
) : null}
<div className="dashboard-contribution-footer">
{canCompose && selectedPostbox ? (
<Link
className="btn btn-secondary"
to={`/postbox?quickAction=compose&postbox=${encodeURIComponent(selectedPostbox.id)}`}
state={quickAccessLaunchState(launchContext)}
onClick={close}
>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_compose
</Link>
) : null}
<span className="muted small-note">{messages.length} / {data?.total ?? 0}</span>
</div>
</LoadingFrame>
);
}
function messageDate(message: PostboxMessage, language: string): string {
return new Intl.DateTimeFormat(language, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(message.delivered_at));
}
+16
View File
@@ -3,6 +3,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
const en = { const en = {
"i18n:govoplan-postbox.postbox": "Postbox", "i18n:govoplan-postbox.postbox": "Postbox",
"i18n:govoplan-postbox.quick_access_description": "Institutional messages addressed to your functions.", "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.postboxes": "Postboxes",
"i18n:govoplan-postbox.postbox_inbox": "Postbox inbox", "i18n:govoplan-postbox.postbox_inbox": "Postbox inbox",
"i18n:govoplan-postbox.postbox_inbox_description": "Unread messages across accessible Postboxes.", "i18n:govoplan-postbox.postbox_inbox_description": "Unread messages across accessible Postboxes.",
@@ -237,6 +245,14 @@ const de = {
...en, ...en,
"i18n:govoplan-postbox.postbox": "Postfach", "i18n:govoplan-postbox.postbox": "Postfach",
"i18n:govoplan-postbox.quick_access_description": "Institutionelle Nachrichten an Ihre Funktionen.", "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.postboxes": "Postfächer",
"i18n:govoplan-postbox.postbox_inbox": "Postfach-Eingang", "i18n:govoplan-postbox.postbox_inbox": "Postfach-Eingang",
"i18n:govoplan-postbox.postbox_inbox_description": "Ungelesene Nachrichten aus zugänglichen Postfächern.", "i18n:govoplan-postbox.postbox_inbox_description": "Ungelesene Nachrichten aus zugänglichen Postfächern.",
+2 -5
View File
@@ -8,6 +8,7 @@ import {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget"; import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
import PostboxQuickAccess from "./features/postbox/PostboxQuickAccess";
import "./styles/postbox.css"; import "./styles/postbox.css";
@@ -64,11 +65,7 @@ const postboxQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [ tools: [
{ {
id: "postbox.messages", id: "postbox.messages",
render: ({ settings }) => createElement(PostboxInboxWidget, { render: (context) => createElement(PostboxQuickAccess, context)
settings,
refreshKey: 0,
configuration: { maxItems: 7 }
})
} }
] ]
}; };
+22
View File
@@ -554,6 +554,28 @@
resize: vertical; 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 { .postbox-dialog-actions.end {
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;