Files
multi-seal-mail-webui/src/features/campaigns/components/AttachmentRulesOverlay.tsx
2026-06-13 04:15:29 +02:00

287 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
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[];
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
id?: string;
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
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 = "No attachment files or matching rules configured yet.",
basePaths = [],
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
function closeOverlay() {
setOpen(false);
}
function addOverlayRule() {
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? "")]);
}
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" onClick={closeOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<AttachmentRulesDataGrid
rules={rules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
activeChooserRuleIndex={null}
onChange={onChange}
/>
</div>
<footer className="modal-footer">
<Button variant="primary" onClick={addOverlayRule} disabled={disabled}>Add file</Button>
<Button onClick={closeOverlay}>Close</Button>
</footer>
</div>
</div>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={() => setOpen(true)} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
{label}
</Button>
{dialog}
</>
);
}
export function AttachmentRulesTable({
showAddButton = true,
onChange,
...tableProps
}: AttachmentRulesTableProps) {
function addRule() {
onChange([
...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
]);
}
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled}>Add file</Button>
</div>
)}
</div>
</div>
);
}
export function AttachmentRulesDataGrid({
rules,
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function removeRule(index: number) {
setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function openFileChooser(ruleIndex: number) {
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
}
const rule = rules[ruleIndex] ?? {};
const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const currentBasePathId = getText(rule, "base_path_id");
const basePath = basePaths.find((item) => item.id === currentBasePathId) ?? basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null;
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
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, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={emptyText}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
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)}`}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>
)}
</>
);
}
type AttachmentRuleColumnContext = {
disabled: boolean;
basePaths: AttachmentBasePath[];
activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
openFileChooser: (ruleIndex: number) => void;
removeRule: (index: number) => void;
};
function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "Base path",
width: 250,
sortable: true,
filterable: true,
render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const currentBasePathId = getText(rule, "base_path_id");
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ?? basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? basePaths[0];
return basePaths.length > 0 ? (
<select
value={selectedBasePath?.id ?? ""}
disabled={disabled}
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 });
}}
>
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select>
) : (
<input value={currentBasePathValue} disabled={disabled} readOnly placeholder="optional/folder" />
);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
},
{
id: "file_filter",
header: "File / pattern",
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}
readOnly
tabIndex={-1}
placeholder="Choose a managed file or pattern"
onClick={() => !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
</div>
),
value: (rule) => getText(rule, "file_filter")
},
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "",
width: 145,
sticky: "end",
render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button>
}
];
}