feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MessageSquare, ShieldCheck, UserRound } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
ReferenceMultiSelect,
|
||||
StatusBadge,
|
||||
hasScope
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
campaignCollaborationMentionProvider,
|
||||
createCampaignCollaborationEntry,
|
||||
listCampaignCollaboration,
|
||||
redactCampaignCollaborationEntry,
|
||||
withdrawCampaignCollaborationEntry,
|
||||
type CampaignCollaborationCreate,
|
||||
type CampaignCollaborationEntry,
|
||||
type CampaignCollaborationReferenceKind
|
||||
} from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
|
||||
type PendingTombstone = {
|
||||
action: "withdraw" | "redact";
|
||||
entry: CampaignCollaborationEntry;
|
||||
} | null;
|
||||
|
||||
export default function CampaignCollaborationPage({
|
||||
settings,
|
||||
auth,
|
||||
campaignId
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
campaignId: string;
|
||||
}) {
|
||||
const workspace = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [entries, setEntries] = useState<CampaignCollaborationEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingThread, setLoadingThread] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [visibility, setVisibility] = useState<CampaignCollaborationEntry["visibility"]>("collaborators");
|
||||
const [mentionUserIds, setMentionUserIds] = useState<string[]>([]);
|
||||
const [referenceKind, setReferenceKind] = useState<CampaignCollaborationReferenceKind | "">("");
|
||||
const [referenceId, setReferenceId] = useState("");
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [pendingTombstone, setPendingTombstone] = useState<PendingTombstone>(null);
|
||||
const [tombstoneBusy, setTombstoneBusy] = useState(false);
|
||||
const canPost = hasScope(auth, "campaigns:discussion:post");
|
||||
const canModerate = hasScope(auth, "campaigns:discussion:moderate");
|
||||
const mentionProvider = useMemo(
|
||||
() => campaignCollaborationMentionProvider(settings, campaignId),
|
||||
[campaignId, settings]
|
||||
);
|
||||
|
||||
const loadThread = useCallback(async () => {
|
||||
setLoadingThread(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignCollaboration(settings, campaignId);
|
||||
setEntries(response.items);
|
||||
setNextCursor(response.next_cursor ?? null);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingThread(false);
|
||||
}
|
||||
}, [campaignId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadThread();
|
||||
}, [loadThread]);
|
||||
|
||||
async function reload() {
|
||||
setMessage("");
|
||||
await Promise.all([loadThread(), workspace.reload({ force: true })]);
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignCollaboration(settings, campaignId, {
|
||||
cursor: nextCursor
|
||||
});
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
...response.items.filter((entry) => !current.some((item) => item.id === entry.id))
|
||||
]);
|
||||
setNextCursor(response.next_cursor ?? null);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function postEntry() {
|
||||
const cleanContent = content.trim();
|
||||
if (!cleanContent || posting || (referenceKind && !referenceId.trim())) return;
|
||||
setPosting(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const payload: CampaignCollaborationCreate = {
|
||||
content: cleanContent,
|
||||
visibility,
|
||||
mention_user_ids: mentionUserIds
|
||||
};
|
||||
if (referenceKind) {
|
||||
payload.reference = {
|
||||
kind: referenceKind,
|
||||
id: referenceId.trim()
|
||||
};
|
||||
}
|
||||
const created = await createCampaignCollaborationEntry(settings, campaignId, payload);
|
||||
setEntries((current) => [created, ...current.filter((entry) => entry.id !== created.id)]);
|
||||
setContent("");
|
||||
setVisibility("collaborators");
|
||||
setMentionUserIds([]);
|
||||
setReferenceKind("");
|
||||
setReferenceId("");
|
||||
setMessage("i18n:govoplan-campaign.collaboration_posted");
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTombstone() {
|
||||
if (!pendingTombstone || tombstoneBusy) return;
|
||||
setTombstoneBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = pendingTombstone.action === "withdraw"
|
||||
? await withdrawCampaignCollaborationEntry(settings, campaignId, pendingTombstone.entry.id)
|
||||
: await redactCampaignCollaborationEntry(settings, campaignId, pendingTombstone.entry.id);
|
||||
setEntries((current) => current.map((entry) => entry.id === updated.id ? updated : entry));
|
||||
setMessage(pendingTombstone.action === "withdraw"
|
||||
? "i18n:govoplan-campaign.collaboration_withdrawn"
|
||||
: "i18n:govoplan-campaign.collaboration_redacted");
|
||||
setPendingTombstone(null);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setTombstoneBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const referenceNeedsTypedId = Boolean(referenceKind && referenceKind !== "campaign_version");
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
mode="workspace"
|
||||
interfaceId="campaigns.page.activity"
|
||||
helpContextId="campaign.activity"
|
||||
helpModuleId="campaign"
|
||||
title="i18n:govoplan-campaign.collaboration"
|
||||
description="i18n:govoplan-campaign.collaboration_description"
|
||||
loading={loadingThread || workspace.loading}
|
||||
loadingLabel="i18n:govoplan-campaign.loading_collaboration"
|
||||
error={error || workspace.error}
|
||||
success={message}
|
||||
actions={
|
||||
<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void reload(),
|
||||
loading: loadingThread || workspace.loading
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DismissibleAlert tone="info" resetKey={campaignId}>
|
||||
i18n:govoplan-campaign.collaboration_audit_boundary
|
||||
</DismissibleAlert>
|
||||
|
||||
{canPost ? (
|
||||
<Card
|
||||
title="i18n:govoplan-campaign.new_collaboration_entry"
|
||||
interfaceId="campaigns.activity.composer"
|
||||
helpContextId="campaign.activity.composer"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
<div className="campaign-collaboration-composer">
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.comment"
|
||||
help="i18n:govoplan-campaign.comment_help"
|
||||
>
|
||||
<textarea
|
||||
rows={5}
|
||||
maxLength={8000}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
disabled={posting}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-collaboration-counter" aria-live="polite">
|
||||
{content.length} / 8000
|
||||
</div>
|
||||
<div className="campaign-collaboration-options">
|
||||
{canModerate ? (
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.visibility"
|
||||
help="i18n:govoplan-campaign.visibility_help"
|
||||
>
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.target.value as CampaignCollaborationEntry["visibility"])}
|
||||
disabled={posting}
|
||||
>
|
||||
<option value="collaborators">i18n:govoplan-campaign.visibility_collaborators</option>
|
||||
<option value="moderators">i18n:govoplan-campaign.visibility_moderators</option>
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.reference_context"
|
||||
help="i18n:govoplan-campaign.reference_context_help"
|
||||
>
|
||||
<select
|
||||
value={referenceKind}
|
||||
onChange={(event) => {
|
||||
setReferenceKind(event.target.value as CampaignCollaborationReferenceKind | "");
|
||||
setReferenceId("");
|
||||
}}
|
||||
disabled={posting}
|
||||
>
|
||||
<option value="">i18n:govoplan-campaign.no_reference</option>
|
||||
<option value="campaign_version">i18n:govoplan-campaign.reference_campaign_version</option>
|
||||
<option value="recipient_import_batch">i18n:govoplan-campaign.reference_recipient_import</option>
|
||||
<option value="attachment_rule">i18n:govoplan-campaign.reference_attachment_rule</option>
|
||||
<option value="delivery_job">i18n:govoplan-campaign.reference_delivery_job</option>
|
||||
<option value="report">i18n:govoplan-campaign.reference_report</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{referenceKind === "campaign_version" ? (
|
||||
<FormField label="i18n:govoplan-campaign.campaign_version">
|
||||
<select
|
||||
value={referenceId}
|
||||
onChange={(event) => setReferenceId(event.target.value)}
|
||||
disabled={posting}
|
||||
>
|
||||
<option value="">i18n:govoplan-campaign.select_version</option>
|
||||
{workspace.data.versions.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
Version {version.version_number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
{referenceNeedsTypedId ? (
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.stable_reference_id"
|
||||
help="i18n:govoplan-campaign.stable_reference_id_help"
|
||||
>
|
||||
<input
|
||||
value={referenceId}
|
||||
maxLength={500}
|
||||
onChange={(event) => setReferenceId(event.target.value)}
|
||||
disabled={posting}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.mentions"
|
||||
help="i18n:govoplan-campaign.mentions_help"
|
||||
>
|
||||
<ReferenceMultiSelect
|
||||
values={mentionUserIds}
|
||||
onChange={setMentionUserIds}
|
||||
provider={mentionProvider}
|
||||
disabled={posting}
|
||||
aria-label="Mention campaign collaborators"
|
||||
placeholder="i18n:govoplan-campaign.add_mention"
|
||||
searchLimit={20}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="button-row compact-actions campaign-collaboration-submit">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void postEntry()}
|
||||
disabled={posting || !content.trim() || Boolean(referenceKind && !referenceId.trim())}
|
||||
helpContextId="campaign.activity.action.post"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
<MessageSquare size={16} aria-hidden="true" />
|
||||
{posting ? "i18n:govoplan-campaign.posting" : "i18n:govoplan-campaign.post_comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<DismissibleAlert tone="info" resetKey={`${campaignId}:read-only`}>
|
||||
i18n:govoplan-campaign.collaboration_read_only
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<Card title="i18n:govoplan-campaign.discussion" interfaceId="campaigns.activity.thread">
|
||||
{entries.length === 0 ? (
|
||||
<p className="muted">i18n:govoplan-campaign.no_collaboration_entries</p>
|
||||
) : (
|
||||
<ol className="campaign-collaboration-thread" aria-label="Campaign collaboration thread">
|
||||
{entries.map((entry) => {
|
||||
const ownEntry = entry.actor_user_id === auth.user.id;
|
||||
return (
|
||||
<li key={entry.id} className="campaign-collaboration-entry">
|
||||
<article aria-labelledby={`campaign-collaboration-${entry.id}-actor`}>
|
||||
<header className="campaign-collaboration-entry-header">
|
||||
<span className="campaign-collaboration-actor" id={`campaign-collaboration-${entry.id}-actor`}>
|
||||
<UserRound size={16} aria-hidden="true" />
|
||||
{entry.actor_label}
|
||||
</span>
|
||||
<time dateTime={entry.created_at}>{formatDateTime(entry.created_at)}</time>
|
||||
{entry.visibility === "moderators" ? (
|
||||
<StatusBadge status="restricted" label="i18n:govoplan-campaign.moderators_only" />
|
||||
) : null}
|
||||
</header>
|
||||
{entry.tombstone ? (
|
||||
<div className="campaign-collaboration-tombstone" role="status">
|
||||
<ShieldCheck size={17} aria-hidden="true" />
|
||||
<span>
|
||||
{entry.tombstone === "redacted"
|
||||
? "i18n:govoplan-campaign.entry_redacted_tombstone"
|
||||
: "i18n:govoplan-campaign.entry_withdrawn_tombstone"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="campaign-collaboration-content">{entry.content}</p>
|
||||
)}
|
||||
{entry.reference ? (
|
||||
<p className="campaign-collaboration-reference">
|
||||
<strong>i18n:govoplan-campaign.reference_label</strong>{" "}
|
||||
{entry.reference.label || entry.reference.kind.replaceAll("_", " ")}
|
||||
<code>{entry.reference.id}</code>
|
||||
</p>
|
||||
) : null}
|
||||
{!entry.tombstone && (ownEntry || canModerate) ? (
|
||||
<div className="button-row compact-actions campaign-collaboration-entry-actions">
|
||||
{ownEntry && canPost ? (
|
||||
<Button
|
||||
onClick={() => setPendingTombstone({ action: "withdraw", entry })}
|
||||
helpContextId="campaign.activity.action.withdraw"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
i18n:govoplan-campaign.withdraw
|
||||
</Button>
|
||||
) : null}
|
||||
{canModerate ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setPendingTombstone({ action: "redact", entry })}
|
||||
helpContextId="campaign.activity.action.redact"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
i18n:govoplan-campaign.redact
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{hasMore ? (
|
||||
<div className="button-row compact-actions campaign-collaboration-load-more">
|
||||
<Button onClick={() => void loadOlder()} disabled={loadingMore}>
|
||||
{loadingMore ? "i18n:govoplan-campaign.loading_older_entries" : "i18n:govoplan-campaign.load_older_entries"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingTombstone)}
|
||||
title={pendingTombstone?.action === "redact"
|
||||
? "i18n:govoplan-campaign.redact_collaboration_entry"
|
||||
: "i18n:govoplan-campaign.withdraw_collaboration_entry"}
|
||||
message={pendingTombstone?.action === "redact"
|
||||
? "i18n:govoplan-campaign.redact_collaboration_entry_confirmation"
|
||||
: "i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation"}
|
||||
confirmLabel={pendingTombstone?.action === "redact"
|
||||
? "i18n:govoplan-campaign.redact"
|
||||
: "i18n:govoplan-campaign.withdraw"}
|
||||
tone="danger"
|
||||
busy={tombstoneBusy}
|
||||
helpContextId={pendingTombstone?.action === "redact"
|
||||
? "campaign.activity.action.redact"
|
||||
: "campaign.activity.action.withdraw"}
|
||||
helpModuleId="campaign"
|
||||
onCancel={() => !tombstoneBusy && setPendingTombstone(null)}
|
||||
onConfirm={() => void applyTombstone()}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "re
|
||||
import {
|
||||
ConcurrencyConflictProvider,
|
||||
WorkspaceLayout,
|
||||
hasScope,
|
||||
useGuardedNavigate
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
|
||||
import SectionSidebar from "../../layout/SectionSidebar";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import SectionSidebar, { type CampaignWorkspaceNavigationSection } from "../../layout/SectionSidebar";
|
||||
|
||||
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
|
||||
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
|
||||
@@ -23,8 +24,9 @@ const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage"));
|
||||
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
|
||||
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
|
||||
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
|
||||
const CampaignCollaborationPage = lazy(() => import("./CampaignCollaborationPage"));
|
||||
|
||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
|
||||
overview: "",
|
||||
campaign: "recipients",
|
||||
"global-settings": "global-settings",
|
||||
@@ -38,6 +40,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
"mail-policy": "mail-policy",
|
||||
review: "review",
|
||||
report: "report",
|
||||
activity: "activity",
|
||||
audit: "audit",
|
||||
json: "json"
|
||||
};
|
||||
@@ -73,7 +76,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
|
||||
|
||||
function select(section: CampaignWorkspaceSection) {
|
||||
function select(section: CampaignWorkspaceNavigationSection) {
|
||||
const path = sectionPaths[section];
|
||||
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -87,7 +90,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
|
||||
return (
|
||||
<WorkspaceLayout
|
||||
primary={<SectionSidebar active={active} onSelect={select} />}
|
||||
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} />}
|
||||
primaryLabel="Campaign sections"
|
||||
contentLabel="Campaign workspace"
|
||||
interfaceId="campaign.workspace"
|
||||
@@ -112,6 +115,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
|
||||
@@ -130,7 +134,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
);
|
||||
}
|
||||
|
||||
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
function sectionFromPath(pathname: string): CampaignWorkspaceNavigationSection {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const section = segments[2];
|
||||
|
||||
@@ -148,6 +152,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
if (section === "review") return "review";
|
||||
if (section === "send") return "review";
|
||||
if (section === "report" || section === "reports") return "report";
|
||||
if (section === "activity" || section === "collaboration") return "activity";
|
||||
if (section === "audit") return "audit";
|
||||
if (section === "json") return "json";
|
||||
return "overview";
|
||||
|
||||
Reference in New Issue
Block a user