FileChooser in Attachments
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { listFileSpaces, type FileSpace } from "../../api/files";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
@@ -15,13 +15,15 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||
import ManagedFileChooser from "./components/ManagedFileChooser";
|
||||
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, nextAttachmentLabel, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||
|
||||
type PathChooserState = { index: number };
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
@@ -41,7 +43,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void listFileSpaces(settings)
|
||||
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
||||
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
if (locked) return;
|
||||
@@ -67,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
function addGlobalAttachmentRule() {
|
||||
if (locked) return;
|
||||
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "")]);
|
||||
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules))]);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +107,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-attachment-sources`}
|
||||
rows={basePaths}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBasePath, setPathChooser })}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser })}
|
||||
getRowKey={(basePath) => basePath.id}
|
||||
emptyText="No attachment sources configured."
|
||||
className="attachment-sources-table-wrap attachment-sources-table"
|
||||
@@ -116,6 +124,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
disabled={locked}
|
||||
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
|
||||
basePaths={basePaths}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
||||
/>
|
||||
</Card>
|
||||
@@ -137,10 +147,24 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
</LoadingFrame>
|
||||
|
||||
{pathChooser && (
|
||||
<MockPathChooserOverlay
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
mode="folder"
|
||||
source={basePaths[pathChooser.index]?.source}
|
||||
basePath={basePaths[pathChooser.index]?.path}
|
||||
onClose={() => setPathChooser(null)}
|
||||
onSelect={(path) => {
|
||||
patchBasePath(pathChooser.index, path);
|
||||
onSelectFolder={(selection) => {
|
||||
const current = basePaths[pathChooser.index];
|
||||
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
|
||||
patchBasePath(pathChooser.index, {
|
||||
source: selection.source,
|
||||
path: selection.folderPath || ".",
|
||||
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
|
||||
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
|
||||
: current.name
|
||||
});
|
||||
setPathChooser(null);
|
||||
}}
|
||||
/>
|
||||
@@ -152,12 +176,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
type AttachmentSourceColumnContext = {
|
||||
locked: boolean;
|
||||
basePaths: AttachmentBasePath[];
|
||||
fileSpaces: FileSpace[];
|
||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||
removeBasePath: (index: number) => void;
|
||||
setPathChooser: (state: PathChooserState | null) => void;
|
||||
};
|
||||
|
||||
function attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
return [
|
||||
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||
{
|
||||
@@ -168,10 +193,10 @@ function attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBaseP
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (basePath, index) => (
|
||||
<div className="field-with-action">
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
value={basePath.path}
|
||||
value={formatAttachmentSourcePath(basePath, fileSpaces)}
|
||||
disabled={locked}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -184,40 +209,24 @@ function attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBaseP
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
|
||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
|
||||
</div>
|
||||
),
|
||||
value: (basePath) => basePath.path
|
||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||
},
|
||||
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
||||
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
||||
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_basePath, index) => <Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button> }
|
||||
];
|
||||
}
|
||||
|
||||
function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
|
||||
return createPortal(
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
|
||||
<div className="modal-panel attachment-rules-modal">
|
||||
<header className="modal-header">
|
||||
<h2 id="mock-path-chooser-title">Choose attachment base path</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 directories in the available campaign, group, tenant or user spaces.</p>
|
||||
<div className="placeholder-stack">
|
||||
{mockAttachmentPathOptions.map((path) => (
|
||||
<Button key={path.path} onClick={() => onSelect(path)}>
|
||||
{path.label}: <code>{path.path}</code>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
const matchingSpace = parsedSource
|
||||
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
||||
: undefined;
|
||||
const rootLabel = matchingSpace?.label
|
||||
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
|
||||
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
|
||||
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
|
||||
return `${relativePath || "."}/`;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-data`}
|
||||
rows={inlineEntries.slice(0, 100)}
|
||||
columns={recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField })}
|
||||
columns={recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="No recipient data found."
|
||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||
@@ -114,6 +114,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
}
|
||||
|
||||
type RecipientDataColumnContext = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
locked: boolean;
|
||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||
@@ -121,7 +123,7 @@ type RecipientDataColumnContext = {
|
||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
function recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
{
|
||||
@@ -151,7 +153,10 @@ function recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBa
|
||||
<AttachmentRulesOverlay
|
||||
title={`Attachments for recipient ${index + 1}`}
|
||||
rules={attachments}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
disabled={locked}
|
||||
buttonLabel={`entries: ${attachments.length}`}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
572
src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
572
src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
listFileSpaces,
|
||||
listFiles,
|
||||
listFolders,
|
||||
resolveFilePatterns,
|
||||
shareFileWithCampaign,
|
||||
type FileFolder,
|
||||
type FileSpace,
|
||||
type ManagedFile
|
||||
} from "../../../api/files";
|
||||
import Button from "../../../components/Button";
|
||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
||||
import Dialog from "../../../components/Dialog";
|
||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
||||
import { FolderTree } from "../../files/components/FileManagerComponents";
|
||||
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
|
||||
import type { FolderNode } from "../../files/types";
|
||||
import {
|
||||
buildExplorerEntries,
|
||||
buildFolderTree,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
normalizeFilePath,
|
||||
normalizeFolder,
|
||||
parentFolderPath
|
||||
} from "../../files/utils/fileManagerUtils";
|
||||
import {
|
||||
encodeManagedAttachmentSource,
|
||||
parseManagedAttachmentSource,
|
||||
type ManagedAttachmentSource
|
||||
} from "../utils/attachments";
|
||||
|
||||
type ManagedFileChooserMode = "folder" | "attachment";
|
||||
type AttachmentChoiceMode = "file" | "pattern";
|
||||
|
||||
type RememberedChooserState = {
|
||||
spaceId?: string;
|
||||
folder?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
export type ManagedFolderSelection = {
|
||||
space: FileSpace;
|
||||
folderPath: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ManagedAttachmentSelection = ManagedFolderSelection & {
|
||||
fileFilter: string;
|
||||
matchCount: number;
|
||||
fileIds: string[];
|
||||
selectionType: AttachmentChoiceMode;
|
||||
};
|
||||
|
||||
type ManagedFileChooserProps = {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
mode: ManagedFileChooserMode;
|
||||
source?: string;
|
||||
basePath?: string;
|
||||
initialPattern?: string;
|
||||
rememberKey?: string;
|
||||
onClose: () => void;
|
||||
onSelectFolder?: (selection: ManagedFolderSelection) => void;
|
||||
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
|
||||
};
|
||||
|
||||
export default function ManagedFileChooser({
|
||||
open,
|
||||
settings,
|
||||
campaignId,
|
||||
mode,
|
||||
source,
|
||||
basePath = ".",
|
||||
initialPattern = "",
|
||||
rememberKey = mode,
|
||||
onClose,
|
||||
onSelectFolder,
|
||||
onSelectAttachment
|
||||
}: ManagedFileChooserProps) {
|
||||
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
|
||||
const normalizedBasePath = normalizeManagedBasePath(basePath);
|
||||
const storageKey = useMemo(
|
||||
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
|
||||
[campaignId, rememberKey]
|
||||
);
|
||||
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
|
||||
const [spaces, setSpaces] = useState<FileSpace[]>([]);
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState("");
|
||||
const [files, setFiles] = useState<ManagedFile[]>([]);
|
||||
const [folders, setFolders] = useState<FileFolder[]>([]);
|
||||
const [currentFolder, setCurrentFolder] = useState("");
|
||||
const [pattern, setPattern] = useState("");
|
||||
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
|
||||
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
|
||||
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
|
||||
const sourceMatchesSpace = selectedSpace
|
||||
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
|
||||
: false;
|
||||
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
|
||||
const entries = useMemo(
|
||||
() => buildExplorerEntries(files, folders, currentFolder, false),
|
||||
[currentFolder, files, folders]
|
||||
);
|
||||
const childFolders = entries.filter((entry) => entry.kind === "folder");
|
||||
const visibleFiles = entries.filter((entry): entry is Extract<typeof entry, { kind: "file" }> => entry.kind === "file");
|
||||
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
|
||||
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
|
||||
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
|
||||
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
|
||||
activeSpaceId: selectedSpaceId,
|
||||
currentFolder,
|
||||
onOpenFolder: (_spaceId, path) => openFolder(path)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setPattern(remembered.pattern ?? initialPattern.trim());
|
||||
setPatternMatches(null);
|
||||
setPendingExactFile(null);
|
||||
void listFileSpaces(settings)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setSpaces(response.spaces);
|
||||
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
|
||||
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
|
||||
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
|
||||
setSelectedSpaceId(firstSpace?.id ?? "");
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) setError(errorMessage(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedSpace) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setPatternMatches(null);
|
||||
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
|
||||
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
|
||||
setCurrentFolder(initialFolder || effectiveRoot);
|
||||
void Promise.all([
|
||||
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
|
||||
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
|
||||
])
|
||||
.then(([fileResponse, folderResponse]) => {
|
||||
if (cancelled) return;
|
||||
setFiles(fileResponse.files);
|
||||
setFolders(folderResponse.folders);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) setError(errorMessage(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedSpaceId) return;
|
||||
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
|
||||
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
|
||||
|
||||
function openFolder(path: string) {
|
||||
const normalized = normalizeFolder(path);
|
||||
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
|
||||
setCurrentFolder(normalized);
|
||||
setPatternMatches(null);
|
||||
}
|
||||
|
||||
async function previewPattern(): Promise<ManagedFile[]> {
|
||||
if (!selectedSpace) return [];
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) {
|
||||
setPatternMatches([]);
|
||||
return [];
|
||||
}
|
||||
setResolving(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await resolveFilePatterns(settings, {
|
||||
patterns: [trimmed],
|
||||
owner_type: selectedSpace.owner_type,
|
||||
owner_id: selectedSpace.owner_id,
|
||||
path_prefix: effectiveRoot || undefined,
|
||||
include_unmatched: false
|
||||
});
|
||||
const matches = response.patterns[0]?.matches ?? [];
|
||||
setPatternMatches(matches);
|
||||
return matches;
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
return [];
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestExactFile(file: ManagedFile) {
|
||||
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
||||
const currentPattern = pattern.trim();
|
||||
if (currentPattern && currentPattern !== exactPattern) {
|
||||
setPendingExactFile(file);
|
||||
return;
|
||||
}
|
||||
applyExactFile(file);
|
||||
}
|
||||
|
||||
function applyExactFile(file: ManagedFile) {
|
||||
setPattern(relativePath(file.display_path, effectiveRoot));
|
||||
setPatternMatches([file]);
|
||||
setPendingExactFile(null);
|
||||
}
|
||||
|
||||
async function shareMatches(matches: ManagedFile[]) {
|
||||
const toShare = matches.filter((file) => !file.shares?.some((share) => (
|
||||
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
|
||||
)));
|
||||
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
|
||||
}
|
||||
|
||||
async function confirmSelection() {
|
||||
if (!selectedSpace || !sourceMatchesSpace) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "folder") {
|
||||
onSelectFolder?.({
|
||||
space: selectedSpace,
|
||||
folderPath: currentFolder,
|
||||
source: encodeManagedAttachmentSource(selectedSpace)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = patternMatches ?? await previewPattern();
|
||||
await shareMatches(matches);
|
||||
const trimmedPattern = pattern.trim();
|
||||
onSelectAttachment?.({
|
||||
space: selectedSpace,
|
||||
folderPath: effectiveRoot,
|
||||
source: encodeManagedAttachmentSource(selectedSpace),
|
||||
fileFilter: trimmedPattern,
|
||||
matchCount: matches.length,
|
||||
fileIds: matches.map((file) => file.id),
|
||||
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
|
||||
});
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
const dialog = (
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
|
||||
onClose={onClose}
|
||||
closeDisabled={submitting}
|
||||
backdropClassName="managed-file-chooser-backdrop"
|
||||
className="managed-file-chooser-dialog"
|
||||
bodyClassName="managed-file-chooser-body"
|
||||
footerClassName="managed-file-chooser-footer"
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void confirmSelection()}
|
||||
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
|
||||
>
|
||||
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{mode === "attachment" && !parsedSource && (
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments → Attachment sources</strong> first.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
|
||||
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<div className="managed-file-chooser-layout" aria-busy={loading}>
|
||||
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
|
||||
<div className="file-tree-heading">Spaces</div>
|
||||
<div className="file-tree-list">
|
||||
{spaces.map((space) => {
|
||||
const selected = space.id === selectedSpaceId;
|
||||
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
|
||||
return (
|
||||
<div className="file-tree-space" key={space.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
|
||||
onClick={() => {
|
||||
if (!selected) setSelectedSpaceId(space.id);
|
||||
else openFolder(effectiveRoot);
|
||||
}}
|
||||
disabled={loading || lockedOut}
|
||||
>
|
||||
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
|
||||
<span>{space.label}</span>
|
||||
</button>
|
||||
{selected && (
|
||||
<FolderTree
|
||||
nodes={treeNodes}
|
||||
activeSpaceId={selectedSpaceId}
|
||||
spaceId={space.id}
|
||||
currentFolder={currentFolder}
|
||||
expandedKeys={expandedTreeNodes}
|
||||
onOpen={(_spaceId, path) => openFolder(path)}
|
||||
onToggle={toggleTreeFolder}
|
||||
dragDropEnabled={false}
|
||||
contextMenuEnabled={false}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="managed-file-chooser-browser">
|
||||
<div className="managed-file-chooser-toolbar">
|
||||
<div className="managed-file-breadcrumb" aria-label="Current folder">
|
||||
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
|
||||
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
|
||||
</button>
|
||||
{breadcrumbs.map((crumb) => (
|
||||
<span key={crumb.path}>
|
||||
<span aria-hidden="true">/</span>
|
||||
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "attachment" && (
|
||||
<div className="managed-pattern-editor">
|
||||
<label>
|
||||
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
value={pattern}
|
||||
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
|
||||
placeholder="folder/file.pdf or **/*.pdf"
|
||||
/>
|
||||
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
|
||||
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
{patternMatches !== null && (
|
||||
<div className="managed-pattern-summary">
|
||||
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
|
||||
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{patternMatches !== null && mode === "attachment" ? (
|
||||
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
|
||||
) : (
|
||||
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
|
||||
{currentFolder !== effectiveRoot && (
|
||||
<button type="button" className="managed-file-entry is-folder" onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}>
|
||||
<FolderOpen size={18} aria-hidden="true" />
|
||||
<span><strong>..</strong><small>Parent folder</small></span>
|
||||
</button>
|
||||
)}
|
||||
{childFolders.map((entry) => (
|
||||
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
|
||||
</button>
|
||||
))}
|
||||
{visibleFiles.map((entry) => {
|
||||
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
|
||||
const selected = mode === "attachment" && pattern.trim() === exactPattern;
|
||||
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.file.id}
|
||||
className={`managed-file-entry is-file ${selected ? "is-selected" : ""}`}
|
||||
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
|
||||
disabled={mode === "folder"}
|
||||
>
|
||||
<File size={18} aria-hidden="true" />
|
||||
<span><strong>{entry.file.filename}</strong><small>{formatBytes(entry.file.size_bytes)} · {formatDate(entry.file.updated_at)}</small></span>
|
||||
{campaignShared && <span className="managed-file-shared-badge"><Link2 size={13} aria-hidden="true" /> linked</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!loading && childFolders.length === 0 && visibleFiles.length === 0 && (
|
||||
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "folder" && (
|
||||
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
|
||||
)}
|
||||
{mode === "attachment" && (
|
||||
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingExactFile)}
|
||||
title="Replace the current pattern?"
|
||||
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
|
||||
confirmLabel="Replace pattern"
|
||||
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
|
||||
onCancel={() => setPendingExactFile(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return createPortal(dialog, document.body);
|
||||
}
|
||||
|
||||
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
|
||||
return (
|
||||
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
|
||||
<div className="file-list-table-head" role="row">
|
||||
<span>Name</span><span>Size</span><span>Modified</span>
|
||||
</div>
|
||||
<div className="file-list-table">
|
||||
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
|
||||
{files.map((file) => (
|
||||
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
|
||||
<span className="file-list-name-cell">
|
||||
<span className="file-list-name">
|
||||
<File className="file-row-icon" size={20} aria-hidden="true" />
|
||||
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span>{formatBytes(file.size_bytes)}</span>
|
||||
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
|
||||
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
|
||||
}
|
||||
|
||||
function normalizeManagedBasePath(value: string): string {
|
||||
const normalized = normalizeFolder(value);
|
||||
return normalized === "." ? "" : normalized;
|
||||
}
|
||||
|
||||
function relativePath(path: string, root: string): string {
|
||||
const normalizedPath = normalizeFilePath(path);
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
if (!normalizedRoot) return normalizedPath;
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
|
||||
}
|
||||
|
||||
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
|
||||
const normalizedFolder = normalizeFolder(folder);
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
|
||||
? normalizedFolder.slice(normalizedRoot.length + 1)
|
||||
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
|
||||
const parts = relative.split("/").filter(Boolean);
|
||||
return parts.map((name, index) => ({
|
||||
name,
|
||||
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
|
||||
}));
|
||||
}
|
||||
|
||||
function parentWithinRoot(folder: string, root: string): string {
|
||||
const normalizedFolder = normalizeFolder(folder);
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
|
||||
const parent = parentFolderPath(normalizedFolder);
|
||||
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
|
||||
}
|
||||
|
||||
function isWithinRoot(path: string, root: string): boolean {
|
||||
const normalizedPath = normalizeFolder(path);
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
||||
}
|
||||
|
||||
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
if (!normalizedRoot) return nodes;
|
||||
const node = findNode(nodes, normalizedRoot);
|
||||
return node?.children ?? [];
|
||||
}
|
||||
|
||||
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return node;
|
||||
const child = findNode(node.children, path);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExactPattern(value: string): boolean {
|
||||
return Boolean(value) && !/[?*\[\]{}]/.test(value);
|
||||
}
|
||||
|
||||
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
|
||||
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
|
||||
}
|
||||
|
||||
function readRememberedState(key: string): RememberedChooserState {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRememberedState(key: string, state: RememberedChooserState) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage is optional; chooser behavior remains functional without it.
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
return reason instanceof Error ? reason.message : String(reason);
|
||||
}
|
||||
@@ -1,9 +1,29 @@
|
||||
import type { FileSpace } from "../../../api/files";
|
||||
import { asArray, asRecord, isRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
|
||||
|
||||
export type AttachmentRule = Record<string, unknown>;
|
||||
|
||||
export type ManagedAttachmentSource = {
|
||||
ownerType: "user" | "group";
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
|
||||
|
||||
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
|
||||
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
|
||||
}
|
||||
|
||||
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
|
||||
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
|
||||
const [, ownerType, ...ownerIdParts] = value.split(":");
|
||||
const ownerId = ownerIdParts.join(":").trim();
|
||||
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
|
||||
return { ownerType, ownerId };
|
||||
}
|
||||
|
||||
export type AttachmentBasePath = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -59,10 +79,10 @@ export function createAttachmentBasePath(name = "New attachment source", path =
|
||||
};
|
||||
}
|
||||
|
||||
export function createAttachmentRule(baseDir = ""): AttachmentRule {
|
||||
export function createAttachmentRule(baseDir = "", label = ""): AttachmentRule {
|
||||
return {
|
||||
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
label: "",
|
||||
label,
|
||||
base_dir: baseDir,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
@@ -100,6 +120,17 @@ export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): A
|
||||
return enabled.length > 0 ? enabled : paths;
|
||||
}
|
||||
|
||||
|
||||
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
|
||||
let highest = 0;
|
||||
for (const rule of rules) {
|
||||
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
|
||||
if (match) highest = Math.max(highest, Number(match[1]));
|
||||
}
|
||||
highest = Math.max(highest, rules.length);
|
||||
return `Attachment ${highest + 1}`;
|
||||
}
|
||||
|
||||
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).map((rule) => ({
|
||||
|
||||
@@ -76,7 +76,7 @@ export function FolderTree({
|
||||
activeSpaceId,
|
||||
spaceId,
|
||||
currentFolder,
|
||||
dropTargetKey,
|
||||
dropTargetKey = "",
|
||||
expandedKeys,
|
||||
onOpen,
|
||||
onToggle,
|
||||
@@ -87,6 +87,8 @@ export function FolderTree({
|
||||
onRequestDragExpand,
|
||||
onDragStartFolder,
|
||||
onDragEndFolder,
|
||||
dragDropEnabled = true,
|
||||
contextMenuEnabled = true,
|
||||
disabled,
|
||||
depth = 1
|
||||
}: {
|
||||
@@ -94,17 +96,19 @@ export function FolderTree({
|
||||
activeSpaceId: string;
|
||||
spaceId: string;
|
||||
currentFolder: string;
|
||||
dropTargetKey: string;
|
||||
dropTargetKey?: string;
|
||||
expandedKeys: Set<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||
onDropOnTarget: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||
onClearDropState: () => void;
|
||||
onRequestDragExpand: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder: () => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||
onClearDropState?: () => void;
|
||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder?: () => void;
|
||||
dragDropEnabled?: boolean;
|
||||
contextMenuEnabled?: boolean;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
@@ -114,7 +118,7 @@ export function FolderTree({
|
||||
{nodes.map((node) => {
|
||||
const isActive = activeSpaceId === spaceId && currentFolder === node.path;
|
||||
const target = { spaceId, folderPath: node.path };
|
||||
const isDropTarget = dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
||||
const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
|
||||
return (
|
||||
@@ -122,16 +126,16 @@ export function FolderTree({
|
||||
<div
|
||||
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
draggable={!disabled}
|
||||
onDragStart={(event) => onDragStartFolder(spaceId, node.path, event)}
|
||||
onDragEnd={onDragEndFolder}
|
||||
onContextMenu={(event) => onContextMenu(event, spaceId, node.path)}
|
||||
onDragOver={(event) => {
|
||||
draggable={dragDropEnabled && !disabled}
|
||||
onDragStart={dragDropEnabled && onDragStartFolder ? (event) => onDragStartFolder(spaceId, node.path, event) : undefined}
|
||||
onDragEnd={dragDropEnabled ? onDragEndFolder : undefined}
|
||||
onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined}
|
||||
onDragOver={dragDropEnabled && onDragOverTarget ? (event) => {
|
||||
onDragOverTarget(event, target);
|
||||
if (hasChildren && !isExpanded) onRequestDragExpand(spaceId, node.path);
|
||||
}}
|
||||
onDragLeave={onClearDropState}
|
||||
onDrop={(event) => void onDropOnTarget(event, target)}
|
||||
if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path);
|
||||
} : undefined}
|
||||
onDragLeave={dragDropEnabled ? onClearDropState : undefined}
|
||||
onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -172,6 +176,8 @@ export function FolderTree({
|
||||
onRequestDragExpand={onRequestDragExpand}
|
||||
onDragStartFolder={onDragStartFolder}
|
||||
onDragEndFolder={onDragEndFolder}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
contextMenuEnabled={contextMenuEnabled}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user