Files
govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage.tsx

652 lines
35 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
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 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, i18nMessage } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, 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 navigate = useGuardedNavigate();
const filesModuleInstalled = usePlatformModuleInstalled("files");
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
const managedFilesAvailable = Boolean(ManagedFileChooser);
const listManagedFileSpaces = filesModuleInstalled ? filesFileExplorer?.listFileSpaces : undefined;
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
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: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67",
unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb"
});
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]);
const attachmentPreviewEntry = useMemo(
() => asArray(asRecord(displayDraft.entries).inline).map(asRecord).find((entry) => entry.active !== false) ?? {},
[displayDraft.entries]
);
const attachmentPreviewContext = useMemo(
() => buildTemplatePreviewContext(displayDraft, attachmentPreviewEntry),
[attachmentPreviewEntry, displayDraft]
);
useEffect(() => {
if (!listManagedFileSpaces) {
setFileSpaces([]);
return;
}
let cancelled = false;
void listManagedFileSpaces(settings).
then((response) => {if (!cancelled) setFileSpaces(response.spaces);}).
catch(() => {if (!cancelled) setFileSpaces([]);});
return () => {cancelled = true;};
}, [listManagedFileSpaces, 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}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</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="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<div className="admin-table-surface attachment-sources-table-surface">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
className="attachment-sources-table-wrap attachment-sources-table" />
</div>
</Card>
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
<div className="attachment-zip-master-toggle">
<ToggleSwitch
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
checked={zipConfig.enabled}
disabled={locked}
onChange={setZipEnabled} />
</div>
<div className="admin-table-surface attachment-zip-table-surface">
<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 ? "i18n:govoplan-campaign.no_zip_attachments_configured.29c16424" : "i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d"}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined}
className="attachment-zip-table-wrap" />
</div>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) &&
<DismissibleAlert tone="warning">i18n:govoplan-campaign.no_password_field_exists_add_one_under_fields_wi.2b6d4a7f <strong>i18n:govoplan-campaign.password.8be3c943</strong>.</DismissibleAlert>
}
{zipArchiveNameValidation.message &&
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
}
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
</Card>
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
disabled={locked}
emptyText="i18n:govoplan-campaign.no_global_attachments_are_configured_yet_add_fil.d9fdfbb8"
basePaths={basePaths}
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={managedFilesAvailable}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)} />
</Card>
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
</dl>
<p className="muted small-note">{managedFilesAvailable ?
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
filesModuleInstalled ?
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
</Card>
</>
</LoadingFrame>
<TemplateExpressionEditorDialog
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
title="i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"
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="i18n:govoplan-campaign.use_a_fixed_filename_or_combine_text_with_recipi.43a091fd"
saveLabel="i18n:govoplan-campaign.save_filename.2dbd2273"
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 &&
<ManagedFileChooser
open
settings={settings}
storageScope={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 === "i18n:govoplan-campaign.new_attachment_source.563145ba" || current.name === "i18n:govoplan-campaign.campaign_files.96e7004b" ?
`${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` :
current.name
});
setPathChooser(null);
}} />
}
<ConfirmDialog
open={Boolean(individualDisable)}
title="i18n:govoplan-campaign.disable_individual_attachments_for_this_source.185f9a95"
message={individualDisable ? i18nMessage("i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7", { value0:
individualDisable.usageCount, value1: individualDisable.usageCount === 1 ? "i18n:govoplan-campaign.entry_uses.5f269cab" : "i18n:govoplan-campaign.entries_use.e9169679" }) :
""}
confirmLabel="i18n:govoplan-campaign.remove_individual_attachments.9e5f0ad3"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
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: "i18n:govoplan-campaign.archive_name.6310f9e1", 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={i18nMessage("i18n:govoplan-campaign.edit_zip_archive_filename_value.deb41dfc", { value0: archive.name || index + 1 })}
title={invalidNameIndexes.has(index) ? "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639" : "i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"}
onClick={() => onEditName(index)}>
<span className="attachment-zip-name-value">{archive.name || "i18n:govoplan-campaign.unnamed_zip_attachment.54f515bb"}</span>
<Pencil size={15} aria-hidden="true" />
</button>,
value: (archive) => archive.name
},
{
id: "standard", header: "i18n:govoplan-campaign.standard.2dfa6607", width: 150, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "standard", label: "i18n:govoplan-campaign.standard.2dfa6607" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] },
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.standard.2dfa6607" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
value: (archive) => archive.standard ? "standard" : "optional"
},
{
id: "password_enabled", header: "i18n:govoplan-campaign.password.8be3c943", width: 165, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "protected", label: "i18n:govoplan-campaign.protected.28531336" }, { value: "none", label: "i18n:govoplan-campaign.no_password.18518368" }] },
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
value: (archive) => archive.password_enabled ? "protected" : "none"
},
{
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...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="">i18n:govoplan-campaign.select_password_field.4007aeb0</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: "i18n:govoplan-campaign.value_scope.5e1e16b2", width: 190, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "local", label: "i18n:govoplan-campaign.recipient_local.418a289f" }, { value: "global", label: "i18n:govoplan-campaign.campaign_global.ba944948" }] },
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">i18n:govoplan-campaign.recipient_local.418a289f</option>
<option value="global">i18n:govoplan-campaign.campaign_global.ba944948</option>
</select>,
value: (archive) => archive.password_scope
},
{
id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", 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="i18n:govoplan-campaign.add_zip_attachment_below.776dabc1"
removeLabel="i18n:govoplan-campaign.remove_zip_attachment.d2bdf662"
moveUpLabel="i18n:govoplan-campaign.move_zip_attachment_up.63446ac0"
moveDownLabel="i18n:govoplan-campaign.move_zip_attachment_down.4ead4805" />
}];
}
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 "i18n:govoplan-campaign.archive_filename_must_not_be_empty.4234b817";
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
return duplicate ? "i18n:govoplan-campaign.another_zip_attachment_already_uses_this_effecti.d05eb098" : "";
}
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("i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427");
if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`);
return { invalidIndexes, message: i18nMessage("i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0", { value0: parts.join(". ") }) };
}
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: FilesFileSpace[];
managedFilesAvailable: boolean;
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, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
id: "path",
header: "i18n:govoplan-campaign.path.519e3913",
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={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked}
readOnly={managedFilesAvailable}
tabIndex={managedFilesAvailable ? -1 : undefined}
placeholder="i18n:govoplan-campaign.attachments_placeholder"
onChange={(event) => {
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
}}
onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}} />
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>i18n:govoplan-campaign.choose_folder.1838a415</Button>}
</div>,
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
},
{ id: "individual", header: "i18n:govoplan-campaign.individual_attachments.8164fc7a", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "i18n:govoplan-campaign.individual.a7abed83" }, { value: "global_only", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.individual.a7abed83" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global_only" },
{ id: "unsent_warning", header: "i18n:govoplan-campaign.unsent_warning.11bdfbf7", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "i18n:govoplan-campaign.warn.3009d557" }, { value: "off", label: "i18n:govoplan-campaign.off.e3de5ab0" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.unsent.0d09fa0d" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
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="i18n:govoplan-campaign.add_attachment_source_below.635c6a61"
removeLabel="i18n:govoplan-campaign.remove_attachment_source.95855c67"
moveUpLabel="i18n:govoplan-campaign.move_attachment_source_up.956ec756"
moveDownLabel="i18n:govoplan-campaign.move_attachment_source_down.cbf9ebd8" />
}];
}
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): 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" ? "i18n:govoplan-campaign.my_files.71d01a41" : parsedSource?.ownerType === "group" ? "i18n:govoplan-campaign.group_files.1aacad0b" : "");
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
return `${relativePath || "."}/`;
}