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:
+3
-2
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
Reference in New Issue
Block a user