Release v0.1.4
This commit is contained in:
@@ -3,13 +3,14 @@ import { createPortal } from "react-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
||||
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||
|
||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||
|
||||
@@ -160,6 +161,9 @@ export function AttachmentRulesDataGrid({
|
||||
onChange
|
||||
}: AttachmentRulesTableProps) {
|
||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||
const managedFilesAvailable = Boolean(ManagedFileChooser);
|
||||
|
||||
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
||||
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||
@@ -183,7 +187,7 @@ export function AttachmentRulesDataGrid({
|
||||
}
|
||||
|
||||
function openFileChooser(ruleIndex: number) {
|
||||
if (!filesModuleInstalled) return;
|
||||
if (!managedFilesAvailable) return;
|
||||
if (onOpenFileChooser) {
|
||||
onOpenFileChooser(ruleIndex);
|
||||
return;
|
||||
@@ -200,7 +204,7 @@ export function AttachmentRulesDataGrid({
|
||||
setFileChooser({ ruleIndex, basePath });
|
||||
}
|
||||
|
||||
function selectAttachment(selection: ManagedAttachmentSelection) {
|
||||
function selectAttachment(selection: FilesManagedAttachmentSelection) {
|
||||
if (!fileChooser) return;
|
||||
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
||||
patchRule(fileChooser.ruleIndex, {
|
||||
@@ -219,24 +223,26 @@ export function AttachmentRulesDataGrid({
|
||||
<DataGrid
|
||||
id={id}
|
||||
rows={rules}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
|
||||
className="attachment-rules-table-wrap attachment-rules-table"
|
||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||
/>
|
||||
{fileChooser && filesModuleInstalled && (
|
||||
{fileChooser && ManagedFileChooser && (
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
storageScope={campaignId}
|
||||
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
|
||||
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)}`}
|
||||
previewContext={previewContext}
|
||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||
onClose={() => setFileChooser(null)}
|
||||
onSelectAttachment={selectAttachment}
|
||||
/>
|
||||
|
||||
@@ -1,788 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, 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,
|
||||
shareFilesWithCampaign,
|
||||
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";
|
||||
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||
|
||||
type ManagedFileChooserMode = "folder" | "attachment";
|
||||
type AttachmentChoiceMode = "file" | "pattern";
|
||||
|
||||
type RememberedChooserState = {
|
||||
spaceId?: string;
|
||||
folder?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
|
||||
|
||||
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;
|
||||
previewContext?: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onSelectFolder?: (selection: ManagedFolderSelection) => void;
|
||||
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
|
||||
};
|
||||
|
||||
export default function ManagedFileChooser({
|
||||
open,
|
||||
settings,
|
||||
campaignId,
|
||||
mode,
|
||||
source,
|
||||
basePath = ".",
|
||||
initialPattern = "",
|
||||
rememberKey = mode,
|
||||
previewContext,
|
||||
onClose,
|
||||
onSelectFolder,
|
||||
onSelectAttachment
|
||||
}: ManagedFileChooserProps) {
|
||||
const filesExplorerUi = usePlatformUiCapability<FilesFileExplorerUiCapability>("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<FileSpace[]>([]);
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState("");
|
||||
const [files, setFiles] = useState<ManagedFile[]>([]);
|
||||
const [folders, setFolders] = useState<FileFolder[]>([]);
|
||||
const [currentFolder, setCurrentFolder] = useState("");
|
||||
const [pattern, setPattern] = useState("");
|
||||
const patternRef = useRef("");
|
||||
const rememberDraftTimerRef = useRef<number | null>(null);
|
||||
const [patternHasText, setPatternHasText] = useState(false);
|
||||
const [exactSelectionPattern, setExactSelectionPattern] = 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 [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("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 openFolder = useCallback((path: string) => {
|
||||
const normalized = normalizeFolder(path);
|
||||
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
|
||||
setCurrentFolder(normalized);
|
||||
setPatternMatches(null);
|
||||
}, [effectiveRoot, mode]);
|
||||
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
|
||||
activeSpaceId: selectedSpaceId,
|
||||
currentFolder,
|
||||
onOpenFolder: (_spaceId, path) => openFolder(path)
|
||||
});
|
||||
|
||||
const toggleSort = useCallback((column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((current) => current === "asc" ? "desc" : "asc");
|
||||
return;
|
||||
}
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === "name" ? "asc" : "desc");
|
||||
}, [sortColumn]);
|
||||
|
||||
const applyExactFile = useCallback((file: ManagedFile) => {
|
||||
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
||||
patternRef.current = exactPattern;
|
||||
setPattern(exactPattern);
|
||||
setPatternHasText(true);
|
||||
setExactSelectionPattern(exactPattern);
|
||||
setPatternMatches([file]);
|
||||
setPendingExactFile(null);
|
||||
}, [effectiveRoot]);
|
||||
|
||||
const scheduleRememberedState = useCallback((patternValue = patternRef.current) => {
|
||||
if (!open || !selectedSpaceId) return;
|
||||
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
|
||||
rememberDraftTimerRef.current = window.setTimeout(() => {
|
||||
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue });
|
||||
rememberDraftTimerRef.current = null;
|
||||
}, 350);
|
||||
}, [currentFolder, open, selectedSpaceId, storageKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const requestExactFile = useCallback((file: ManagedFile) => {
|
||||
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
||||
const currentPattern = patternRef.current.trim();
|
||||
if (currentPattern && currentPattern !== exactPattern) {
|
||||
setPendingExactFile(file);
|
||||
return;
|
||||
}
|
||||
applyExactFile(file);
|
||||
}, [applyExactFile, effectiveRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const initialPatternValue = remembered.pattern ?? initialPattern.trim();
|
||||
patternRef.current = initialPatternValue;
|
||||
setPattern(initialPatternValue);
|
||||
setPatternHasText(Boolean(initialPatternValue.trim()));
|
||||
setExactSelectionPattern("");
|
||||
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(() => {
|
||||
scheduleRememberedState();
|
||||
}, [currentFolder, pattern, scheduleRememberedState]);
|
||||
|
||||
function updatePatternInput(value: string) {
|
||||
patternRef.current = value;
|
||||
setPatternHasText((current) => {
|
||||
const next = Boolean(value.trim());
|
||||
return current === next ? current : next;
|
||||
});
|
||||
setExactSelectionPattern("");
|
||||
if (patternMatches !== null) setPatternMatches(null);
|
||||
scheduleRememberedState(value);
|
||||
}
|
||||
|
||||
async function previewPattern(): Promise<ManagedFile[]> {
|
||||
if (!selectedSpace) return [];
|
||||
const trimmed = patternRef.current.trim();
|
||||
if (!trimmed) {
|
||||
setPatternMatches([]);
|
||||
setPattern("");
|
||||
setPatternHasText(false);
|
||||
return [];
|
||||
}
|
||||
const previewPatternValue = renderChooserPattern(trimmed, previewContext);
|
||||
if (!previewPatternValue) {
|
||||
setPatternMatches([]);
|
||||
setPattern(trimmed);
|
||||
return [];
|
||||
}
|
||||
setPattern(trimmed);
|
||||
setPatternHasText(true);
|
||||
setResolving(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await resolveFilePatterns(settings, {
|
||||
patterns: [previewPatternValue],
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function shareMatches(matches: ManagedFile[]) {
|
||||
const fileIds = matches
|
||||
.filter((file) => !file.shares?.some((share) => (
|
||||
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
|
||||
)))
|
||||
.map((file) => file.id);
|
||||
const uniqueIds = [...new Set(fileIds)];
|
||||
if (uniqueIds.length > 0) await shareFilesWithCampaign(settings, uniqueIds, 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 = patternRef.current.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 || !patternHasText))}
|
||||
>
|
||||
{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 && (
|
||||
<FolderTreeComponent
|
||||
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 ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
|
||||
<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" && (
|
||||
<ManagedPatternEditor
|
||||
value={pattern}
|
||||
effectiveRoot={effectiveRoot}
|
||||
resolving={resolving}
|
||||
matchCount={patternMatches?.length ?? null}
|
||||
previewContext={previewContext}
|
||||
onDraftChange={updatePatternInput}
|
||||
onPreview={() => void previewPattern()}
|
||||
onBackToFolder={() => setPatternMatches(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{patternMatches !== null && mode === "attachment" ? (
|
||||
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
|
||||
) : (
|
||||
<ChooserFileBrowser
|
||||
mode={mode}
|
||||
loading={loading}
|
||||
currentFolder={currentFolder}
|
||||
effectiveRoot={effectiveRoot}
|
||||
sortedEntries={sortedEntries}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
campaignId={campaignId}
|
||||
selectedExactPattern={exactSelectionPattern}
|
||||
onOpenFolder={openFolder}
|
||||
onToggleSort={toggleSort}
|
||||
onRequestExactFile={requestExactFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 "${patternRef.current.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 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 (
|
||||
<button
|
||||
type="button"
|
||||
className={active ? "is-sorted" : ""}
|
||||
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
<span>{label}</span><Icon size={14} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedPatternEditor({
|
||||
value,
|
||||
effectiveRoot,
|
||||
resolving,
|
||||
matchCount,
|
||||
previewContext,
|
||||
onDraftChange,
|
||||
onPreview,
|
||||
onBackToFolder
|
||||
}: {
|
||||
value: string;
|
||||
effectiveRoot: string;
|
||||
resolving: boolean;
|
||||
matchCount: number | null;
|
||||
previewContext?: Record<string, string>;
|
||||
onDraftChange: (value: string) => void;
|
||||
onPreview: () => void;
|
||||
onBackToFolder: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext), [draft, previewContext]);
|
||||
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
function updateDraft(next: string) {
|
||||
setDraft(next);
|
||||
onDraftChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<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={draft}
|
||||
onChange={(event) => updateDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") onPreview(); }}
|
||||
placeholder="folder/file.pdf or **/*.pdf"
|
||||
/>
|
||||
<Button onClick={onPreview} disabled={resolving || !draft.trim()}>
|
||||
<Search size={15} aria-hidden="true" /> {resolving ? "Checking..." : "Preview"}
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
{matchCount !== null && (
|
||||
<div className="managed-pattern-summary">
|
||||
<span>{matchCount} current file{matchCount === 1 ? "" : "s"} match.</span>
|
||||
<Button variant="ghost" onClick={onBackToFolder}>Back to folder</Button>
|
||||
</div>
|
||||
)}
|
||||
{patternIsRendered && <small className="managed-pattern-rendered">Preview resolves to <code>{renderedPattern}</code>.</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChooserFileBrowser = memo(function ChooserFileBrowser({
|
||||
mode,
|
||||
loading,
|
||||
currentFolder,
|
||||
effectiveRoot,
|
||||
sortedEntries,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
campaignId,
|
||||
selectedExactPattern,
|
||||
onOpenFolder,
|
||||
onToggleSort,
|
||||
onRequestExactFile
|
||||
}: {
|
||||
mode: ManagedFileChooserMode;
|
||||
loading: boolean;
|
||||
currentFolder: string;
|
||||
effectiveRoot: string;
|
||||
sortedEntries: ChooserExplorerEntry[];
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
campaignId: string;
|
||||
selectedExactPattern: string;
|
||||
onOpenFolder: (path: string) => void;
|
||||
onToggleSort: (column: SortColumn) => void;
|
||||
onRequestExactFile: (file: ManagedFile) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="managed-file-entry-table">
|
||||
<div className="managed-file-entry-head" role="row">
|
||||
<span aria-hidden="true" />
|
||||
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
|
||||
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
|
||||
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
|
||||
</div>
|
||||
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
|
||||
<button
|
||||
type="button"
|
||||
className="managed-file-entry is-folder is-parent"
|
||||
onClick={() => onOpenFolder(parentWithinRoot(currentFolder, effectiveRoot))}
|
||||
disabled={loading || currentFolder === effectiveRoot}
|
||||
>
|
||||
<FolderOpen size={18} aria-hidden="true" />
|
||||
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
|
||||
<span className="managed-file-entry-size">-</span>
|
||||
<span className="managed-file-entry-modified">-</span>
|
||||
</button>
|
||||
{sortedEntries.map((entry) => {
|
||||
if (entry.kind === "folder") {
|
||||
return (
|
||||
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => onOpenFolder(entry.path)}>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
|
||||
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
|
||||
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
|
||||
const selected = mode === "attachment" && selectedExactPattern === exactPattern;
|
||||
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.file.id}
|
||||
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
|
||||
onClick={() => mode === "attachment" ? onRequestExactFile(entry.file) : undefined}
|
||||
disabled={mode === "folder"}
|
||||
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
|
||||
>
|
||||
<File size={18} aria-hidden="true" />
|
||||
<span className="managed-file-entry-name">
|
||||
<strong>{entry.file.filename}</strong>
|
||||
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
|
||||
</span>
|
||||
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
|
||||
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!loading && sortedEntries.length === 0 && (
|
||||
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 renderChooserPattern(pattern: string, previewContext?: Record<string, string>): string {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed || !previewContext) return trimmed;
|
||||
return renderTemplatePreviewText(trimmed, previewContext, false).trim();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user