diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx index 76b4c0a..5a8655f 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx @@ -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 { usePrompt } from '../../../../hooks/usePrompt'; +import { getFolderTree, createFolder } from '../../../../api/fileApi'; import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree'; import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider'; import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree'; @@ -11,22 +14,82 @@ import type { FieldRendererProps } from './index'; export const UserFileFolderPicker: React.FC = ({ param, value, onChange, request }) => { 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(null); const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []); const strVal = typeof value === 'string' ? value : ''; 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( (node: TreeNode) => { if (node.type === 'folder') { + setPickedName(node.name); onChange(node.id); + setPanelOpen(false); } }, [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 (
@@ -35,32 +98,71 @@ export const UserFileFolderPicker: React.FC = ({ param, valu )} {request && ( <> - + + {strVal ? ( + + ) : null} +
{panelOpen && (
= ({ param, valu }} >
onChange('')} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onChange(''); - } - }} style={{ - padding: '8px 12px', - fontSize: 12, - fontWeight: 600, - cursor: 'pointer', + display: 'flex', + alignItems: 'stretch', borderBottom: '1px solid var(--color-border, #e2e8f0)', - background: rootSelected - ? 'rgba(37, 99, 235, 0.12)' - : 'var(--table-header-bg, #f8fafc)', + background: 'var(--table-header-bg, #f8fafc)', }} > - {t('Stamm — Meine Dateien')} +
{ + 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')} +
+
)} + )} diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx index 01b60d7..20feb91 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -62,6 +62,7 @@ function _buildChildMap(nodes: TreeNode[]): Map( nodes: TreeNode[], expandedIds: Set, + confirmedEmptyFolderIds: Set, ): FlatEntry[] { const childMap = _buildChildMap(nodes); const result: FlatEntry[] = []; @@ -70,8 +71,22 @@ function _flatten( const children = childMap.get(parentKey); if (!children) return; for (const node of children) { - const nodeChildren = childMap.get(node.id); - const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder'; + const loadedChildren = childMap.get(node.id) ?? []; + 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 }); if (hasChildren && expandedIds.has(node.id)) { _walk(node.id, depth + 1); @@ -134,6 +149,7 @@ interface TreeNodeRowProps { onDragLeave: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent, node: TreeNode) => void; hideRowActionButtons?: boolean; + dragDropEnabled?: boolean; } const TreeNodeRow = React.memo(function TreeNodeRow({ @@ -163,6 +179,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ onDragLeave, onDrop, hideRowActionButtons = false, + dragDropEnabled = true, }: TreeNodeRowProps) { const { node, depth, hasChildren } = entry; const renameRef = useRef(null); @@ -243,11 +260,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ className={rowClasses} onClick={_handleRowClick} onDoubleClick={_handleDoubleClick} - draggable={!hideRowActionButtons} - onDragStart={hideRowActionButtons ? undefined : (e) => onDragStart(e, node)} - onDragOver={hideRowActionButtons ? undefined : (e) => onDragOver(e, node)} - onDragLeave={hideRowActionButtons ? undefined : onDragLeave} - onDrop={hideRowActionButtons ? undefined : (e) => onDrop(e, node)} + draggable={dragDropEnabled} + onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined} + onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined} + onDragLeave={dragDropEnabled ? onDragLeave : undefined} + onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined} data-node-id={node.id} title={node.name} role="treeitem" @@ -425,6 +442,8 @@ export function FormGeneratorTree({ className, embedMaxHeight, hideRowActionButtons = false, + hideSectionHeader = false, + enableDragDrop, }: FormGeneratorTreeProps) { const { prompt, PromptDialog } = usePrompt(); const [nodes, setNodes] = useState[]>([]); @@ -437,12 +456,15 @@ export function FormGeneratorTree({ const [dragOverId, setDragOverId] = useState(null); const [draggingIds, setDraggingIds] = useState>(new Set()); 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()); const lastSelectedIdRef = useRef(null); const treeContentRef = useRef(null); const _loadRoot = useCallback(async () => { setLoading(true); try { + setConfirmedEmptyFolderIds(new Set()); const rootNodes = await provider.loadChildren(null, ownership); setNodes(rootNodes); if (defaultCollapsed && rootNodes.length === 0) { @@ -457,7 +479,10 @@ export function FormGeneratorTree({ _loadRoot(); }, [_loadRoot]); - const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]); + const flatEntriesRaw = useMemo( + () => _flatten(nodes, expandedIds, confirmedEmptyFolderIds), + [nodes, expandedIds, confirmedEmptyFolderIds], + ); const flatEntries = useMemo(() => { const term = filterText.trim().toLowerCase(); @@ -506,6 +531,13 @@ export function FormGeneratorTree({ const childNodes = await provider.loadChildren(id, ownership); if (childNodes.length > 0) { 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(() => { @@ -623,6 +655,11 @@ export function FormGeneratorTree({ const newNode = await provider.createChild(parentId, trimmed); setNodes((prev) => [...prev, newNode]); if (parentId) { + setConfirmedEmptyFolderIds((prev) => { + const next = new Set(prev); + next.delete(parentId); + return next; + }); setExpandedIds((prev) => new Set(prev).add(parentId)); } } catch { @@ -851,6 +888,8 @@ export function FormGeneratorTree({ ); }, [provider, ownership]); + const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons; + const _filteredIdsForAction = useCallback( (action: TreeBatchAction): string[] => { const ids = [...selectedIds]; @@ -890,7 +929,7 @@ export function FormGeneratorTree({ : undefined } > - {title && ( + {title && !hideSectionHeader && (
setSectionCollapsed((v) => !v) : undefined} @@ -1030,6 +1069,7 @@ export function FormGeneratorTree({ onDragLeave={_handleDragLeave} onDrop={_handleDrop} hideRowActionButtons={hideRowActionButtons} + dragDropEnabled={dragDropEnabled} /> )) )} diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx index 1857340..11b9546 100644 --- a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -23,7 +23,9 @@ interface FileData { 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 { id: folder.id, name: folder.name, @@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode { neutralize: folder.neutralize, contextOrphan: folder.contextOrphan, icon: , + hasSubfoldersInApiTree, + mayHaveLazyFileChildren, }; } @@ -77,7 +81,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } }); const allFolders: FolderData[] = foldersRes.data ?? []; 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) { try { @@ -140,7 +144,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { async createChild(parentId, name) { 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'); return node; }, @@ -164,7 +168,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { await Promise.all( ids.map((id) => { 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 }); }), ); }, diff --git a/src/components/FormGenerator/FormGeneratorTree/types.ts b/src/components/FormGenerator/FormGeneratorTree/types.ts index 5ad75c4..32c8d5d 100644 --- a/src/components/FormGenerator/FormGeneratorTree/types.ts +++ b/src/components/FormGenerator/FormGeneratorTree/types.ts @@ -16,6 +16,16 @@ export interface TreeNode { isLoading?: boolean; sizeBytes?: number; 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 { @@ -66,7 +76,12 @@ export interface FormGeneratorTreeProps { /** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */ 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; + /** When true, folders remain draggable despite `hideRowActionButtons`. */ + enableDragDrop?: boolean; + /** Hides the titled section header (count, refresh, new folder) — for compact embedded pickers. */ + hideSectionHeader?: boolean; }