Complete governed campaign lifecycle actions
This commit is contained in:
@@ -85,6 +85,21 @@ export type CampaignVersionListItem = {
|
||||
execution_snapshot_at?: string | null;
|
||||
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
|
||||
delivery_mode_selected_at?: string | null;
|
||||
archived_at?: string | null;
|
||||
archived_by_user_id?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignLifecycleAction = {
|
||||
allowed: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignLifecyclePolicy = {
|
||||
policy_id: string;
|
||||
policy_version: string;
|
||||
state_token: string;
|
||||
actions: Record<string, CampaignLifecycleAction>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||
@@ -1004,10 +1019,59 @@ payload: CampaignUpdatePayload)
|
||||
|
||||
export async function archiveCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
campaignId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/archive`, {
|
||||
method: "POST"
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_state_token: expectedStateToken })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaignLifecyclePolicy(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string | null)
|
||||
: Promise<CampaignLifecyclePolicy> {
|
||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
return apiFetch<CampaignLifecyclePolicy>(settings, `/api/v1/campaigns/${campaignId}/lifecycle-policy${suffix}`);
|
||||
}
|
||||
|
||||
export async function deleteCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<void> {
|
||||
await apiFetch<void>(settings, `/api/v1/campaigns/${campaignId}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ expected_state_token: expectedStateToken })
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
sourceVersionId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_version_id: sourceVersionId,
|
||||
expected_state_token: expectedStateToken
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<CampaignVersionListItem> {
|
||||
return apiFetch<CampaignVersionListItem>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_state_token: expectedStateToken })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Archive, ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
|
||||
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
@@ -10,14 +10,20 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import {
|
||||
archiveCampaign,
|
||||
archiveCampaignVersion,
|
||||
copyCampaign,
|
||||
deleteCampaign,
|
||||
getCampaignLifecyclePolicy,
|
||||
lockCampaignVersionPermanently,
|
||||
lockCampaignVersionTemporarily,
|
||||
unlockCampaignVersionUserLock,
|
||||
updateCampaignMetadata,
|
||||
type CampaignLifecyclePolicy,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
@@ -39,22 +45,32 @@ import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddre
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
type LockAction = "temporary" | "unlock" | "permanent";
|
||||
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
||||
type LifecycleAction = "archive_campaign" | "delete_campaign" | "copy_campaign" | "archive_version";
|
||||
type PendingLifecycleAction = {
|
||||
action: LifecycleAction;
|
||||
policy: CampaignLifecyclePolicy;
|
||||
version?: CampaignVersionListItem;
|
||||
} | null;
|
||||
|
||||
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const campaign = data.campaign;
|
||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||
const [showArchivedVersions, setShowArchivedVersions] = useState(false);
|
||||
const archivedVersionCount = useMemo(() => data.versions.filter((version) => Boolean(version.archived_at)).length, [data.versions]);
|
||||
const versions = useMemo(() => data.versions.filter((version) => showArchivedVersions || !version.archived_at).sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions, showArchivedVersions]);
|
||||
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
||||
const [identityDirty, setIdentityDirty] = useState(false);
|
||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy") && hasScope(auth, "campaigns:recipient:read");
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: identityDirty,
|
||||
@@ -149,20 +165,56 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
await reload({ force: true });
|
||||
}
|
||||
|
||||
async function applyArchive() {
|
||||
if (!campaign || archiving) return;
|
||||
setArchiving(true);
|
||||
async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) {
|
||||
if (!campaign || lifecycleBusy || identityDirty) return;
|
||||
setLifecycleBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await archiveCampaign(settings, campaign.id);
|
||||
setArchiveDialogOpen(false);
|
||||
setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7");
|
||||
const policy = await getCampaignLifecyclePolicy(settings, campaign.id, version?.id);
|
||||
const decision = policy.actions[action];
|
||||
if (!decision?.allowed) {
|
||||
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
|
||||
return;
|
||||
}
|
||||
setPendingLifecycleAction({ action, policy, version });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLifecycleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyLifecycleAction() {
|
||||
if (!campaign || !pendingLifecycleAction || lifecycleBusy) return;
|
||||
const pending = pendingLifecycleAction;
|
||||
setLifecycleBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
if (pending.action === "archive_campaign") {
|
||||
await archiveCampaign(settings, campaign.id, pending.policy.state_token);
|
||||
setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7");
|
||||
} else if (pending.action === "delete_campaign") {
|
||||
await deleteCampaign(settings, campaign.id, pending.policy.state_token);
|
||||
setPendingLifecycleAction(null);
|
||||
navigate("/campaigns");
|
||||
return;
|
||||
} else if (pending.action === "copy_campaign" && pending.version) {
|
||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
||||
setPendingLifecycleAction(null);
|
||||
navigate(`/campaigns/${created.campaign.id}`);
|
||||
return;
|
||||
} else if (pending.action === "archive_version" && pending.version) {
|
||||
await archiveCampaignVersion(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
||||
setMessage(`Version #${pending.version.version_number} archived.`);
|
||||
}
|
||||
setPendingLifecycleAction(null);
|
||||
await reload({ force: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
setLifecycleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,10 +226,25 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{canCopy && data.currentVersion && <Button
|
||||
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before copying." : undefined}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
Copy campaign
|
||||
</Button>}
|
||||
{canDelete && <Button
|
||||
variant="danger"
|
||||
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
Delete draft
|
||||
</Button>}
|
||||
{canArchive && <Button
|
||||
variant="danger"
|
||||
onClick={() => setArchiveDialogOpen(true)}
|
||||
disabled={loading || savingIdentity || lockBusy || identityDirty}
|
||||
onClick={() => void prepareLifecycleAction("archive_campaign")}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0" : undefined}>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
i18n:govoplan-campaign.archive_campaign.26dcfb8a
|
||||
@@ -220,14 +287,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Versions" collapsible actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>}>
|
||||
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
|
||||
{archivedVersionCount > 0 && <ToggleSwitch
|
||||
label={`Show archived (${archivedVersionCount})`}
|
||||
checked={showArchivedVersions}
|
||||
onChange={setShowArchivedVersions} />}
|
||||
<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>
|
||||
</div>}>
|
||||
<div className="metric-grid inside campaign-versions-metrics">
|
||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||
@@ -244,26 +316,33 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
rows={versions}
|
||||
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
|
||||
columns={versionColumns(
|
||||
setPendingLockAction,
|
||||
navigate,
|
||||
campaign?.current_version_id,
|
||||
canCopy,
|
||||
hasScope(auth, "campaigns:campaign:archive"),
|
||||
(action, version) => void prepareLifecycleAction(action, version)
|
||||
)}
|
||||
getRowKey={(version) => version.id}
|
||||
initialSort={{ columnId: "version", direction: "desc" }}
|
||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||
className="version-history-table"
|
||||
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
||||
rowClassName={(version) => version.archived_at ? "archived-version-row" : version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
open={archiveDialogOpen}
|
||||
title="i18n:govoplan-campaign.archive_campaign.26dcfb8a"
|
||||
message="i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1"
|
||||
confirmLabel="i18n:govoplan-campaign.archive_campaign.26dcfb8a"
|
||||
open={Boolean(pendingLifecycleAction)}
|
||||
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
||||
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
||||
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
||||
tone="danger"
|
||||
busy={archiving}
|
||||
onCancel={() => setArchiveDialogOpen(false)}
|
||||
onConfirm={() => void applyArchive()} />
|
||||
busy={lifecycleBusy}
|
||||
onCancel={() => setPendingLifecycleAction(null)}
|
||||
onConfirm={() => void applyLifecycleAction()} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLockAction)}
|
||||
@@ -336,7 +415,14 @@ function textValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||
function versionColumns(
|
||||
setPendingLockAction: (action: PendingLockAction) => void,
|
||||
navigate: (to: string) => void,
|
||||
currentVersionId: string | null | undefined,
|
||||
canCopy: boolean,
|
||||
canArchive: boolean,
|
||||
onLifecycleAction: (action: LifecycleAction, version: CampaignVersionListItem) => void
|
||||
): DataGridColumn<CampaignVersionListItem>[] {
|
||||
return [
|
||||
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||
@@ -356,6 +442,8 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
|
||||
{ id: "copy-campaign", label: "Copy as new campaign", icon: <Copy aria-hidden="true" />, applicable: canCopy, onClick: () => onLifecycleAction("copy_campaign", version) },
|
||||
{ id: "archive-version", label: "Archive historical version", icon: <Archive aria-hidden="true" />, variant: "danger", applicable: canArchive && !isCurrent && !version.archived_at, onClick: () => onLifecycleAction("archive_version", version) },
|
||||
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
|
||||
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
|
||||
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
|
||||
@@ -367,6 +455,7 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
}
|
||||
|
||||
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
||||
if (version.archived_at) return "Archived from default history";
|
||||
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
|
||||
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
||||
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
||||
@@ -415,3 +504,27 @@ function lockDialogLabel(pending: PendingLockAction): string {
|
||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||
}
|
||||
|
||||
function lifecycleDialogTitle(pending: PendingLifecycleAction): string {
|
||||
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
|
||||
if (pending?.action === "delete_campaign") return "Delete untouched draft";
|
||||
if (pending?.action === "copy_campaign") return "Copy campaign";
|
||||
if (pending?.action === "archive_version") return "Archive historical version";
|
||||
return "Confirm lifecycle action";
|
||||
}
|
||||
|
||||
function lifecycleDialogMessage(pending: PendingLifecycleAction): string {
|
||||
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1";
|
||||
if (pending?.action === "delete_campaign") return "This removes the untouched draft from active work. Drafts with build, delivery, sharing, lock, publication, or snapshot evidence cannot be deleted.";
|
||||
if (pending?.action === "copy_campaign") return `Create a fresh campaign draft from version #${pending.version?.version_number ?? "?"}? Delivery jobs, outcomes, shares, locks, and audit evidence are not copied.`;
|
||||
if (pending?.action === "archive_version") return `Hide historical version #${pending.version?.version_number ?? "?"} from the default history? Its configuration, reports, delivery results, and audit evidence remain available.`;
|
||||
return "Review the lifecycle consequence before continuing.";
|
||||
}
|
||||
|
||||
function lifecycleDialogLabel(pending: PendingLifecycleAction): string {
|
||||
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
|
||||
if (pending?.action === "delete_campaign") return "Delete draft";
|
||||
if (pending?.action === "copy_campaign") return "Create copy";
|
||||
if (pending?.action === "archive_version") return "Archive version";
|
||||
return "Confirm";
|
||||
}
|
||||
|
||||
@@ -1285,6 +1285,11 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version-history-table .data-grid-body-cell.archived-version-row {
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.mock-message-detail {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--line-subtle);
|
||||
@@ -2761,3 +2766,18 @@
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.related-link-card,
|
||||
.recipient-import-step-icon {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.related-link-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.review-flow-stage[data-state="running"] .review-flow-stage-node {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user