UI changes, attachment page redesign
This commit is contained in:
246
src/features/campaigns/components/AttachmentRulesOverlay.tsx
Normal file
246
src/features/campaigns/components/AttachmentRulesOverlay.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
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";
|
||||
|
||||
export type AttachmentRule = Record<string, unknown>;
|
||||
export type AttachmentBasePath = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
source?: string;
|
||||
allow_individual?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
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 summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
|
||||
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
|
||||
|
||||
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={() => setOpen(false)}>×</button>
|
||||
</header>
|
||||
<div className="modal-body attachment-rules-body">
|
||||
<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}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button variant="primary" onClick={() => setOpen(false)}>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,
|
||||
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() {
|
||||
const firstBasePath = basePaths[0]?.path ?? "";
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `attachment-${Date.now()}`,
|
||||
label: "",
|
||||
base_dir: firstBasePath,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
include_subdirs: false
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
function openFileChooser(ruleIndex: number) {
|
||||
const rule = rules[ruleIndex] ?? {};
|
||||
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{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 ?? "");
|
||||
return (
|
||||
<tr key={String(rule.id ?? index)}>
|
||||
<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 value={getText(rule, "file_filter")} disabled={disabled} readOnly placeholder="file.pdf or {{local:id}}.pdf" />
|
||||
<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">
|
||||
<Button onClick={addRule} disabled={disabled}>Add file</Button>
|
||||
</div>
|
||||
)}
|
||||
{fileChooser && (
|
||||
<MockFileChooserOverlay
|
||||
basePath={fileChooser.basePath}
|
||||
onSelect={(fileFilter) => {
|
||||
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
||||
setFileChooser(null);
|
||||
}}
|
||||
onClose={() => setFileChooser(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
||||
const files = [
|
||||
"welcome.pdf",
|
||||
"terms-and-conditions.pdf",
|
||||
"invoice_{{local:invoice_number}}.pdf",
|
||||
"{{local:recipient_id}}/certificate.pdf",
|
||||
"attachments/{{local:email}}/*.pdf"
|
||||
];
|
||||
|
||||
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">
|
||||
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded files below <code>{basePath || "."}</code>.</p>
|
||||
<div className="placeholder-stack">
|
||||
{files.map((file) => (
|
||||
<Button key={file} onClick={() => onSelect(file)}>
|
||||
<code>{file}</code>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
|
||||
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
|
||||
if (isDirectAttachmentRule(rule)) {
|
||||
summary.direct += 1;
|
||||
} else {
|
||||
summary.rules += 1;
|
||||
}
|
||||
return summary;
|
||||
}, { direct: 0, rules: 0 });
|
||||
}
|
||||
|
||||
function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
const explicitType = getText(rule, "type");
|
||||
if (explicitType === "direct") return true;
|
||||
if (explicitType === "pattern") return false;
|
||||
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
||||
if (!fileFilter) return false;
|
||||
return !/[{}*?\[\]]/.test(fileFilter);
|
||||
}
|
||||
Reference in New Issue
Block a user