import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react"; import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui"; import type { ApiSettings } from "../../../types"; import { listFileSpaces, listFiles, listFolders, resolveFilePatterns, shareFileWithCampaign, type FileFolder, type FileSpace, type ManagedFile } from "../../../api/files"; import { Button } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { FolderTree } from "../../files/components/FileManagerComponents"; import { useFileTreeState } from "../../files/hooks/useFileTreeState"; import type { FolderNode, SortColumn, SortDirection } from "../../files/types"; import { buildExplorerEntries, buildFolderTree, formatBytes, formatDate, normalizeFilePath, normalizeFolder, parentFolderPath, sortExplorerEntries } 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 filesExplorerUi = usePlatformUiCapability("files.fileExplorer"); const FolderTreeComponent = filesExplorerUi?.FolderTree ?? FolderTree; 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 [sortColumn, setSortColumn] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const sortedEntries = useMemo( () => sortExplorerEntries(entries, sortColumn, sortDirection), [entries, sortColumn, sortDirection] ); 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 toggleSort(column: SortColumn) { if (sortColumn === column) { setSortDirection((current) => current === "asc" ? "desc" : "asc"); return; } setSortColumn(column); setSortDirection(column === "name" ? "asc" : "desc"); } 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" ? ( ) : (
{sortedEntries.map((entry) => { if (entry.kind === "folder") { return ( ); } const exactPattern = relativePath(entry.file.display_path, effectiveRoot); const selected = mode === "attachment" && pattern.trim() === exactPattern; const campaignShared = isSharedWithCampaign(entry.file, campaignId); return ( ); })} {!loading && sortedEntries.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 ChooserSortButton({ column, label, activeColumn, direction, onSort, }: { column: SortColumn; label: string; activeColumn: SortColumn; direction: SortDirection; onSort: (column: SortColumn) => void; }) { const active = activeColumn === column; const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; return ( ); } 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); }