Files
govoplan-campaign/webui/src/features/campaigns/components/AttachmentRulesOverlay.tsx

421 lines
17 KiB
TypeScript

import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
type AttachmentRulesOverlayProps = {
title: string;
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
buttonLabel?: string;
emptyText?: string;
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
id?: string;
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
type FileChooserState = {
ruleIndex: number;
basePath: AttachmentBasePath | null;
};
export default function AttachmentRulesOverlay({
title,
rules,
settings,
campaignId,
disabled = false,
buttonLabel,
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
previewContext,
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const [draftRules, setDraftRules] = useState<AttachmentRule[]>([]);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
function openOverlay() {
setDraftRules(cloneAttachmentRules(rules));
setOpen(true);
}
function cancelOverlay() {
setOpen(false);
}
function saveOverlay() {
if (disabled) return;
onChange(draftRules);
setOpen(false);
}
const dialog = open ? createPortal(
<Dialog
open
title={title}
className="attachment-rules-modal"
bodyClassName="attachment-rules-body"
onClose={cancelOverlay}
footer={
<>
<Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
</>
}>
<AttachmentRulesTable
rules={draftRules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={previewContext}
activeChooserRuleIndex={null}
onChange={setDraftRules} />
</Dialog>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={i18nMessage("i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9", { value0: summary.direct, value1: summary.rules })}>
{label}
</Button>
{dialog}
</>);
}
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
return rules.map((rule) => ({
...rule,
...(rule.zip && typeof rule.zip === "object" ? { zip: { ...asRecord(rule.zip) } } : {})
}));
}
export function AttachmentRulesTable({
onChange,
...tableProps
}: AttachmentRulesTableProps) {
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
</div>
</div>);
}
export function AttachmentRulesDataGrid({
rules,
settings,
campaignId,
disabled = false,
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
previewContext,
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
const managedFilesAvailable = Boolean(ManagedFileChooser);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function addRule(afterIndex = rules.length - 1) {
if (disabled || basePaths.length === 0) return;
const nextRule = createAttachmentRule(basePaths[0].path, nextAttachmentLabel(rules), basePaths[0].id);
onChange(insertAfter(rules, afterIndex, nextRule));
}
function removeRule(index: number) {
setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function moveRule(index: number, targetIndex: number) {
if (disabled || index === targetIndex) return;
setFileChooser(null);
onChange(moveArrayItem(rules, index, targetIndex));
}
function openFileChooser(ruleIndex: number) {
if (!managedFilesAvailable) return;
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
}
const rule = rules[ruleIndex] ?? {};
const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
basePaths.find((item) => item.path === currentPath) ?? (
!explicitlyReferenced ? basePaths[0] : undefined) ??
null;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: FilesManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
base_path_id: fileChooser.basePath?.id ?? "",
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
file_filter: selection.fileFilter,
type: selection.selectionType === "file" ? "direct" : "pattern",
include_subdirs: false,
label: getText(currentRule, "label") || `Attachment ${fileChooser.ruleIndex + 1}`
});
setFileChooser(null);
}
return (
<>
<DataGrid
id={id}
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
{fileChooser && ManagedFileChooser &&
<ManagedFileChooser
open
settings={settings}
storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment} />
}
</>);
}
type AttachmentRuleColumnContext = {
disabled: boolean;
rules: AttachmentRule[];
basePaths: AttachmentBasePath[];
zipConfig: AttachmentZipCollection;
filesModuleInstalled: boolean;
activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
addRule: (afterIndex?: number) => void;
moveRule: (index: number, targetIndex: number) => void;
openFileChooser: (ruleIndex: number) => void;
removeRule: (index: number) => void;
};
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="i18n:govoplan-campaign.attachment_label.a340f70e" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "i18n:govoplan-campaign.base_path.6a4867ca",
width: 250,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select
value={selectedBasePath?.id ?? ""}
disabled={disabled || basePaths.length === 0}
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}>
{!selectedBasePath &&
<option value="" disabled>{basePaths.length === 0 ? "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a" : "i18n:govoplan-campaign.unavailable_attachment_source.7ff1096e"}</option>
}
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select>);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
},
{
id: "file_filter",
header: "i18n:govoplan-campaign.file_pattern.86320b07",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
onChange={(event) => {
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
}}
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}} />
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
</div>,
value: (rule) => getText(rule, "file_filter")
},
{
id: "message_filename_template",
header: "i18n:govoplan-campaign.message_filename_template.0b7ac934",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "message_filename_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_filename.9b805045"
onChange={(event) => patchRule(index, { message_filename_template: event.target.value })} />,
value: (rule) => getText(rule, "message_filename_template")
},
{
id: "zip_entry_name_template",
header: "i18n:govoplan-campaign.zip_entry_name_template.83772a73",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "zip_entry_name_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4"
onChange={(event) => patchRule(index, { zip_entry_name_template: event.target.value })} />,
value: (rule) => getText(rule, "zip_entry_name_template")
},
...(zipConfig.enabled ? [{
id: "zip",
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "i18n:govoplan-campaign.exclude_from_zip.a6422da1" },
{ value: "inherit", label: "i18n:govoplan-campaign.campaign_standard.c88ce44c" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))]
},
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
return (
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
<option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
<option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
</select>);
},
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (rule, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_rule, index) =>
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
}];
}