chore: sync GovOPlaN module split state
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||
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";
|
||||
@@ -19,14 +19,15 @@ 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 } from "@govoplan/core-webui";
|
||||
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 };
|
||||
type PathChooserState = {index: number;};
|
||||
type IndividualDisableState = {index: number;usageCount: number;};
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
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;
|
||||
@@ -47,8 +48,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "files",
|
||||
unsavedTitle: "Unsaved attachment settings",
|
||||
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
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]);
|
||||
@@ -78,10 +79,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void listManagedFileSpaces(settings)
|
||||
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
||||
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
||||
return () => { cancelled = true; };
|
||||
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[]) {
|
||||
@@ -153,9 +154,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
}
|
||||
|
||||
function setZipEnabled(enabled: boolean) {
|
||||
const archives = enabled && zipConfig.archives.length === 0
|
||||
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
|
||||
: zipConfig.archives;
|
||||
const archives = enabled && zipConfig.archives.length === 0 ?
|
||||
[createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] :
|
||||
zipConfig.archives;
|
||||
patchZipCollection({ enabled, archives });
|
||||
}
|
||||
|
||||
@@ -195,9 +196,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
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;
|
||||
return String(ruleZip.archive_id ?? "") === removed.id ?
|
||||
{ ...rule, zip: { ...ruleZip, archive_id: "inherit" } } :
|
||||
rule;
|
||||
};
|
||||
setDraft((current) => {
|
||||
const source = asRecord(current ?? {});
|
||||
@@ -232,44 +233,44 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
||||
<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={() => { window.location.href = "/files"; }}>Manage files</Button>}
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
||||
{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="This page is read-only for the selected version." />}
|
||||
{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="Loading campaign draft…">
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<Card title="Attachment sources">
|
||||
<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="No attachment sources configured."
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
|
||||
className="attachment-sources-table-wrap attachment-sources-table"
|
||||
/>
|
||||
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="ZIP attachments" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
label="Enable ZIP attachments"
|
||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||
checked={zipConfig.enabled}
|
||||
disabled={locked}
|
||||
onChange={setZipEnabled}
|
||||
/>
|
||||
onChange={setZipEnabled} />
|
||||
|
||||
</div>
|
||||
<div className="admin-table-surface attachment-zip-table-surface">
|
||||
<DataGrid
|
||||
@@ -288,48 +289,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
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"
|
||||
/>
|
||||
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">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>
|
||||
{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="Global Attachments" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||
<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."
|
||||
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)}
|
||||
/>
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
||||
|
||||
</Card>
|
||||
|
||||
<Card title="Statistics" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
|
||||
<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>{managedFilesAvailable ? "Connected through Files" : "Manual paths"}</dd></div>
|
||||
<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
|
||||
? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build."
|
||||
: filesModuleInstalled
|
||||
? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability."
|
||||
: "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
|
||||
<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>
|
||||
|
||||
</>
|
||||
@@ -337,13 +338,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
<TemplateExpressionEditorDialog
|
||||
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
|
||||
title="Edit ZIP archive filename"
|
||||
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
|
||||
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="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
|
||||
saveLabel="Save filename"
|
||||
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) => {
|
||||
@@ -356,52 +357,52 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
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 }
|
||||
]);
|
||||
...existingFields,
|
||||
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }]
|
||||
);
|
||||
}}
|
||||
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
|
||||
/>
|
||||
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 === "New attachment source" || current.name === "Campaign files"
|
||||
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
|
||||
: current.name
|
||||
});
|
||||
setPathChooser(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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="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"
|
||||
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>
|
||||
);
|
||||
onCancel={() => setIndividualDisable(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type ZipArchiveColumnContext = {
|
||||
@@ -419,73 +420,73 @@ type ZipArchiveColumnContext = {
|
||||
|
||||
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>
|
||||
{
|
||||
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: "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>
|
||||
</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: "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"
|
||||
/>
|
||||
}
|
||||
];
|
||||
</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";
|
||||
@@ -511,19 +512,19 @@ function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilena
|
||||
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");
|
||||
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) }))
|
||||
];
|
||||
...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.";
|
||||
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 ? "Another ZIP attachment already uses this effective filename." : "";
|
||||
return duplicate ? "i18n:govoplan-campaign.another_zip_attachment_already_uses_this_effecti.d05eb098" : "";
|
||||
}
|
||||
|
||||
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
|
||||
@@ -545,14 +546,14 @@ function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNa
|
||||
}
|
||||
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 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 (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: `${parts.join(". ")}. Saving is disabled until this is corrected.` };
|
||||
return { invalidIndexes, message: i18nMessage("i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0", { value0: parts.join(". ") }) };
|
||||
}
|
||||
|
||||
function normalizeZipArchiveName(value: string): string {
|
||||
@@ -580,70 +581,70 @@ type AttachmentSourceColumnContext = {
|
||||
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, 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">
|
||||
{ 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="attachments"
|
||||
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}>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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
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" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
|
||||
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 || "."}/`;
|
||||
|
||||
Reference in New Issue
Block a user