finished file tree folder selection in file create node

This commit is contained in:
Ida 2026-05-06 09:11:35 +02:00
parent 47b3c1ab23
commit 0941b9e0ad
4 changed files with 266 additions and 55 deletions

View file

@ -1,9 +1,12 @@
/** /**
* userFileFolder same folder tree as Meine Dateien (FormGeneratorTree) inside a collapsible panel. * userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree.
*/ */
import React, { useMemo, useCallback, useState } from 'react'; import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { FaFolderPlus } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { usePrompt } from '../../../../hooks/usePrompt';
import { getFolderTree, createFolder } from '../../../../api/fileApi';
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree'; import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider'; import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree'; import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
@ -11,22 +14,82 @@ import type { FieldRendererProps } from './index';
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => { export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [panelOpen, setPanelOpen] = useState(true); const { prompt, PromptDialog } = usePrompt();
const [panelOpen, setPanelOpen] = useState(false);
/** Remount embedded tree after create/rename elsewhere */
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
const [creating, setCreating] = useState(false);
/** Display name for saved folderId (resolved from API when graph loads). */
const [pickedName, setPickedName] = useState<string | null>(null);
const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []); const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
const strVal = typeof value === 'string' ? value : ''; const strVal = typeof value === 'string' ? value : '';
const rootSelected = strVal === ''; const rootSelected = strVal === '';
useEffect(() => {
if (!strVal) {
setPickedName(null);
return;
}
if (!request) return;
let cancelled = false;
getFolderTree(request, 'me')
.then((folders) => {
if (cancelled) return;
const f = folders.find((x) => x.id === strVal);
setPickedName(f?.name ?? null);
})
.catch(() => {
if (!cancelled) setPickedName(null);
});
return () => {
cancelled = true;
};
}, [strVal, request]);
const handleNodeClick = useCallback( const handleNodeClick = useCallback(
(node: TreeNode) => { (node: TreeNode) => {
if (node.type === 'folder') { if (node.type === 'folder') {
setPickedName(node.name);
onChange(node.id); onChange(node.id);
setPanelOpen(false);
} }
}, },
[onChange], [onChange],
); );
const clearFolder = useCallback(() => {
onChange('');
setPickedName(null);
}, [onChange]);
const triggerLabel = strVal ? (pickedName ?? '…') : t('Wähle einen Zielordner');
const handleCreateFolder = useCallback(async () => {
if (!request || creating) return;
const parentHint = strVal && pickedName ? ` („${pickedName}“)` : strVal ? '' : ' (Stamm)';
const entered = await prompt(`Ordnername${parentHint}:`, {
title: 'Neuer Ordner',
placeholder: 'Ordnername',
confirmLabel: t('Anlegen'),
});
const trimmed = entered?.trim();
if (!trimmed) return;
setCreating(true);
try {
const parentId = strVal || null;
const folder = await createFolder(request, trimmed, parentId);
setPickedName(folder.name);
onChange(folder.id);
setTreeRefreshKey((k) => k + 1);
} catch {
// stay silent in minimal UI; devtools / global handler may log
} finally {
setCreating(false);
}
}, [request, creating, strVal, pickedName, prompt, onChange, t]);
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
@ -35,32 +98,71 @@ export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, valu
)} )}
{request && ( {request && (
<> <>
<button <div
type="button"
onClick={() => setPanelOpen((o) => !o)}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%', width: '100%',
padding: '6px 10px', alignItems: 'stretch',
marginBottom: panelOpen ? 6 : 0,
borderRadius: 6, borderRadius: 6,
border: '1px solid var(--color-border, #cbd5e1)', border: '1px solid var(--color-border, #cbd5e1)',
background: 'var(--table-header-bg, #f1f5f9)', background: 'var(--table-header-bg, #f1f5f9)',
cursor: 'pointer', overflow: 'hidden',
fontSize: 12, marginBottom: panelOpen ? 6 : 0,
textAlign: 'left',
color: 'var(--color-text, #334155)',
}} }}
> >
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <button
{panelOpen ? t('Ordnerbaum ausblenden') : t('Ordnerbaum einblenden')} type="button"
</span> onClick={() => setPanelOpen((o) => !o)}
<span aria-hidden style={{ marginLeft: 8, flexShrink: 0 }}> style={{
{panelOpen ? '▾' : '▸'} flex: 1,
</span> display: 'flex',
</button> alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 12,
textAlign: 'left',
color: 'var(--color-text, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{triggerLabel}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Zielordner entfernen (Stamm — Meine Dateien)')}
aria-label={t('Zielordner entfernen')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearFolder();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid var(--color-border, #cbd5e1)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--color-text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && ( {panelOpen && (
<div <div
@ -72,42 +174,92 @@ export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, valu
}} }}
> >
<div <div
role="button"
tabIndex={0}
onClick={() => onChange('')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange('');
}
}}
style={{ style={{
padding: '8px 12px', display: 'flex',
fontSize: 12, alignItems: 'stretch',
fontWeight: 600,
cursor: 'pointer',
borderBottom: '1px solid var(--color-border, #e2e8f0)', borderBottom: '1px solid var(--color-border, #e2e8f0)',
background: rootSelected background: 'var(--table-header-bg, #f8fafc)',
? 'rgba(37, 99, 235, 0.12)'
: 'var(--table-header-bg, #f8fafc)',
}} }}
> >
{t('Stamm — Meine Dateien')} <div
role="button"
tabIndex={0}
onClick={() => {
clearFolder();
setPanelOpen(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearFolder();
setPanelOpen(false);
}
}}
style={{
flex: 1,
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
minHeight: 36,
background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
}}
>
{t('Stamm — Meine Dateien')}
</div>
<button
type="button"
aria-label={t('Neuen Ordner erstellen')}
title={
creating
? t('Wird angelegt…')
: strVal
? `Unterordner von: ${pickedName ?? '…'}`
: 'Unter dem Stamm (oberste Ebene)'
}
disabled={creating}
onClick={(e) => {
e.stopPropagation();
void handleCreateFolder();
}}
style={{
flexShrink: 0,
width: 40,
minHeight: 36,
alignSelf: 'stretch',
border: 'none',
borderLeft: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent',
cursor: creating ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--primary-color, #2563eb)',
opacity: creating ? 0.5 : 1,
}}
>
<FaFolderPlus size={14} aria-hidden />
</button>
</div> </div>
<FormGeneratorTree <FormGeneratorTree
key={`user-folder-tree-${treeRefreshKey}`}
provider={provider} provider={provider}
ownership="own" ownership="own"
title={t('Ordner')}
compact compact
allowCreateFolder allowCreateFolder={false}
showFilter={false} showFilter={false}
emptyMessage={t('Noch keine Ordner')} emptyMessage={t('Noch keine Ordner')}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
embedMaxHeight={260} embedMaxHeight={240}
hideRowActionButtons hideRowActionButtons
hideSectionHeader
enableDragDrop
/> />
</div> </div>
)} )}
<PromptDialog />
</> </>
)} )}
</div> </div>

View file

@ -62,6 +62,7 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
function _flatten<T>( function _flatten<T>(
nodes: TreeNode<T>[], nodes: TreeNode<T>[],
expandedIds: Set<string>, expandedIds: Set<string>,
confirmedEmptyFolderIds: Set<string>,
): FlatEntry<T>[] { ): FlatEntry<T>[] {
const childMap = _buildChildMap(nodes); const childMap = _buildChildMap(nodes);
const result: FlatEntry<T>[] = []; const result: FlatEntry<T>[] = [];
@ -70,8 +71,22 @@ function _flatten<T>(
const children = childMap.get(parentKey); const children = childMap.get(parentKey);
if (!children) return; if (!children) return;
for (const node of children) { for (const node of children) {
const nodeChildren = childMap.get(node.id); const loadedChildren = childMap.get(node.id) ?? [];
const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder'; const hasLoadedKids = loadedChildren.length > 0;
let hasChildren = false;
if (node.type !== 'folder') {
hasChildren = hasLoadedKids;
} else if (hasLoadedKids) {
hasChildren = true;
} else if (confirmedEmptyFolderIds.has(node.id)) {
hasChildren = false;
} else if (node.hasSubfoldersInApiTree === false && node.mayHaveLazyFileChildren === false) {
hasChildren = false;
} else {
hasChildren = true;
}
result.push({ node, depth, hasChildren }); result.push({ node, depth, hasChildren });
if (hasChildren && expandedIds.has(node.id)) { if (hasChildren && expandedIds.has(node.id)) {
_walk(node.id, depth + 1); _walk(node.id, depth + 1);
@ -134,6 +149,7 @@ interface TreeNodeRowProps<T = any> {
onDragLeave: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void; onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
hideRowActionButtons?: boolean; hideRowActionButtons?: boolean;
dragDropEnabled?: boolean;
} }
const TreeNodeRow = React.memo(function TreeNodeRow<T>({ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
@ -163,6 +179,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
onDragLeave, onDragLeave,
onDrop, onDrop,
hideRowActionButtons = false, hideRowActionButtons = false,
dragDropEnabled = true,
}: TreeNodeRowProps<T>) { }: TreeNodeRowProps<T>) {
const { node, depth, hasChildren } = entry; const { node, depth, hasChildren } = entry;
const renameRef = useRef<HTMLInputElement>(null); const renameRef = useRef<HTMLInputElement>(null);
@ -243,11 +260,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
className={rowClasses} className={rowClasses}
onClick={_handleRowClick} onClick={_handleRowClick}
onDoubleClick={_handleDoubleClick} onDoubleClick={_handleDoubleClick}
draggable={!hideRowActionButtons} draggable={dragDropEnabled}
onDragStart={hideRowActionButtons ? undefined : (e) => onDragStart(e, node)} onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined}
onDragOver={hideRowActionButtons ? undefined : (e) => onDragOver(e, node)} onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined}
onDragLeave={hideRowActionButtons ? undefined : onDragLeave} onDragLeave={dragDropEnabled ? onDragLeave : undefined}
onDrop={hideRowActionButtons ? undefined : (e) => onDrop(e, node)} onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined}
data-node-id={node.id} data-node-id={node.id}
title={node.name} title={node.name}
role="treeitem" role="treeitem"
@ -425,6 +442,8 @@ export function FormGeneratorTree<T = any>({
className, className,
embedMaxHeight, embedMaxHeight,
hideRowActionButtons = false, hideRowActionButtons = false,
hideSectionHeader = false,
enableDragDrop,
}: FormGeneratorTreeProps<T>) { }: FormGeneratorTreeProps<T>) {
const { prompt, PromptDialog } = usePrompt(); const { prompt, PromptDialog } = usePrompt();
const [nodes, setNodes] = useState<TreeNode<T>[]>([]); const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
@ -437,12 +456,15 @@ export function FormGeneratorTree<T = any>({
const [dragOverId, setDragOverId] = useState<string | null>(null); const [dragOverId, setDragOverId] = useState<string | null>(null);
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set()); const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState(''); const [filterText, setFilterText] = useState('');
/** Folders we expanded and confirmed have no visible children → hide chevron like a real leaf */
const [confirmedEmptyFolderIds, setConfirmedEmptyFolderIds] = useState(() => new Set<string>());
const lastSelectedIdRef = useRef<string | null>(null); const lastSelectedIdRef = useRef<string | null>(null);
const treeContentRef = useRef<HTMLDivElement>(null); const treeContentRef = useRef<HTMLDivElement>(null);
const _loadRoot = useCallback(async () => { const _loadRoot = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setConfirmedEmptyFolderIds(new Set());
const rootNodes = await provider.loadChildren(null, ownership); const rootNodes = await provider.loadChildren(null, ownership);
setNodes(rootNodes); setNodes(rootNodes);
if (defaultCollapsed && rootNodes.length === 0) { if (defaultCollapsed && rootNodes.length === 0) {
@ -457,7 +479,10 @@ export function FormGeneratorTree<T = any>({
_loadRoot(); _loadRoot();
}, [_loadRoot]); }, [_loadRoot]);
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]); const flatEntriesRaw = useMemo(
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
[nodes, expandedIds, confirmedEmptyFolderIds],
);
const flatEntries = useMemo(() => { const flatEntries = useMemo(() => {
const term = filterText.trim().toLowerCase(); const term = filterText.trim().toLowerCase();
@ -506,6 +531,13 @@ export function FormGeneratorTree<T = any>({
const childNodes = await provider.loadChildren(id, ownership); const childNodes = await provider.loadChildren(id, ownership);
if (childNodes.length > 0) { if (childNodes.length > 0) {
setNodes((prev) => [...prev, ...childNodes]); setNodes((prev) => [...prev, ...childNodes]);
setConfirmedEmptyFolderIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else if (node.type === 'folder') {
setConfirmedEmptyFolderIds((prev) => new Set(prev).add(id));
} }
} }
setTimeout(() => { setTimeout(() => {
@ -623,6 +655,11 @@ export function FormGeneratorTree<T = any>({
const newNode = await provider.createChild(parentId, trimmed); const newNode = await provider.createChild(parentId, trimmed);
setNodes((prev) => [...prev, newNode]); setNodes((prev) => [...prev, newNode]);
if (parentId) { if (parentId) {
setConfirmedEmptyFolderIds((prev) => {
const next = new Set(prev);
next.delete(parentId);
return next;
});
setExpandedIds((prev) => new Set(prev).add(parentId)); setExpandedIds((prev) => new Set(prev).add(parentId));
} }
} catch { } catch {
@ -851,6 +888,8 @@ export function FormGeneratorTree<T = any>({
); );
}, [provider, ownership]); }, [provider, ownership]);
const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons;
const _filteredIdsForAction = useCallback( const _filteredIdsForAction = useCallback(
(action: TreeBatchAction): string[] => { (action: TreeBatchAction): string[] => {
const ids = [...selectedIds]; const ids = [...selectedIds];
@ -890,7 +929,7 @@ export function FormGeneratorTree<T = any>({
: undefined : undefined
} }
> >
{title && ( {title && !hideSectionHeader && (
<div <div
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`} className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined} onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
@ -1030,6 +1069,7 @@ export function FormGeneratorTree<T = any>({
onDragLeave={_handleDragLeave} onDragLeave={_handleDragLeave}
onDrop={_handleDrop} onDrop={_handleDrop}
hideRowActionButtons={hideRowActionButtons} hideRowActionButtons={hideRowActionButtons}
dragDropEnabled={dragDropEnabled}
/> />
)) ))
)} )}

View file

@ -23,7 +23,9 @@ interface FileData {
sysCreatedBy?: string; sysCreatedBy?: string;
} }
function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode { function _mapFolderToNode(folder: FolderData, ownership: Ownership, allFolders: FolderData[], includeFilesInTree: boolean): TreeNode {
const hasSubfoldersInApiTree = allFolders.some((f) => (f.parentId ?? null) === folder.id);
const mayHaveLazyFileChildren = includeFilesInTree && !hasSubfoldersInApiTree;
return { return {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
neutralize: folder.neutralize, neutralize: folder.neutralize,
contextOrphan: folder.contextOrphan, contextOrphan: folder.contextOrphan,
icon: <FaFolder />, icon: <FaFolder />,
hasSubfoldersInApiTree,
mayHaveLazyFileChildren,
}; };
} }
@ -77,7 +81,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } }); const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? []; const allFolders: FolderData[] = foldersRes.data ?? [];
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId); const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership))); nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
if (includeFiles) { if (includeFiles) {
try { try {
@ -140,7 +144,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
async createChild(parentId, name) { async createChild(parentId, name) {
const res = await api.post('/api/files/folders', { name, parentId }); const res = await api.post('/api/files/folders', { name, parentId });
const node = _mapFolderToNode(res.data, 'own'); const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
typeMap.set(node.id, 'folder'); typeMap.set(node.id, 'folder');
return node; return node;
}, },
@ -164,7 +168,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
await Promise.all( await Promise.all(
ids.map((id) => { ids.map((id) => {
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId }); if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
return api.post(`/api/files/folders/${id}/move`, { targetParentId }); return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
}), }),
); );
}, },

View file

@ -16,6 +16,16 @@ export interface TreeNode<T = any> {
isLoading?: boolean; isLoading?: boolean;
sizeBytes?: number; sizeBytes?: number;
data?: T; data?: T;
/**
* From bulk `/folders/tree` response: another folder references this folder as parent.
* When false AND no lazy-file mode, omit expand affordance immediately.
*/
hasSubfoldersInApiTree?: boolean;
/**
* Folder tree mixes in files lazily (`includeFiles` in FolderFileProvider). When true but
* no subfolders in API snapshot, expand may still reveal files keep chevron until loaded.
*/
mayHaveLazyFileChildren?: boolean;
} }
export interface TreeBatchAction { export interface TreeBatchAction {
@ -66,7 +76,12 @@ export interface FormGeneratorTreeProps<T = any> {
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */ /** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
embedMaxHeight?: number; embedMaxHeight?: number;
/** /**
* Hides checkbox, size column, per-row emoji actions, drag-drop, and batch toolbar saves space in pickers. * Hides checkbox, size column, per-row emoji actions, and batch toolbar saves space in pickers.
* Drag-drop defaults off when hidden; pass `enableDragDrop` to keep moving folders inside the mini tree.
*/ */
hideRowActionButtons?: boolean; hideRowActionButtons?: boolean;
/** When true, folders remain draggable despite `hideRowActionButtons`. */
enableDragDrop?: boolean;
/** Hides the titled section header (count, refresh, new folder) — for compact embedded pickers. */
hideSectionHeader?: boolean;
} }