Migrate Postbox interface patterns

This commit is contained in:
2026-08-03 12:26:20 +02:00
parent 8f8d259a76
commit a97eb3bcef
12 changed files with 1209 additions and 152 deletions
+170 -53
View File
@@ -18,10 +18,13 @@ import {
X
} from "lucide-react";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
DataGridPaginationBar,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
IconButton,
SegmentedControl,
@@ -30,7 +33,11 @@ import {
StatusBadge,
ToggleSwitch,
hasScope,
i18nMessage,
isApiError,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
@@ -49,6 +56,12 @@ import {
type PostboxGrouping,
type PostboxMessage
} from "../../api/postbox";
import {
POSTBOX_DOCUMENTATION,
POSTBOX_FIELD_DOCUMENTATION,
POSTBOX_INTERFACE_I18N,
postboxBusyReason
} from "./interfacePatterns";
type GroupingDraft = {
@@ -114,13 +127,20 @@ export default function PostboxPage({
const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const [groupingBaseline, setGroupingBaseline] = useState<GroupingDraft>(emptyGrouping);
const [deleteGroupingTarget, setDeleteGroupingTarget] = useState<GroupingDraft | null>(null);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
const [messageBaseline, setMessageBaseline] = useState<MessageDraft>(emptyMessageDraft);
const { language } = usePlatformLanguage();
const { requestDiscard } = useUnsavedChanges();
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const canSend = hasScope(auth, "postbox:message:write");
const canReply = hasScope(auth, "postbox:message:reply");
const groupingDirty = groupingDialogOpen && draftKey(groupingDraft) !== draftKey(groupingBaseline);
const messageDirty = messageDialogOpen && draftKey(messageDraft) !== draftKey(messageBaseline);
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId]
@@ -138,6 +158,32 @@ export default function PostboxPage({
return postboxes.map((postbox) => postbox.id);
}, [postboxes, selectedGrouping, selectedPostboxId]);
const scopeKey = scopePostboxIds.join("|");
const composeDisabledReason = postboxBusyReason(false, busy)
?? (!canSend ? POSTBOX_INTERFACE_I18N.noSendReason : undefined)
?? (!postboxes.length ? POSTBOX_INTERFACE_I18N.noPostbox : undefined);
const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined);
const acknowledgeDisabledReason = postboxBusyReason(false, busy)
?? (!canAcknowledge ? POSTBOX_INTERFACE_I18N.noAcknowledgeReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined)
?? (selectedMessage?.acknowledged_at
? POSTBOX_INTERFACE_I18N.alreadyAcknowledged
: undefined);
useUnsavedDraftGuard({
dirty: groupingDirty || messageDirty,
title: "Unsaved Postbox draft",
message: "Save or discard the open Postbox draft before leaving this surface.",
onSave: () => groupingDirty ? saveGrouping() : submitMessage(),
onDiscard: discardOpenDraft
});
const loadDirectory = useCallback(async () => {
setLoadingDirectory(true);
@@ -336,22 +382,26 @@ export default function PostboxPage({
}
function openNewGrouping() {
setGroupingDraft(emptyGrouping());
const next = emptyGrouping();
setGroupingDraft(next);
setGroupingBaseline(next);
setGroupingDialogOpen(true);
}
function openGrouping(grouping: PostboxGrouping) {
setGroupingDraft({
const next = {
id: grouping.id,
name: grouping.name,
is_default: grouping.is_default,
postbox_ids: [...grouping.postbox_ids]
});
};
setGroupingDraft(next);
setGroupingBaseline(next);
setGroupingDialogOpen(true);
}
async function saveGrouping() {
if (!groupingDraft.name.trim()) return;
async function saveGrouping(): Promise<boolean> {
if (!groupingDraft.name.trim()) return false;
setBusy(true);
setError("");
const payload = {
@@ -368,24 +418,28 @@ export default function PostboxPage({
setSelectedScope(saved.id);
setSelectedPostboxId("");
setGroupingDialogOpen(false);
setGroupingBaseline(groupingDraft);
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
async function removeGrouping() {
if (!groupingDraft.id) return;
if (!deleteGroupingTarget?.id) return;
setBusy(true);
setError("");
try {
const existing = groupings.find((item) => item.id === groupingDraft.id);
const existing = groupings.find((item) => item.id === deleteGroupingTarget.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
setGroupingDialogOpen(false);
setDeleteGroupingTarget(null);
await loadDirectory();
} catch (actionError) {
setError(errorMessage(actionError));
@@ -398,30 +452,34 @@ export default function PostboxPage({
const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return;
setReplyParent(null);
setMessageDraft({
const next = {
...emptyMessageDraft(),
postbox_id: postbox.id,
classification: postbox.classification
});
};
setMessageDraft(next);
setMessageBaseline(next);
setMessageDialogOpen(true);
}
function openReply() {
if (!selectedMessage) return;
setReplyParent(selectedMessage);
setMessageDraft({
const next = {
...emptyMessageDraft(),
postbox_id: selectedMessage.postbox_id,
subject: selectedMessage.subject.toLowerCase().startsWith("re:")
? selectedMessage.subject
: `Re: ${selectedMessage.subject}`,
classification: selectedMessage.classification
});
};
setMessageDraft(next);
setMessageBaseline(next);
setMessageDialogOpen(true);
}
async function submitMessage() {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return;
async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
setBusy(true);
setError("");
const participants = messageDraft.recipients
@@ -467,13 +525,47 @@ export default function PostboxPage({
);
setMessages(refreshed.messages);
setTotal(refreshed.total);
setMessageBaseline(messageDraft);
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
function discardOpenDraft() {
if (groupingDialogOpen) {
setGroupingDraft(groupingBaseline);
setGroupingDialogOpen(false);
}
if (messageDialogOpen) {
setMessageDraft(messageBaseline);
setMessageDialogOpen(false);
setReplyParent(null);
}
}
function closeGroupingDialog() {
const close = () => {
setGroupingDraft(groupingBaseline);
setGroupingDialogOpen(false);
};
if (groupingDirty) requestDiscard(close);
else close();
}
function closeMessageDialog() {
const close = () => {
setMessageDraft(messageBaseline);
setMessageDialogOpen(false);
setReplyParent(null);
};
if (messageDirty) requestDiscard(close);
else close();
}
return (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
@@ -482,18 +574,22 @@ export default function PostboxPage({
<div className="postbox-bar-title">
<Inbox size={17} aria-hidden="true" />
<strong>Postbox</strong>
<DocumentationHelpLink reference={POSTBOX_DOCUMENTATION} />
</div>
<div className="postbox-icon-actions">
<IconButton
label="New unified view"
icon={<Plus size={16} />}
onClick={openNewGrouping}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
/>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void loadDirectory()}
onClick={() => requestDiscard(() => void loadDirectory())}
disabled={loadingDirectory || busy}
disabledReason={postboxBusyReason(loadingDirectory, busy)}
/>
</div>
</div>
@@ -530,6 +626,20 @@ export default function PostboxPage({
<Archive size={20} />
<strong>No assigned postboxes</strong>
<p>Postboxes appear when your account has a current matching function assignment.</p>
<ActionBlockerHint
reason={{
summary: POSTBOX_INTERFACE_I18N.noPostbox,
requiredAction: POSTBOX_INTERFACE_I18N.assignmentAction,
actor: POSTBOX_INTERFACE_I18N.assignmentActor,
target: POSTBOX_INTERFACE_I18N.assignmentDestination
}}
labels={{
requiredAction: POSTBOX_INTERFACE_I18N.requiredAction,
actor: POSTBOX_INTERFACE_I18N.actor,
target: POSTBOX_INTERFACE_I18N.destination
}}
documentation={POSTBOX_DOCUMENTATION}
/>
</div>
) : null}
{postboxes.length ? (
@@ -585,13 +695,15 @@ export default function PostboxPage({
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={!canSend || !postboxes.length || busy}
disabled={Boolean(composeDisabledReason)}
disabledReason={composeDisabledReason}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
onClick={() => requestDiscard(() => void loadMessages())}
disabled={loadingMessages || busy}
disabledReason={postboxBusyReason(loadingMessages, busy)}
/>
</div>
</div>
@@ -679,7 +791,7 @@ export default function PostboxPage({
>
<span className="postbox-message-heading">
<strong>{message.subject}</strong>
<time>{formatDate(message.delivered_at)}</time>
<time>{formatDate(message.delivered_at, language)}</time>
</span>
<span className="postbox-message-preview">
{message.sender_label || message.producer_module || "Platform"}
@@ -734,31 +846,15 @@ export default function PostboxPage({
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={
!canReply ||
!selectedMessage ||
selectedMessage.availability !== "available" ||
busy
}
disabled={Boolean(replyDisabledReason)}
disabledReason={replyDisabledReason}
>
<Reply size={16} /> Reply
</Button>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
disabled={Boolean(acknowledgeDisabledReason)}
disabledReason={acknowledgeDisabledReason}
>
<CheckCheck size={16} /> Acknowledge
</Button>
@@ -789,27 +885,29 @@ export default function PostboxPage({
open={groupingDialogOpen}
title={groupingDraft.id ? "Edit unified view" : "New unified view"}
className="postbox-dialog"
onClose={() => setGroupingDialogOpen(false)}
onClose={closeGroupingDialog}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions">
{groupingDraft.id ? (
<Button
variant="danger"
onClick={() => void removeGrouping()}
onClick={() => setDeleteGroupingTarget(groupingDraft)}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
>
<Trash2 size={16} /> Delete
</Button>
) : <span />}
<div className="button-row compact-actions">
<Button onClick={() => setGroupingDialogOpen(false)} disabled={busy}>
<Button onClick={closeGroupingDialog} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void saveGrouping()}
disabled={busy || !groupingDraft.name.trim()}
disabledReason={postboxBusyReason(false, busy) ?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
Save
</Button>
@@ -818,7 +916,7 @@ export default function PostboxPage({
}
>
<div className="postbox-form-grid">
<FormField label="Name">
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={groupingDraft.name}
onChange={(event) =>
@@ -871,11 +969,11 @@ export default function PostboxPage({
open={messageDialogOpen}
title={replyParent ? "Reply" : "New message"}
className="postbox-dialog postbox-message-dialog"
onClose={() => setMessageDialogOpen(false)}
onClose={closeMessageDialog}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions end">
<Button onClick={() => setMessageDialogOpen(false)} disabled={busy}>
<Button onClick={closeMessageDialog} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
Cancel
</Button>
<Button
@@ -886,6 +984,7 @@ export default function PostboxPage({
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
}
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
<Send size={16} /> Send
</Button>
@@ -893,7 +992,7 @@ export default function PostboxPage({
}
>
<div className="postbox-compose-grid">
<FormField label="Postbox">
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.postbox_id}
disabled={Boolean(replyParent)}
@@ -915,7 +1014,7 @@ export default function PostboxPage({
))}
</select>
</FormField>
<FormField label="Classification">
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.classification}
onChange={(event) =>
@@ -937,7 +1036,7 @@ export default function PostboxPage({
</select>
</FormField>
<div className="postbox-compose-wide">
<FormField label="Recipients">
<FormField label="Recipients" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={messageDraft.recipients}
placeholder="Separate addresses with commas"
@@ -951,7 +1050,7 @@ export default function PostboxPage({
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Subject">
<FormField label="Subject" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={messageDraft.subject}
onChange={(event) =>
@@ -964,7 +1063,7 @@ export default function PostboxPage({
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Message">
<FormField label="Message" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<textarea
rows={10}
value={messageDraft.body_text}
@@ -979,6 +1078,19 @@ export default function PostboxPage({
</div>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(deleteGroupingTarget)}
title="Delete unified view"
message={deleteGroupingTarget
? i18nMessage("i18n:govoplan-postbox.delete_grouping_confirmation", { name: deleteGroupingTarget.name })
: ""}
confirmLabel="Delete"
tone="danger"
busy={busy}
onConfirm={() => void removeGrouping()}
onCancel={() => setDeleteGroupingTarget(null)}
/>
</main>
);
}
@@ -990,6 +1102,7 @@ function MessageDetail({
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
}) {
const { language } = usePlatformLanguage();
return (
<div className="postbox-message-detail">
{message.availability !== "available" ? (
@@ -1014,7 +1127,7 @@ function MessageDetail({
<h1>{message.subject}</h1>
<div className="postbox-detail-byline">
<span>{message.sender_label || message.producer_module || "Platform"}</span>
<time>{formatLongDate(message.delivered_at)}</time>
<time>{formatLongDate(message.delivered_at, language)}</time>
</div>
</header>
<section className="postbox-provenance">
@@ -1122,10 +1235,10 @@ function classificationLabel(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function formatDate(value: string): string {
function formatDate(value: string, language: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(language, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -1133,10 +1246,10 @@ function formatDate(value: string): string {
}).format(date);
}
function formatLongDate(value: string): string {
function formatLongDate(value: string, language: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(language, {
dateStyle: "long",
timeStyle: "short"
}).format(date);
@@ -1145,3 +1258,7 @@ function formatLongDate(value: string): string {
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}
function draftKey(value: unknown): string {
return JSON.stringify(value);
}