feat(webui): complete postbox quick access
This commit is contained in:
@@ -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."
|
||||
),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PostboxDirectoryItem[]>([]);
|
||||
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
|
||||
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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
+2
-5
@@ -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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user