Files
multi-seal-mail-webui/src/features/campaigns/components/AttachmentRulesOverlay.tsx

289 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 Button from "../../../components/Button";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
import { createAttachmentRule, mockAttachmentFiles, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
type AttachmentRulesOverlayProps = {
title: string;
rules: AttachmentRule[];
disabled?: boolean;
buttonLabel?: string;
emptyText?: string;
basePaths?: AttachmentBasePath[];
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
type FileChooserState = {
ruleIndex: number;
basePath: string;
};
export default function AttachmentRulesOverlay({
title,
rules,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function openFileChooser(ruleIndex: number) {
const rule = rules[ruleIndex] ?? {};
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
}
function selectFileFilter(fileFilter: string) {
if (!fileChooser) return;
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
setFileChooser(null);
}
function closeOverlay() {
setFileChooser(null);
setOpen(false);
}
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
const activeBasePath = fileChooser
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
: "";
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">{fileChooser ? "Choose file or pattern" : title}</h2>
<button className="modal-close" onClick={closeOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
{fileChooser ? (
<MockFileChooserContent
basePath={activeBasePath}
onSelect={selectFileFilter}
onClose={() => setFileChooser(null)}
/>
) : (
<>
<p className="muted small-note">Use direct files for fixed attachments and rules/patterns for files resolved during build.</p>
<AttachmentRulesTable
rules={rules}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
activeChooserRuleIndex={null}
onOpenFileChooser={openFileChooser}
onChange={onChange}
/>
</>
)}
</div>
<footer className="modal-footer">
{fileChooser ? (
<Button onClick={() => setFileChooser(null)}>Back to rules</Button>
) : (
<Button variant="primary" 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({
rules,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
showAddButton = true,
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 addRule() {
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "")]);
}
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] ?? {};
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
}
function selectFileFilter(fileFilter: string) {
if (!fileChooser) return;
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
setFileChooser(null);
}
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
const activeBasePath = fileChooser
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
: "";
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
{rules.length === 0 ? (
<p className="muted attachment-rules-empty">{emptyText}</p>
) : (
<div className="app-table-wrap attachment-rules-table-wrap">
<table className="app-table attachment-rules-table">
<thead>
<tr>
<th>Label</th>
<th>Base path</th>
<th>File / pattern</th>
<th>Required</th>
<th>Subdirs</th>
<th></th>
</tr>
</thead>
<tbody>
{rules.map((rule, index) => {
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const isChoosingFile = (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index;
return (
<tr key={String(rule.id ?? index)} className={isChoosingFile ? "is-choosing-file" : undefined}>
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
<td>
{basePaths.length > 0 ? (
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
</select>
) : (
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
)}
</td>
<td>
<div className="field-with-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled}
readOnly
tabIndex={-1}
placeholder="file.pdf or {{local:id}}.pdf"
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>
</td>
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} /></td>
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button onClick={addRule} disabled={disabled}>Add file</Button>
</div>
)}
</div>
{fileChooser && (
<MockFileChooserOverlay
basePath={activeBasePath}
onSelect={selectFileFilter}
onClose={() => setFileChooser(null)}
/>
)}
</div>
);
}
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
return createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="mock-file-chooser-title">Choose file or pattern</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<MockFileChooserContent basePath={basePath} onSelect={onSelect} onClose={onClose} />
</div>
<footer className="modal-footer">
<Button onClick={onClose}>Cancel</Button>
</footer>
</div>
</div>,
document.body
);
}
function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
return (
<div className="attachment-file-browser-content" aria-label="Choose file or pattern">
<p className="muted small-note">Mock browser below <code>{basePath || "."}</code>. Later this will browse uploaded files and directories.</p>
<div className="placeholder-stack attachment-file-browser-list">
{mockAttachmentFiles.map((file) => (
<Button key={file} onClick={() => onSelect(file)}>
<code>{file}</code>
</Button>
))}
</div>
<div className="button-row compact-actions attachment-file-browser-actions">
<Button onClick={onClose}>Back</Button>
</div>
</div>
);
}