initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

View File

@@ -0,0 +1,379 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
import { asRecord } from "../utils/campaignView";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
type AttachmentRulesOverlayProps = {
title: string;
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
buttonLabel?: string;
emptyText?: string;
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
id?: string;
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
type FileChooserState = {
ruleIndex: number;
basePath: AttachmentBasePath | null;
};
export default function AttachmentRulesOverlay({
title,
rules,
settings,
campaignId,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const [draftRules, setDraftRules] = useState<AttachmentRule[]>([]);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
function openOverlay() {
setDraftRules(cloneAttachmentRules(rules));
setOpen(true);
}
function cancelOverlay() {
setOpen(false);
}
function saveOverlay() {
if (disabled) return;
onChange(draftRules);
setOpen(false);
}
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<AttachmentRulesDataGrid
rules={draftRules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
</div>
<footer className="modal-footer">
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</footer>
</div>
</div>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
{label}
</Button>
{dialog}
</>
);
}
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
return rules.map((rule) => ({
...rule,
...(rule.zip && typeof rule.zip === "object" ? { zip: { ...asRecord(rule.zip) } } : {})
}));
}
export function AttachmentRulesTable({
showAddButton = true,
onChange,
...tableProps
}: AttachmentRulesTableProps) {
function addRule() {
onChange([
...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
]);
}
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
</div>
)}
</div>
</div>
);
}
export function AttachmentRulesDataGrid({
rules,
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function addRule(afterIndex = rules.length - 1) {
if (disabled || basePaths.length === 0) return;
const nextRule = createAttachmentRule(basePaths[0].path, nextAttachmentLabel(rules), basePaths[0].id);
onChange(insertAfter(rules, afterIndex, nextRule));
}
function removeRule(index: number) {
setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function moveRule(index: number, targetIndex: number) {
if (disabled || index === targetIndex) return;
setFileChooser(null);
onChange(moveArrayItem(rules, index, targetIndex));
}
function openFileChooser(ruleIndex: number) {
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
}
const rule = rules[ruleIndex] ?? {};
const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId)
?? basePaths.find((item) => item.path === currentPath)
?? (!explicitlyReferenced ? basePaths[0] : undefined)
?? null;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
base_path_id: fileChooser.basePath?.id ?? "",
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
file_filter: selection.fileFilter,
type: selection.selectionType === "file" ? "direct" : "pattern",
include_subdirs: false,
label: getText(currentRule, "label") || `Attachment ${fileChooser.ruleIndex + 1}`
});
setFileChooser(null);
}
return (
<>
<DataGrid
id={id}
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>
)}
</>
);
}
type AttachmentRuleColumnContext = {
disabled: boolean;
rules: AttachmentRule[];
basePaths: AttachmentBasePath[];
zipConfig: AttachmentZipCollection;
activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
addRule: (afterIndex?: number) => void;
moveRule: (index: number, targetIndex: number) => void;
openFileChooser: (ruleIndex: number) => void;
removeRule: (index: number) => void;
};
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "Base path",
width: 250,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
?? (!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select
value={selectedBasePath?.id ?? ""}
disabled={disabled || basePaths.length === 0}
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}
>
{!selectedBasePath && (
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
)}
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select>
);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
},
{
id: "file_filter",
header: "File / pattern",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) => (
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly
tabIndex={-1}
placeholder="Choose a managed file or pattern"
onClick={() => !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
</div>
),
value: (rule) => getText(rule, "file_filter")
},
...(zipConfig.enabled ? [{
id: "zip",
header: "ZIP archive",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "Exclude from ZIP" },
{ value: "inherit", label: "Campaign standard" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
]
},
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
return (
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
>
<option value="exclude">Exclude from ZIP</option>
<option value="inherit">Campaign standard</option>
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
</select>
);
},
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_rule, index) => (
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="Add attachment below"
removeLabel="Remove attachment"
moveUpLabel="Move attachment up"
moveDownLabel="Move attachment down"
/>
)
}
];
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, CampaignListItem } from "../../../types";
import {
getCampaignShares,
getCampaignShareTargets,
revokeCampaignShare,
updateCampaignOwner,
upsertCampaignShare,
type CampaignShare,
type CampaignShareTargets
} from "../../../api/campaigns";
import Button from "../../../components/Button";
import Card from "../../../components/Card";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import Dialog from "../../../components/Dialog";
import FormField from "../../../components/FormField";
import StatusBadge from "../../../components/StatusBadge";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
settings,
campaign,
onChanged,
onError
}: {
settings: ApiSettings;
campaign: CampaignListItem;
onChanged: () => Promise<void>;
onError: (message: string) => void;
}) {
const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]);
const [ownerOpen, setOwnerOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<CampaignShare | null>(null);
const [ownerType, setOwnerType] = useState<TargetType>(campaign.owner_group_id ? "group" : "user");
const [ownerId, setOwnerId] = useState(campaign.owner_group_id || campaign.owner_user_id || "");
const [shareType, setShareType] = useState<TargetType>("user");
const [shareTargetId, setShareTargetId] = useState("");
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [busy, setBusy] = useState(false);
async function load() {
try {
const [nextTargets, nextShares] = await Promise.all([
getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id)
]);
setTargets(nextTargets);
setShares(nextShares.filter((item) => !item.revoked_at));
setAvailable(true);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.startsWith("403 ")) setAvailable(false);
else onError(message);
}
}
useEffect(() => {
setOwnerType(campaign.owner_group_id ? "group" : "user");
setOwnerId(campaign.owner_group_id || campaign.owner_user_id || "");
void load();
}, [campaign.id, campaign.owner_group_id, campaign.owner_user_id, settings.accessToken, settings.apiBaseUrl]);
const targetOptions = ownerType === "user" ? targets.users : targets.groups;
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
]), [targets]);
const ownerLabel = campaign.owner_group_id
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
async function saveOwner() {
if (!ownerId) return;
setBusy(true);
try {
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
? { owner_user_id: ownerId, owner_group_id: null }
: { owner_user_id: null, owner_group_id: ownerId });
setOwnerOpen(false);
await onChanged();
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function saveShare() {
if (!shareTargetId) return;
setBusy(true);
try {
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
setShareOpen(false);
setShareTargetId("");
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function revoke() {
if (!revokeTarget) return;
setBusy(true);
try {
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
setRevokeTarget(null);
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{
id: "target",
header: "Shared with",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>;
}
},
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> },
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
], [targetMap]);
if (available === false) return null;
return (
<>
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}>
<p><strong>Owner:</strong> {ownerLabel}</p>
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
</Card>
<Dialog open={ownerOpen} title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
</Dialog>
<Dialog open={shareOpen} title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>
);
}

View File

@@ -0,0 +1,36 @@
import { inputValueToFieldValue, normalizeFieldType, valueToInputText } from "../utils/fieldDefinitions";
export type FieldValueInputProps = {
fieldType?: string;
value: unknown;
disabled?: boolean;
placeholder?: string;
className?: string;
onChange: (value: unknown) => void;
};
export default function FieldValueInput({ fieldType = "string", value, disabled = false, placeholder, className, onChange }: FieldValueInputProps) {
const normalizedType = normalizeFieldType(fieldType);
const inputType = inputTypeForField(normalizedType);
const step = normalizedType === "integer" ? "1" : normalizedType === "double" ? "any" : undefined;
return (
<input
className={className}
type={inputType}
step={step}
value={valueToInputText(value, normalizedType)}
disabled={disabled}
placeholder={placeholder}
autoComplete={normalizedType === "password" ? "new-password" : undefined}
onChange={(event) => onChange(inputValueToFieldValue(normalizedType, event.target.value))}
/>
);
}
function inputTypeForField(fieldType: string): string {
if (fieldType === "integer" || fieldType === "double") return "number";
if (fieldType === "date") return "date";
if (fieldType === "password") return "password";
return "text";
}

View File

@@ -0,0 +1,235 @@
import { useState } from "react";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import {
forkCampaignVersion,
lockCampaignVersionPermanently,
unlockCampaignVersionUserLock,
unlockCampaignVersionValidation,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../../api/campaigns";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isHistoricalCampaignVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
} from "../utils/campaignView";
type LockedVersionNoticeProps = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | CampaignVersionListItem | null;
currentVersionId?: string | null;
reload: () => Promise<void>;
message?: string;
};
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
export default function LockedVersionNotice({ settings, campaignId, version, currentVersionId, reload, message }: LockedVersionNoticeProps) {
const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState("");
const [localMessage, setLocalMessage] = useState("");
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const historicalVersion = isHistoricalCampaignVersion(version, currentVersionId);
const validationLock = !historicalVersion && canUnlockValidationVersion(version);
const temporaryUserLock = !historicalVersion && isTemporaryUserLockedVersion(version);
const permanentUserLock = isPermanentUserLockedVersion(version);
const finalLock = isFinalLockedVersion(version);
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
const presentation = lockPresentation(version, {
historicalVersion,
validationLock,
temporaryUserLock,
permanentUserLock,
finalLock,
});
async function runAction(action: Exclude<ConfirmAction, null>) {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
} else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again.");
} else {
await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
}
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function createEditableCopy() {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null,
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<DismissibleAlert tone="info" dismissible={false} className={`locked-version-notice lock-kind-${presentation.kind}`}>
<div className="locked-version-copy">
<strong>{presentation.title}</strong>{" "}
<span>{presentation.description}</span>
{message && <span className="locked-version-context"> {message}</span>}
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
{localError && <span className="locked-version-error"> {localError}</span>}
</div>
<div className="button-row compact-actions locked-version-actions">
{validationLock && (
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"}
</Button>
)}
{temporaryUserLock && (
<>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"}
</Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently
</Button>
</>
)}
{canCreateEditableCopy && (
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"}
</Button>
)}
</div>
<ConfirmDialog
open={confirmAction !== null}
title={confirmDialogTitle(confirmAction)}
message={confirmDialogMessage(confirmAction)}
confirmLabel={confirmDialogLabel(confirmAction)}
tone={confirmAction === "unlock-user" ? "default" : "danger"}
busy={busy}
onCancel={() => setConfirmAction(null)}
onConfirm={() => {
const action = confirmAction;
setConfirmAction(null);
if (action) void runAction(action);
}}
/>
</DismissibleAlert>
);
}
type LockFlags = {
historicalVersion: boolean;
validationLock: boolean;
temporaryUserLock: boolean;
permanentUserLock: boolean;
finalLock: boolean;
};
function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags,
): { kind: string; title: string; description: string; info: string } {
if (flags.historicalVersion) {
return {
kind: "historical",
title: "Historical version.",
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
info: `Version #${version?.version_number ?? "—"}.`,
};
}
if (flags.temporaryUserLock) {
return {
kind: "temporary-user",
title: "Temporarily locked version.",
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
};
}
if (flags.permanentUserLock) {
return {
kind: "permanent-user",
title: "Permanently locked version.",
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
info: version?.user_locked_at || version?.published_at
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
: "",
};
}
if (flags.validationLock) {
return {
kind: "validation",
title: "Temporarily locked after validation.",
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
};
}
if (flags.finalLock) {
return {
kind: "delivery",
title: "Permanently locked delivery snapshot.",
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
};
}
return {
kind: "other",
title: "Locked version.",
description: "This version is read-only. Create an editable copy to continue working.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
};
}
function confirmDialogTitle(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation?";
if (action === "unlock-user") return "Unlock temporary lock?";
if (action === "permanent") return "Lock permanently?";
return "Confirm action";
}
function confirmDialogMessage(action: ConfirmAction): string {
if (action === "unlock-validation") {
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
}
if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
}
if (action === "permanent") {
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
}
return "Continue?";
}
function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation";
if (action === "unlock-user") return "Unlock";
if (action === "permanent") return "Lock permanently";
return "Confirm";
}

View File

@@ -0,0 +1,635 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import type { ApiSettings } from "../../../types";
import {
listFileSpaces,
listFiles,
listFolders,
resolveFilePatterns,
shareFileWithCampaign,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { FolderTree } from "../../files/components/FileManagerComponents";
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
import {
buildExplorerEntries,
buildFolderTree,
formatBytes,
formatDate,
normalizeFilePath,
normalizeFolder,
parentFolderPath,
sortExplorerEntries
} from "../../files/utils/fileManagerUtils";
import {
encodeManagedAttachmentSource,
parseManagedAttachmentSource,
type ManagedAttachmentSource
} from "../utils/attachments";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
type RememberedChooserState = {
spaceId?: string;
folder?: string;
pattern?: string;
};
export type ManagedFolderSelection = {
space: FileSpace;
folderPath: string;
source: string;
};
export type ManagedAttachmentSelection = ManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: AttachmentChoiceMode;
};
type ManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
campaignId: string;
mode: ManagedFileChooserMode;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
};
export default function ManagedFileChooser({
open,
settings,
campaignId,
mode,
source,
basePath = ".",
initialPattern = "",
rememberKey = mode,
onClose,
onSelectFolder,
onSelectAttachment
}: ManagedFileChooserProps) {
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
const normalizedBasePath = normalizeManagedBasePath(basePath);
const storageKey = useMemo(
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
[campaignId, rememberKey]
);
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
const [spaces, setSpaces] = useState<FileSpace[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [files, setFiles] = useState<ManagedFile[]>([]);
const [folders, setFolders] = useState<FileFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState("");
const [pattern, setPattern] = useState("");
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
const [loading, setLoading] = useState(false);
const [resolving, setResolving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
const entries = useMemo(
() => buildExplorerEntries(files, folders, currentFolder, false),
[currentFolder, files, folders]
);
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const sortedEntries = useMemo(
() => sortExplorerEntries(entries, sortColumn, sortDirection),
[entries, sortColumn, sortDirection]
);
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
setPattern(remembered.pattern ?? initialPattern.trim());
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
.then((response) => {
if (cancelled) return;
setSpaces(response.spaces);
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpace) return;
let cancelled = false;
setLoading(true);
setError("");
setPatternMatches(null);
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
setCurrentFolder(initialFolder || effectiveRoot);
void Promise.all([
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
])
.then(([fileResponse, folderResponse]) => {
if (cancelled) return;
setFiles(fileResponse.files);
setFolders(folderResponse.folders);
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpaceId) return;
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
function toggleSort(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}
function openFolder(path: string) {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = pattern.trim();
if (!trimmed) {
setPatternMatches([]);
return [];
}
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [trimmed],
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: effectiveRoot || undefined,
include_unmatched: false
});
const matches = response.patterns[0]?.matches ?? [];
setPatternMatches(matches);
return matches;
} catch (reason) {
setError(errorMessage(reason));
return [];
} finally {
setResolving(false);
}
}
function requestExactFile(file: ManagedFile) {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = pattern.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}
function applyExactFile(file: ManagedFile) {
setPattern(relativePath(file.display_path, effectiveRoot));
setPatternMatches([file]);
setPendingExactFile(null);
}
async function shareMatches(matches: ManagedFile[]) {
const toShare = matches.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)));
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
}
async function confirmSelection() {
if (!selectedSpace || !sourceMatchesSpace) return;
setSubmitting(true);
setError("");
try {
if (mode === "folder") {
onSelectFolder?.({
space: selectedSpace,
folderPath: currentFolder,
source: encodeManagedAttachmentSource(selectedSpace)
});
return;
}
const matches = patternMatches ?? await previewPattern();
await shareMatches(matches);
const trimmedPattern = pattern.trim();
onSelectAttachment?.({
space: selectedSpace,
folderPath: effectiveRoot,
source: encodeManagedAttachmentSource(selectedSpace),
fileFilter: trimmedPattern,
matchCount: matches.length,
fileIds: matches.map((file) => file.id),
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
});
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
}
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
<Dialog
open
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={(
<>
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
>
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
</Button>
</>
)}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{mode === "attachment" && !parsedSource && (
<DismissibleAlert tone="warning" dismissible={false}>
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments Attachment sources</strong> first.
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
<div className="managed-file-chooser-layout" aria-busy={loading}>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
<div className="file-tree-heading">Spaces</div>
<div className="file-tree-list">
{spaces.map((space) => {
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className="file-tree-space" key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);
else openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}
>
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{selected && (
<FolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
dragDropEnabled={false}
contextMenuEnabled={false}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
</div>
</aside>
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
<div className="managed-file-chooser-toolbar">
<div className="managed-file-breadcrumb" aria-label="Current folder">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
</span>
))}
</div>
</div>
{mode === "attachment" && (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={pattern}
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
</Button>
</div>
</label>
{patternMatches !== null && (
<div className="managed-pattern-summary">
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
</div>
)}
</div>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size"></span>
<span className="managed-file-entry-modified"></span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && pattern.trim() === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
)}
{mode === "folder" && (
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
)}
{mode === "attachment" && (
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
)}
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
/>
</>
);
return createPortal(dialog, document.body);
}
function ChooserSortButton({
column,
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<button
type="button"
className={active ? "is-sorted" : ""}
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
onClick={() => onSort(column)}
>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
}
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
</div>
<div className="file-list-table">
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{files.map((file) => (
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
<span className="file-list-name-cell">
<span className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
</div>
</div>
);
}
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
}
function normalizeManagedBasePath(value: string): string {
const normalized = normalizeFolder(value);
return normalized === "." ? "" : normalized;
}
function relativePath(path: string, root: string): string {
const normalizedPath = normalizeFilePath(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return normalizedPath;
const prefix = `${normalizedRoot}/`;
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
? normalizedFolder.slice(normalizedRoot.length + 1)
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const parts = relative.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
}));
}
function parentWithinRoot(folder: string, root: string): string {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
const parent = parentFolderPath(normalizedFolder);
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
}
function isWithinRoot(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return nodes;
const node = findNode(nodes, normalizedRoot);
return node?.children ?? [];
}
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
for (const node of nodes) {
if (node.path === path) return node;
const child = findNode(node.children, path);
if (child) return child;
}
return null;
}
function isExactPattern(value: string): boolean {
return Boolean(value) && !/[?*\[\]{}]/.test(value);
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function readRememberedState(key: string): RememberedChooserState {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeRememberedState(key: string, state: RememberedChooserState) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -0,0 +1,165 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import Button from "../../../components/Button";
export type MessagePreviewAttachment = {
filename?: string | null;
label?: string | null;
detail?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
archiveGroup?: string | null;
archiveLabel?: string | null;
};
export type MessagePreviewMetaItem = {
label: string;
value: React.ReactNode;
};
export type MessagePreviewNavigation = {
index: number;
total: number;
onFirst: () => void;
onPrevious: () => void;
onNext: () => void;
onLast: () => void;
};
export type MessagePreviewOverlayProps = {
title?: string;
subject?: string | null;
bodyMode?: "text" | "html";
text?: string | null;
html?: string | null;
recipientLabel?: React.ReactNode;
recipientNote?: React.ReactNode;
metaItems?: MessagePreviewMetaItem[];
attachments?: MessagePreviewAttachment[];
raw?: string | null;
rawLabel?: string;
navigation?: MessagePreviewNavigation;
closeLabel?: string;
onClose: () => void;
};
export default function MessagePreviewOverlay({
title = "Message preview",
subject,
bodyMode = "text",
text,
html,
recipientLabel,
recipientNote,
metaItems = [],
attachments = [],
raw,
rawLabel = "Raw MIME",
navigation,
closeLabel = "Close",
onClose
}: MessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject";
const shownText = text?.trim() || "No plain-text body to preview.";
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
<div className="modal-panel template-preview-modal message-preview-modal">
<header className="modal-header">
<h2 id="message-preview-title">{title}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
{(recipientLabel || recipientNote || navigation) && (
<div className="template-preview-toolbar">
<div>
{recipientLabel && <strong>{recipientLabel}</strong>}
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
</div>
{navigation && (
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
)}
</div>
)}
{metaItems.length > 0 && (
<div className="detail-grid message-preview-meta">
{metaItems.map((item) => (
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
))}
</div>
)}
<div className="template-preview-box">
<h3>{shownSubject}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
) : (
<pre>{shownText}</pre>
)}
</div>
<MessagePreviewAttachmentBoxes attachments={attachments} />
{raw && (
<details className="message-preview-raw">
<summary>{rawLabel}</summary>
<pre className="mock-message-raw">{raw}</pre>
</details>
)}
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
</div>
</div>
);
}
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
const archiveGroups = new Map<string, MessagePreviewAttachment[]>();
for (const attachment of attachments) {
if (!attachment.archiveGroup) continue;
const group = archiveGroups.get(attachment.archiveGroup) ?? [];
group.push(attachment);
archiveGroups.set(attachment.archiveGroup, group);
}
function attachmentChip(attachment: MessagePreviewAttachment, index: number) {
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
return (
<div className="attachment-file-chip" key={`${filename}:${index}`}>
<strong>{filename}</strong>
{details && <span>{details}</span>}
</div>
);
}
return (
<div className="template-preview-attachments message-preview-attachments">
<h3>Attachments</h3>
{attachments.length === 0 ? (
<p className="muted small-note">No attachments are effective for this message.</p>
) : (
<div className="message-preview-attachment-layout">
{direct.length > 0 && <div className="attachment-chip-grid">{direct.map(attachmentChip)}</div>}
{[...archiveGroups.entries()].map(([groupName, items]) => (
<section className="attachment-zip-group" key={groupName}>
<header>
<strong>{items[0]?.archiveLabel || groupName}</strong>
<span>{items.length} file{items.length === 1 ? "" : "s"} inside ZIP</span>
</header>
<div className="attachment-chip-grid">{items.map(attachmentChip)}</div>
</section>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import {
buildUndefinedPlaceholders,
extractTemplatePlaceholders,
removePlaceholderFromText,
type TemplateNamespace,
type UndefinedPlaceholder
} from "../utils/templatePlaceholders";
import {
TemplateFieldChipList,
UndefinedPlaceholderDecisionDialog,
UndefinedPlaceholderList,
type TemplateFieldOption
} from "./TemplatePlaceholderControls";
export default function TemplateExpressionEditorDialog({
open,
title,
value,
localFields,
globalFields,
placeholder,
description,
saveLabel = "Save",
validate,
onCancel,
onSave,
onAddField,
canAddField = () => true
}: {
open: boolean;
title: string;
value: string;
localFields: TemplateFieldOption[];
globalFields: TemplateFieldOption[];
placeholder?: string;
description?: string;
saveLabel?: string;
validate?: (value: string) => string;
onCancel: () => void;
onSave: (value: string) => void;
onAddField: (name: string) => void;
canAddField?: (name: string) => boolean;
}) {
const [draftValue, setDraftValue] = useState(value);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setDraftValue(value);
setUndefinedDialog(null);
}, [open, value]);
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(draftValue), [draftValue]);
const localNames = useMemo(() => new Set(localFields.map((field) => field.name)), [localFields]);
const globalNames = useMemo(() => new Set(globalFields.map((field) => field.name)), [globalFields]);
const availableNames = useMemo(() => new Set([...localNames, ...globalNames]), [globalNames, localNames]);
const undefinedPlaceholders = useMemo(
() => buildUndefinedPlaceholders(usedPlaceholders, availableNames, { local: localNames, global: globalNames }),
[availableNames, globalNames, localNames, usedPlaceholders]
);
const validationMessage = validate?.(draftValue) ?? "";
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
const input = inputRef.current;
const token = `{{${namespace}:${name}}}`;
const defaultPosition = draftValue.toLocaleLowerCase().endsWith(".zip") ? draftValue.length - 4 : draftValue.length;
const start = input?.selectionStart ?? defaultPosition;
const end = input?.selectionEnd ?? start;
setDraftValue(`${draftValue.slice(0, start)}${token}${draftValue.slice(end)}`);
window.requestAnimationFrame(() => {
input?.focus();
const cursor = start + token.length;
input?.setSelectionRange(cursor, cursor);
});
}
function requestSave() {
if (validationMessage) return;
if (undefinedPlaceholders.length > 0) {
setUndefinedDialog(undefinedPlaceholders[0]);
return;
}
onSave(draftValue.trim());
}
function removeUndefined(field: UndefinedPlaceholder) {
setDraftValue((current) => removePlaceholderFromText(current, field.raw));
setUndefinedDialog(null);
}
function addUndefined(field: UndefinedPlaceholder) {
if (!field.name || field.reason === "invalid-namespace") return;
onAddField(field.name);
setUndefinedDialog(null);
}
if (!open || typeof document === "undefined") return null;
return createPortal(
<>
<Dialog
open={open && !undefinedDialog}
title={title}
closeLabel="Cancel filename changes"
className="template-expression-dialog"
headerClassName="template-expression-dialog-header"
bodyClassName="template-expression-dialog-body"
footerClassName="template-expression-dialog-footer"
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel}>Cancel</Button>
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
</>
)}
>
{description && <p className="muted template-expression-description">{description}</p>}
<label className="template-expression-value-field">
<span>Archive filename</span>
<input
ref={inputRef}
value={draftValue}
placeholder={placeholder}
aria-invalid={Boolean(validationMessage) || undefined}
onChange={(event) => setDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
requestSave();
}
}}
/>
</label>
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
<h3 className="section-mini-heading">Recipient fields</h3>
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
<TemplateFieldChipList
namespace="local"
fields={localFields}
usedPlaceholders={usedPlaceholders}
empty="No recipient fields are available."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Campaign fields</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFields}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values are available."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="archive filename"
removeLabel="Remove from filename"
onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined}
onAddField={addUndefined}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/>
</>,
document.body
);
}

View File

@@ -0,0 +1,118 @@
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {
name: string;
label: string;
};
export function TemplateFieldChipList({
namespace,
fields,
usedPlaceholders = [],
empty,
disabled = false,
onInsert
}: {
namespace: TemplateNamespace;
fields: TemplateFieldOption[];
usedPlaceholders?: TemplatePlaceholder[];
empty: string;
disabled?: boolean;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
if (fields.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{fields.map((field) => {
const used = usedPlaceholders.some((placeholder) => placeholder.validNamespace && placeholder.namespace === namespace && placeholder.name === field.name);
return (
<button
type="button"
className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`}
disabled={disabled}
onClick={() => onInsert(namespace, field.name)}
>
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
</button>
);
})}
</div>
);
}
export function UndefinedPlaceholderList({
items,
empty = "No undefined placeholders detected.",
onSelect
}: {
items: UndefinedPlaceholder[];
empty?: string;
onSelect: (item: UndefinedPlaceholder) => void;
}) {
if (items.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button
type="button"
className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)}
>
{item.display}
</button>
))}
</div>
);
}
export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
onCancel,
onRemove,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
return (
<Dialog
open={Boolean(field)}
title="Undefined field reference"
closeLabel="Return to editing"
className="template-action-dialog"
onClose={onCancel}
footer={field ? (
<>
<Button onClick={onCancel}>Continue editing</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</>
) : undefined}
>
{field && (
<>
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{field.reason === "invalid-namespace" && (
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
)}
{field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)}
</>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = {
version: CampaignVersionDetail | CampaignVersionListItem | null;
versions?: CampaignVersionListItem[];
status?: string;
loadedAt?: string | null;
};
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
const first = currentIndex > 0 ? sorted[0] : null;
const previous = currentIndex > 0 ? sorted[currentIndex - 1] : null;
const next = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[currentIndex + 1] : null;
const latest = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[sorted.length - 1] : null;
const suffix = status ?? `Loaded ${formatDateTime(loadedAt ?? version?.updated_at)}`;
function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl));
}
return (
<p className="mono-small version-line">
<VersionArrow
icon="first"
target={first}
fallbackLabel="Already at first version"
onOpen={openVersion}
/>
<VersionArrow
icon="previous"
target={previous}
fallbackLabel="No older version"
onOpen={openVersion}
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span>
<VersionArrow
icon="next"
target={next}
fallbackLabel="No newer version"
onOpen={openVersion}
/>
<VersionArrow
icon="latest"
target={latest}
fallbackLabel="Already at latest version"
onOpen={openVersion}
/>
<span className="version-line-separator">·</span>
<span>{suffix}</span>
</p>
);
}
type VersionArrowProps = {
icon: "first" | "previous" | "next" | "latest";
target: CampaignVersionListItem | null;
fallbackLabel: string;
onOpen: (target: CampaignVersionListItem) => void;
};
function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps) {
const Icon = getVersionIcon(icon);
const actionLabel = getVersionActionLabel(icon, target);
if (!target) {
return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" />
</span>
);
}
return (
<button
type="button"
className="version-arrow"
onClick={() => onOpen(target)}
title={actionLabel}
aria-label={actionLabel}
>
<Icon aria-hidden="true" />
</button>
);
}
function getVersionIcon(icon: VersionArrowProps["icon"]) {
switch (icon) {
case "first":
return ArrowBigLeftDash;
case "previous":
return ArrowBigLeft;
case "next":
return ArrowBigRight;
case "latest":
return ArrowBigRightDash;
}
}
function getVersionActionLabel(icon: VersionArrowProps["icon"], target: CampaignVersionListItem | null): string {
const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) {
case "first":
return `Open first ${versionLabel}`;
case "previous":
return `Open previous ${versionLabel}`;
case "next":
return `Open next ${versionLabel}`;
case "latest":
return `Open latest ${versionLabel}`;
}
}
function versionTarget(pathname: string, search: string, versionId: string): string {
const params = new URLSearchParams(search);
params.set("version", versionId);
return `${pathname}?${params.toString()}`;
}