initial commit after split
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
160
webui/src/features/campaigns/components/CampaignAccessCard.tsx
Normal file
160
webui/src/features/campaigns/components/CampaignAccessCard.tsx
Normal 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()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
webui/src/features/campaigns/components/FieldValueInput.tsx
Normal file
36
webui/src/features/campaigns/components/FieldValueInput.tsx
Normal 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";
|
||||
}
|
||||
235
webui/src/features/campaigns/components/LockedVersionNotice.tsx
Normal file
235
webui/src/features/campaigns/components/LockedVersionNotice.tsx
Normal 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";
|
||||
}
|
||||
635
webui/src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
635
webui/src/features/campaigns/components/ManagedFileChooser.tsx
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
127
webui/src/features/campaigns/components/VersionLine.tsx
Normal file
127
webui/src/features/campaigns/components/VersionLine.tsx
Normal 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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user