Migrate Postbox interface patterns
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user