chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:21 +02:00
parent 18e6c3eb9b
commit 13fd7fc3bd
50 changed files with 12678 additions and 1109 deletions

View File

@@ -3,24 +3,24 @@ import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import {
listFileSpaces,
listFiles,
listFolders,
listManagedFileSnapshot,
resolveFilePatterns,
shareFilesWithTarget,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
type ManagedFile } from
"../../../api/files";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
LoadingFrame,
type FilesManagedAttachmentSelection,
type FilesManagedFileChooserProps,
type FilesManagedFileLinkTarget,
type FilesManagedFolderSelection
} from "@govoplan/core-webui";
type FilesManagedFolderSelection, i18nMessage } from
"@govoplan/core-webui";
import { useFileTreeState } from "../hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../types";
import {
@@ -32,8 +32,8 @@ import {
normalizeFolder,
parentFolderPath,
sortExplorerEntries,
treeNodeKey
} from "../utils/fileManagerUtils";
treeNodeKey } from
"../utils/fileManagerUtils";
type ManagedFileChooserMode = FilesManagedFileChooserProps["mode"];
@@ -57,7 +57,7 @@ type ManagedFileSource = {
const MANAGED_FILE_SOURCE_PREFIX = "managed:";
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
type VirtualFolderTreeRow = { node: FolderNode; depth: number };
type VirtualFolderTreeRow = {node: FolderNode;depth: number;};
export type ManagedFolderSelection = FilesManagedFolderSelection;
export type ManagedAttachmentSelection = FilesManagedAttachmentSelection;
@@ -104,9 +104,9 @@ export default function ManagedFileChooser({
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? mode === "folder" || !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const sourceMatchesSpace = selectedSpace ?
mode === "folder" || !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),
@@ -187,22 +187,23 @@ export default function ManagedFileChooser({
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; };
void listFileSpaces(settings).
then((response) => {
if (cancelled) return;
const managedSpaces = response.spaces.filter((space) => space.space_type !== "connector");
setSpaces(managedSpaces);
const sourceSpace = managedSpaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = managedSpaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? managedSpaces[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(() => {
@@ -214,22 +215,23 @@ export default function ManagedFileChooser({
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, path_prefix: mode === "attachment" ? effectiveRoot || undefined : undefined }),
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; };
void listManagedFileSnapshot(settings, {
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: mode === "attachment" ? effectiveRoot || undefined : undefined
}).
then((response) => {
if (cancelled) return;
setFiles(response.files);
setFolders(response.folders);
}).
catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [effectiveRoot, mode, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
@@ -287,9 +289,9 @@ export default function ManagedFileChooser({
async function shareMatches(matches: ManagedFile[]) {
if (!linkTarget) return;
const fileIds = matches
.filter((file) => !isLinkedToTarget(file, linkTarget))
.map((file) => file.id);
const fileIds = matches.
filter((file) => !isLinkedToTarget(file, linkTarget)).
map((file) => file.id);
const uniqueIds = [...new Set(fileIds)];
if (uniqueIds.length > 0) await shareFilesWithTarget(settings, uniqueIds, linkTarget);
}
@@ -308,7 +310,7 @@ export default function ManagedFileChooser({
return;
}
const matches = patternMatches ?? await previewPattern();
const matches = patternMatches ?? (await previewPattern());
await shareMatches(matches);
const trimmedPattern = patternRef.current.trim();
onSelectAttachment?.({
@@ -329,148 +331,150 @@ export default function ManagedFileChooser({
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
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>
open
title={mode === "folder" ? "i18n:govoplan-files.choose_managed_attachment_source.b3709493" : "i18n:govoplan-files.choose_managed_file_or_pattern.bcf8e183"}
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}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !patternHasText))}
>
{submitting ? (linkTarget ? "Linking…" : "Saving…") : mode === "folder" ? "Use this folder" : "Use pattern"}
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || mode === "attachment" && (!parsedSource || !patternHasText)}>
{submitting ? linkTarget ? "i18n:govoplan-files.linking.6f640897" : "i18n:govoplan-files.saving.56a2285c" : mode === "folder" ? "i18n:govoplan-files.use_this_folder.0e77d6d1" : "i18n:govoplan-files.use_pattern.ce54ba3e"}
</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.
{mode === "attachment" && !parsedSource &&
<DismissibleAlert tone="warning" dismissible={false}>
i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f <strong>i18n:govoplan-files.attachments_attachment_sources.87d92d5b</strong> i18n:govoplan-files.first.49ec0838
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading &&
<DismissibleAlert tone="danger" dismissible={false}>i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729</DismissibleAlert>
}
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_connector_files.e5b0871d" className="managed-file-chooser-loading-frame">
<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>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="i18n:govoplan-files.file_spaces_and_folders.443d2056">
<div className="file-tree-heading">i18n:govoplan-files.spaces.9b584b52</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 ${selected ? "is-active" : ""}`} key={space.id}>
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className={`file-tree-space ${selected ? "is-active" : ""}`} 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}
>
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 && (
<VirtualChooserFolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
{selected &&
<VirtualChooserFolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
disabled={loading} />
}
</div>);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">i18n:govoplan-files.no_accessible_file_spaces_were_found.abcf5003</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">
<div className="managed-file-breadcrumb" aria-label="i18n:govoplan-files.current_folder.5aeab2f0">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "i18n:govoplan-files.files.6ce6c512"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
{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}
renderPatternPreview={renderPatternPreview}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)}
/>
)}
{mode === "attachment" &&
<ManagedPatternEditor
value={pattern}
effectiveRoot={effectiveRoot}
resolving={resolving}
matchCount={patternMatches?.length ?? null}
previewContext={previewContext}
renderPatternPreview={renderPatternPreview}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)} />
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} linkTarget={linkTarget} onChoose={requestExactFile} />
) : (
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
linkTarget={linkTarget}
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>
)}
{patternMatches !== null && mode === "attachment" ?
<PatternResultList files={patternMatches} linkTarget={linkTarget} onChoose={requestExactFile} /> :
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
linkTarget={linkTarget}
selectedExactPattern={exactSelectionPattern}
onOpenFolder={openFolder}
onToggleSort={toggleSort}
onRequestExactFile={requestExactFile} />
}
{mode === "folder" &&
<p className="muted small-note managed-file-chooser-note">i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7</p>
}
{mode === "attachment" &&
<p className="muted small-note managed-file-chooser-note">i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24</p>
}
</section>
</div>
</LoadingFrame>
</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)}
/>
</>
);
open={Boolean(pendingExactFile)}
title="i18n:govoplan-files.replace_the_current_pattern.96f80707"
message={pendingExactFile ? i18nMessage("i18n:govoplan-files.replace_value_with_the_exact_file_value.4a63236f", { value0: patternRef.current.trim(), value1: relativePath(pendingExactFile.display_path, effectiveRoot) }) : "i18n:govoplan-files.replace_the_current_pattern.96f80707"}
confirmLabel="i18n:govoplan-files.replace_pattern.d98e4c92"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)} />
</>;
return createPortal(dialog, document.body);
}
@@ -480,26 +484,26 @@ function ChooserSortButton({
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
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;
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)}
>
aria-label={i18nMessage("i18n:govoplan-files.value_value_activate_to_sort.3953ba71", { value0: label, value1: active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "i18n:govoplan-files.not_sorted.0abc03c5" })}
onClick={() => onSort(column)}>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
</button>);
}
function ManagedPatternEditor({
@@ -512,17 +516,17 @@ function ManagedPatternEditor({
onDraftChange,
onPreview,
onBackToFolder
}: {
value: string;
effectiveRoot: string;
resolving: boolean;
matchCount: number | null;
previewContext?: Record<string, string>;
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"];
onDraftChange: (value: string) => void;
onPreview: () => void;
onBackToFolder: () => void;
}) {
}: {value: string;effectiveRoot: string;resolving: boolean;matchCount: number | null;previewContext?: Record<string, string>;renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"];onDraftChange: (value: string) => void;onPreview: () => void;onBackToFolder: () => void;}) {
const [draft, setDraft] = useState(value);
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext, renderPatternPreview), [draft, previewContext, renderPatternPreview]);
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
@@ -539,28 +543,28 @@ function ManagedPatternEditor({
return (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<span>i18n:govoplan-files.file_or_pattern_relative_to.0ab4d831 <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"
/>
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"}
<Search size={15} aria-hidden="true" /> {resolving ? "i18n:govoplan-files.checking.494d0f68" : "i18n:govoplan-files.preview.f1fbb2b4"}
</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>
{matchCount !== null &&
<div className="managed-pattern-summary">
<span>{matchCount} i18n:govoplan-files.current_file.1fbfcf3d{matchCount === 1 ? "" : "s"} i18n:govoplan-files.match.c79af5c2</span>
<Button variant="ghost" onClick={onBackToFolder}>i18n:govoplan-files.back_to_folder.34ba1ed1</Button>
</div>
)}
{patternIsRendered && <small className="managed-pattern-rendered">Preview resolves to <code>{renderedPattern}</code>.</small>}
</div>
);
}
{patternIsRendered && <small className="managed-pattern-rendered">i18n:govoplan-files.preview_resolves_to.a8d99432 <code>{renderedPattern}</code>.</small>}
</div>);
}
function VirtualChooserFolderTree({
@@ -572,16 +576,16 @@ function VirtualChooserFolderTree({
onOpen,
onToggle,
disabled
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
expandedKeys: Set<string>;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
disabled?: boolean;
}) {
}: {nodes: FolderNode[];activeSpaceId: string;spaceId: string;currentFolder: string;expandedKeys: Set<string>;onOpen: (spaceId: string, path: string) => void;onToggle: (spaceId: string, path: string) => void;disabled?: boolean;}) {
const rows = useMemo(() => flattenVisibleFolderTree(nodes, spaceId, expandedKeys), [expandedKeys, nodes, spaceId]);
const resetKey = useMemo(
() => `${spaceId}:${currentFolder}:${rows.length}:${Array.from(expandedKeys).sort().join("|")}`,
@@ -600,12 +604,12 @@ function VirtualChooserFolderTree({
className="managed-file-tree-virtual-list"
ref={virtualRows.viewportRef}
role="tree"
aria-label="Folders"
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}
>
{virtualRows.topSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
)}
aria-label="i18n:govoplan-files.folders.19adc47b"
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}>
{virtualRows.topSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
}
{rows.slice(virtualRows.startIndex, virtualRows.endIndex).map(({ node, depth }, index) => {
const nodeId = treeNodeKey(spaceId, node.path);
const hasChildren = node.children.length > 0;
@@ -619,8 +623,8 @@ function VirtualChooserFolderTree({
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
role="treeitem"
aria-expanded={hasChildren ? expanded : undefined}
aria-selected={active}
>
aria-selected={active}>
<button
type="button"
className="file-tree-toggle"
@@ -629,26 +633,26 @@ function VirtualChooserFolderTree({
if (hasChildren) onToggle(spaceId, node.path);
}}
disabled={disabled || !hasChildren}
aria-label={`${expanded ? "Collapse" : "Expand"} ${node.name}`}
>
aria-label={i18nMessage("i18n:govoplan-files.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-files.collapse.9cf188d3" : "i18n:govoplan-files.expand.9869e506", value1: node.name })}>
{expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />}
</button>
<button
type="button"
className="file-tree-node"
onClick={() => onOpen(spaceId, node.path)}
disabled={disabled}
>
disabled={disabled}>
<span>{node.name}</span>
</button>
</div>
);
</div>);
})}
{virtualRows.bottomSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
)}
</div>
);
{virtualRows.bottomSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
}
</div>);
}
const ChooserFileBrowser = memo(function ChooserFileBrowser({
@@ -664,20 +668,20 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
onOpenFolder,
onToggleSort,
onRequestExactFile
}: {
mode: ManagedFileChooserMode;
loading: boolean;
currentFolder: string;
effectiveRoot: string;
sortedEntries: ChooserExplorerEntry[];
sortColumn: SortColumn;
sortDirection: SortDirection;
linkTarget?: FilesManagedFileLinkTarget;
selectedExactPattern: string;
onOpenFolder: (path: string) => void;
onToggleSort: (column: SortColumn) => void;
onRequestExactFile: (file: ManagedFile) => void;
}) {
}: {mode: ManagedFileChooserMode;loading: boolean;currentFolder: string;effectiveRoot: string;sortedEntries: ChooserExplorerEntry[];sortColumn: SortColumn;sortDirection: SortDirection;linkTarget?: FilesManagedFileLinkTarget;selectedExactPattern: string;onOpenFolder: (path: string) => void;onToggleSort: (column: SortColumn) => void;onRequestExactFile: (file: ManagedFile) => void;}) {
const resetKey = `${mode}:${currentFolder}:${sortColumn}:${sortDirection}:${sortedEntries.length}`;
const virtualRows = useFixedVirtualRows<HTMLButtonElement>({
itemCount: sortedEntries.length,
@@ -696,14 +700,14 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
className="managed-file-entry is-folder"
onClick={() => onOpenFolder(entry.path)}
aria-posinset={position}
aria-setsize={sortedEntries.length + 1}
>
aria-setsize={sortedEntries.length + 1}>
<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-name"><strong>{entry.name}</strong><small>{entry.fileCount} i18n:govoplan-files.file_s.ca61e97f {entry.folderCount} i18n:govoplan-files.folder_s.10e54730</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
</button>);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && selectedExactPattern === exactPattern;
@@ -715,36 +719,36 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
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}
aria-label={mode === "folder" ? i18nMessage("i18n:govoplan-files.value_file_shown_for_context.3d6e0acb", { value0: entry.file.filename }) : entry.file.filename}
aria-posinset={position}
aria-setsize={sortedEntries.length + 1}
>
aria-setsize={sortedEntries.length + 1}>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{linked ? <><Link2 size={12} aria-hidden="true" /> linked to {linkTarget?.label || "target"}</> : "File"}</small>
<small>{linked ? <><Link2 size={12} aria-hidden="true" /> i18n:govoplan-files.linked_to.ca188768 {linkTarget?.label || "target"}</> : "i18n:govoplan-files.file.2c3cafa4"}</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>
);
</button>);
}
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} />
<ChooserSortButton column="name" label="i18n:govoplan-files.name.709a2322" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="size" label="i18n:govoplan-files.size.b7152342" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="modified" label="i18n:govoplan-files.modified.19a532c8" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
</div>
<div
className="managed-file-entry-list"
ref={virtualRows.viewportRef}
role="listbox"
aria-label={mode === "folder" ? "Folders and files" : "Managed files"}
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}
>
aria-label={mode === "folder" ? "i18n:govoplan-files.folders_and_files.d107ac99" : "i18n:govoplan-files.managed_files.4dd6ad11"}
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}>
<button
type="button"
ref={virtualRows.measureRowRef}
@@ -752,31 +756,31 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
onClick={() => onOpenFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
aria-posinset={1}
aria-setsize={sortedEntries.length + 1}
>
aria-setsize={sortedEntries.length + 1}>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-name"><strong>..</strong><small>i18n:govoplan-files.parent_folder.d8ed4c2a</small></span>
<span className="managed-file-entry-size">-</span>
<span className="managed-file-entry-modified">-</span>
</button>
{virtualRows.topSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
)}
{sortedEntries.slice(virtualRows.startIndex, virtualRows.endIndex).map((entry, index) => (
renderEntry(entry, virtualRows.startIndex + index + 2)
))}
{virtualRows.bottomSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
)}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
{virtualRows.topSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
}
{sortedEntries.slice(virtualRows.startIndex, virtualRows.endIndex).map((entry, index) =>
renderEntry(entry, virtualRows.startIndex + index + 2)
)}
{virtualRows.bottomSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
}
{!loading && sortedEntries.length === 0 &&
<p className="managed-file-empty muted">i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a</p>
}
</div>
</div>
);
</div>);
});
function PatternResultList({ files, linkTarget, onChoose }: { files: ManagedFile[]; linkTarget?: FilesManagedFileLinkTarget; onChoose: (file: ManagedFile) => void }) {
function PatternResultList({ files, linkTarget, onChoose }: {files: ManagedFile[];linkTarget?: FilesManagedFileLinkTarget;onChoose: (file: ManagedFile) => void;}) {
const resetKey = `${files.length}:${files[0]?.id ?? ""}:${files[files.length - 1]?.id ?? ""}`;
const virtualRows = useFixedVirtualRows<HTMLButtonElement>({
itemCount: files.length,
@@ -785,45 +789,45 @@ function PatternResultList({ files, linkTarget, onChoose }: { files: ManagedFile
});
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results" aria-rowcount={files.length + 1}>
<div className="managed-pattern-results" role="table" aria-label="i18n:govoplan-files.pattern_preview_results.56cea56c" aria-rowcount={files.length + 1}>
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
<span>i18n:govoplan-files.name.709a2322</span><span>i18n:govoplan-files.size.b7152342</span><span>i18n:govoplan-files.modified.19a532c8</span>
</div>
<div
className="file-list-table"
ref={virtualRows.viewportRef}
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}
>
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{virtualRows.topSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
)}
{files.slice(virtualRows.startIndex, virtualRows.endIndex).map((file, index) => (
<button
type="button"
className="file-list-row file-row managed-pattern-result-row"
role="row"
key={file.id}
ref={index === 0 ? virtualRows.measureRowRef : undefined}
onClick={() => onChoose(file)}
aria-rowindex={virtualRows.startIndex + index + 2}
>
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}>
{files.length === 0 && <div className="file-list-empty">i18n:govoplan-files.no_current_files_match_this_pattern.b84c5836</div>}
{virtualRows.topSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
}
{files.slice(virtualRows.startIndex, virtualRows.endIndex).map((file, index) =>
<button
type="button"
className="file-list-row file-row managed-pattern-result-row"
role="row"
key={file.id}
ref={index === 0 ? virtualRows.measureRowRef : undefined}
onClick={() => onChoose(file)}
aria-rowindex={virtualRows.startIndex + index + 2}>
<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>{isLinkedToTarget(file, linkTarget) && <small>Linked to {linkTarget?.label || "target"}</small>}</span>
<span><strong>{file.display_path}</strong>{isLinkedToTarget(file, linkTarget) && <small>i18n:govoplan-files.linked_to.e7ca9e02 {linkTarget?.label || "target"}</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
{virtualRows.bottomSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
)}
{virtualRows.bottomSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
}
</div>
</div>
);
</div>);
}
function useFixedVirtualRows<T extends HTMLElement>({
@@ -832,13 +836,13 @@ function useFixedVirtualRows<T extends HTMLElement>({
defaultRowHeight,
rowGap = 0,
leadingRows = 0
}: {
itemCount: number;
resetKey: string;
defaultRowHeight: number;
rowGap?: number;
leadingRows?: number;
}) {
}: {itemCount: number;resetKey: string;defaultRowHeight: number;rowGap?: number;leadingRows?: number;}) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const measureRowRef = useRef<T | null>(null);
const [viewportHeight, setViewportHeight] = useState(0);
@@ -917,12 +921,12 @@ function useFixedVirtualRows<T extends HTMLElement>({
}
function flattenVisibleFolderTree(
nodes: FolderNode[],
spaceId: string,
expandedKeys: Set<string>,
depth = 1,
rows: VirtualFolderTreeRow[] = []
): VirtualFolderTreeRow[] {
nodes: FolderNode[],
spaceId: string,
expandedKeys: Set<string>,
depth = 1,
rows: VirtualFolderTreeRow[] = [])
: VirtualFolderTreeRow[] {
for (const node of nodes) {
rows.push({ node, depth });
if (node.children.length > 0 && expandedKeys.has(treeNodeKey(spaceId, node.path))) {
@@ -944,7 +948,7 @@ function parseManagedFileSource(value: unknown): ManagedFileSource | null {
if (typeof value !== "string" || !value.startsWith(MANAGED_FILE_SOURCE_PREFIX)) return null;
const [, ownerType, ...ownerIdParts] = value.split(":");
const ownerId = ownerIdParts.join(":").trim();
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
return { ownerType, ownerId };
}
@@ -961,12 +965,12 @@ function relativePath(path: string, root: string): string {
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
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 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,
@@ -1009,10 +1013,10 @@ function isExactPattern(value: string): boolean {
}
function renderChooserPattern(
pattern: string,
previewContext?: Record<string, string>,
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"]
): string {
pattern: string,
previewContext?: Record<string, string>,
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"])
: string {
const trimmed = pattern.trim();
if (!trimmed) return trimmed;
return (renderPatternPreview?.(trimmed, previewContext) ?? trimmed).trim();
@@ -1038,10 +1042,10 @@ function writeRememberedState(key: string, state: RememberedChooserState) {
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
// Storage is optional; chooser behavior remains functional without it.
}}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}