287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
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>
|
||
}
|
||
];
|
||
}
|