Release v0.1.3
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } 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";
|
||||
@@ -23,6 +24,7 @@ type AttachmentRulesOverlayProps = {
|
||||
basePaths?: AttachmentBasePath[];
|
||||
zipConfig?: AttachmentZipCollection;
|
||||
filesModuleInstalled?: boolean;
|
||||
previewContext?: Record<string, string>;
|
||||
onChange: (rules: AttachmentRule[]) => void;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,7 @@ type AttachmentRulesTableProps = {
|
||||
activeChooserRuleIndex?: number | null;
|
||||
zipConfig?: AttachmentZipCollection;
|
||||
filesModuleInstalled?: boolean;
|
||||
previewContext?: Record<string, string>;
|
||||
onOpenFileChooser?: (ruleIndex: number) => void;
|
||||
onChange: (rules: AttachmentRule[]) => void;
|
||||
};
|
||||
@@ -57,6 +60,7 @@ export default function AttachmentRulesOverlay({
|
||||
basePaths = [],
|
||||
zipConfig = { enabled: false, archives: [] },
|
||||
filesModuleInstalled = false,
|
||||
previewContext,
|
||||
onChange
|
||||
}: AttachmentRulesOverlayProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -80,32 +84,33 @@ export default function AttachmentRulesOverlay({
|
||||
}
|
||||
|
||||
const dialog = open ? createPortal(
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
||||
<div className="modal-panel attachment-rules-modal">
|
||||
<header className="modal-header">
|
||||
<h2 id="attachment-rules-title">{title}</h2>
|
||||
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
|
||||
</header>
|
||||
<div className="modal-body attachment-rules-body">
|
||||
<AttachmentRulesTable
|
||||
rules={draftRules}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
disabled={disabled}
|
||||
emptyText={emptyText}
|
||||
basePaths={basePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
activeChooserRuleIndex={null}
|
||||
onChange={setDraftRules}
|
||||
/>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Dialog
|
||||
open
|
||||
title={title}
|
||||
className="attachment-rules-modal"
|
||||
bodyClassName="attachment-rules-body"
|
||||
onClose={cancelOverlay}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={cancelOverlay}>Cancel</Button>
|
||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<AttachmentRulesTable
|
||||
rules={draftRules}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
disabled={disabled}
|
||||
emptyText={emptyText}
|
||||
basePaths={basePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={previewContext}
|
||||
activeChooserRuleIndex={null}
|
||||
onChange={setDraftRules}
|
||||
/>
|
||||
</Dialog>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
@@ -150,6 +155,7 @@ export function AttachmentRulesDataGrid({
|
||||
activeChooserRuleIndex = null,
|
||||
zipConfig = { enabled: false, archives: [] },
|
||||
filesModuleInstalled = false,
|
||||
previewContext,
|
||||
onOpenFileChooser,
|
||||
onChange
|
||||
}: AttachmentRulesTableProps) {
|
||||
@@ -230,6 +236,7 @@ export function AttachmentRulesDataGrid({
|
||||
basePath={fileChooser.basePath?.path ?? "."}
|
||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||
previewContext={previewContext}
|
||||
onClose={() => setFileChooser(null)}
|
||||
onSelectAttachment={selectAttachment}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
listFiles,
|
||||
listFolders,
|
||||
resolveFilePatterns,
|
||||
shareFileWithCampaign,
|
||||
shareFilesWithCampaign,
|
||||
type FileFolder,
|
||||
type FileSpace,
|
||||
type ManagedFile
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
parseManagedAttachmentSource,
|
||||
type ManagedAttachmentSource
|
||||
} from "../utils/attachments";
|
||||
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||
|
||||
type ManagedFileChooserMode = "folder" | "attachment";
|
||||
type AttachmentChoiceMode = "file" | "pattern";
|
||||
@@ -45,6 +46,8 @@ type RememberedChooserState = {
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
|
||||
|
||||
export type ManagedFolderSelection = {
|
||||
space: FileSpace;
|
||||
folderPath: string;
|
||||
@@ -67,6 +70,7 @@ type ManagedFileChooserProps = {
|
||||
basePath?: string;
|
||||
initialPattern?: string;
|
||||
rememberKey?: string;
|
||||
previewContext?: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onSelectFolder?: (selection: ManagedFolderSelection) => void;
|
||||
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
|
||||
@@ -81,6 +85,7 @@ export default function ManagedFileChooser({
|
||||
basePath = ".",
|
||||
initialPattern = "",
|
||||
rememberKey = mode,
|
||||
previewContext,
|
||||
onClose,
|
||||
onSelectFolder,
|
||||
onSelectAttachment
|
||||
@@ -100,6 +105,10 @@ export default function ManagedFileChooser({
|
||||
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);
|
||||
@@ -126,18 +135,70 @@ export default function ManagedFileChooser({
|
||||
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("");
|
||||
setPattern(remembered.pattern ?? initialPattern.trim());
|
||||
const initialPatternValue = remembered.pattern ?? initialPattern.trim();
|
||||
patternRef.current = initialPatternValue;
|
||||
setPattern(initialPatternValue);
|
||||
setPatternHasText(Boolean(initialPatternValue.trim()));
|
||||
setExactSelectionPattern("");
|
||||
setPatternMatches(null);
|
||||
setPendingExactFile(null);
|
||||
void listFileSpaces(settings)
|
||||
@@ -186,38 +247,42 @@ export default function ManagedFileChooser({
|
||||
}, [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]);
|
||||
scheduleRememberedState();
|
||||
}, [currentFolder, pattern, scheduleRememberedState]);
|
||||
|
||||
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);
|
||||
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 = pattern.trim();
|
||||
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: [trimmed],
|
||||
patterns: [previewPatternValue],
|
||||
owner_type: selectedSpace.owner_type,
|
||||
owner_id: selectedSpace.owner_id,
|
||||
path_prefix: effectiveRoot || undefined,
|
||||
@@ -234,27 +299,14 @@ export default function ManagedFileChooser({
|
||||
}
|
||||
}
|
||||
|
||||
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)));
|
||||
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() {
|
||||
@@ -273,7 +325,7 @@ export default function ManagedFileChooser({
|
||||
|
||||
const matches = patternMatches ?? await previewPattern();
|
||||
await shareMatches(matches);
|
||||
const trimmedPattern = pattern.trim();
|
||||
const trimmedPattern = patternRef.current.trim();
|
||||
onSelectAttachment?.({
|
||||
space: selectedSpace,
|
||||
folderPath: effectiveRoot,
|
||||
@@ -309,7 +361,7 @@ export default function ManagedFileChooser({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void confirmSelection()}
|
||||
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
|
||||
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !patternHasText))}
|
||||
>
|
||||
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
|
||||
</Button>
|
||||
@@ -384,90 +436,35 @@ export default function ManagedFileChooser({
|
||||
</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>
|
||||
<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} />
|
||||
) : (
|
||||
<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={toggleSort} />
|
||||
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
||||
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
||||
</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={() => openFolder(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={() => openFolder(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" && 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" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
|
||||
onClick={() => mode === "attachment" ? requestExactFile(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>
|
||||
<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" && (
|
||||
@@ -483,7 +480,7 @@ export default function ManagedFileChooser({
|
||||
<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?"}
|
||||
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)}
|
||||
@@ -521,6 +518,153 @@ function ChooserSortButton({
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -610,6 +754,12 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
|
||||
|
||||
@@ -59,6 +59,34 @@ export default function CampaignMessagePreviewOverlay({
|
||||
const shownSubject = subject?.trim() || "No subject";
|
||||
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (!navigation) return;
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
if (navigation.index > 0) navigation.onPrevious();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
if (navigation.index < navigation.total - 1) navigation.onNext();
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
if (navigation.index > 0) navigation.onFirst();
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
if (navigation.index < navigation.total - 1) navigation.onLast();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [navigation, onClose]);
|
||||
|
||||
return (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
||||
<div className="modal-panel template-preview-modal message-preview-modal">
|
||||
@@ -109,6 +137,11 @@ export default function CampaignMessagePreviewOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
|
||||
}
|
||||
|
||||
|
||||
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
|
||||
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildUndefinedPlaceholders,
|
||||
extractTemplatePlaceholders,
|
||||
removePlaceholderFromText,
|
||||
replacePlaceholderInText,
|
||||
type TemplateNamespace,
|
||||
type UndefinedPlaceholder
|
||||
} from "../utils/templatePlaceholders";
|
||||
@@ -100,6 +101,11 @@ export default function TemplateExpressionEditorDialog({
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
function replaceUndefined(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
|
||||
setDraftValue((current) => replacePlaceholderInText(current, field.raw, `{{${namespace}:${name}}}`));
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -168,7 +174,10 @@ export default function TemplateExpressionEditorDialog({
|
||||
removeLabel="Remove from filename"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removeUndefined}
|
||||
onReplace={replaceUndefined}
|
||||
onAddField={addUndefined}
|
||||
localFields={localFields}
|
||||
globalFields={globalFields}
|
||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
|
||||
/>
|
||||
</>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
@@ -74,19 +75,49 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
field,
|
||||
contextLabel = "template",
|
||||
removeLabel = "Remove placeholder",
|
||||
localFields = [],
|
||||
globalFields = [],
|
||||
onCancel,
|
||||
onRemove,
|
||||
onReplace,
|
||||
onAddField,
|
||||
addDisabled = false
|
||||
}: {
|
||||
field: UndefinedPlaceholder | null;
|
||||
contextLabel?: string;
|
||||
removeLabel?: string;
|
||||
localFields?: TemplateFieldOption[];
|
||||
globalFields?: TemplateFieldOption[];
|
||||
onCancel: () => void;
|
||||
onRemove: (field: UndefinedPlaceholder) => void;
|
||||
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
|
||||
onAddField: (field: UndefinedPlaceholder) => void;
|
||||
addDisabled?: boolean;
|
||||
}) {
|
||||
const replacementOptions = useMemo(
|
||||
() => [
|
||||
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
|
||||
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))
|
||||
],
|
||||
[globalFields, localFields]
|
||||
);
|
||||
const [replacement, setReplacement] = useState("");
|
||||
const firstReplacement = replacementOptions[0];
|
||||
const effectiveReplacement = replacement || (firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
|
||||
|
||||
useEffect(() => {
|
||||
setReplacement(firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
|
||||
}, [field?.raw, firstReplacement?.namespace, firstReplacement?.name]);
|
||||
|
||||
function replaceWithSelected() {
|
||||
if (!field || !onReplace || !effectiveReplacement) return;
|
||||
const separator = effectiveReplacement.indexOf(":");
|
||||
if (separator < 0) return;
|
||||
const namespace = effectiveReplacement.slice(0, separator) as TemplateNamespace;
|
||||
const name = effectiveReplacement.slice(separator + 1);
|
||||
onReplace(field, namespace, name);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(field)}
|
||||
@@ -98,6 +129,7 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
<>
|
||||
<Button onClick={onCancel}>Continue editing</Button>
|
||||
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
|
||||
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
|
||||
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
@@ -111,6 +143,18 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
{field.reason === "missing-field" && (
|
||||
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
|
||||
)}
|
||||
{onReplace && replacementOptions.length > 0 && (
|
||||
<label className="template-replacement-field">
|
||||
<span>Replace by existing field</span>
|
||||
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
|
||||
{replacementOptions.map((option) => (
|
||||
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
|
||||
{option.namespace}:{option.label || option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user