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,623 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import type { ApiSettings } from "../../types";
import { listFileSpaces, type FileSpace } from "../../api/files";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "files",
unsavedTitle: "Unsaved attachment settings",
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
const zipArchiveNameValidation = useMemo(
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
[zipConfig.archives, zipConfig.enabled]
);
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
useEffect(() => {
let cancelled = false;
void listFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
const normalized = ensureAttachmentBasePaths(paths);
setDraft((current) => {
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
});
markDirty();
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
}
function addBasePath(afterIndex = basePaths.length - 1) {
patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", ".")));
}
function removeBasePath(index: number) {
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
}
function moveBasePath(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
}
function setIndividualEligibility(index: number, checked: boolean) {
if (locked) return;
if (checked) {
patchBasePath(index, { allow_individual: true });
return;
}
const basePath = basePaths[index];
if (!basePath) return;
const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath);
if (usageCount > 0) {
setIndividualDisable({ index, usageCount });
return;
}
patchBasePath(index, { allow_individual: false });
}
function confirmIndividualDisable() {
if (!individualDisable) return;
const basePath = basePaths[individualDisable.index];
if (!basePath) {
setIndividualDisable(null);
return;
}
const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item);
setDraft((current) => {
const source = current ?? {};
const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths);
const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || ".");
return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath));
});
markDirty();
setIndividualDisable(null);
}
function patchZipCollection(next: AttachmentZipCollection) {
if (locked) return;
patch(["attachments", "zip"], next);
}
function setZipEnabled(enabled: boolean) {
const archives = enabled && zipConfig.archives.length === 0
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
: zipConfig.archives;
patchZipCollection({ enabled, archives });
}
function patchZipArchive(index: number, archivePatch: Partial<AttachmentZipArchive>) {
const archives = zipConfig.archives.map((archive, currentIndex) => {
if (currentIndex !== index) return archive;
const next = { ...archive, ...archivePatch };
if (archivePatch.password_field !== undefined || archivePatch.password_scope !== undefined) {
next.password_mode = "field";
delete next.password;
delete next.password_template;
}
return next;
});
patchZipCollection({ ...zipConfig, archives });
}
function setStandardZipArchive(index: number) {
patchZipCollection({
...zipConfig,
archives: zipConfig.archives.map((archive, currentIndex) => ({ ...archive, standard: currentIndex === index }))
});
}
function addZipArchive(afterIndex = zipConfig.archives.length - 1) {
const archive = createAttachmentZipArchive(`attachments-${zipConfig.archives.length + 1}.zip`, zipConfig.archives.length === 0);
patchZipCollection({ ...zipConfig, archives: insertAfter(zipConfig.archives, afterIndex, archive) });
}
function removeZipArchive(index: number) {
const removed = zipConfig.archives[index];
if (!removed) return;
const removedWasStandard = removed.standard;
const archives = zipConfig.archives.filter((_, currentIndex) => currentIndex !== index);
if (removedWasStandard && archives.length > 0) archives[0] = { ...archives[0], standard: true };
const resetRule = (value: unknown) => {
const rule = asRecord(value);
const ruleZip = asRecord(rule.zip);
return String(ruleZip.archive_id ?? "") === removed.id
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } }
: rule;
};
setDraft((current) => {
const source = asRecord(current ?? {});
const currentAttachments = asRecord(source.attachments);
const currentEntries = asRecord(source.entries);
return {
...source,
attachments: {
...currentAttachments,
zip: { ...zipConfig, archives },
global: asArray(currentAttachments.global).map(resetRule)
},
entries: {
...currentEntries,
inline: asArray(currentEntries.inline).map((value) => {
const entry = asRecord(value);
return { ...entry, attachments: asArray(entry.attachments).map(resetRule) };
})
}
};
});
markDirty();
}
function moveZipArchive(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Attachment sources">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
</Card>
<Card title="ZIP attachments" collapsible>
<div className="attachment-zip-master-toggle">
<ToggleSwitch
label="Enable ZIP attachments"
checked={zipConfig.enabled}
disabled={locked}
onChange={setZipEnabled}
/>
</div>
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
)}
{zipArchiveNameValidation.message && (
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
)}
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
</Card>
<Card title="Global Attachments">
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
disabled={locked}
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
basePaths={basePaths}
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>
<Card title="Statistics">
<dl className="detail-list">
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>Connected through Files</dd></div>
</dl>
<p className="muted small-note">Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.</p>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
<TemplateExpressionEditorDialog
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
title="Edit ZIP archive filename"
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
placeholder="attachments.zip"
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
saveLabel="Save filename"
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
onCancel={() => setZipNameEditorIndex(null)}
onSave={(name) => {
if (zipNameEditorIndex === null) return;
patchZipArchive(zipNameEditorIndex, { name });
setZipNameEditorIndex(null);
}}
onAddField={(name) => {
if (!draft || locked || !name) return;
const existingFields = asArray(draft.fields).map(asRecord);
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
patch(["fields"], [
...existingFields,
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }
]);
}}
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
mode="folder"
source={basePaths[pathChooser.index]?.source}
basePath={basePaths[pathChooser.index]?.path}
onClose={() => setPathChooser(null)}
onSelectFolder={(selection) => {
const current = basePaths[pathChooser.index];
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
patchBasePath(pathChooser.index, {
source: selection.source,
path: selection.folderPath || ".",
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
: current.name
});
setPathChooser(null);
}}
/>
)}
<ConfirmDialog
open={Boolean(individualDisable)}
title="Disable individual attachments for this source?"
message={individualDisable
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
: ""}
confirmLabel="Remove individual attachments"
cancelLabel="Cancel"
tone="danger"
onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)}
/>
</div>
);
}
type ZipArchiveColumnContext = {
disabled: boolean;
archives: AttachmentZipArchive[];
invalidNameIndexes: ReadonlySet<number>;
onEditName: (index: number) => void;
passwordFields: ReturnType<typeof getDraftFields>;
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
setStandard: (index: number) => void;
addArchive: (afterIndex?: number) => void;
moveArchive: (index: number, targetIndex: number) => void;
removeArchive: (index: number) => void;
};
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [
{
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) => (
<button
type="button"
className="attachment-zip-name-button"
disabled={disabled}
aria-invalid={invalidNameIndexes.has(index) || undefined}
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`}
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"}
onClick={() => onEditName(index)}
>
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span>
<Pencil size={15} aria-hidden="true" />
</button>
),
value: (archive) => archive.name
},
{
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] },
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
value: (archive) => archive.standard ? "standard" : "optional"
},
{
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] },
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
value: (archive) => archive.password_enabled ? "protected" : "none"
},
{
id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "No field" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
render: (archive, index) => (
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
<option value="">Select password field</option>
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
),
value: (archive) => archive.password_field
},
{
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] },
render: (archive, index) => (
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
<option value="local">Recipient / local</option>
<option value="global">Campaign / global</option>
</select>
),
value: (archive) => archive.password_scope
},
{
id: "actions", header: "Actions", width: 180, sticky: "end",
render: (_archive, index) => <DataGridRowActions
disabled={disabled}
onAddBelow={() => addArchive(index)}
onRemove={() => removeArchive(index)}
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
addLabel="Add ZIP attachment below"
removeLabel="Remove ZIP attachment"
moveUpLabel="Move ZIP attachment up"
moveDownLabel="Move ZIP attachment down"
/>
}
];
}
type ZipFilenameNamespace = "local" | "global";
type ZipFilenameFieldOption = {
namespace: ZipFilenameNamespace;
name: string;
label: string;
};
type ZipArchiveNameValidation = {
invalidIndexes: ReadonlySet<number>;
message: string;
};
const EMPTY_ZIP_ARCHIVE_NAME_VALIDATION: ZipArchiveNameValidation = {
invalidIndexes: new Set<number>(),
message: ""
};
function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilenameFieldOption[] {
const fieldDefinitions = new Map(getDraftFields(draft).map((field) => [field.name, field]));
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name]));
const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]);
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))])
.filter((name) => fieldDefinitions.get(name)?.type !== "password");
return [
...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })),
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))
];
}
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
const normalized = normalizeZipArchiveName(value);
if (!normalized) return "Archive filename must not be empty.";
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
return duplicate ? "Another ZIP attachment already uses this effective filename." : "";
}
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
const invalidIndexes = new Set<number>();
const byName = new Map<string, number[]>();
archives.forEach((archive, index) => {
const normalized = normalizeZipArchiveName(archive.name);
if (!normalized) {
invalidIndexes.add(index);
return;
}
const indexes = byName.get(normalized) ?? [];
indexes.push(index);
byName.set(normalized, indexes);
});
for (const indexes of byName.values()) {
if (indexes.length < 2) continue;
indexes.forEach((index) => invalidIndexes.add(index));
}
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
const duplicateNames = [...byName.entries()]
.filter(([, indexes]) => indexes.length > 1)
.map(([, indexes]) => archives[indexes[0]]?.name.trim())
.filter(Boolean);
const parts: string[] = [];
if (hasEmpty) parts.push("Archive filenames must not be empty");
if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`);
return { invalidIndexes, message: `${parts.join(". ")}. Saving is disabled until this is corrected.` };
}
function normalizeZipArchiveName(value: string): string {
const trimmed = value.trim().toLocaleLowerCase();
if (!trimmed) return "";
return trimmed.endsWith(".zip") ? trimmed : `${trimmed}.zip`;
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void;
moveBasePath: (index: number, targetIndex: number) => void;
removeBasePath: (index: number) => void;
setPathChooser: (state: PathChooserState | null) => void;
};
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
id: "path",
header: "Path",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (basePath, index) => (
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={formatAttachmentSourcePath(basePath, fileSpaces)}
disabled={locked}
readOnly
tabIndex={-1}
placeholder="attachments"
onClick={() => !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (!locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
</div>
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
},
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_basePath, index) => (
<DataGridRowActions
disabled={locked}
removeDisabled={basePaths.length <= 1}
onAddBelow={() => addBasePath(index)}
onRemove={() => removeBasePath(index)}
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
addLabel="Add attachment source below"
removeLabel="Remove attachment source"
moveUpLabel="Move attachment source up"
moveDownLabel="Move attachment source down"
/>
)
}
];
}
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
const parsedSource = parseManagedAttachmentSource(basePath.source);
const matchingSpace = parsedSource
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
: undefined;
const rootLabel = matchingSpace?.label
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
return `${relativePath || "."}/`;
}