Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 23318c709a
commit ab2343aa88
22 changed files with 2176 additions and 197 deletions

View File

@@ -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}
/>

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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 ?? "")}
/>
</>,

View File

@@ -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>