From 25abb6fff9e6c74f3fb65da512b860dc53efed5c Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 6 May 2026 09:11:35 +0200 Subject: [PATCH] finished file tree folder selection in file create node --- .../UserFileFolderPicker.tsx | 234 +++++++++++++++--- .../FormGeneratorTree/FormGeneratorTree.tsx | 122 ++++----- .../providers/FolderFileProvider.tsx | 39 +-- .../FormGenerator/FormGeneratorTree/types.ts | 17 +- 4 files changed, 284 insertions(+), 128 deletions(-) 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 13155bb..ec55009 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -104,6 +104,7 @@ function _buildChildMap(nodes: TreeNode[]): Map( nodes: TreeNode[], expandedIds: Set, + confirmedEmptyFolderIds: Set, ): FlatEntry[] { const childMap = _buildChildMap(nodes); const result: FlatEntry[] = []; @@ -112,8 +113,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); @@ -181,6 +196,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({ @@ -215,6 +231,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ onDragLeave, onDrop, hideRowActionButtons = false, + dragDropEnabled = true, }: TreeNodeRowProps) { const { node, depth, hasChildren } = entry; const renameRef = useRef(null); @@ -301,11 +318,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" @@ -485,6 +502,8 @@ export function FormGeneratorTree({ className, embedMaxHeight, hideRowActionButtons = false, + hideSectionHeader = false, + enableDragDrop, }: FormGeneratorTreeProps) { const { t } = useLanguage(); const { confirm, ConfirmDialog } = useConfirm(); @@ -499,8 +518,8 @@ export function FormGeneratorTree({ const [dragOverId, setDragOverId] = useState(null); const [draggingIds, setDraggingIds] = useState>(new Set()); const [filterText, setFilterText] = useState(''); - /** Map of nodeId -> set of action keys currently pending (for spinner rendering). */ - const [pendingActions, setPendingActions] = useState>>(new Map()); + /** 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); /** Tracks node ids for which auto-expand has already fired (one-shot). */ @@ -597,6 +616,7 @@ export function FormGeneratorTree({ autoExpandedRef.current.clear(); setExpandedIds(new Set()); try { + setConfirmedEmptyFolderIds(new Set()); const rootNodes = await provider.loadChildren(null, ownership); setNodes(rootNodes); if (defaultCollapsed && rootNodes.length === 0) { @@ -611,46 +631,10 @@ export function FormGeneratorTree({ _loadRoot(); }, [_loadRoot]); - /** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id. - * Fetches children first, then sets expandedIds + merges atomically so the - * expanded arrow never appears without visible children. */ - useEffect(() => { - const targets = nodes.filter( - (n) => n.defaultExpanded === true && !autoExpandedRef.current.has(n.id), - ); - if (targets.length === 0) return; - const targetIds = targets.map((t) => t.id); - for (const id of targetIds) autoExpandedRef.current.add(id); - let cancelled = false; - (async () => { - const childMap = _buildChildMap(nodes); - const toFetch = targetIds.filter((id) => { - const existing = childMap.get(id); - return !existing || existing.length === 0; - }); - if (toFetch.length > 0) { - const results = await Promise.all( - toFetch.map((id) => - provider.loadChildren(id, ownership).catch(() => [] as TreeNode[]), - ), - ); - if (cancelled) return; - const flat = results.flat(); - if (flat.length > 0) { - setNodes((prev) => _mergeNodes(prev, flat)); - } - } - if (cancelled) return; - setExpandedIds((prev) => { - const next = new Set(prev); - for (const id of targetIds) next.add(id); - return next; - }); - })(); - return () => { cancelled = true; }; - }, [nodes, provider, ownership, _mergeNodes]); - - 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(); @@ -684,15 +668,21 @@ export function FormGeneratorTree({ async (id: string) => { const wasExpanded = expandedIds.has(id); - if (wasExpanded) { - // Collapse: remove all descendants from nodes state and expandedIds. - const descendantIds = new Set(); - const _collectDescendants = (parentId: string) => { - for (const n of nodes) { - if (n.parentId === parentId && !descendantIds.has(n.id)) { - descendantIds.add(n.id); - _collectDescendants(n.id); - } + const node = nodes.find((n) => n.id === id); + if (node && !wasExpanded) { + const childMap = _buildChildMap(nodes); + const existingChildren = childMap.get(id); + if (!existingChildren || existingChildren.length === 0) { + 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)); } }; _collectDescendants(id); @@ -828,6 +818,7 @@ export function FormGeneratorTree({ if (!trimmed) return; try { const newNode = await provider.createChild(parentId, trimmed); +<<<<<<< HEAD setNodes((prev) => _mergeNodes(prev, [newNode])); // The provider may have re-parented `newNode` (e.g. onto a synth-root) // when `parentId === null`; expand whichever parent the resulting node @@ -835,6 +826,16 @@ export function FormGeneratorTree({ const visibleParent = newNode.parentId ?? null; if (visibleParent) { setExpandedIds((prev) => new Set(prev).add(visibleParent)); +======= + 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)); +>>>>>>> ae63020 (finished file tree folder selection in file create node) } } catch { await _handleRefresh(); @@ -1095,6 +1096,8 @@ export function FormGeneratorTree({ ); }, [provider, ownership]); + const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons; + const _filteredIdsForAction = useCallback( (action: TreeBatchAction): string[] => { const ids = [...selectedIds]; @@ -1134,7 +1137,7 @@ export function FormGeneratorTree({ : undefined } > - {title && ( + {title && !hideSectionHeader && (
setSectionCollapsed((v) => !v) : undefined} @@ -1287,6 +1290,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 c96a4c4..6c3a132 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, }; } @@ -123,13 +127,8 @@ 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) === apiParentId); - const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership)); - // Re-parent top-level folders onto the synthetic root. - if (apiParentId === null) { - for (const n of folderNodes) n.parentId = synthRootId; - } - nodes.push(...folderNodes); + const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId); + nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles))); if (includeFiles) { try { @@ -198,22 +197,9 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { }, async createChild(parentId, name) { - // Creating a folder under "/" means a top-level folder; map back to null - // for the API. The FE-only synth-root id never travels to the backend. - const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId; - const res = await api.post('/api/files/folders', { name, parentId: apiParentId }); - const node = _mapFolderToNode(res.data, 'own'); - // Bind the new folder visually to the parent the user actually clicked. - // - explicit synth-root parentId -> attach there ("/" + new top-level folder) - // - explicit parent (real folder) -> the API echoes the same parentId - // - parentId === null (no clicked parent, e.g. global "+" with no - // selection): default to the OWN tree's synth-root so the new folder - // shows up inside "/" instead of at the legacy top-level row. - if (parentId && parentId.startsWith('__filesRoot:')) { - node.parentId = parentId; - } else if (parentId === null) { - node.parentId = _SYNTH_ROOT_ID('own'); - } + const res = await api.post('/api/files/folders', { name, parentId }); + const node = _mapFolderToNode(res.data, 'own', [], includeFiles); + typeMap.set(node.id, 'folder'); return node; }, @@ -239,9 +225,8 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { : targetParentId; await Promise.all( ids.map((id) => { - if (id.startsWith('__filesRoot:')) return Promise.resolve(); - if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget }); - return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget }); + if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: 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 9762e9b..39c34d6 100644 --- a/src/components/FormGenerator/FormGeneratorTree/types.ts +++ b/src/components/FormGenerator/FormGeneratorTree/types.ts @@ -47,6 +47,16 @@ export interface TreeNode { * pending spinner on click. Tree has no knowledge of action semantics. */ extraActions?: NodeAction[]; 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 { @@ -125,7 +135,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; }