feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-22 00:09:34 +02:00
parent 5a21067e44
commit c2f083e5f6
25 changed files with 2315 additions and 24 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -35,7 +35,8 @@
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs"
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs",
"test:campaign-collaboration": "node tests/campaign-collaboration-ui-structure.test.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"
+102
View File
@@ -38,6 +38,49 @@ export type CampaignShare = {
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type CampaignCollaborationReferenceKind =
| "campaign_version"
| "recipient_import_batch"
| "attachment_rule"
| "delivery_job"
| "report";
export type CampaignCollaborationReference = {
kind: CampaignCollaborationReferenceKind;
id: string;
label?: string | null;
};
export type CampaignCollaborationEntry = {
id: string;
campaign_id: string;
actor_user_id?: string | null;
actor_label: string;
visibility: "collaborators" | "moderators";
content?: string | null;
content_sha256: string;
mention_user_ids: string[];
reference?: CampaignCollaborationReference | null;
tombstone?: "withdrawn" | "redacted" | null;
tombstone_reason?: string | null;
withdrawn_at?: string | null;
redacted_at?: string | null;
created_at: string;
};
export type CampaignCollaborationListResponse = {
items: CampaignCollaborationEntry[];
next_cursor?: string | null;
has_more: boolean;
};
export type CampaignCollaborationCreate = {
content: string;
visibility?: CampaignCollaborationEntry["visibility"];
reference?: Pick<CampaignCollaborationReference, "kind" | "id"> | null;
mention_user_ids?: string[];
};
export type CampaignArchiveEncryptionPolicy = {
available: boolean;
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
@@ -1763,6 +1806,65 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
}
export async function listCampaignCollaboration(
settings: ApiSettings,
campaignId: string,
options: {cursor?: string | null;limit?: number;} = {}
): Promise<CampaignCollaborationListResponse> {
const params = new URLSearchParams({ limit: String(options.limit ?? 25) });
if (options.cursor) params.set("cursor", options.cursor);
return apiFetch<CampaignCollaborationListResponse>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration?${params.toString()}`
);
}
export async function createCampaignCollaborationEntry(
settings: ApiSettings,
campaignId: string,
payload: CampaignCollaborationCreate
): Promise<CampaignCollaborationEntry> {
return apiFetch<CampaignCollaborationEntry>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function withdrawCampaignCollaborationEntry(
settings: ApiSettings,
campaignId: string,
entryId: string
): Promise<CampaignCollaborationEntry> {
return apiFetch<CampaignCollaborationEntry>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration/${encodeURIComponent(entryId)}/withdraw`,
{ method: "POST", body: JSON.stringify({}) }
);
}
export async function redactCampaignCollaborationEntry(
settings: ApiSettings,
campaignId: string,
entryId: string
): Promise<CampaignCollaborationEntry> {
return apiFetch<CampaignCollaborationEntry>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration/${encodeURIComponent(entryId)}/redact`,
{ method: "POST", body: JSON.stringify({}) }
);
}
export function campaignCollaborationMentionProvider(
settings: ApiSettings,
campaignId: string
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration/mention-options`
);
}
export function campaignShareTargetProvider(
settings: ApiSettings,
campaignId: string,
@@ -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";
+92
View File
@@ -2,6 +2,52 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-campaign.collaboration": "Collaboration",
"i18n:govoplan-campaign.collaboration_description": "Bounded human discussion linked to stable Campaign evidence.",
"i18n:govoplan-campaign.loading_collaboration": "Loading campaign collaboration…",
"i18n:govoplan-campaign.collaboration_audit_boundary": "This thread contains human discussion only. System events and durable audit evidence remain in the Audit surface and are not replaced by comments.",
"i18n:govoplan-campaign.new_collaboration_entry": "New discussion entry",
"i18n:govoplan-campaign.comment": "Comment",
"i18n:govoplan-campaign.comment_help": "Posted text cannot be edited. Withdrawal or redaction removes the text and keeps an auditable tombstone.",
"i18n:govoplan-campaign.visibility": "Visibility",
"i18n:govoplan-campaign.visibility_help": "Collaborator entries are visible to discussion readers. Moderator entries require moderation authority.",
"i18n:govoplan-campaign.visibility_collaborators": "Campaign collaborators",
"i18n:govoplan-campaign.visibility_moderators": "Moderators only",
"i18n:govoplan-campaign.reference_context": "Reference context",
"i18n:govoplan-campaign.reference_context_help": "Optionally link the comment to stable Campaign evidence. The referenced version remains unchanged.",
"i18n:govoplan-campaign.no_reference": "No reference",
"i18n:govoplan-campaign.reference_campaign_version": "Campaign version",
"i18n:govoplan-campaign.reference_recipient_import": "Recipient import batch",
"i18n:govoplan-campaign.reference_attachment_rule": "Attachment rule",
"i18n:govoplan-campaign.reference_delivery_job": "Delivery job",
"i18n:govoplan-campaign.reference_report": "Report",
"i18n:govoplan-campaign.campaign_version": "Campaign version",
"i18n:govoplan-campaign.select_version": "Select a version",
"i18n:govoplan-campaign.stable_reference_id": "Stable reference ID",
"i18n:govoplan-campaign.stable_reference_id_help": "Use the ID shown by the referenced Campaign evidence. Version-bound references include the immutable version ID.",
"i18n:govoplan-campaign.mentions": "Mentions",
"i18n:govoplan-campaign.mentions_help": "Only active users who already have access to this Campaign can be mentioned.",
"i18n:govoplan-campaign.add_mention": "Add a campaign collaborator",
"i18n:govoplan-campaign.posting": "Posting…",
"i18n:govoplan-campaign.post_comment": "Post comment",
"i18n:govoplan-campaign.collaboration_posted": "The discussion entry was posted.",
"i18n:govoplan-campaign.collaboration_withdrawn": "The discussion entry was withdrawn; its tombstone remains.",
"i18n:govoplan-campaign.collaboration_redacted": "The discussion entry was redacted; its tombstone remains.",
"i18n:govoplan-campaign.collaboration_read_only": "You may read this discussion but do not have the separate permission required to post.",
"i18n:govoplan-campaign.discussion": "Discussion",
"i18n:govoplan-campaign.no_collaboration_entries": "No human discussion has been recorded for this Campaign.",
"i18n:govoplan-campaign.moderators_only": "Moderators only",
"i18n:govoplan-campaign.entry_redacted_tombstone": "This entry was redacted by an authorized moderator. Its timestamp and evidence hash remain.",
"i18n:govoplan-campaign.entry_withdrawn_tombstone": "This entry was withdrawn by its author. Its timestamp and evidence hash remain.",
"i18n:govoplan-campaign.reference_label": "Reference:",
"i18n:govoplan-campaign.withdraw": "Withdraw",
"i18n:govoplan-campaign.redact": "Redact",
"i18n:govoplan-campaign.loading_older_entries": "Loading older entries…",
"i18n:govoplan-campaign.load_older_entries": "Load older entries",
"i18n:govoplan-campaign.redact_collaboration_entry": "Redact discussion entry",
"i18n:govoplan-campaign.withdraw_collaboration_entry": "Withdraw discussion entry",
"i18n:govoplan-campaign.redact_collaboration_entry_confirmation": "Redact this text? The text will be removed while its tombstone and audit evidence remain.",
"i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation": "Withdraw this text? The text will be removed while its tombstone and audit evidence remain.",
"i18n:govoplan-campaign.unassigned_file_policy": "Unassigned file policy",
"i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected",
"i18n:govoplan-campaign.watched_sources": "Watched sources",
@@ -1346,6 +1392,52 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
},
"de": {
"i18n:govoplan-campaign.collaboration": "Zusammenarbeit",
"i18n:govoplan-campaign.collaboration_description": "Begrenzte menschliche Diskussion mit Bezug auf stabile Kampagnennachweise.",
"i18n:govoplan-campaign.loading_collaboration": "Kampagnenzusammenarbeit wird geladen…",
"i18n:govoplan-campaign.collaboration_audit_boundary": "Dieser Verlauf enthält nur menschliche Diskussionen. Systemereignisse und dauerhafte Auditnachweise bleiben im Audit-Bereich und werden nicht durch Kommentare ersetzt.",
"i18n:govoplan-campaign.new_collaboration_entry": "Neuer Diskussionseintrag",
"i18n:govoplan-campaign.comment": "Kommentar",
"i18n:govoplan-campaign.comment_help": "Veröffentlichter Text kann nicht bearbeitet werden. Rücknahme oder Schwärzung entfernt den Text und erhält einen auditierbaren Platzhalter.",
"i18n:govoplan-campaign.visibility": "Sichtbarkeit",
"i18n:govoplan-campaign.visibility_help": "Einträge für Mitwirkende sind für Diskussionsleser sichtbar. Moderationseinträge erfordern eine Moderationsberechtigung.",
"i18n:govoplan-campaign.visibility_collaborators": "Kampagnenmitwirkende",
"i18n:govoplan-campaign.visibility_moderators": "Nur Moderation",
"i18n:govoplan-campaign.reference_context": "Referenzkontext",
"i18n:govoplan-campaign.reference_context_help": "Der Kommentar kann optional mit einem stabilen Kampagnennachweis verknüpft werden. Die referenzierte Version bleibt unverändert.",
"i18n:govoplan-campaign.no_reference": "Keine Referenz",
"i18n:govoplan-campaign.reference_campaign_version": "Kampagnenversion",
"i18n:govoplan-campaign.reference_recipient_import": "Empfänger-Importlauf",
"i18n:govoplan-campaign.reference_attachment_rule": "Anlagenregel",
"i18n:govoplan-campaign.reference_delivery_job": "Sendeauftrag",
"i18n:govoplan-campaign.reference_report": "Bericht",
"i18n:govoplan-campaign.campaign_version": "Kampagnenversion",
"i18n:govoplan-campaign.select_version": "Version auswählen",
"i18n:govoplan-campaign.stable_reference_id": "Stabile Referenz-ID",
"i18n:govoplan-campaign.stable_reference_id_help": "Verwenden Sie die beim Kampagnennachweis angezeigte ID. Versionsgebundene Referenzen enthalten die unveränderliche Versions-ID.",
"i18n:govoplan-campaign.mentions": "Erwähnungen",
"i18n:govoplan-campaign.mentions_help": "Nur aktive Personen, die bereits Zugriff auf diese Kampagne haben, können erwähnt werden.",
"i18n:govoplan-campaign.add_mention": "Kampagnenmitwirkende hinzufügen",
"i18n:govoplan-campaign.posting": "Wird veröffentlicht…",
"i18n:govoplan-campaign.post_comment": "Kommentar veröffentlichen",
"i18n:govoplan-campaign.collaboration_posted": "Der Diskussionseintrag wurde veröffentlicht.",
"i18n:govoplan-campaign.collaboration_withdrawn": "Der Diskussionseintrag wurde zurückgenommen; sein Platzhalter bleibt erhalten.",
"i18n:govoplan-campaign.collaboration_redacted": "Der Diskussionseintrag wurde geschwärzt; sein Platzhalter bleibt erhalten.",
"i18n:govoplan-campaign.collaboration_read_only": "Sie dürfen diese Diskussion lesen, besitzen aber nicht die separate Berechtigung zum Veröffentlichen.",
"i18n:govoplan-campaign.discussion": "Diskussion",
"i18n:govoplan-campaign.no_collaboration_entries": "Für diese Kampagne wurde noch keine menschliche Diskussion erfasst.",
"i18n:govoplan-campaign.moderators_only": "Nur Moderation",
"i18n:govoplan-campaign.entry_redacted_tombstone": "Dieser Eintrag wurde durch eine berechtigte Moderation geschwärzt. Zeitstempel und Nachweis-Hash bleiben erhalten.",
"i18n:govoplan-campaign.entry_withdrawn_tombstone": "Dieser Eintrag wurde durch die verfassende Person zurückgenommen. Zeitstempel und Nachweis-Hash bleiben erhalten.",
"i18n:govoplan-campaign.reference_label": "Referenz:",
"i18n:govoplan-campaign.withdraw": "Zurücknehmen",
"i18n:govoplan-campaign.redact": "Schwärzen",
"i18n:govoplan-campaign.loading_older_entries": "Ältere Einträge werden geladen…",
"i18n:govoplan-campaign.load_older_entries": "Ältere Einträge laden",
"i18n:govoplan-campaign.redact_collaboration_entry": "Diskussionseintrag schwärzen",
"i18n:govoplan-campaign.withdraw_collaboration_entry": "Diskussionseintrag zurücknehmen",
"i18n:govoplan-campaign.redact_collaboration_entry_confirmation": "Diesen Text schwärzen? Der Text wird entfernt; Platzhalter und Auditnachweis bleiben erhalten.",
"i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation": "Diesen Text zurücknehmen? Der Text wird entfernt; Platzhalter und Auditnachweis bleiben erhalten.",
"i18n:govoplan-campaign.unassigned_file_policy": "Richtlinie für nicht zugeordnete Dateien",
"i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien",
"i18n:govoplan-campaign.watched_sources": "Überwachte Quellen",
+16 -4
View File
@@ -1,7 +1,9 @@
import type { CampaignWorkspaceSection } from "../types";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
export type CampaignWorkspaceNavigationSection = CampaignWorkspaceSection | "activity";
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] = [
{
items: [{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b", primary: true }]
},
@@ -38,6 +40,7 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
title: "i18n:govoplan-campaign.report.7b8ddb90",
items: [
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
{ id: "activity", label: "i18n:govoplan-campaign.collaboration" },
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
},
@@ -49,10 +52,19 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
export default function SectionSidebar({
active,
onSelect
onSelect,
canReadActivity
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
}: {
active: CampaignWorkspaceNavigationSection;
onSelect: (section: CampaignWorkspaceNavigationSection) => void;
canReadActivity: boolean;
}) {
const groups = campaignSubnav.map((group) => ({
...group,
items: group.items.filter((item) => item.id !== "activity" || canReadActivity)
}));
return <ModuleSubnav active={active} groups={groups} onSelect={onSelect} />;
}
+7
View File
@@ -91,6 +91,13 @@ export const campaignModule: PlatformWebModule = {
optionalDependencies: ["files", "mail"],
translations,
viewSurfaces: [
{
id: "campaigns.page.activity",
moduleId: "campaigns",
kind: "page",
label: "Campaign collaboration",
order: 45
},
{
id: "campaigns.widget.activity",
moduleId: "campaigns",
+25
View File
@@ -2780,3 +2780,28 @@
.campaign-residual-file-form { grid-template-columns: minmax(0, 1fr); }
.campaign-residual-file-wide { grid-column: auto; }
}
.campaign-collaboration-composer { display: grid; gap: 10px; }
.campaign-collaboration-composer textarea { width: 100%; resize: vertical; }
.campaign-collaboration-counter { justify-self: end; color: var(--muted); font-size: var(--font-size-sm); }
.campaign-collaboration-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.campaign-collaboration-options input,
.campaign-collaboration-options select { width: 100%; }
.campaign-collaboration-submit { justify-content: flex-end; }
.campaign-collaboration-thread { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.campaign-collaboration-entry { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-bg); }
.campaign-collaboration-entry article { display: grid; gap: 10px; padding: 14px; }
.campaign-collaboration-entry-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; color: var(--muted); font-size: var(--font-size-sm); }
.campaign-collaboration-actor { display: inline-flex; align-items: center; gap: 7px; color: var(--text); font-weight: 650; }
.campaign-collaboration-content { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.55; }
.campaign-collaboration-reference { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin: 0; color: var(--muted); font-size: var(--font-size-sm); }
.campaign-collaboration-reference code { overflow-wrap: anywhere; }
.campaign-collaboration-tombstone { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 9px 11px; border: var(--border-line); border-radius: var(--radius-sm); color: var(--muted); background: var(--subtle-bg); font-style: italic; }
.campaign-collaboration-entry-actions { justify-content: flex-end; padding-top: 4px; border-top: var(--border-line); }
.campaign-collaboration-load-more { justify-content: center; margin-top: 14px; }
@media (max-width: 760px) {
.campaign-collaboration-options { grid-template-columns: minmax(0, 1fr); }
.campaign-collaboration-submit,
.campaign-collaboration-entry-actions { justify-content: flex-start; }
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const page = fs.readFileSync(
path.join(root, "src/features/campaigns/CampaignCollaborationPage.tsx"),
"utf8"
);
const workspace = fs.readFileSync(
path.join(root, "src/features/campaigns/CampaignWorkspace.tsx"),
"utf8"
);
const sidebar = fs.readFileSync(
path.join(root, "src/layout/SectionSidebar.tsx"),
"utf8"
);
const api = fs.readFileSync(path.join(root, "src/api/campaigns.ts"), "utf8");
assert.match(page, /archetype="collection"/);
assert.match(page, /variant="collection"/);
assert.match(page, /refreshable/);
assert.match(page, /ReferenceMultiSelect/);
assert.match(page, /ConfirmDialog/);
assert.match(page, /campaigns:discussion:post/);
assert.match(page, /campaigns:discussion:moderate/);
assert.match(page, /maxLength=\{8000\}/);
assert.match(page, /<ol[^>]+aria-label="Campaign collaboration thread"/);
assert.match(page, /entry\.tombstone/);
assert.match(page, /collaboration_audit_boundary/);
assert.doesNotMatch(page, /updateCampaignVersion|saveCampaignVersion/);
assert.match(workspace, /path="activity"/);
assert.match(workspace, /CampaignCollaborationPage/);
assert.match(sidebar, /canReadActivity/);
assert.match(sidebar, /id: "activity"/);
assert.match(api, /\/collaboration\?/);
assert.match(api, /\/withdraw/);
assert.match(api, /\/redact/);
assert.match(api, /\/collaboration\/mention-options/);
console.log("Campaign collaboration UI structure checks passed.");