FileChooser in Attachments

This commit is contained in:
2026-06-13 04:05:43 +02:00
parent 8d2fe5b77b
commit 76ff0f9d5f
10 changed files with 1145 additions and 195 deletions

View File

@@ -1,16 +1,20 @@
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, mockAttachmentFiles, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
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;
@@ -20,6 +24,8 @@ type AttachmentRulesOverlayProps = {
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
@@ -32,12 +38,14 @@ type AttachmentRulesTableProps = {
type FileChooserState = {
ruleIndex: number;
basePath: string;
basePath: AttachmentBasePath | null;
};
export default function AttachmentRulesOverlay({
title,
rules,
settings,
campaignId,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
@@ -45,70 +53,39 @@ 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 || "")
: "";
function addOverlayRule() {
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(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">{fileChooser ? "Choose file or pattern" : title}</h2>
<h2 id="attachment-rules-title">{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}
/>
</>
)}
<AttachmentRulesDataGrid
rules={rules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
activeChooserRuleIndex={null}
onChange={onChange}
/>
</div>
<footer className="modal-footer">
{fileChooser ? (
<Button onClick={() => setFileChooser(null)}>Back to rules</Button>
) : (
<Button variant="primary" onClick={closeOverlay}>Close</Button>
)}
<Button variant="primary" onClick={addOverlayRule} disabled={disabled}>Add file</Button>
<Button onClick={closeOverlay}>Close</Button>
</footer>
</div>
</div>,
@@ -131,7 +108,10 @@ export function AttachmentRulesTable({
...tableProps
}: AttachmentRulesTableProps) {
function addRule() {
onChange([...tableProps.rules, createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "")]);
onChange([
...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules))
]);
}
return (
@@ -140,7 +120,7 @@ export function AttachmentRulesTable({
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button onClick={addRule} disabled={tableProps.disabled}>Add file</Button>
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled}>Add file</Button>
</div>
)}
</div>
@@ -150,6 +130,8 @@ export function AttachmentRulesTable({
export function AttachmentRulesDataGrid({
rules,
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
@@ -157,7 +139,7 @@ export function AttachmentRulesDataGrid({
activeChooserRuleIndex = null,
onOpenFileChooser,
onChange
}: Omit<AttachmentRulesTableProps, "showAddButton">) {
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
@@ -175,40 +157,47 @@ export function AttachmentRulesDataGrid({
return;
}
const rule = rules[ruleIndex] ?? {};
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const basePath = basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null;
setFileChooser({ ruleIndex, basePath });
}
function selectFileFilter(fileFilter: string) {
function selectAttachment(selection: ManagedAttachmentSelection) {
if (!fileChooser) return;
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
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);
}
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>
) : (
<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}
/>
)}
<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 && (
<MockFileChooserOverlay
basePath={activeBasePath}
onSelect={selectFileFilter}
<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}
/>
)}
</>
@@ -253,14 +242,14 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
sortable: true,
filterable: true,
render: (rule, index) => (
<div className="field-with-action">
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled}
readOnly
tabIndex={-1}
placeholder="file.pdf or {{local:id}}.pdf"
placeholder="Choose a managed file or pattern"
onClick={() => !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
@@ -275,45 +264,12 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
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: "subdirs", header: "Subdirs", width: 165, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} />, value: (rule) => getBool(rule, "include_subdirs") ? "subdirs" : "current" },
{ id: "actions", header: "", width: 120, sticky: "end", render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button> }
{
id: "actions",
header: "",
width: 145,
sticky: "end",
render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button>
}
];
}
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>
);
}