UI polishes, file chooser

This commit is contained in:
2026-06-10 14:09:50 +02:00
parent 1f34435893
commit 4544a89443
13 changed files with 357 additions and 115 deletions

View File

@@ -29,6 +29,8 @@ type AttachmentRulesTableProps = {
emptyText?: string;
basePaths?: AttachmentBasePath[];
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -47,28 +49,70 @@ export default function AttachmentRulesOverlay({
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">{title}</h2>
<button className="modal-close" onClick={() => setOpen(false)}>×</button>
<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">
<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}
/>
{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">
<Button variant="primary" onClick={() => setOpen(false)}>Close</Button>
{fileChooser ? (
<Button onClick={() => setFileChooser(null)}>Back to rules</Button>
) : (
<Button variant="primary" onClick={closeOverlay}>Close</Button>
)}
</footer>
</div>
</div>,
@@ -91,6 +135,8 @@ export function AttachmentRulesTable({
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
showAddButton = true,
activeChooserRuleIndex = null,
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
@@ -115,90 +161,112 @@ export function AttachmentRulesTable({
}
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 (
<>
{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>
)}
<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={fileChooser.basePath}
onSelect={(fileFilter) => {
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
setFileChooser(null);
}}
basePath={activeBasePath}
onSelect={selectFileFilter}
onClose={() => setFileChooser(null)}
/>
)}
</>
</div>
);
}
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">
@@ -207,14 +275,7 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
<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>
<MockFileChooserContent basePath={basePath} onSelect={onSelect} onClose={onClose} />
</div>
<footer className="modal-footer">
<Button onClick={onClose}>Cancel</Button>
@@ -225,6 +286,32 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
);
}
function MockFileChooserContent({ 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 (
<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">
{files.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>
);
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {