652 lines
35 KiB
TypeScript
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 || "."}/`;
|
|
}
|