From 76ff0f9d5f2d47085ca63accd242e840ea6d25da Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 13 Jun 2026 04:05:43 +0200 Subject: [PATCH] FileChooser in Attachments --- src/components/table/DataGrid.tsx | 12 +- .../campaigns/AttachmentsDataPage.tsx | 89 +-- .../campaigns/RecipientDetailsPage.tsx | 9 +- .../components/AttachmentRulesOverlay.tsx | 192 +++--- .../components/ManagedFileChooser.tsx | 572 ++++++++++++++++++ src/features/campaigns/utils/attachments.ts | 35 +- .../components/FileManagerComponents.tsx | 44 +- src/styles/campaign-workspace.css | 363 ++++++++++- src/styles/components.css | 16 + src/styles/layout.css | 8 +- 10 files changed, 1145 insertions(+), 195 deletions(-) create mode 100644 src/features/campaigns/components/ManagedFileChooser.tsx diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx index 9ee149c..fcec904 100644 --- a/src/components/table/DataGrid.tsx +++ b/src/components/table/DataGrid.tsx @@ -341,10 +341,14 @@ export default function DataGrid({ className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()} style={stickyStyle(column, stickyOffsets[columnIndex])} > - + {column.sortable ? ( + + ) : ( +
{column.header}
+ )} {column.filterable && ( + ), - value: (basePath) => basePath.path + value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) }, { id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => 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) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" }, { id: "actions", header: "Actions", width: 120, sticky: "end", render: (_basePath, index) => } ]; } - -function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial) => void }) { - return createPortal( -
-
-
-

Choose attachment base path

- -
-
-

Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.

-
- {mockAttachmentPathOptions.map((path) => ( - - ))} -
-
-
- -
-
-
, - 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 || "."}/`; } diff --git a/src/features/campaigns/RecipientDetailsPage.tsx b/src/features/campaigns/RecipientDetailsPage.tsx index 8204948..6518be8 100644 --- a/src/features/campaigns/RecipientDetailsPage.tsx +++ b/src/features/campaigns/RecipientDetailsPage.tsx @@ -96,7 +96,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting 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; individualAttachmentBasePaths: ReturnType; @@ -121,7 +123,7 @@ type RecipientDataColumnContext = { updateEntryField: (index: number, field: string, value: unknown) => void; }; -function recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn>[] { +function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn>[] { return [ { id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => {index + 1}, value: (_entry, index) => index + 1 }, { @@ -151,7 +153,10 @@ function recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBa updateEntryAttachments(index, rules)} /> diff --git a/src/features/campaigns/components/AttachmentRulesOverlay.tsx b/src/features/campaigns/components/AttachmentRulesOverlay.tsx index ce631b4..01f0902 100644 --- a/src/features/campaigns/components/AttachmentRulesOverlay.tsx +++ b/src/features/campaigns/components/AttachmentRulesOverlay.tsx @@ -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(null); const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]); const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`; - function patchRule(index: number, patch: Partial) { - 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(
-

{fileChooser ? "Choose file or pattern" : title}

+

{title}

- {fileChooser ? ( - setFileChooser(null)} - /> - ) : ( - <> -

Use direct files for fixed attachments and rules/patterns for files resolved during build.

- - - )} +
- {fileChooser ? ( - - ) : ( - - )} + +
, @@ -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({ {showAddButton && (
- +
)} @@ -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) { const [fileChooser, setFileChooser] = useState(null); function patchRule(index: number, patch: Partial) { @@ -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 ? ( -

{emptyText}

- ) : ( - 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} - /> - )} + 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 && ( - setFileChooser(null)} + onSelectAttachment={selectAttachment} /> )} @@ -253,14 +242,14 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a sortable: true, filterable: true, render: (rule, index) => ( -
+
!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) => 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) => patchRule(index, { include_subdirs: checked })} />, value: (rule) => getBool(rule, "include_subdirs") ? "subdirs" : "current" }, - { id: "actions", header: "", width: 120, sticky: "end", render: (_rule, index) => } + { + id: "actions", + header: "", + width: 145, + sticky: "end", + render: (_rule, index) => + } ]; } - -function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) { - return createPortal( -
-
-
-

Choose file or pattern

- -
-
- -
-
- -
-
-
, - document.body - ); -} - -function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) { - return ( -
-

Mock browser below {basePath || "."}. Later this will browse uploaded files and directories.

-
- {mockAttachmentFiles.map((file) => ( - - ))} -
-
- -
-
- ); -} diff --git a/src/features/campaigns/components/ManagedFileChooser.tsx b/src/features/campaigns/components/ManagedFileChooser.tsx new file mode 100644 index 0000000..0af3599 --- /dev/null +++ b/src/features/campaigns/components/ManagedFileChooser.tsx @@ -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([]); + const [selectedSpaceId, setSelectedSpaceId] = useState(""); + const [files, setFiles] = useState([]); + const [folders, setFolders] = useState([]); + const [currentFolder, setCurrentFolder] = useState(""); + const [pattern, setPattern] = useState(""); + const [patternMatches, setPatternMatches] = useState(null); + const [pendingExactFile, setPendingExactFile] = useState(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 => 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 { + 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 = ( + <> + + + + + )} + > + {error && {error}} + {mode === "attachment" && !parsedSource && ( + + This attachment base path is not connected to a managed file space. Choose its folder under Attachments → Attachment sources first. + + )} + {mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && ( + The configured managed file space is no longer available to this user. + )} + +
+ + +
+
+
+ + {breadcrumbs.map((crumb) => ( + + + + + ))} +
+
+ + {mode === "attachment" && ( +
+ + {patternMatches !== null && ( +
+ {patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match. + +
+ )} +
+ )} + + {patternMatches !== null && mode === "attachment" ? ( + + ) : ( +
+ {currentFolder !== effectiveRoot && ( + + )} + {childFolders.map((entry) => ( + + ))} + {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 ( + + ); + })} + {!loading && childFolders.length === 0 && visibleFiles.length === 0 && ( +

This folder is empty. Upload files in the top-level Files module.

+ )} +
+ )} + + {mode === "folder" && ( +

Choose the folder that should act as this campaign attachment source.

+ )} + {mode === "attachment" && ( +

Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.

+ )} +
+
+
+ + 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 ( +
+
+ NameSizeModified +
+
+ {files.length === 0 &&
No current files match this pattern.
} + {files.map((file) => ( + + ))} +
+
+ ); +} + +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); +} diff --git a/src/features/campaigns/utils/attachments.ts b/src/features/campaigns/utils/attachments.ts index 1fcb427..ca9a589 100644 --- a/src/features/campaigns/utils/attachments.ts +++ b/src/features/campaigns/utils/attachments.ts @@ -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; +export type ManagedAttachmentSource = { + ownerType: "user" | "group"; + ownerId: string; +}; + +const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:"; + +export function encodeManagedAttachmentSource(space: Pick): 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) => ({ diff --git a/src/features/files/components/FileManagerComponents.tsx b/src/features/files/components/FileManagerComponents.tsx index b5989f1..576c7b3 100644 --- a/src/features/files/components/FileManagerComponents.tsx +++ b/src/features/files/components/FileManagerComponents.tsx @@ -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; onOpen: (spaceId: string, path: string) => void; onToggle: (spaceId: string, path: string) => void; - onContextMenu: (event: ReactMouseEvent, spaceId: string, path: string) => void; - onDragOverTarget: (event: ReactDragEvent, target: FileActionTarget) => void; - onDropOnTarget: (event: ReactDragEvent, target: FileActionTarget) => Promise; - onClearDropState: () => void; - onRequestDragExpand: (spaceId: string, path: string) => void; - onDragStartFolder: (spaceId: string, path: string, event: ReactDragEvent) => void; - onDragEndFolder: () => void; + onContextMenu?: (event: ReactMouseEvent, spaceId: string, path: string) => void; + onDragOverTarget?: (event: ReactDragEvent, target: FileActionTarget) => void; + onDropOnTarget?: (event: ReactDragEvent, target: FileActionTarget) => Promise; + onClearDropState?: () => void; + onRequestDragExpand?: (spaceId: string, path: string) => void; + onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent) => 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({
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} >